Содержание статьи
Для начала нужно разобраться, что собой представляет Unicorn Engine. Это эмулятор процессора, он поддерживает множество архитектур и сам является мультиплатформенным. У Unicorn Engine в принципе нет сложных подсистем. Ты сам занимаешься разметкой памяти и загрузкой данных, эмулятор не понимает команды из std
, поэтому их необходимо реализовывать самостоятельно или вообще пропускать.
Существует множество решений, которые способны трассировать команды на хостовую систему, загружать исполняемые файлы в память и многое другое, так зачем тогда использовать Unicorn Engine?
При исследовании нативных библиотек часто не нужно эмулировать работу всего процесса. Нам достаточно смоделировать работу какой-то конкретной функции, не используя AVD или полноценные эмуляторы Android/iOS, чтобы получить результат отдельно от основного процесса или устройства.
INFO
Хороший пример можно найти в моей прошлой статье, где был описан метод MITM-атаки на приложение с использованием Xposed.
Одна из рекомендаций по защите от подобного типа атаки — подписывать передаваемые данные. Но что, если разработчики не будут использовать стандартный алгоритм, для которого просто нужно получить ключ из нативного приложения, а пойдут дальше и изменят его?
Восстанавливать весь алгоритм достаточно трудоемко и требует большого количества знаний — как в криптографии, так и в Reverse Engineering. Здесь нам может помочь Unicorn Engine: определив, как передаются входящие параметры, мы можем проэмулировать работу искомой функции без понимания алгоритма ее работы.
В этой статье мы исследуем упрощенный вариант подписи данных.
INFO
Статья рассчитана на то, что ты знаешь, что такое регистры, как работает стек, и не теряешь сознание при виде ассемблерного кода.
Тестовый стенд
Для демонстрации атаки мы будем использовать самописное приложение для Android, которое считывает данные из полей ввода в JSON и генерирует подпись, используя некий алгоритм в нативной библиотеке. Наша цель — получить такую же подпись и научиться генерировать валидную подпись для любых данных. В реальной жизни этот JSON с подписью отправлялся бы на сервер, но здесь этот момент опускается.
В нативном приложении реализован некий кастомный алгоритм подписи. Его сложно назвать криптостойким, но для демонстрации он идеален: не очень объемный, но не слишком простой, как обычный XOR. Все необходимые исходники ты можешь найти на моем GitHub.
Также нам понадобится Android Studio и Android SDK с NDK, установленный Unicorn Engine и устройство или эмулятор для запуска. В этой статье я буду использовать AVD x86.
На устройстве (эмуляторе) должен находиться gdbserver
, который можно найти по такому адресу:
<android-sdk>/ndk-bundle/prebuilt/<device-system>/gdbserver
Я обычно перемещаю его в /data/local/gdbserver
на устройстве.
Собираем информацию
Начнем анализ с того, что загрузим наше приложение в Android Studio: File → Profile or Debug APK. Когда проект загрузится, нам нужно исправить Run/Debug Configurations: во вкладке Debugger переключить Debug type в Java. Если этого не сделать, то к приложению будет подключен отладчик из Android Studio и подключить свой мы уже не сможем.
Прежде чем начать, давай попробуем запустить приложение и ввести тестовые данные test/pass. Запишем полученную подпись, так как она нам еще пригодится.
77 21 4f 57 4c 64 00 2e 39 01 4c 4e 7e 00 2e 2f 01 48 4a 7e 00 7b 6c 51 5c 09 37 00 7c 62 50 4b 09 70
INFO
Если приложение не имеет флага android:debuggable="true"
, то его нужно добавить, пересобрав приложение и отредактировав AndroidManifest
с помощью apktool
.
Реверс APK
Для начала найдем основной класс. Он находится в loony/com/nativeexample/MainActivity
.
.field getSign:Landroid/widget/Button;
.field loginField:Landroid/widget/EditText;
.field passwordField:Landroid/widget/EditText;
.field sign:Landroid/widget/TextView;
Видим в начале объявление кнопки и двух полей для заполнения.
.line 28
const-string v0, "native-lib"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
Ниже происходит загрузка библиотеки и объявлен нативный метод .method
public native magic(Ljava/lang/String;)[I
, который принимает строку, а на выходе возвращает массив чисел. В том же классе есть функция .method
private getHexString(I)Ljava/lang/String;
, которая принимает массив чисел и возвращает hex
-строку.
Посмотрим, что происходит при создании класса, и перейдем в onCreate
.
new-instance v1, Lloony/com/nativeexample/MainActivity$1;
invoke-direct {v1, p0}, Lloony/com/nativeexample/MainActivity$1;->
invoke-virtual {v0, v1}, Landroid/widget/Button;->setOnClickListener(Landroid/view/View$OnClickListener;)V
Создается новый обработчик нажатий, в него передается экземпляр loony/com/nativeexample/MainActivity$1
, перейдем туда. Нас интересуют функции, которые отвечают за действия, в нашем случае это только onClick
.
В коде видно, что создается org/json/JSONObject;
, считываются данные из loginField
,passwordField
и помещаются в JSONObject
с ключами login
и password
соответственно.
invoke-virtual {v0}, Lorg/json/JSONObject;->toString()Ljava/lang/String;
move-result-object v2
invoke-virtual {v1, v2}, Lloony/com/nativeexample/MainActivity;->magic(Ljava/lang/String;)[I
move-result-object v1
JSONObject
преобразовывается в строку и передается в нативную функцию.
Подключение отладчика
Чтобы подключить отладчик, нам нужно поставить брейк-пойнт в Android Studio до вызова magic(Ljava/lang/String;)[I
. У себя в тестах я ставил точку останова еще в onCreate
.
Запустим наше приложение в режиме отладки. Откроется эмулятор, и «Студия» подключит отладчик Java. Приложение остановлено. Управление находится в Android Studio.
Итак, теперь нужно найти PID процесса. Для этого подключимся к устройству через adb и запустим ps
.
$ adb shell
$ ps | grep loony
u0_a66 12949 1342 1390432 45556 futex_wait aef07424 S loony.com.nativeexample
Теперь, когда у нас есть PID 12949
, мы можем подключать к нему сервер GDB.
$ /data/local/gdbserver :5039 --attach 12949
Attached; pid = 12949
Listening on port 5039
Здесь мы запускаем сервер на 5039-м порте и подключаем его к нашему процессу. Теперь в другой вкладке терминала пропишем передачу этого порта с эмулятора в основную систему.
$ adb forward tcp:5039 tcp:5039
Только после этого можно запускать GDB и подключать его к нашему девайсу. Путь к GDB будет такой:
<android-sdk>/ndk-bundle/prebuilt/darwin-x86_64/bin
$ ./gdb
(gdb) target remote :5039
INFO
Когда запускается отладчик, управление и команды передаются в его терминал. В начале строки у тебя должно отображаться (gdb)
, и все команды, которые будут ниже и имеют такую приставку, должны быть введены в терминал GDB.
Загрузятся библиотеки, и ты увидишь что-то вроде 0xaef07424 in __kernel_vsyscall ()
. У тебя адреса, в которых работает приложение, скорее всего, будут другими.
Этот ответ означает, что отладчик подключился и получил данные от сервера. Если библиотеки не загрузились, то повтори подключение снова. Не забывай, что в этот момент тот отладчик, который был подключен от имени Android Studio, тоже остановился. Управление сейчас находится у GDB.
Анализ функции
Теперь нам нужно определить, где находится исследуемая функция, что она использует, как передаются данные и как их потом можно получить. Для этого найдем функцию с помощью GDB.
info function <regex>
Или можно запустить просто info function
, но тогда будут отображены все функции, что не очень удобно.
Названия всех нативных функций собираются из приставки Java
, названия пакета и названия функции, а весь regex начинается с ^
. Сделаем поиск по началу имени пакета.
(gdb) info functions ^Java_loony_com
All functions matching regular expression "^Java_loony_com
":
Non-debugging symbols:
0xa96cb720 Java_loony_com_nativeexample_MainActivity_magic
Итак, мы нашли нужную функцию. Ставим на нее брейк-пойнт (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
. Здесь нам нужно найти точку, после которой начинается основное тело функции — в нашем случае подсчет подписи. Кроме того, нужно постараться минимизировать количество кода для эмуляции, потому что чем больше кода мы эмулируем, тем больше проблем может возникнуть.
Ниже приведены лишь некоторые важные блоки. Не забывай, что я опустил часть кода, чтобы не перегружать статью.
Dump of assembler code for function Java_loony_com_nativeexample_MainActivity_magic:
=> 0xa96cb720 <+0>: push ebp
0xa96cb721 <+1>: mov ebp,esp
0xa96cb72e <+14>: call 0xa96cb733 <Java_loony_com_nativeexample_MainActivity_magic+19>
0xa96cb779 <+89>: call 0xa96cb540 <_ZN7_JNIEnv17GetStringUTFCharsEP8_jstringPh@plt>
0xa96cb79e <+126>: call 0xa96cb540 <_ZN7_JNIEnv17GetStringUTFCharsEP8_jstringPh@plt>
0xa96cb7bc <+156>: call 0xa96cb550 <_Z7getSizePc@plt>
0xa96cb7d0 <+176>: mov edx,esp
0xa96cb7da <+186>: call 0xa96cb560 <_ZN7_JNIEnv11NewIntArrayEi@plt>
0xa96cb83b <+283>: mov eax,DWORD PTR [esi+0x40]
0xa96cb83e <+286>: mov ecx,DWORD PTR [esi+0x2c]
0xa96cb841 <+289>: movsx eax,BYTE PTR [eax+ecx*1]
0xa96cb845 <+293>: cmp eax,0x0
0xa96cb848 <+296>: je 0xa96cb8be <Java_loony_com_nativeexample_MainActivity_magic+414>
0xa96cb862 <+322>: cmp DWORD PTR [esi+0x28],0x118
0xa96cb869 <+329>: jge 0xa96cb880 <Java_loony_com_nativeexample_MainActivity_magic+352>
0xa96cb87b <+347>: jmp 0xa96cb88d <Java_loony_com_nativeexample_MainActivity_magic+365>
0xa96cb88d <+365>: cmp DWORD PTR [esi+0x38],0x5
0xa96cb891 <+369>: jle 0xa96cb8a1 <Java_loony_com_nativeexample_MainActivity_magic+385>
0xa96cb897 <+375>: xor eax,eax
0xa96cb899 <+377>: mov DWORD PTR [esi+0xc],eax
0xa96cb89c <+380>: jmp 0xa96cb8aa <Java_loony_com_nativeexample_MainActivity_magic+394>
0xa96cb8b9 <+409>: jmp 0xa96cb83b <Java_loony_com_nativeexample_MainActivity_magic+283>
0xa96cb8be <+414>: mov eax,DWORD PTR [ebp+0x8]
Мы видим несколько функций 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
.
(gdb) x/s $eax
0xa10a1c70: "{\"login\":\"test\",\"password\":\"pass\"}"
Чтобы вставить свою строку, нам нужно переписать адрес 0xa10a1c70
, с которого считывается строка и который находится в esi+0x40
.
Что насчет считывания результата? Уберем точку останова, для этого выведем список поставленных точек (gdb) info break
, найдем индекс нужной и выполним (gdb) del <index>
. Теперь поставим останов после выхода из массива (gdb) b *0xa96cb8be
и продолжим работу (gdb) с
. Посмотрим, что находится в регистрах (gdb) i r
.
eax 0x0 0
ecx 0x22 34
edx 0xbf84f2d0 -1081806128
ebx 0xa96cefd8 -1452478504
esp 0xbf84f2d0 0xbf84f2d0
ebp 0xbf84f3e8 0xbf84f3e8
esi 0xbf84f360 -1081805984
edi 0xbf84f2d0 -1081806128
Очевидно, что ecx
— длина входящего или выходящего массива. Отобразим содержимое памяти остальных регистров. Мы пытаемся найти байт-массив из 34 элементов. Выполним (gdb) x/34x $edx
, где после /
стоит количество элементов, которые будут выведены, дальше тип и регистр. Больше информации можешь получить, выполнив (gdb) help x
.
0xbf84f2d0: 0x00000077 0x00000021 0x0000004f 0x00000057
0xbf84f2e0: 0x0000004c 0x00000064 0x00000000 0x0000002e
0xbf84f2f0: 0x00000039 0x00000001 0x0000004c 0x0000004e
0xbf84f300: 0x0000007e 0x00000000 0x0000002e 0x0000002f
0xbf84f310: 0x00000001 0x00000048 0x0000004a 0x0000007e
0xbf84f320: 0x00000000 0x0000007b 0x0000006c 0x00000051
0xbf84f330: 0x0000005c 0x00000009 0x00000037 0x00000000
0xbf84f340: 0x00000078 0x00000066 0x00000050 0x0000004c
0xbf84f350: 0x00000009 0x00000070
Забегая вперед, скажу, что GDB нам еще понадобится, так что терминал можешь не закрывать. А пока поговорим про базовые методы работы с Unicorn.
Unicorn 101
Установку для твоей системы можно посмотреть на официальном сайте. Я буду работать с версией библиотеки для Python, так как это сильно ускоряет и упрощает разработку решения, но можно использовать версию для любого языка, к примеру C, Java, Go, Ruby и даже FreePascal. Со всем перечнем доступных языков можно ознакомиться в репозитории на GitHub.
Теперь создаем пустой файл python и импортируем Unicorn.
from unicorn import *
from unicorn.x86_const import *
Вторая строка может меняться в зависимости от архитектуры, которую ты собираешься эмулировать. А выбрать у Unicorn Engine есть из чего: здесь и привычный x86, и ARM, ARMv8, M68K, MIPS, и даже Sparc. Эмуляторы AVD приоритетно работают с архитектурой x86.
Дальше нужно создать эмулятор, передать архитектуру и разрядность системы.
mu = Uc(UC_ARCH_X86, UC_MODE_32)
Так как этот эмулятор буквально ничего не делает, кроме эмуляции кода, то размечать память и загружать все необходимые данные мы будем самостоятельно. Для этого нужно передать в функцию mem_map
адрес, с которого начинать разметку, и размер блока, задаваемый в байтах. Пока ты этого не сделаешь, ты не сможешь загружать данные в эту часть памяти. Размечать память можно в любой адрес, но зачастую мы будем использовать адреса с рабочего приложения.
ADDRESS = 0x1000000
mu.mem_map(ADDRESS, 2 * 1024 * 1024)
Теперь загрузим данные в память, для этого возьмем небольшой кусок ассемблерного кода из примера на GitHub. О том, как загрузить в память большие объемы данных или дампов, мы поговорим в разделе «Атаки».
X86_CODE32 = b"\x41\x4a\x66\x0f\xef\xc1" # INC ecx; DEC edx; PXOR xmm0, xmm1
mu.mem_write(ADDRESS, X86_CODE32)
Для работы с регистрами используются две функции: reg_read
и reg_write
. В качестве параметров для записи передается константа регистра и hex
-число, но не больше разрядности системы. То есть если это 32-битная система, то это не больше четырех байтов. Для чтения нужна только константа регистра.
mu.reg_write(UC_X86_REG_ECX, 0x1234)
mu.reg_write(UC_X86_REG_EDX, 0x7890)
Чтобы отслеживать изменения и процесс работы системы, нужно добавить хуки на команды и работу с памятью; после написания эмулятора их можно удалить. Объявим функцию, которая принимает необходимые параметры: экземпляр эмулятора, адрес команды, размер и параметр от пользователя. О том, как работать с пользовательскими параметрами, я расскажу в разделе «Атаки».
def hook_code(uc, address, size, user_data):
print(">>> Tracing instruction at 0x%x, instruction size = 0x%x" %(address, size))
mu.hook_add(UC_HOOK_CODE, hook_code)
Теперь все готово для того, чтобы запустить наш первый эмулятор. В качестве параметров передаются адреса начала и окончания эмуляции.
mu.emu_start(ADDRESS, ADDRESS + len(X86_CODE32))
После этого, чтобы посмотреть на результат работы, выведем информацию с регистров.
r_ecx = mu.reg_read(UC_X86_REG_ECX)
r_edx = mu.reg_read(UC_X86_REG_EDX)
print(">>> ECX = 0x%x" %r_ecx)
print(">>> EDX = 0x%x" %r_edx)
Для работы с памятью используются команды mem_read
и mem_write
. О том, как записывать в память, мы поговорим позднее, а здесь я покажу, как можно считывать данные. Для этого в функцию mem_read
передается адрес и количество байтов, которые необходимо считать, функция возвращает массив байтов. После этого можно запускать написанный код.
tmp = mu.mem_read(ADDRESS, 4)
print(">>> Read 4 bytes from [0x%x] = 0x" %(ADDRESS), end="")
for i in reversed(tmp):
print("%x" %(i), end="")
print("")
Больше примеров работы c Unicorn Engine ты можешь посмотреть в репозитории проекта.
Получение дампов из библиотеки
Как ты прочитал выше, нам нужно будет загрузить в память исполняемый код функции, которую мы собираемся эмулировать, и окружение. Это необходимо для вызовов и обращений, которые происходят внутри функции. Для начала получим дамп только нашей библиотеки, а все необходимое окружение загрузим уже в процессе эмуляции.
Сначала заново подключим отладчик, чтобы итерация цикла получения подписи ни разу не выполнялась до этого. Как это сделать, описано в разделе «Подключение отладчика». Мы будем эмулировать работу с 0xa96cb83b
, так что поставим останов именно в точке (gdb) b *0xa96cb83b
, чтобы получить окружение, соответствующее той же команде, с которой мы начинаем эмуляцию.
Продолжим работу программы. Введем тестовые данные test/pass и попадем в точку останова.
Для начала выведем текущее состояние регистров.
eax 0xa96cefd8 -1452478504
ecx 0x22 34
edx 0x90 144
ebx 0xa96cefd8 -1452478504
esp 0xbf84f6d0 0xbf84f6d0
ebp 0xbf84f7e8 0xbf84f7e8
esi 0xbf84f760 -1081804960
edi 0xbf84f6d0 -1081805104
eip 0xa96cb83b 0xa96cb83b <Java_loony_com_nativeexample_MainActivity_magic+283>
Теперь посмотрим, в какой части памяти находится нативная библиотека и стек, который будет необходим для эмулятора.
(gdb) info proc mappings
...
0xa96cb000 0xa96cc000 0x1000 0x0 /data/app/loony.com.nativeexample-1/lib/x86/libnative-lib.so
0xa96cc000 0xa96cd000 0x1000 0x0 /data/app/loony.com.nativeexample-1/lib/x86/libnative-lib.so
0xa96cd000 0xa96ce000 0x1000 0x1000 /data/app/loony.com.nativeexample-1/lib/x86/libnative-lib.so
...
0xbf055000 0xbf854000 0x7ff000 0x0 [stack]
Воспользуемся командой dump binary memory <file_name> <start_address> <end_address>
. Выполним ее для нашей библиотеки и стека.
(gdb) dump binary memory dump_native_lib 0xa96cb000 0xa96ce000
(gdb) dump binary memory stack 0xbf055000 0xbf854000
Пишем свой эмулятор
Пока не закрывай терминал GDB, он еще понадобится. Скопируем дампы в папку и в ней же создадим пустой файл. Сделаем заготовку для работы, как в тестовом примере, и добавим загрузку данных из собранных дампов.
from __future__ import print_function
from unicorn import *
from unicorn.x86_const import *
def read(name):
with open(name,'rb') as f:
return f.read()
def hook_code(mu, address, size, user_data):
print('>>> Tracing instruction at 0x%x, instruction size = 0x%x' %(address, size))
libnative_adr = 0xa96cb000 # dump_native_lib 0xa96cd000 0xa96d0000
libnative_size = 0x1000 + 0x1000 + 0x1000
main_stack_adr = 0xbf055000 # dump_main_stack 0xbf055000 0xbf854000
main_stack_size = 0x7ff000
mu = Uc(UC_ARCH_X86, UC_MODE_32)
mu.mem_map(libnative_adr,libnative_size,UC_PROT_ALL)
mu.mem_write(libnative_adr, read("dump_native_lib"))
mu.mem_map(main_stack_adr,main_stack_size,UC_PROT_ALL)
mu.mem_write(main_stack_adr, read("stack"))
mu.hook_add(UC_HOOK_CODE, hook_code)
mu.reg_write(UC_X86_REG_EAX,0xa96ccfd8)
mu.reg_write(UC_X86_REG_ECX,0x22)
mu.reg_write(UC_X86_REG_EDX,0x90)
mu.reg_write(UC_X86_REG_EBX,0xa96ccfd8)
mu.reg_write(UC_X86_REG_ESP,0xbf84f2d0)
mu.reg_write(UC_X86_REG_EBP,0xbf84f3e8)
mu.reg_write(UC_X86_REG_ESI,0xbf84f360)
mu.reg_write(UC_X86_REG_EDI,0xbf84f2d0)
mu.reg_write(UC_X86_REG_EIP,0xa96cb83b)
mu.emu_start(0xa96cb83b,0xa96cb8be)
Как только мы запустим текущую версию, получим ошибку чтения неразмеченных данных.
$ python test.py
>>> Tracing instruction at 0xa96cb83b, instruction size = 0x3
>>> Tracing instruction at 0xa96cb83e, instruction size = 0x3
>>> Tracing instruction at 0xa96cb841, instruction size = 0x4
Traceback (most recent call last):
File "test.py", line 38, in
mu.emu_start(0xa96cb83b,0xa96cb8be)
File "/usr/local/lib/python2.7/site-packages/unicorn/unicorn.py", line 288, in emu_start
raise UcError(status)
unicorn.unicorn.UcError: Invalid memory read (UC_ERR_READ_UNMAPPED)
Но в таком виде ошибка неинформативна: мы не знаем, по какому адресу была попытка чтения. Допишем эмулятор, добавив хуки на доступ к памяти.
def hook_mem_invalid(uc, access, address, size, value, user_data):
if access == UC_MEM_WRITE_UNMAPPED:
print(">>> Missing memory is being WRITE at 0x%x, data size = %u, data value = 0x%x" %(address, size, value))
return True
else:
print(">>> Missing memory is being READ at 0x%x, data size = %u, data value = 0x%x" %(address, size, value))
return False
def hook_mem_access(uc, access, address, size, value, user_data):
if access == UC_MEM_WRITE:
print(">>> Memory is being WRITE at 0x%x, data size = %u, data value = 0x%x" %(address, size, value))
else: # READ
print(">>> Memory is being READ at 0x%x, data size = %u" %(address, size))
mu.hook_add(UC_HOOK_MEM_READ_UNMAPPED | UC_HOOK_MEM_WRITE_UNMAPPED, hook_mem_invalid)
mu.hook_add(UC_HOOK_MEM_WRITE, hook_mem_access)
mu.hook_add(UC_HOOK_MEM_READ, hook_mem_access)
Запустим еще раз и посмотрим, что теперь выводит этот код.
$ python main.py
>>> Tracing instruction at 0xa96cb83b, instruction size = 0x3
>>> Memory is being READ at 0xbf84f3a0, data size = 4
>>> Tracing instruction at 0xa96cb83e, instruction size = 0x3
>>> Memory is being READ at 0xbf84f38c, data size = 4
>>> Tracing instruction at 0xa96cb841, instruction size = 0x4
>>> Missing memory is being READ at 0xa10a1c70, data size = 1, data value = 0x0
Traceback (most recent call last):
File "test.py", line 56, in
mu.emu_start(0xa96cb83b,0xa96cb8be)
File "/usr/local/lib/python2.7/site-packages/unicorn/unicorn.py", line 288, in emu_start
raise UcError(status)
unicorn.unicorn.UcError: Invalid memory read (UC_ERR_READ_UNMAPPED)
Теперь видно, в чем проблема: код не может считать память по адресу 0xa10a1c70
. Кажется, я уже где-то видел этот адрес! Он попадался, когда мы искали, где хранится входящая строка. Проверим это, выполнив запрос в ранее открытом терминале GDB.
(gdb) x/s 0xa10a1c70
0xa10a1c70: "{\"login\":\"test\",\"password\":\"pass\"}"
Эти данные не нужны, нас интересует самостоятельная загрузка своих данных для подписи. Посмотрим в GDB, что происходит по адресу последней успешно выполненной команды 0xa96cb841
.
(gdb) disassemble
...
0xa96cb83b <+283>: mov eax,DWORD PTR [esi+0x40]
0xa96cb83e <+286>: mov ecx,DWORD PTR [esi+0x2c]
0xa96cb841 <+289>: movsx eax,BYTE PTR [eax+ecx*1]
0xa96cb845 <+293>: cmp eax,0x0
...
В eax
передается адрес, который находится в esi+0x40
. Разметим свой пустой участок памяти и перезапишем адрес в esi+0x40
на свой.
string_for_hash = '{"login":"test","password":"pass"}'
mu.reg_write(UC_X86_REG_ECX,len(string_for_hash))
mu.reg_write(UC_X86_REG_EDX,len(string_for_hash)*4)
mu.mem_map(0x0,0x1000,UC_PROT_ALL)
mu.mem_write(0x0, bytes(string_for_hash))
mu.mem_write(mu.reg_read(UC_X86_REG_ESI)+0x40,b'\x00\x00\x00\x00')
Итак, мы создали свою строку string_for_hash
, которую записываем в нашу часть памяти. Для этого я выделил кусок, начиная с 0x0
, длиной 1000 байтов. Дальше мы получаем адрес в памяти из регистра — это указатель на кучу — и добавляем отступ 0x40
. Потом в функцию mem_write
передаем получившийся адрес и четыре байта, которые указывают на наш участок памяти, в данном случае мы записываем адрес 0x00000000
. Так как мы уже ссылаемся на свою строку, то стоит поменять константы, которые находятся в ecx
и edx
, а именно размер входящей строки в символах и размер выходящего массива в байтах.
INFO
На каждый элемент строки выделено четыре байта массива, так как элемент в массиве — это число, а не символ.
Попробуем запустить.
Теперь нам нужно получить посчитанные данные. Вспомним этап сбора данных: указатель на массив находится в edx
. Считаем с него данные сразу, как эмуляция завершится, и выгрузим массив из памяти.
result = []
tmp = mu.mem_read(mu.reg_read(UC_X86_REG_EDX),len(string_for_hash)*4)
for index in range(len(string_for_hash)):
time_int = hex(tmp[index*4:index*4+4][::-1][3])[2:]
if len(time_int)<2:
time_int = '0'+ time_int
result.append(time_int)
print(" ".join(result))
Здесь мы обращаемся к регистру edx
, результат с него передаем в функцию считывания памяти и получаем len(string_for_hash)*4
байтов, так как у нас одно число занимает четыре байта. Дальше создаем цикл по количеству символов и режем наш массив на четырехбайтные числа, забирая только последнюю часть числа (необходимые числа всегда будут в диапазоне одного байта). Функция hex()
возвращает строку вида 0x23
, поэтому мы отрезаем первые два символа и, наконец, проверяем, что число не состоит из одного шестнадцатеричного (числа меньше 0xf
), и добавляем 0
— это нужно для красоты вывода. Запустим и посмотрим, что получилось.
>>> Tracing instruction at 0xa96cb841, instruction size = 0x4
>>> Memory is being READ at 0x22, data size = 1
>>> Tracing instruction at 0xa96cb845, instruction size = 0x3
>>> Tracing instruction at 0xa96cb848, instruction size = 0x6
77 21 4f 57 4c 64 00 2e 39 01 4c 4e 7e 00 2e 2f 01 48 4a 7e 00 7b 6c 51 5c 09 37 00 7c 62 50 4b 09 70
Отлично, то, что нужно! Если помнишь, в начале статьи я продемонстрировал подпись для тестовых данных, — результат идентичен. Теперь попробуем передать более сложные данные, заменив нашу строку в коде на {"login":"long_login","password":"long_pass"}
. Посчитаем подпись и посмотрим, совпадает ли она с той, которая выводится исходным приложением. Для этого запустим приложение без отладки и введем эти данные.
Emulator - 00 00 ... 00 00 00 7f 21 5e
Original - 77 21 ... 5b 6c 00 7f 21 5e
Почти весь массив, полученный от эмулятора, пустой, но зато последние элементы совпадают. Интересно, почему так получается? Давай попробуем выводить элемент массива сразу после добавления. Мы точно знаем, что в начале цикла (если это не первая итерация, конечно) будет находиться массив с недавно добавленным элементом. Сперва выведем значение регистров в начале каждой итерации. Для этого в функцию hook_code
добавим следующий код и выполним запуск.
if address == 0xa96cb83b:
print('----')
print("EAX "+hex(int(mu.reg_read(UC_X86_REG_EAX))))
print("ECX "+hex(int(mu.reg_read(UC_X86_REG_ECX))))
print("EDX "+hex(int(mu.reg_read(UC_X86_REG_EDX))))
В консоли видим, что регистр eax
после итераций цикла содержит количество посчитанных и добавленных в массив элементов.
----
EAX 0x1
ECX 0x0
EDX 0xbf84f2d0
----
EAX 0x2
ECX 0x1
EDX 0xbf84f2d0
----
EAX 0x3
ECX 0x2
EDX 0xbf84f2d0
----
EAX 0x4
ECX 0x3
EDX 0xbf84f2d0
----
EAX 0x5
ECX 0x4
EDX 0xbf84f2d0
Добавим сбор данных с массива сразу в начале итерации цикла. Для этого перепишем полностью hook_code
и передадим свой массив внутрь через user_data
, с помощью которого я получу данные из функции.
def hook_code(mu, address, size, user_data):
if address == 0xa96cb83b:
if int(mu.reg_read(UC_X86_REG_EAX))< 0x1180:
tmp = mu.mem_read(mu.reg_read(UC_X86_REG_EDX)+(int(mu.reg_read(UC_X86_REG_EAX))-1)*4,4)
time_int = hex(tmp[::-1][3])[2:]
if len(time_int)<2:
time_int = '0'+ time_int
user_data.append(time_int)
result = []
mu.hook_add(UC_HOOK_CODE, hook_code, user_data=result)
print(" ".join(result))
Результат стал намного лучше. Но постой, один байт не совпадает!
Emulator - 77 21 4f ... 67 05 6c 30 7f 21 5e
Original - 77 21 4f ... 67 5b 6c 00 7f 21 5e
Почему же так произошло в этот раз? В одном из случаев такой «битый» байт у меня был следующим по счету байтом после последнего в массиве. Тем не менее сейчас этот байт дальше от границы массива, чем прошлые запуски. Но я все еще считаю, что проблема непосредственно в размере массива. Не забывай, что я пропускаю функцию _ZN7_JNIEnv11NewIntArrayEi@plt
, а это может приводить к проблемам. Раз уж мы забираем данные сразу после итерации цикла, то почему бы не забирать данные на этапе присваивания одного элемента? Мы знаем, что наш массив находится по адресу из edx
, посмотрим, кто обращается к этому регистру в диапазоне 0xa96cb83b — 0xa96cb8be
.
0xa96cd85f <+319>: mov DWORD PTR [esi+0x28],eax
0xa96cd862 <+322>: cmp DWORD PTR [esi+0x28],0x118
0xa96cd869 <+329>: jge 0xa96cd880 ...
0xa96cb86f <+335>: mov eax,DWORD PTR [esi+0x28]
0xa96cb872 <+338>: mov ecx,DWORD PTR [esi+0x2c]
0xa96cb875 <+341>: mov edx,DWORD PTR [esi+0x10]
0xa96cb878 <+344>: mov DWORD PTR [edx+ecx*4],eax
В 0xa96cb878
происходит запись в edx
, но это получится, только если eax < 0x118
, о чем говорит нам 0xa96cd85f — 0xa96cd869
. Давай изменим наш hook_code
, чтобы забирать данные сразу на этапе сравнения, то есть в 0xa96cd862
.
def hook_code(mu, address, size, user_data):
print('>>> Tracing instruction at 0x%x, instruction size = 0x%x' %(address, size))
if address == 0xa96cb862:
result.append("".join('{:02x}'.format(mu.reg_read(UC_X86_REG_EAX))))
Результат не очень красивый: 77 21 4f 57 4c 64 *3a1a481f* ...
. Сопоставим это с оригинальной подписью и увидим, что в выделенном месте находится 0x00
. Перепишем сбор результата с учетом этой особенности.
if address == 0xa96cb862:
if mu.reg_read(UC_X86_REG_EAX)>0x118:
result.append("00")
else:
result.append("".join('{:02x}'.format(mu.reg_read(UC_X86_REG_EAX))))
Запустим в последний раз и получим тот же результат.
Emulator - 77 21 4f 57 ... 44 67 5b 6c 00 7f 21 5e
Original - 77 21 4f 57 ... 44 67 5b 6c 00 7f 21 5e
Выводы
Я очень рад, если ты добрался до этой строки, прочитал и попробовал все, что я описал. Это очень важно, ведь все элементы тесно связаны между собой. В конечном счете у нас получилось подделать подпись для любых своих данных. Сработало это благодаря Unicorn Engine, который позволил создать эмулятор специально для этой задачи. В реальной жизни ты встретишь алгоритмы подписи, шифрования, любой другой защиты куда сложнее, чем мой примитивный алгоритм, но подход к решению будет почти такой же. Unicorn Engine — это отличный швейцарский нож, который в грамотных руках может почти все.