Гайд по реверсу клиент-серверного apk на примере задания NeoQUEST-2020
Сегодня у нас насыщенная программа (еще бы, столько областей кибербезопасности за раз!): рассмотрим декомпиляцию Android-приложения, перехватим трафик для получения URL-адресов, пересоберем apk без исходного кода, поработаем криптоаналитиками и многое другое:)
Согласно легенде NeoQUEST-2020, герой нашел старые детали робота, которые необходимо использовать для получения ключа. Let’s get it started!
1. Реверсим apk
Итак, перед нами то немногое, что удалось извлечь из полуразобранного робота — apk-приложение, которое каким-то образом должно помочь нам получить ключ. Сделаем самое очевидное: запустим apk и посмотрим на его функционал. Более чем минималистичный интерфейс приложения сомнений не оставляет — это кастомный файловый клиент FileDroid, позволяющий скачать файл с удаленного сервера. Окей, выглядит несложно. Подключаем телефон к Интернету, делаем пробную попытку скачивания (сразу key.txt — ну, а вдруг?) — безуспешно, файл на сервере отсутствует.
Переходим к следующему по уровню сложности мероприятию — декомпилируем apk c помощью JADX и анализируем исходный код приложения, который, к счастью, совсем не обфусцирован. Наша текущая задача — понять, какие файлы предлагает удаленный сервер для скачивания, и выбрать из них тот самый, с ключом.
Начинаем с класса com.ctf.filedroid.MainActivity, содержащего пока самый интересный для нас метод onClick (), в котором обрабатывается нажатие на кнопку «Download». Внутри этого метода дважды происходит обращение к классу ConnectionHandler: cперва вызывается метод ConnectionHandler.getToken (), а только затем — ConnectionHandler.getEncryptedFile (), в который передается имя файла, запрошенного пользователем.
Ага, то есть сначала нам нужен токен! Разберемся чуть подробнее с процессом его получения.
Метод ConnectionHandler.getToken () принимает на вход две строки, а затем отправляет GET-запрос, передавая эти строки в качестве параметров «crc» и «sign». В ответ сервер присылает данные в JSON-формате, из которых наше приложение извлекает токен доступа и использует его для скачивания файла. Это всё, конечно, хорошо, но что за «crc» и «sign»?
Чтобы понять это, двигаемся дальше в сторону класса Checks, любезно предоставляющего методы badHash () и badSign (). Первый из них подсчитывает контрольную сумму от classes.dex и resources.arsc, конкатенирует эти два значения и оборачивает в Base64 (обратим внимание на флаг 10 = NO_WRAP | URL_SAFE, вдруг пригодится). А что же второй метод? А он делает тоже самое с SHA-256 fingerprint«ом подписи приложения. Эх, похоже, что FileDroid не очень-то жаждет быть пересобранным :(
Окей, допустим, что токен получили. Что дальше? Передаем его на вход метода ConnectionHandler.getEncryptedFile (), который присовокупляет к токену имя запрошенного файла и формирует еще один GET-запрос, на этот раз с параметрами «token» и «file». Сервер в ответ (судя по названию метода) отправляет зашифрованный файл, который сохраняется на /sdcard/.
Итак, подведем небольшой промежуточный итог: у нас есть две новости, и… обе плохие. Во-первых, FileDroid не очень поддерживает наше рвение к модификации apk (происходит проверка контрольной суммы и подписи), а во-вторых, полученный от сервера файл обещает быть зашифрованным.
Ладно, будем решать проблемы по мере их поступления, а сейчас наша основная проблема состоит в том, что мы всё еще не знаем, какой файл нам нужно скачать. Однако в процессе изучения класса ConnectionHandler мы не могли не заметить, что прямо между методами getToken () и getEncryptedFile () разработчики FileDroid забыли еще один очень соблазнительный метод под говорящим названием getListing (). Значит, сервер такой функционал поддерживает… Кажется, это то, что нужно!
Для получения листинга нам потребуются уже известные «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 так себе.
При более пристальном рассмотрении становится понятно, что всё это не слишком приятное содержимое метода можно заменить на привычный 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 сохранен в виде закодированного массива байтов, а сама функция формирует из этих байтов строку и возвращает её.
Хорошо. Но далее строка передается в функцию 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): файловый клиент проверяет, что «зашитый» в приложение сертификат соответствует серверу, с которым осуществляется взаимодействие. Теперь понятно, почему контролируется целостность ресурсов приложения!
Однако в перехваченном трафике мы можем увидеть хотя бы доменное имя сервера, к которому обращается файловый клиент, а значит, надежда расшифровать URL-адреса остается!
Вернемся к функции 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:
Кажется, никто никогда еще так не радовался ARMag3dd0n«у! Дело за малым: последовательно декодируем из base64 URL-адреса и ксорим с найденным ключом. Но…, а если бы это был не XOR, а самопальный перестановочный шифр, который не подберешь и со ста попыток?
3. Пересобираем apk с помощью Frida
В рамках этого write-up«а рассмотрим более безболезненный (и, на наш взгляд, более красивый) способ решения — с помощью фреймфорка Frida, который позволит в run-time исполнить произвольные методы apk-приложения с нужными нам аргументами. Для этого потребуется телефон с root-правами или эмулятор. Предполагаем следующий план действий:
- Установка компонентов Frida на ПК и подопытный телефон.
- Восстановление URL-адресов, соответствующих запросам на получение токена или листинга, и скачивание файла (с помощью Frida).
- Извлечение значений контрольной суммы и подписи оригинального приложения.
- Получение листинга файлов, хранящихся на сервере, и выявление нужного файла.
- Скачивание и расшифрование файла.
Для начала уточним взаимоотношения рутованного телефона и apk. Устанавливаем приложение, запускаем, но файловый клиент не желает полноценно загрузиться, лишь мигает и закрывается. Проверяем сообщения через logcat — да, так и есть, FileDroid уже чувствует неладное и сопротивляется, как может.
Вновь обращаемся к классу MainActivity и обнаруживаем, что в onCreate () вызывается метод doChecks (), который и вывел в лог приведенные ошибки:
Кроме того, в onResume () также проверяется, открыт ли типичный для Frida порт:
Наш файловый клиент оказывается немного нетолерантным к отладке, руту и самой Frida. Такое противодействие абсолютно не входит в наши планы, поэтому получаем smali-код приложения с помощью утилиты apktool, открываем в любом текстовом редакторе файл MainActivity.smali, находим метод onCreate () и превращаем вызов doChecks () в безобидный комментарий:
Затем лишаем метод suicide () возможности действительно завершить работу приложения:
Далее снова соберем наше слегка улучшенное приложение с помощью 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
Переустанавливаем приложение на телефоне, запускаем его — ура, загрузка проходит без происшествий, лог чист!
Переходим к установке фреймворка 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 на мобильном устройстве и «цепляемся» нашим скриптом к соответствующему процессу:
4. Получаем листинг файлов на сервере
Наконец-то удаленный сервер стал для нас чуть ближе! Теперь нам известно, что сервер поддерживает запросы по следующим путям:
- filedroid.neoquest.ru/api/verifyme? crc={crc}&sign={sign}
- filedroid.neoquest.ru/api/list_post_apocalyptic_collection? crc={crc}&sign={sign}
- 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, например, тут:
Для того, чтобы выполнить аналогичную процедуру для подписи приложения, в окне JADX переходим в раздел «APK Signature», копируем значение «SHA-256 Fingerprint» и кодируем его в base64 как байтовый массив:
Важно: в оригинальном apk base64-кодирование осуществляется с флагом URL_SAFE, т.е. вместо символов »+» и »/» используются »–» и »_» соответственно. Необходимо убедиться, что при самостоятельном кодировании это будет тоже соблюдаться. Для этого при кодировании онлайн можно заменить используемый алфавит с «ABCDEFGHIJKLMNOPQRSTUVWXYZabcde fghijklmnopqrstuvwxyz0123456789+/» на «ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl mnopqrstuvwxyz0123456789–_», а при использовании скрипта на python3 — просто вызвать функцию base64.urlsafe_b64encode ().
Наконец-то у нас есть все составляющие успешного получения листинга файлов:
- filedroid.neoquest.ru/api/list_post_apocalyptic_collection? crc={crc}&sign={sign}
- crc: MTI3Njk0NTgxMzI4MTQxNjY1ODM=
- sign: HeiTSPWdCuhpbmVxqLxW-uhrozfG_QWpTv9ygn45eHY=
Выполняем GET-запрос — и ура, листинг наш! Причем название одного из файлов говорит само за себя — «open-if-you-want-to-escape» — похоже, он-то нам и нужен.
Далее запросим одноразовый токен доступа и скачаем файл:
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)
Открываем скачанный файл и вспоминаем об одном небольшом обстоятельстве, оставленном нами на потом:
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!
Это непростое задание требовало от участников знаний и навыков сразу в нескольких сферах инфобеза, и мы рады тому, что большинство соревнующихся успешно с ним справилось!
Надеемся, что те, кому чуть-чуть не хватило времени или знаний до получения ключа, теперь тоже успешно пройдут аналогичные таски в любом CTF:)