Анализ нативных библиотек в приложениях для Android

Анализ нативных библиотек в приложениях для Android

Лучшая практика защиты мобильных приложений — это перенос кода, который отвечает за криптографию, хранение ключей для API и проверку целостности системы, в нативные библиотеки. Это существенно увеличивает сложность анализа защищенности приложения, тем самым выдвигая все больше требований к исследователю. Но так ли это на самом деле?

Для начала нужно разобраться, что собой представляет Unicorn Engine. Это эмулятор процессора, он поддерживает множество архитектур и сам является мультиплатформенным. У Unicorn Engine в принципе нет сложных подсистем. Ты сам занимаешься разметкой памяти и загрузкой данных, эмулятор не понимает команды из std, поэтому их необходимо реализовывать самостоятельно или вообще пропускать.

Существует множество решений, которые способны трассировать команды на хостовую систему, загружать исполняемые файлы в память и многое другое, так зачем тогда использовать Unicorn Engine?

При исследовании нативных библиотек часто не нужно эмулировать работу всего процесса. Нам достаточно смоделировать работу какой-то конкретной функции, не используя AVD или полноценные эмуляторы Android/iOS, чтобы получить результат отдельно от основного процесса или устройства.

Хороший пример можно найти в моей прошлой статье, где был описан метод MITM-атаки на приложение с использованием Xposed.

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

Восстанавливать весь алгоритм достаточно трудоемко и требует большого количества знаний — как в криптографии, так и в Reverse Engineering. Здесь нам может помочь Unicorn Engine: определив, как передаются входящие параметры, мы можем проэмулировать работу искомой функции без понимания алгоритма ее работы.

В этой статье мы исследуем упрощенный вариант подписи данных.

Статья рассчитана на то, что ты знаешь, что такое регистры, как работает стек, и не теряешь сознание при виде ассемблерного кода.

Тестовый стенд

Для демонстрации атаки мы будем использовать самописное приложение для Android, которое считывает данные из полей ввода в JSON и генерирует подпись, используя некий алгоритм в нативной библиотеке. Наша цель — получить такую же подпись и научиться генерировать валидную подпись для любых данных. В реальной жизни этот JSON с подписью отправлялся бы на сервер, но здесь этот момент опускается.

В нативном приложении реализован некий кастомный алгоритм подписи. Его сложно назвать криптостойким, но для демонстрации он идеален: не очень объемный, но не слишком простой, как обычный XOR. Все необходимые исходники ты можешь найти на моем GitHub.

Также нам понадобится Android Studio и Android SDK с NDK, установленный Unicorn Engine и устройство или эмулятор для запуска. В этой статье я буду использовать AVD x86.

На устройстве (эмуляторе) должен находиться gdbserver, который можно найти по такому адресу:

Я обычно перемещаю его в /data/local/gdbserver на устройстве.

Собираем информацию

Начнем анализ с того, что загрузим наше приложение в Android Studio: File → Profile or Debug APK. Когда проект загрузится, нам нужно исправить Run/Debug Configurations: во вкладке Debugger переключить Debug type в Java. Если этого не сделать, то к приложению будет подключен отладчик из Android Studio и подключить свой мы уже не сможем.

Прежде чем начать, давай попробуем запустить приложение и ввести тестовые данные test/pass. Запишем полученную подпись, так как она нам еще пригодится.

Если приложение не имеет флага android:debuggable="true", то его нужно добавить, пересобрав приложение и отредактировав AndroidManifest с помощью apktool.

Реверс APK

Для начала найдем основной класс. Он находится в loony/com/nativeexample/MainActivity.

Видим в начале объявление кнопки и двух полей для заполнения.

Ниже происходит загрузка библиотеки и объявлен нативный метод .method public native magic(Ljava/lang/String;)[I, который принимает строку, а на выходе возвращает массив чисел. В том же классе есть функция .method private getHexString(I)Ljava/lang/String;, которая принимает массив чисел и возвращает hex-строку.

Посмотрим, что происходит при создании класса, и перейдем в onCreate.

Создается новый обработчик нажатий, в него передается экземпляр loony/com/nativeexample/MainActivity$1, перейдем туда. Нас интересуют функции, которые отвечают за действия, в нашем случае это только onClick.

В коде видно, что создается org/json/JSONObject;, считываются данные из loginField, passwordField и помещаются в JSONObject с ключами login и password соответственно.

JSONObject преобразовывается в строку и передается в нативную функцию.

Подключение отладчика

Чтобы подключить отладчик, нам нужно поставить брейк-пойнт в Android Studio до вызова magic(Ljava/lang/String;)[I. У себя в тестах я ставил точку останова еще в onCreate.

Запустим наше приложение в режиме отладки. Откроется эмулятор, и «Студия» подключит отладчик Java. Приложение остановлено. Управление находится в Android Studio.

Итак, теперь нужно найти PID процесса. Для этого подключимся к устройству через adb и запустим ps.

Теперь, когда у нас есть PID 12949, мы можем подключать к нему сервер GDB.

Здесь мы запускаем сервер на 5039-м порте и подключаем его к нашему процессу. Теперь в другой вкладке терминала пропишем передачу этого порта с эмулятора в основную систему.

Только после этого можно запускать GDB и подключать его к нашему девайсу. Путь к GDB будет такой:

Когда запускается отладчик, управление и команды передаются в его терминал. В начале строки у тебя должно отображаться (gdb), и все команды, которые будут ниже и имеют такую приставку, должны быть введены в терминал GDB.

Загрузятся библиотеки, и ты увидишь что-то вроде 0xaef07424 in __kernel_vsyscall (). У тебя адреса, в которых работает приложение, скорее всего, будут другими.

Этот ответ означает, что отладчик подключился и получил данные от сервера. Если библиотеки не загрузились, то повтори подключение снова. Не забывай, что в этот момент тот отладчик, который был подключен от имени Android Studio, тоже остановился. Управление сейчас находится у GDB.

Анализ функции

Теперь нам нужно определить, где находится исследуемая функция, что она использует, как передаются данные и как их потом можно получить. Для этого найдем функцию с помощью GDB.

Или можно запустить просто info function, но тогда будут отображены все функции, что не очень удобно.

Названия всех нативных функций собираются из приставки Java, названия пакета и названия функции, а весь regex начинается с ^. Сделаем поиск по началу имени пакета.

Итак, мы нашли нужную функцию. Ставим на нее брейк-пойнт (gdb) b *0xa96cb720 и запускаем выполнение командой (gdb) c. Теперь управление вернулось к Android Studio, где мы тоже запускаем выполнение программы. Переходим в эмулятор, вводим тестовые данные test/pass и нажимаем кнопку Calculate sign. Если управление вернулось к GDB и ты видишь Thread 1 "m.nativeexample" hit Breakpoint 1, 0xa96cb720, то все сделал верно.

Теперь посмотрим, что собой представляет функция. Но перед этим переключимся на Intel disassembly style, выполнив (gdb) set disassembly-flavor intel. Дальше запустим команду дизассемблирования (gdb) disassemble. Здесь нам нужно найти точку, после которой начинается основное тело функции — в нашем случае подсчет подписи. Кроме того, нужно постараться минимизировать количество кода для эмуляции, потому что чем больше кода мы эмулируем, тем больше проблем может возникнуть.

Ниже приведены лишь некоторые важные блоки. Не забывай, что я опустил часть кода, чтобы не перегружать статью.

Мы видим несколько функций GetStringUTFChars, которые необходимы для преобразования форматов строк. Дальше следует вызов внутренней функции getSize, которая получает размер строки. Этот размер передается в NewIntArray, где создается результирующий массив.

После этого мы видим цикл подсчета подписи. По адресу 0xa96cb8b9 — переход в 0xa96cb83b. Дальше в коде есть вызов преобразования массива и передача его на вывод. Так что все, что нам необходимо, заперто в цикле 0xa96cb83b 0xa96cb8b9. Соответственно, в 0xa96cb8be мы будем иметь сформированный возвращаемый массив, а по адресу 0xa96cb83b точно расположена входящая строка.

Для проверки этого предположения поставим точку останова в (gdb) b *0xa96cb83b и продолжим работу (gdb) c. Перед поиском точки инъекции входящих данных посмотрим на то, что делают 0xa96cb83b 0xa96cb845. Они перебирают по одному символу входящей строки, где один символ получается в результате eax+ecx*1.

Из этого можно сделать вывод, что в eax находится ссылка на начало строки, а в ecx — текущий индекс элемента. Заполнение eax происходит по адресу 0xa96cb83b путем считывания со стека позиции строки в памяти. Следовательно, чтобы словить момент, когда у нас будет ссылка на начало строки, нужно брать начало следующей команды 0xa96cb83e. Сделаем один шаг, чтобы перейти в нужный адрес (gdb) ni.

Теперь посмотрим, что у нас находится в eax. Сделаем вызов (gdb) x/s $eax, где x отображает данные из памяти по заданному адресу и в определенном формате, который задается через /s — формат строки, $eax — получение адреса из регистра eax. В итоге мы получаем команду отображения строки из памяти по адресу из eax.

Чтобы вставить свою строку, нам нужно переписать адрес 0xa10a1c70, с которого считывается строка и который находится в esi+0x40.

Что насчет считывания результата? Уберем точку останова, для этого выведем список поставленных точек (gdb) info break, найдем индекс нужной и выполним (gdb) del <index>. Теперь поставим останов после выхода из массива (gdb) b *0xa96cb8be и продолжим работу (gdb) с. Посмотрим, что находится в регистрах (gdb) i r.

Очевидно, что ecx — длина входящего или выходящего массива. Отобразим содержимое памяти остальных регистров. Мы пытаемся найти байт-массив из 34 элементов. Выполним (gdb) x/34x $edx, где после / стоит количество элементов, которые будут выведены, дальше тип и регистр. Больше информации можешь получить, выполнив (gdb) help x.

Забегая вперед, скажу, что GDB нам еще понадобится, так что терминал можешь не закрывать. А пока поговорим про базовые методы работы с Unicorn.

Unicorn 101

Установку для твоей системы можно посмотреть на официальном сайте. Я буду работать с версией библиотеки для Python, так как это сильно ускоряет и упрощает разработку решения, но можно использовать версию для любого языка, к примеру C, Java, Go, Ruby и даже FreePascal. Со всем перечнем доступных языков можно ознакомиться в репозитории на GitHub.

Теперь создаем пустой файл python и импортируем Unicorn.

Вторая строка может меняться в зависимости от архитектуры, которую ты собираешься эмулировать. А выбрать у Unicorn Engine есть из чего: здесь и привычный x86, и ARM, ARMv8, M68K, MIPS, и даже Sparc. Эмуляторы AVD приоритетно работают с архитектурой x86.

Дальше нужно создать эмулятор, передать архитектуру и разрядность системы.

Так как этот эмулятор буквально ничего не делает, кроме эмуляции кода, то размечать память и загружать все необходимые данные мы будем самостоятельно. Для этого нужно передать в функцию mem_map адрес, с которого начинать разметку, и размер блока, задаваемый в байтах. Пока ты этого не сделаешь, ты не сможешь загружать данные в эту часть памяти. Размечать память можно в любой адрес, но зачастую мы будем использовать адреса с рабочего приложения.

Теперь загрузим данные в память, для этого возьмем небольшой кусок ассемблерного кода из примера на GitHub. О том, как загрузить в память большие объемы данных или дампов, мы поговорим в разделе «Атаки».

Для работы с регистрами используются две функции: reg_read и reg_write. В качестве параметров для записи передается константа регистра и hex-число, но не больше разрядности системы. То есть если это 32-битная система, то это не больше четырех байтов. Для чтения нужна только константа регистра.

Чтобы отслеживать изменения и процесс работы системы, нужно добавить хуки на команды и работу с памятью; после написания эмулятора их можно удалить. Объявим функцию, которая принимает необходимые параметры: экземпляр эмулятора, адрес команды, размер и параметр от пользователя. О том, как работать с пользовательскими параметрами, я расскажу в разделе «Атаки».

Теперь все готово для того, чтобы запустить наш первый эмулятор. В качестве параметров передаются адреса начала и окончания эмуляции.

После этого, чтобы посмотреть на результат работы, выведем информацию с регистров.

Для работы с памятью используются команды mem_read и mem_write. О том, как записывать в память, мы поговорим позднее, а здесь я покажу, как можно считывать данные. Для этого в функцию mem_read передается адрес и количество байтов, которые необходимо считать, функция возвращает массив байтов. После этого можно запускать написанный код.

Больше примеров работы c Unicorn Engine ты можешь посмотреть в репозитории проекта.

Получение дампов из библиотеки

Как ты прочитал выше, нам нужно будет загрузить в память исполняемый код функции, которую мы собираемся эмулировать, и окружение. Это необходимо для вызовов и обращений, которые происходят внутри функции. Для начала получим дамп только нашей библиотеки, а все необходимое окружение загрузим уже в процессе эмуляции.

Сначала заново подключим отладчик, чтобы итерация цикла получения подписи ни разу не выполнялась до этого. Как это сделать, описано в разделе «Подключение отладчика». Мы будем эмулировать работу с 0xa96cb83b, так что поставим останов именно в точке (gdb) b *0xa96cb83b, чтобы получить окружение, соответствующее той же команде, с которой мы начинаем эмуляцию.

Продолжим работу программы. Введем тестовые данные test/pass и попадем в точку останова.

Для начала выведем текущее состояние регистров.

Теперь посмотрим, в какой части памяти находится нативная библиотека и стек, который будет необходим для эмулятора.

Воспользуемся командой dump binary memory <file_name> <start_address> <end_address>. Выполним ее для нашей библиотеки и стека.

Пишем свой эмулятор

Пока не закрывай терминал GDB, он еще понадобится. Скопируем дампы в папку и в ней же создадим пустой файл. Сделаем заготовку для работы, как в тестовом примере, и добавим загрузку данных из собранных дампов.

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

Но в таком виде ошибка неинформативна: мы не знаем, по какому адресу была попытка чтения. Допишем эмулятор, добавив хуки на доступ к памяти.

Запустим еще раз и посмотрим, что теперь выводит этот код.

Теперь видно, в чем проблема: код не может считать память по адресу 0xa10a1c70. Кажется, я уже где-то видел этот адрес! Он попадался, когда мы искали, где хранится входящая строка. Проверим это, выполнив запрос в ранее открытом терминале GDB.

Эти данные не нужны, нас интересует самостоятельная загрузка своих данных для подписи. Посмотрим в GDB, что происходит по адресу последней успешно выполненной команды 0xa96cb841.

В eax передается адрес, который находится в esi+0x40. Разметим свой пустой участок памяти и перезапишем адрес в esi+0x40 на свой.

Итак, мы создали свою строку string_for_hash, которую записываем в нашу часть памяти. Для этого я выделил кусок, начиная с 0x0, длиной 1000 байтов. Дальше мы получаем адрес в памяти из регистра — это указатель на кучу — и добавляем отступ 0x40. Потом в функцию mem_write передаем получившийся адрес и четыре байта, которые указывают на наш участок памяти, в данном случае мы записываем адрес 0x00000000. Так как мы уже ссылаемся на свою строку, то стоит поменять константы, которые находятся в ecx и edx, а именно размер входящей строки в символах и размер выходящего массива в байтах.

На каждый элемент строки выделено четыре байта массива, так как элемент в массиве — это число, а не символ.

Попробуем запустить.

Теперь нам нужно получить посчитанные данные. Вспомним этап сбора данных: указатель на массив находится в edx. Считаем с него данные сразу, как эмуляция завершится, и выгрузим массив из памяти.

Здесь мы обращаемся к регистру edx, результат с него передаем в функцию считывания памяти и получаем len(string_for_hash)*4 байтов, так как у нас одно число занимает четыре байта. Дальше создаем цикл по количеству символов и режем наш массив на четырехбайтные числа, забирая только последнюю часть числа (необходимые числа всегда будут в диапазоне одного байта). Функция hex() возвращает строку вида 0x23, поэтому мы отрезаем первые два символа и, наконец, проверяем, что число не состоит из одного шестнадцатеричного (числа меньше 0xf), и добавляем — это нужно для красоты вывода. Запустим и посмотрим, что получилось.

Отлично, то, что нужно! Если помнишь, в начале статьи я продемонстрировал подпись для тестовых данных, — результат идентичен. Теперь попробуем передать более сложные данные, заменив нашу строку в коде на {"login":"long_login","password":"long_pass"}. Посчитаем подпись и посмотрим, совпадает ли она с той, которая выводится исходным приложением. Для этого запустим приложение без отладки и введем эти данные.

Почти весь массив, полученный от эмулятора, пустой, но зато последние элементы совпадают. Интересно, почему так получается? Давай попробуем выводить элемент массива сразу после добавления. Мы точно знаем, что в начале цикла (если это не первая итерация, конечно) будет находиться массив с недавно добавленным элементом. Сперва выведем значение регистров в начале каждой итерации. Для этого в функцию hook_code добавим следующий код и выполним запуск.

В консоли видим, что регистр eax после итераций цикла содержит количество посчитанных и добавленных в массив элементов.

Добавим сбор данных с массива сразу в начале итерации цикла. Для этого перепишем полностью hook_code и передадим свой массив внутрь через user_data, с помощью которого я получу данные из функции.

Результат стал намного лучше. Но постой, один байт не совпадает!

Почему же так произошло в этот раз? В одном из случаев такой «битый» байт у меня был следующим по счету байтом после последнего в массиве. Тем не менее сейчас этот байт дальше от границы массива, чем прошлые запуски. Но я все еще считаю, что проблема непосредственно в размере массива. Не забывай, что я пропускаю функцию _ZN7_JNIEnv11NewIntArrayEi@plt, а это может приводить к проблемам. Раз уж мы забираем данные сразу после итерации цикла, то почему бы не забирать данные на этапе присваивания одного элемента? Мы знаем, что наш массив находится по адресу из edx, посмотрим, кто обращается к этому регистру в диапазоне 0xa96cb83b 0xa96cb8be.

В 0xa96cb878 происходит запись в edx, но это получится, только если eax < 0x118, о чем говорит нам 0xa96cd85f 0xa96cd869. Давай изменим наш hook_code, чтобы забирать данные сразу на этапе сравнения, то есть в 0xa96cd862.

Результат не очень красивый: 77 21 4f 57 4c 64 *3a1a481f* .... Сопоставим это с оригинальной подписью и увидим, что в выделенном месте находится 0x00. Перепишем сбор результата с учетом этой особенности.

Запустим в последний раз и получим тот же результат.

Выводы

Я очень рад, если ты добрался до этой строки, прочитал и попробовал все, что я описал. Это очень важно, ведь все элементы тесно связаны между собой. В конечном счете у нас получилось подделать подпись для любых своих данных. Сработало это благодаря Unicorn Engine, который позволил создать эмулятор специально для этой задачи. В реальной жизни ты встретишь алгоритмы подписи, шифрования, любой другой защиты куда сложнее, чем мой примитивный алгоритм, но подход к решению будет почти такой же. Unicorn Engine — это отличный швейцарский нож, который в грамотных руках может почти все.

Понравилась статья? Поделиться с друзьями:
Добавить комментарий