Гайд по реверсу клиент-серверного apk на примере задания NeoQUEST-2020

f07jvfe9r6534tbgp9bwdruryvy.jpeg


Сегодня у нас насыщенная программа (еще бы, столько областей кибербезопасности за раз!): рассмотрим декомпиляцию Android-приложения, перехватим трафик для получения URL-адресов, пересоберем apk без исходного кода, поработаем криптоаналитиками и многое другое:)
Согласно легенде NeoQUEST-2020, герой нашел старые детали робота, которые необходимо использовать для получения ключа. Let’s get it started!

1. Реверсим apk


Итак, перед нами то немногое, что удалось извлечь из полуразобранного робота — apk-приложение, которое каким-то образом должно помочь нам получить ключ. Сделаем самое очевидное: запустим apk и посмотрим на его функционал. Более чем минималистичный интерфейс приложения сомнений не оставляет — это кастомный файловый клиент FileDroid, позволяющий скачать файл с удаленного сервера. Окей, выглядит несложно. Подключаем телефон к Интернету, делаем пробную попытку скачивания (сразу key.txt — ну, а вдруг?) — безуспешно, файл на сервере отсутствует.

b4kfz3wd44ev6ckfy664ba0ugho.png

Переходим к следующему по уровню сложности мероприятию — декомпилируем apk c помощью JADX и анализируем исходный код приложения, который, к счастью, совсем не обфусцирован. Наша текущая задача — понять, какие файлы предлагает удаленный сервер для скачивания, и выбрать из них тот самый, с ключом.

Начинаем с класса com.ctf.filedroid.MainActivity, содержащего пока самый интересный для нас метод onClick (), в котором обрабатывается нажатие на кнопку «Download». Внутри этого метода дважды происходит обращение к классу ConnectionHandler: cперва вызывается метод ConnectionHandler.getToken (), а только затем — ConnectionHandler.getEncryptedFile (), в который передается имя файла, запрошенного пользователем.

m566byk1sozhodekerf1ggpf1hc.png

Ага, то есть сначала нам нужен токен! Разберемся чуть подробнее с процессом его получения.
Метод ConnectionHandler.getToken () принимает на вход две строки, а затем отправляет GET-запрос, передавая эти строки в качестве параметров «crc» и «sign». В ответ сервер присылает данные в JSON-формате, из которых наше приложение извлекает токен доступа и использует его для скачивания файла. Это всё, конечно, хорошо, но что за «crc» и «sign»?

yngylgy7dstfocghhk0w_eufgcg.png

Чтобы понять это, двигаемся дальше в сторону класса Checks, любезно предоставляющего методы badHash () и badSign (). Первый из них подсчитывает контрольную сумму от classes.dex и resources.arsc, конкатенирует эти два значения и оборачивает в Base64 (обратим внимание на флаг 10 = NO_WRAP | URL_SAFE, вдруг пригодится). А что же второй метод? А он делает тоже самое с SHA-256 fingerprint«ом подписи приложения. Эх, похоже, что FileDroid не очень-то жаждет быть пересобранным :(

ljyjxoxvrlc8q3pjfxyrerya5ho.png

Окей, допустим, что токен получили. Что дальше? Передаем его на вход метода ConnectionHandler.getEncryptedFile (), который присовокупляет к токену имя запрошенного файла и формирует еще один GET-запрос, на этот раз с параметрами «token» и «file». Сервер в ответ (судя по названию метода) отправляет зашифрованный файл, который сохраняется на /sdcard/.

Итак, подведем небольшой промежуточный итог: у нас есть две новости, и… обе плохие. Во-первых, FileDroid не очень поддерживает наше рвение к модификации apk (происходит проверка контрольной суммы и подписи), а во-вторых, полученный от сервера файл обещает быть зашифрованным.

Ладно, будем решать проблемы по мере их поступления, а сейчас наша основная проблема состоит в том, что мы всё еще не знаем, какой файл нам нужно скачать. Однако в процессе изучения класса ConnectionHandler мы не могли не заметить, что прямо между методами getToken () и getEncryptedFile () разработчики FileDroid забыли еще один очень соблазнительный метод под говорящим названием getListing (). Значит, сервер такой функционал поддерживает… Кажется, это то, что нужно!

fkxzv1ac9ictxo7gy67ku_u44nq.png

Для получения листинга нам потребуются уже известные «crc» и «sign» — не проблема, мы уже знаем, откуда они берутся. Считаем значения, отправляем GET-запрос и … Так, стоп. А куда мы GET-запрос собираемся отправлять? Неплохо было бы сначала получить URL-адрес удаленного сервера. Эх, возвращаемся в MainActivity.onClick () и смотрим, как формируются аргументы netPath для вызова методов getToken () и getEncryptedFile ():

Method getSecureMethod = 
wat.class.getDeclaredMethod("getSecure", new Class[]{String.class});

// . . .

// netPath --> ConnectionHandler.getToken()
(String) getSecureMethod.invoke((Object) null, new Object[]{"fnks"})

// netPath --> ConnectionHandler. getEncryptedFile()
(String) getSecureMethod.invoke((Object) null, new Object[]{"qdkm"})


Странные буквосочетания «fnks» и «qdmk» вынуждают нас обратиться к результату декомпиляции метода wat.getSecure (). Спойлер: этот результат у JADX так себе.

-rihgizr3f3kvj0uge_1ekoaxsa.png

При более пристальном рассмотрении становится понятно, что всё это не слишком приятное содержимое метода можно заменить на привычный switch-case такого вида:

// . . .
switch(CODE)
{
    case «qdkm»: 
        r.2 = com.ctf.filedroid.x37AtsW8g.rlieh786d(2);
        break;
    case «tkog»: 
        r2 = com.ctf.filedroid.x37AtsW8g.rlieh786d(1);
        break;
    case «fnks»: 
        String r2 = com.ctf.filedroid.x37AtsW8g.rlieh786d(0);
	break;
}
java.lang.StringBuilder r1 = new java.lang.StringBuilder 
r1.(r2) 
java.lang.String r0 = r1.toString() 
java.lang.String r1 = radon(r0)
return r1 


Так как «fnks» и «qdmk» уже используются для получения токена и скачивания файла, то «tkog» должен давать URL, необходимый для запроса листинга доступных файлов на сервере. Кажется, появляется надежда дешево получить требуемый путь… В первую очередь посмотрим, как хранятся URL«ы в приложении. Открываем функцию com.ctf.filedroid.x37AtsW8g.rlieh786d () и видим, что каждый URL сохранен в виде закодированного массива байтов, а сама функция формирует из этих байтов строку и возвращает её.

yt4exza37qeytal-i9snbrlo4co.png

Хорошо. Но далее строка передается в функцию com.ctf.filedroid.wat.radon (), реализация которой вынесена в нативную библиотеку libae3d8oe1.so. Реверсить arm64? Хорошая попытка, FileDroid, но давай в другой раз?

2. Получаем URL-адреса сервера


Попробуем подойти с другой стороны: перехватить трафик, получить URL-адреса в открытом виде (а в качестве бонуса — еще и значения контрольной суммы и подписи!), сопоставить их байтовым массивам из com.ctf.filedroid.x37AtsW8g.rlieh786d () — может быть шифрование окажется обычным шифром Цезаря или XOR?… Тогда не составит труда восстановить третий URL-адрес и выполнить листинг.

Для перенаправления трафика можно использовать любой удобный прокси (Charles, fiddler, BURP и т.п.). Выполняем настройку переадресации на мобильном устройстве, устанавливаем соответствующий сертификат, проверяем, что перехват осуществляется успешно, и запускаем FileFroid. Пытаемся скачать произвольный файл и … видим «NetworkError». Вызвана эта ошибка наличием certificate-pinning (см. метод com.ctf.filedroid.ConnectionHandler.sendRequest): файловый клиент проверяет, что «зашитый» в приложение сертификат соответствует серверу, с которым осуществляется взаимодействие. Теперь понятно, почему контролируется целостность ресурсов приложения!

8ysuya2vqxuwxdzrmt9xsuajq7o.png

Однако в перехваченном трафике мы можем увидеть хотя бы доменное имя сервера, к которому обращается файловый клиент, а значит, надежда расшифровать URL-адреса остается!

im2w8l7u7b0optrada1cuhbxcge.png

Вернемся к функции com.ctf.filedroid.x37AtsW8g.rlieh786d () и отметим, что во всех массивах совпадают первые несколько десятков байт:

cArr[0] = new char[]{'K', 'S', 'Y', '5', 'E', 'R', 'Q', 'J', 'S', '0', 't', 'W', 'B', '2', 'w', 'k', 'N', 'j', '8', 'O', 'D', 'l', 'd', 'K', 'C', 'l', 'U', 'B', 'c', 'T', 'Q', '3', 'P', 'h', 'V', 'J', 'Q', 'R', 'F', 'L', 'U', 'R', '5', 'p', 'b', 'i', . . .};

cArr[1] = new char[]{'K', 'S', 'Y', '5', 'E', 'R', 'Q', 'J', 'S', '0', 't', 'W', 'B', '2', 'w', 'k', 'N', 'j', '8', 'O', 'D', 'l', 'd', 'K', 'C', 'l', 'U', 'B', 'c', 'T', 'Q', '3', 'P', 'h', 'V', 'J', 'Q', 'R', 'F', 'L', 'U', 'R', '5', 'p', 'b', 'j', . . .};

cArr[2] = new char[]{'K', 'S', 'Y', '5', 'E', 'R', 'Q', 'J', 'S', '0', 't', 'W', 'B', '2', 'w', 'k', 'N', 'j', '8', 'O', 'D', 'l', 'd', 'K', 'C', 'l', 'U', 'B', 'c', 'T', 'Q', '3', 'P', 'h', 'V', 'J', 'Q', 'R', 'F', 'L', 'U', 'R', '5', 'p', 'b', 'j', . . ., '='};


Кроме того, последний байт третьего массива намекает, что без base64 дело не обошлось. Попробуем декодировать и поксорить получившиеся байты с известной частью URL:

8omh1mtmtqzkcdy7mpqyemyiuzw.png

Кажется, никто никогда еще так не радовался ARMag3dd0n«у! Дело за малым: последовательно декодируем из base64 URL-адреса и ксорим с найденным ключом. Но…, а если бы это был не XOR, а самопальный перестановочный шифр, который не подберешь и со ста попыток?

3. Пересобираем apk с помощью Frida


В рамках этого write-up«а рассмотрим более безболезненный (и, на наш взгляд, более красивый) способ решения — с помощью фреймфорка Frida, который позволит в run-time исполнить произвольные методы apk-приложения с нужными нам аргументами. Для этого потребуется телефон с root-правами или эмулятор. Предполагаем следующий план действий:

  1. Установка компонентов Frida на ПК и подопытный телефон.
  2. Восстановление URL-адресов, соответствующих запросам на получение токена или листинга, и скачивание файла (с помощью Frida).
  3. Извлечение значений контрольной суммы и подписи оригинального приложения.
  4. Получение листинга файлов, хранящихся на сервере, и выявление нужного файла.
  5. Скачивание и расшифрование файла.


Для начала уточним взаимоотношения рутованного телефона и apk. Устанавливаем приложение, запускаем, но файловый клиент не желает полноценно загрузиться, лишь мигает и закрывается. Проверяем сообщения через logcat — да, так и есть, FileDroid уже чувствует неладное и сопротивляется, как может.

vn8s2bzyijssy664hsdgcxqnawc.png

Вновь обращаемся к классу MainActivity и обнаруживаем, что в onCreate () вызывается метод doChecks (), который и вывел в лог приведенные ошибки:

wsxli6hxrjmgqnnkuh6b5a7h8my.png

Кроме того, в onResume () также проверяется, открыт ли типичный для Frida порт:

-kdfkvyea8g0p6y-dkwxpk3flo4.png

Наш файловый клиент оказывается немного нетолерантным к отладке, руту и самой Frida. Такое противодействие абсолютно не входит в наши планы, поэтому получаем smali-код приложения с помощью утилиты apktool, открываем в любом текстовом редакторе файл MainActivity.smali, находим метод onCreate () и превращаем вызов doChecks () в безобидный комментарий:

z6r2rautxzhef-0prc9odxmj2ek.png

Затем лишаем метод suicide () возможности действительно завершить работу приложения:

iutiq6k6idg5_6h7wd1he2mjyem.png

Далее снова соберем наше слегка улучшенное приложение с помощью apktool и подпишем его, выполнив следующие команды (могут понадобиться права Администратора):

cd "C:\Program Files\Java\jdk-14\bin"
.\keytool -genkey -v -keystore filedroid.keystore -alias filedroid_alias -keyalg RSA -keysize 2048 -validity 10000
.\jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore  filedroid.keystore filedroid_patched.apk filedroid_alias
.\jarsigner -verify -verbose -certs filedroid_patched.apk


Переустанавливаем приложение на телефоне, запускаем его — ура, загрузка проходит без происшествий, лог чист!

qmu4k2ta7zji47msjnyhhq8ibqi.png

Переходим к установке фреймворка Frida на ПК и мобильное устройство:

$ sudo pip3 install frida-tools
$ wget https://github.com/frida/frida/releases/download/$(frida --version)/frida-server-$(frida --version)-android-arm.xz
$ unxz frida-server-$(frida --version)-android-arm.xz
$ adb push frida-server-$(frida --version)-android-arm /data/local/tmp/frida-server


Запускаем сервер фреймворка Frida на мобильном устройстве:

$ adb shell su -с "chmod 755 /data/local/tmp/frida-server"
$ adb shell su -с "/data/local/tmp/frida-server &"  


Подготавливаем простой скрипт get-urls.js, который вызовет wat.getSecure () для всех поддерживаемых серверов запросов:

Java.perform(function () 
{
     const wat = Java.use('com.ctf.filedroid.wat');
	console.log(wat.getSecure("fnks"));
	console.log(wat.getSecure("qdmk"));
	console.log(wat.getSecure("tkog"));
});


Запускаем FileDroid на мобильном устройстве и «цепляемся» нашим скриптом к соответствующему процессу:

pcirifn_grrejaapwi7s-7hg-xe.png

4. Получаем листинг файлов на сервере


Наконец-то удаленный сервер стал для нас чуть ближе! Теперь нам известно, что сервер поддерживает запросы по следующим путям:

  1. filedroid.neoquest.ru/api/verifyme? crc={crc}&sign={sign}
  2. filedroid.neoquest.ru/api/list_post_apocalyptic_collection? crc={crc}&sign={sign}
  3. filedroid.neoquest.ru/api/file? file={file}&token={token}


Для того, чтобы получить листинг доступных файлов, осталось подсчитать значения контрольной суммы и подписи оригинального приложения, а затем закодировать их в base64.

Сделать это позволит вот такой скрипт на python3:

Спойлер
import hashlib
import binascii
import base64
from asn1crypto import cms, x509
from zipfile import ZipFile


def get_info(apk):
    with ZipFile(apk, 'r') as zipObj:
        classes = zipObj.read("classes.dex")
        resources = zipObj.read("resources.arsc")
        cert = zipObj.read("META-INF/CERT.RSA")
        crc = "%s%s" % (get_crc(classes), get_crc(resources))
        return get_full_crc(classes, resources).decode("utf-8"), get_sign(cert).decode("utf-8")


def get_crc(file):
    crc = binascii.crc32(file) & 0xffffffff
    return crc


def get_full_crc(classes, resources):
    crc = "%s%s" % (get_crc(classes), get_crc(resources))
    return base64.urlsafe_b64encode(bytes(crc, "utf-8"))


def get_sign(file):
    pkcs7 = cms.ContentInfo.load(file)
    data = pkcs7['content']['certificates'][0].chosen.dump()
    sha256 = hashlib.sha256()
    sha256.update(data)
    return base64.urlsafe_b64encode(sha256.digest())  

get_info('filedroid.apk')


Вручную тоже можно. Любым удобным инструментом считаем CRC32 от classes.dex и resources.arsc (например, для Linux — стандартной утилитой crc32), получаем значения ‭1276945813‬ и 2814166583 соответственно, конкатенируем их (выйдет 12769458132814166583) и кодируем в base64, например, тут: ‬‬

wbbakmhm-8jmtyyudjhofelaz8s.png

Для того, чтобы выполнить аналогичную процедуру для подписи приложения, в окне JADX переходим в раздел «APK Signature», копируем значение «SHA-256 Fingerprint» и кодируем его в base64 как байтовый массив:

wxs9zgvtahyytsnvxis-k8lq8wg.png

Важно: в оригинальном apk base64-кодирование осуществляется с флагом URL_SAFE, т.е. вместо символов »+» и »/» используются »–» и »_» соответственно. Необходимо убедиться, что при самостоятельном кодировании это будет тоже соблюдаться. Для этого при кодировании онлайн можно заменить используемый алфавит с «ABCDEFGHIJKLMNOPQRSTUVWXYZabcde fghijklmnopqrstuvwxyz0123456789+/» на «ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl mnopqrstuvwxyz0123456789–_», а при использовании скрипта на python3 — просто вызвать функцию base64.urlsafe_b64encode ().

Наконец-то у нас есть все составляющие успешного получения листинга файлов:

  1. filedroid.neoquest.ru/api/list_post_apocalyptic_collection? crc={crc}&sign={sign}
  2. crc: MTI3Njk0NTgxMzI4MTQxNjY1ODM=
  3. sign: HeiTSPWdCuhpbmVxqLxW-uhrozfG_QWpTv9ygn45eHY=


Выполняем GET-запрос — и ура, листинг наш! Причем название одного из файлов говорит само за себя — «open-if-you-want-to-escape» — похоже, он-то нам и нужен.

bkk4vzawktgg1ilhn1x06q90ahq.png

Далее запросим одноразовый токен доступа и скачаем файл:

import requests

response = requests.get('https://filedroid.neoquest.ru/api/verifyme', 
    		params={    'crc': 'MTI3Njk0NTgxMzI4MTQxNjY1ODM=', 
'sign': HeiTSPWdCuhpbmVxqLxW-uhrozfG_QWpTv9ygn45eHY=},
verify=False)
    
token = response.json()['token']
print(token)
response = requests.get('https://filedroid.neoquest.ru/api/file', 
params={'token': token, 'file': '0p3n1fuw4nt2esk4p3.jpg'}, verify=False)

with open("0p3n1fuw4nt2esk4p3.jpg", 'wb') as fd:
        fd.write(response.content)


Открываем скачанный файл и вспоминаем об одном небольшом обстоятельстве, оставленном нами на потом:

ktdcrzjrng635moxnmt5_phu2nu.png

5. Добавим щепоточку криптографии…


Эх, рановато мы отложили FileDroid. Снова вернемся в JADX и посмотрим, не оставили ли разработчики файлового клиента чего-нибудь полезного для нас. Да, это тот случай, когда code cleanup явно не популярен: неиспользуемый метод decryptFile () спокойно ждет нашего внимания в классе ConnectionHandler. Что мы имеем?

Шифрование AES в режиме CBC, синхропосылка занимает первые 16 байт… Лень — двигатель прогресса, лучше снова воспользуемся Frida и расшифруем наш 0p3n1fuw4nt2esk4p3.jpg без лишних усилий. Вот только что передать в качестве ключ шифрования? Вариантов не так много, а с учетом наличия еще одного «забытого» метода savePlainFile (String file, String token) выбор очевиден.

Подготовим следующий скрипт decrypt.js (в качестве token укажем актуальное значение, например, 'HoHknc572mVpZESSQN1Xa7S9zOidxX1PMbykdoM1EXI='):

Java.perform(function () {
    const JavaString = Java.use('java.lang.String');
    const file_name = JavaString.$new('0p3n1fuw4nt2esk4p3.jpg');
    const ConnectionHandler = Java.use('com.ctf.filedroid.ConnectionHandler');
    const result = ConnectionHandler.savePlainFile(file_name, );
    console.log(result);
});


Помещаем зашифрованный файл 0p3n1fuw4nt2esk4p3.jpg на /sdcard/, запускаем FileDroid и инжектим скрипт decrypt.js с помощью Frida. После того, как скрипт отработает, на /sdcard/ появится файл plainfile.jpg. Открываем его и … just solved!

fb3-ahlzcqlnfjsromkmbz9eil0.png

Это непростое задание требовало от участников знаний и навыков сразу в нескольких сферах инфобеза, и мы рады тому, что большинство соревнующихся успешно с ним справилось!

Надеемся, что те, кому чуть-чуть не хватило времени или знаний до получения ключа, теперь тоже успешно пройдут аналогичные таски в любом CTF:)

© Habrahabr.ru