Содержание статьи
Немного словоблудия
Представь, что тебе в руки попал семпл малвари. Ты запускаешь его в эмуляторе и пытаешься проанализировать поведение. Но оказывается, что в эмуляторе он работает совсем не так, как на реальном устройстве, и никакой подозрительной активности не проявляет: малварь умеет определять, что находится в эмулируемой среде.
Ты об этом догадываешься и поэтому решаешь запустить малварь под дебаггером (предварительно распаковав зловред и добавив строчку android:debuggable="true"
в AndroidManifest.xml
), чтобы определить, как именно малварь производит проверку на эмулятор. И снова проблема: она умеет определять, что работает под отладчиком, и просто падает при запуске. Следующий шаг: статический анализ кода с помощью декомпилятора и дизассемблера, правка с целью вырезать куски, проверяющие наличие отладчика и эмулируемой среды, снова правка кода по причине ошибки и все в таком духе.
А теперь представь, что у тебя есть инструмент, позволяющий прямо во время работы приложения отключить все эти проверки, просто переписав проверочные функции на JavaScript. Никаких дизассемблерных листингов smali, никаких правок низкоуровневого кода, никаких пересборок приложения; ты просто подключаешься к работающему приложению, находишь нужную функцию и переписываешь ее тело. Недурно, не так ли?
Frida
Frida — это так называемый Dinamic Instrumentation Toolkit, то есть набор инструментов, позволяющих на лету внедрять собственный код в другие приложения. Ближайшие аналоги Frida — это знаменитый Cydia Substrate для iOS и Xposed Framework для Android, те самые фреймворки, благодаря которым появились твики. Frida отличается от них тем, что нацелена на быструю правку кода в режиме реального времени. Отсюда и язык JavaScript вместо Objective-C или Java, и отсутствие необходимости упаковывать «твики» в настоящие приложения. Ты просто подключаешься к процессу и меняешь его поведение, используя интерактивную JS-консоль (ну или отдаешь команду на загрузку ранее написанного скрипта).
Frida умеет работать с приложениями, написанными для всех популярных ОС, включая Windows, Linux, macOS, iOS и даже QNX. Мы же будем использовать ее для модификации приложений под Android.
Итак, что тебе нужно:
- Машина под управлением Linux. Можно и Windows, но, когда занимаешься пентестом приложений для Android, лучше использовать Linux.
- Установленный adb. В Ubuntu/Debian/Mint устанавливается командой
sudo apt-get install adb
. - Рутованный смартфон или эмулятор на базе Android 4.2 и выше. Frida умеет работать и на нерутованном, но для этого тебе придется модифицировать APK подопытного приложения. Это просто неудобно.
Для начала установим Frida:
$ sudo pip install frida
Далее скачаем сервер Frida, который необходимо установить на смартфон. Сервер можно найти на GitHub, его версия должна точно совпадать с версией Frida, которую мы установили на комп. На момент написания статьи это была 10.6.55. Скачиваем:
$ cd ~/Downloads
$ wget https://github.com/frida/frida/releases/download/10.6.55/frida-server-10.6.55-android-arm.xz
$ unxz frida-server-10.6.55-android-arm.xz
Подключаем смартфон к компу, включаем отладку по USB (Настройки → Для разработчиков → Отладка по USB) и закидываем сервер на смартфон:
$ adb push frida-server-10.6.55-android-arm /data/local/tmp/frida-server
Теперь заходим на смартфон с помощью adb shell
, выставляем нужные права на сервер и запускаем его:
$ adb shell
> su
> cd /data/local/tmp
> chmod 755 frida-server
> ./frida-server
Первые шаги
Ок, Frida установлена на комп, сервер запущен на смартфоне (не закрывай терминал с запущенным сервером). Теперь надо проверить, все ли работает как надо. Для этого воспользуемся командой frida-ps
:
$ frida-ps -U
Команда должна вывести все процессы, запущенные на смартфоне (флаг -U
означает USB, без него Frida выведет список процессов на локальной машине). Если ты видишь этот список, значит, все хорошо и можно приступать к более интересным вещам.
Для начала попробуем выполнить трассировку системных вызовов. Frida позволяет отследить обращения к любым нативным функциям, в том числе системные вызовы ядра Linux. Для примера возьмем системный вызов open()
, который используется для открытия файлов на чтение и/или запись. Запустим трассировку Телеграма:
$ frida-trace -i "open" -U org.telegram.messenger
Возьми телефон и немного потыкай интерфейс Телеграма. На экран должны посыпаться сообщения примерно следующего содержания:
open(pathname="/data/user/0/org.telegram.messenger/shared_prefs/userconfing.xml", flags=0x241)
Эта строка означает, что Телеграм открыл файл userconfig.xml
внутри каталога shared_prefs
в своем приватном каталоге. Каталог shared_prefs
в Android используется для хранения настроек, поэтому нетрудно догадаться, что файл userconfig.xml
содержит настройки приложения. Еще одна строка:
open(pathname="/storage/emulated/0/Android/data/org.telegram.messenger/cache/223023676_121163.jpg", flags=0x0)
Здесь все еще проще. Телеграм агрессивно кеширует загруженные данные, поэтому для отображения картинки он взял ее из кеша.
open(pathname="/data/user/0/org.telegram.messenger/shared_prefs/stats.xml", flags=0x241)
Еще один файл в каталоге shared_prefs
. Судя по всему, какая-то статистика использования.
open(pathname="/dev/ashmem", flags=0x2)
Выглядит странно, не так ли? На самом деле все просто. Файл /dev/ashmem
виртуальный, он используется для обмена данными между процессами и системой с помощью IPC-механизма Binder. Проще говоря, эта строка означает, что Телеграм обратился к Android, чтобы выполнить какую-то системную функцию или получить информацию. Такие строки можно смело пропускать.
Пишем код
Мы можем перехватывать обращения к любым другим системным вызовам, например connect()
, который используется для подключения к удаленным хостам:
$ frida-trace -i "connect" -U com.yandex.browser
Но вывод в данном случае будет не особо информативным:
2028 ms connect(sockfd=0x90, addr=0x94e86374, addrlen=0x6e)
2034 ms connect(sockfd=0x90, addr=0x94e86374, addrlen=0x6e)
Причина в том, что второй аргумент системного вызова connect()
— это указатель на структуру sockaddr
. Frida не умеет ее парсить и поэтому выводит адрес участка памяти, в которой хранится эта структура. Но! Мы можем изменить код, который выполняет Frida при перехвате системного вызова или функции. А это значит, что мы можем пропарсить sockaddr
сами!
Когда ты запускал команду frida-trace
, то наверняка заметил примерно такую строку:
connect: Auto-generated handler at "/home/j1m/__handlers__/libc.so/connect.js"
Это автоматически сгенерированный код хука, который Frida выполняет, когда подопытное приложение обращается к указанной функции. Именно он ответственен за вывод тех малоинформативных строк, которые мы увидели. По умолчанию код выглядит так:
onEnter: function (log, args, state) {
log("connect(" +
"sockfd=" + args[0] +
", addr=" + args[1] +
", addrlen=" + args[2] +
")");
},
Видно, что хук просто выводит второй аргумент как есть. Но мы знаем, что второй аргумент системного вызова connect()
— это указатель на структуру sockaddr, то есть просто адрес в памяти. Сама структура sockaddr имеет следующий вид:
struct sockaddr {
unsigned short sa_family; // address family, AF_xxx
char sa_data[14]; // 14 bytes of protocol address
};
А в случае с сокетами типа AF_INET, которые нам и нужны, такой:
struct sockaddr_in {
short sin_family; // e.g. AF_INET, AF_INET6
unsigned short sin_port; // e.g. htons(3490)
struct in_addr sin_addr; // see struct in_addr, below
char sin_zero[8]; // zero this if you want to
};
struct in_addr {
unsigned long s_addr; // load with inet_pton()
};
То есть сам IP-адрес находится в этой структуре по смещению 4 байта (short sin_family + unsigned short sin_port) и занимает 8 байт (unsigned long). Это значит, что нам нужно добавить к исходному адресу 4, затем прочитать 8 байт по полученному адресу и пропарсить их, чтобы получить текстовый IP-адрес с точками. Сделаем это, заменив изначальный хук таким:
onEnter: function (log, args, state) {
var addr = args[1].add("4")
var ip = Memory.readULong(addr)
var ipString = [ip & 0xFF, ip >>> 8 & 0xFF, ip >>> 16 & 0xFF, ip >>> 24].join('.')
log("connect(" +
"sockfd=" + args[0] +
", addr=" + ipString +
", addrlen=" + args[2] +
")");
},
Обрати внимание, что мы парсим адрес, начиная с конца, то есть разворачиваем его. Это необходимо, так как все современные процессоры ARM используют little-endian порядок байтов. Также обрати внимание на класс Memory
и метод add()
, это части API Frida.
Сохраняем файл и вновь запускаем frida-trace
:
connect(sockfd=0xbb, addr=173.194.222.139, addrlen=0x10)
connect(sockfd=0xba, addr=74.125.205.94, addrlen=0x10)
Вуаля. Правда, есть один нюанс. Так как наш код не умеет различать сокеты типа AF_UNIX, AF_INET и AF_INET6 и все их интерпретирует как AF_INET, иногда он будет выводить несуществующие адреса. То есть он будет пытаться парсить имя файла сокета AF_UNIX и выводить его как IP (или пытаться вывести IPv6-адрес как адрес IPv4). Отбраковать такие адреса очень легко, обычно они идут подряд и часто повторяются. В моем случае это был адрес 101.118.47.115
.
Внедряемся
Конечно же, возможности Frida гораздо шире, чем перехват обращений к нативным функциям и системным вызовам. Если мы взглянем на упоминавшийся API Frida, то увидим, что в нем есть объект Java. С его помощью мы можем перехватывать обращения к любым Java-объектам и методам, а значит, изменить практически любой аспект поведения любого приложения для Android (в том числе написанного на Kotlin).
Начнем с простого — попробуем узнать обо всех загруженных в приложение классах. Создай новый файл (пусть он называется enumerate.js
) и добавь в него следующие строки:
Java.perform(function() {
Java.enumerateLoadedClasses({
onMatch: function(className) {
console.log(className);
},
onComplete: function() {}
});
});
Это очень простой код. Сначала мы вызываем метод Java.perform()
, означающий, что мы хотим подключиться к виртуальной машине Java (или Dalvik/ART в случае Android). Далее мы вызываем метод Java.enumerateLoadedClasses()
и передаем ему два колбэка: onMatch()
будет выполнен при «обнаружении» класса, onComplete()
— в самом конце (как видно, нам этот колбэк не нужен, и мы оставляем его пустым).
Запускаем:
$ frida -U -l enumerate.js org.telegram.messenger
И видим на экране длинный, кажущийся бесконечным список классов, некоторые из них — часть самого приложения, но подавляющее большинство — стандартные классы фреймворка Android (Android загружает весь фреймворк в каждый процесс в режиме copy-on-write).
На самом деле нам этот список не особо интересен. Намного интереснее то, что в любой из этих классов можно внедрить свой код, а если быть точным — переписать тело любого метода любого из этих классов. Для примера возьмем такой код:
Java.perform(function () {
var Activity = Java.use("android.app.Activity");
Activity.onResume.implementation = function () {
console.log("onResume() got called!");
this.onResume();
};
});
Сначала мы используем Java.use()
, чтобы получить объект-обертку для работы с классом android.app.Activity
. Затем мы переписываем его метод onResume()
, вызывая в конце оригинальный метод (this.onResume
).
Те, кто знаком с разработкой приложений для Android, должны знать, что класс Activity
предназначен для создания «экранов» приложения. Он имеет множество методов, один из которых называется onResume()
. На самом деле это колбэк, который вызывается во время создания экрана, а также при возврате на него.
Если ты загрузишь данный скрипт во Frida, запустишь Телеграм, затем выйдешь из него, затем снова откроешь, то заметишь, что при каждом возврате в Телеграм в терминале будет появляться сообщение «onResume() got called!».
Точно таким же образом мы можем перехватывать нажатия на кнопки:
Java.perform(function () {
MainActivity.onClick.implementation = function (v) {
consle.log('onClick');
this.onClick(v);
}
});
А вот пример логирования всех URL, к которым обращается приложение:
Java.perform(function() {
var httpclient = Java.use("com.squareup.okhttp.v_1_5_1.OkHttpClient");
httpclient.open.overload("java.net.URL").implementation = function(url) {
console.log("request url:");
console.log(url.toString());
return this.open(url);
}
});
В данном случае мы внедряемся в очень популярную библиотеку OkHttp и переписываем ее метод okHttpClient.open()
. Остальное должно быть ясно.
Frida CodeShare
У Frida есть официальный репозиторий скриптов, в котором можно найти такие полезности, как fridantiroot — комплексный скрипт, позволяющий отключить проверки на root, Universal Android SSL Pinning Bypass — обход SSL Pinning, Alert On MainActivity — пример кода, который реализует полноценное диалоговое окно Android на JavaScript.
Любой из этих скриптов можно запустить без предварительного скачивания с помощью такой команды:
$ frida --codeshare dzonerzy/fridantiroot -U -f com.example.vulnapp
Ломаем CrackMe
А теперь давай попробуем взломать что-то реальное. На просторах интернета можно найти множество разных CrackMe. Возьмем первый попавшийся. Точнее, первый из пяти опубликованных в данном репозитории. Crackme-one.apk записывает файл в свой приватный каталог, а наша задача — вытащить содержимое этого файла. Сразу скажу, что существует масса способов сделать это за двадцать секунд, но в то же время это хороший пример, чтобы понять, как работать с Frida.
Итак, скачиваем и устанавливаем приложение:
$ wget https://www.dropbox.com/s/mrjnme2xiv45j4g/crackme-one.apk
$ adb install crackme-one.apk
Нам предлагают нажать кнопку для записи файла либо ввести ответ для проверки. Очевидно, чтобы взломать этот CrackMe, мы должны перехватить управление в момент записи файла. Но как это сделать? На самом деле очень просто. Большинство приложений для Android используют для записи данных либо класс java.io.OutputStream
, либо класс java.io.OutputStreamWriter
. У каждого из них есть метод write()
, который и отвечает за запись файла. Нам необходимо лишь подменить его на свою реализацию и вывести на экран первый аргумент, который содержит либо массив байтов, либо строку:
Java.perform(function () {
var os = Java.use("java.io.OutputStreamWriter");
os.write.overload('java.lang.String', 'int', 'int').implementation = function (string, off, len) {
console.log(string)
this.write(string, off, len);
};
});
Запускаем:
$ frida -U -f com.reoky.crackme.challengeone -l outputstream_write.js --no-pause
Вуаля, на экране появляется строка
poorly-protected-secret
Отмечу три момента:
- В этот раз мы использовали метод
overload()
, так как класс OutputStreamWriter реализует сразу три метода write() с разным набором аргументов. - Мы использовали опцию
--no-pause
, которая нужна, если мы хотим выполнить холодный старт приложения и при этом не хотим, чтобы Frida остановила приложение в самом начале. - На самом деле взломать этот CraсkMe можно было бы, просто перейдя в его приватный каталог и прочитав файл (это возможно, так как у нас рутованный смартфон) либо путем декомпиляции приложения (текст лежит в открытом виде). Здесь, однако, есть нюанс: если бы CrackMe хранил строку в зашифрованном виде и расшифровывал ее только перед записью, декомпиляция была бы бесполезна (ну, по крайней мере до тех пор, пока ты не извлек бы ключ шифрования и не написал скрипт расшифровки).
Выводы
Frida — очень мощный инструмент, с помощью которого можно сделать с подопытным приложением практически все, что угодно. Но это инструмент не для всех, он требует знания JavaScript, понимания принципов работы Android и приложений для него. Так что, если ты рядовой скрипт-кидди, тебе остается довольствоваться автоматизированными инструментами, созданными на основе Frida, например appmon.