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

Гайд по реверсу клиент-серверного 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.<init>(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-правами или эмулятор. Предполагаем следующий план действий:

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

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



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



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



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



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



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

cd "C:Program FilesJavajdk-14bin" .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. Получаем листинг файлов на сервере


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

  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, например, тут :‬‬



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



Важно: в оригинальном 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» – похоже, он-то нам и нужен.



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

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, <token>);     console.log(result); }); 

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



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

Где кванты и ИИ становятся искусством?

На перекрестке науки и фантазии — наш канал

Подписаться