task_1.c – исходный код сервера
task_1 – сервер
n24.elf – обфусцированный сервер
descr – описание задачи
solution_18.py – решение автора
Дан бинарный файл, в котором реализован сервер. Файл обфусцирован. Необходимо разобрать, каким образом он авторизует пользователя на сервере, написать авторизатор, зайти на сервер по ssh и найти флаг.

Начальные условия

  • файл UwRJ8iaEEd4tSQIe_n24.elf;
  • IP-адрес сервера, на котором необходимо авторизоваться для получения флага.

Опрос сервера на наличие доступа по стандартным протоколам подключения показал наличие доступа по SSH (порт 22).

Предоставленный файл является исполняемым ELF (на что тонко намекнули расширением в названии) для Linux.

#file UwRJ8iaEEd4tSQIe_n24.elf

UwRJ8iaEEd4tSQIe_n24.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically
linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, stripped

Использование утилиты strings показало наличие строк «/home/task/.ssh» и «/home/task/.ssh/authorized_keys». Вывод о возможности доступа к файлу ключей беспарольной авторизации SSH со стороны исполняемого файла ELF (далее – сервиса).

В символьной таблице присутствуют необходимые функции для открытия файлов и записи:

# readelf --dyn-syms UwRJ8iaEEd4tSQIe_n24.elf | grep fopen

 23: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fopen@GLIBC_2.2.5 (2)

# readelf --dyn-syms UwRJ8iaEEd4tSQIe_n24.elf | grep write

 32: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fwrite@GLIBC_2.2.5 (2)

В символьной таблице также присутствуют функции для работы с сокетами, по созданию процессов и по подсчету MD5.

Реверс файла показал наличие большого числа прыжков (некоего рода обфускация). При этом прыжки осуществляются между блоками кода, которые в общем могут быть разбиты на несколько типов:

  • блоки по схеме «выполнил определённый функционал и прыгнул на следующий блок на основе установленного флага OF процессора», как тут (вывод утилиты objdump):
 95b69b: 48 0f 44 c7 cmove rax,rdi

 95b69f: 48 83 e7 01 and rdi,0x1

 95b6a3: 4d 31 dc xor r12,r11

 95b6a6: 71 05 jno 95b6ad 

 95b6a8: e9 f4 bf e1 ff jmp 7776a1 

 95b6ad: e9 1f 1a de ff jmp 73d0d1 

При этом в таких блоках флаг OF обычно не установлен в силу выполнения инструкций «xor», «and» и других.

  • блоки, модифицирующие ход своего выполнения после первого прохода. В большинстве таких блоков изначально прыжок из них ведет в неисполняемые области. В блоке производится модификация инструкции прыжка, как тут:
 95b401: c7 04 25 2b b4 95 00 mov DWORD PTR ds:0x95b42b,0x34be74
 95b408: 74 be 34 00
 95b40c: 66 c7 04 25 01 b4 95 mov WORD PTR ds:0x95b401,0x13eb
 95b413: 00 eb 13
 95b416: 4c 0f 44 da cmove r11,rdx
 95b41a: 48 d1 ea shr rdx,1
 95b41d: 48 0f 44 ca cmove rcx,rdx
 95b421: 49 89 d3 mov r11,rdx
 95b424: 48 89 ca mov rdx,rcx
 95b427: 4c 89 da mov rdx,r11
 95b42a: e9 8d ad e7 00 jmp 17d61bc
  • простые блоки, выполняющие определённый функционал и идущие дальше.

По результатам реверса сделано предположение о наличии реализации подсчета по алгоритму MD5. Необходимая для расчета таблица не реализована отдельно, а читается прямо в коде в блоках. В коде есть символы с названиями «MD5_Init», «MD5_Update» и «MD5_final».

В целом с использованием возможностей всем известного дизассемблера и его API скриптов можно было определить ход выполнения программы статически. Но лицензия дизассемблера дорогая, пробная версия у них грустная, достать его сложно и я обходился свободно распространяемыми утилитами, да и этот путь дольше. Поэтому динамика, тем более возможность есть.

Закинул файл ELF в виртуалку. Заранее создал директорию «/home/task/.ssh/» на всякий случай.

При запуске требуется указать порт. Учитывая, что мы не контролируем запуск на стороне сервера, подумал, что этот параметр фиктивный. Реальный порт должен быть один. Netstat показал наличие открытого порта 5432 (UDP).

# netstat -ulnp

Active Internet connections (only servers)

Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name

udp 0 0 0.0.0.0:5432 0.0.0.0:* 13611/./UwRJ8iaEEd4

Отправка пакета с данными на указанный порт выводит сообщение об их верификации и некие данные (4 байта) со стороны сервиса:

#echo "test" > /dev/udp/127.0.0.1/5432

# Verifying 74657374

009ec3b8

Перебор различных данных на позволил выявить зависимость вывода от их содержимого.

Далее – отладка с использованием gdb. Первым делом узнаю, где получаем данные, точка останова на recvfrom и backtrace. Получаем в итоге адрес 0x6ae010 и дальше цепочка переходов:

6ae00b: e8 d0 2b d5 ff call 400be0 

6ae010: e9 64 bc ea ff jmp 559c79 


559c79: 89 45 80 mov DWORD PTR [rbp-0x80],eax

559c7c: 83 f8 ff cmp eax,0xffffffff # если не получили, то -1

559c7f: 0f 84 62 7f 1c 00 je 721be7 

559c85: e9 8a d6 2c 00 jmp 827314 


827314: 48 c7 c7 30 d1 f0 00 mov rdi,0xf0d130

82731b: 48 29 27 sub QWORD PTR [rdi],rsp

82731e: 48 89 df mov rdi,rbx

827321: e8 5f 94 fe ff call 810785 

827326: e9 d7 a5 2d 00 jmp b01902 


b01902: 85 c0 test eax,eax

b01904: 0f 84 dd 02 c2 ff je 721be7 

b0190a: e9 7c a9 bb ff jmp 6bc28b 

В цепочке вызов функции по адресу 0x810758 и обработка ее результата.

Ставим break на 0xb01902, отправляем пакет с данными, смотрим код возврата (регистр rax):

(gdb) b *0xb01902

Breakpoint 2 at 0xb01902

(gdb) c

Continuing.

Verifying 74657374

00f82488


Breakpoint 2, 0x0000000000b01902 in MD5_Init ()

(gdb) info reg rax

rax 0x0 0

Код 0 при неправильных данных. Следовательно, предполагаем, что для правильного решения нам нужно вернуть код не 0.

В процессе дальнейшего исследования посмотрел через gdb, что передается в функцию MD5_Update при отправке пакета данных (отправлял также «test»).

(gdb) b MD5_Update

Breakpoint 3 at 0x4c487d (2 locations)

(gdb) c

Continuing.

Verifying 74657374


Breakpoint 3, 0x00000000004c487d in MD5_Update ()

 (gdb) info reg rsi

rsi 0x7fffffffdd90 140737488346512

(gdb) x/20bx $rsi

0x7fffffffdd90: 0x74 0x65 0x73 0x74 0x0a 0xff 0x7f 0x00

0x7fffffffdd98: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00

0x7fffffffdda0: 0x00 0x00 0x00 0x00

(gdb) info reg $rdx

rdx 0x200 512

Результат. MD5 считается от переданного нами сообщения, но размер считаемых данных 512 Байт. Поигравшись с данными, выяснил, что MD5 считается от присланных данных с заполненными до размера 512 байт нулями. Но отправлять нужно минимум 8 байт, чтобы заменить некое 8 байтовое число, хранимое в стеке. Судя по всему, там хранился какой-то адрес. Выводимые при этом сервисом 4 байта на каждый пришедший пакет соответствуют первым 3 байтам MD5-суммы с дополнительным нулем.

Вернулся к функции 0x810758 и ее коду возврата 0. Возвращаемое значение хранится в регистре RAX. Для определения кода возврата установил 2 точки останова на адрес самой функции 0x810758 и адрес после ее выполнения 0x827326.

Отправил данные, сработала точка в 0x810758. Запустил в gdb скрипт:

import gdb


with open("flow.log", "w") as fw:

 while 1:

 s = gdb.execute("info reg rip", to_string=True)

 s = s[s.find("0x"):]

 gdb.execute("ni", to_string=True)

 address = s.split("\t")[0].strip()

 fw.write(address + "\r\n")

 address = int(address, 16)

 if address == 0x827326:

 break

Получил файлик flow.log со всеми пройденными адресами в процессе выполнения исследуемой функции. На самом деле все было не так просто, но пришел в итоге к этому.

Подготовил файлик «disasm.log» с дизассемблированным кодом из objdmp к читабельному виду типа «адрес:инструкция» без лишних строк. Запустил такой вот скрипт:

F_NAME = "disasm.log"

F_FLOW = "flow.log"



def prepare_code_flow(f_path):

 with open(f_path, "rb") as fr:

 data = fr.readlines()

 data = filter(lambda x: x, data)

 start_address = long(data[0].split(":")[0], 16)

 end_address = long(data[-1].split(":")[0], 16)

 res = [""] * (end_address - start_address + 1)

 for _d in data:

 _d = _d.split(":")

 res[long(_d[0].strip(), 16) - start_address] = "".join(_d[1:]).strip()


 return start_address, res



def parse_instruction(code):

 mnem = code[:7].strip()

 ops = code[7:].split(",")

 return [mnem] + ops



def process_instruction(code):

 parse_data = parse_instruction(code)

 if parse_data[1] in ["rax", "eax", "al"]:

 return True

 return False



if __name__ == '__main__':

 # Prepare disassemble data

 start_address, codes = prepare_code_flow(F_NAME)


 with open(F_FLOW, "rb") as fr:

 lines = fr.readlines()

 lines.reverse()

 lines = filter(lambda x: x, lines)


 count = 0

 for _l in lines:

 offset = long(_l.strip(), 16) - start_address

 if process_instruction(codes[offset]):

 print str(count) + " " + hex(offset + start_address) + " " + codes[offset]

 break

 count += 1

 continue

Скрипт просто «идет» по адресам назад от конца до момента пока не получит в первом операнде инструкции регистр RAX. Результат:

0x462c19 mov eax, DWORD PTR [rbp-0x14]

Аналогично скрипт запущен дальше до момента пока не получит в первом операнде инструкции DWORD PTR [rbp-0x14] (предположение, что значение в RBP в нужном диапазоне не меняется было верным, потом проверено так же с gdb). Тут стоит отметить, что второй вариант поиска адреса изменения значения в [rbp-0x14] – это тот же gdb c watch. Результат:

0x67c27c mov DWORD PTR [rbp-0x14], 0x0

Вот оно нулевое значение. Дальше просто шаги назад до какого-либо ветвления (файл «flow.log»):

95b6ad: jmp 73d0d1 

95b6b2: cmp DWORD PTR [rbp-0x2d4],0x133337

95b6bc: jne 67c270 

Адрес 0x95b6b2 – сравнение некоего значения с 0x133337. Точка останова, смотрим, что в [rbp-0x2d4]. Для этого отправляем пакет с данными «testtest»:

# echo -n "testtest" > md5.bin

# truncate -s 512 md5.bin

# md5sum md5.bin

e9b9de230bdc85f3e929b0d2495d0323 md5.bin

# echo -n "testtest" > /dev/udp/127.0.0.1/5432



(gdb) b *0x95b6b2

Breakpoint 6 at 0x95b6b2

(gdb) c

Continuing.

Verifying 74657374

00deb9e9


Breakpoint 6, 0x000000000095b6b2 in MD5_Final ()

(gdb) x/20bx $rbp-0x2d4

0x7fffffffdd7c: 0xe9 0xb9 0xde 0x00 0xe9 0xb9 0xde 0x23

0x7fffffffdd84: 0x0b 0xdc 0x85 0xf3 0xe9 0x29 0xb0 0xd2

0x7fffffffdd8c: 0x49 0x5d 0x03 0x23

Совпадение по 3 первым байтам MD5-суммы. Решение сводится к получению MD5-суммы с первыми 3 байтами «\x37\x33\x13».

Простой скрипт для перебора чисел от нуля с расчетом в бинарном виде MD5 до нужного совпадения. Необходимые данные для отправки получены. Отправляем данные и получаем сообщение от сервиса о назначении нового порта для приема данных:

New salt 508bd11b

Next port 14235

Binding 14235

Waiting for data...3 14235 0

Netstat не показал данного порта, да и вообще новых портов. Но ps показала наличие завершившегося дочернего процесса (зомби). Пришла идея, что порт открывается на некоторое время в дочернем процессе.

Отправил нужный пакет на порт 5432, а за ним на порт 14235. И ничего. Порт перестал открываться. В итоге сгенерировал другие данные и, соответственно, MD5 с нужным началом. Снова сообщение, но на этот раз с другим портом. После перезапуска сервиса сработала первая MD5, снова с портом 14235. Появилась мысль, что сервис запоминает отработанные MD5. Поэтому тестировал, каждый раз перезапуская сервис. Результат:

Binding 22

Waiting for data...Verifying 1BFFFFFFD1FFFFFF8B50

00133337

New salt 508bd11b

Next port 14235

Binding 14235

Waiting for data...Received packet from 127.0.0.1:43614

Data:

3 14235 27

Next port 23038

Binding 23038

Waiting for data...4

Опять новый порт. Здесь я начал думать, что цепочка портов может оказаться длинной…

На самом деле следующий за этим порт (31841) оказался последним. Спустя некоторое время работы с gdb и дизассемблированным кодом и различных тестов обнаружил, что появился файл «/home/task/.ssh/authorized_keys».

Далее обнаружить причину появления файла стало вопросом времени, что записывается в этот файл тоже. В файл в итоге записываются данные пакета, отправленного в след за первым на последний открывшийся порт (если непонятно, в скрипте ниже будет видно).

Дальше генерация RSA ключей и отправка публичного.

Затем авторизация на сервере по SSH, поиск и получение флага.

В процессе применения у меня сработала только третья сгенерированная MD5-сумма. Уже после сдачи задания по результатам реверса выяснил, что на самом деле третья сумма будет срабатывать всегда (точнее до истечения некоего счетчика). Для постоянного срабатывания суммы необходимо, чтобы передаваемое в первых 4 байтах данных пакета (от которого считается MD5) целое число типа int было отрицательным, то есть первый бит четвертого байта был установлен (обратный порядок байт).

Ниже скрипт, использовавшийся для передачи ключа RSA, при решении задачи.

import socket

import time

import SocketServer

import select


d = ['\x1b\xd1\x8bP\x00\x00\x00\x00', '\x16\xbc\xf9 \x00\x00\x00\x00','"\xa5I\x90\x00\x00\x00\x00\x00\x00']

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)

print "Send 1"

s.sendto(d[0], ("95.216.185.52", 5432))

time.sleep(0.2)

print "Send 2"

s.sendto(d[1], ("95.216.185.52", 5432))

time.sleep(0.2)

print "Send 3"

s.sendto(d[2], ("95.216.185.52", 5432))

time.sleep(0.2)

print "Send 4"

s.sendto("\x00", ("95.216.185.52", 41357))

time.sleep(0.2)

print "Send 5"

s.sendto("\x04", ("95.216.185.52", 42381))

# for i in range(256):

time.sleep(0.2)

print "Send 6"

s.sendto("\x02", ("95.216.185.52", 28709))

# Read key

with open("ssh_key.txt", "rb") as fr:

 data = fr.read()


print len(data)

print "Send 7"

s.sendto(data, ("95.216.185.52", 28709))

print s.recvfrom(1500)

s.close()

При успешном выполнении получаем «OK» в ответ.

На деле, как я написал, лишним оказалось отправлять первую и вторую MD5-сумму. Также думаю, что не все решил из требуемого, просто подобралось.

Не думал, что получу инвайт, почти 40 часов прошло со старта задания до момента, когда я отправил флаг. Спасибо.

Разборы задач

Пошаговые инструкции по решению задач соревнований
и квестов.

ZeroNights Quest 2018

bartimaeous@mail.ru

Музыка и слова народные,
исполняет admin@reverseboom.ru
Ru