Reverse engineering: обратная разработка приложений для самых маленьких
Обратная разработка (англ. Reverse Engineering) - метод исследования устройств или программного обеспечения с целью понять принцип его работы или обнаружить недокументированные возможности. В информационной безопасности она занимает значительную роль, благодаря ей специалисты в области ИБ могут исследовать вредоносные приложения, разбираться как они работают для последующего, например, составления сигнатур в базы антивирусов и защиты других пользователей от предстоящей цифровой угрозы.
Выделяют 4 методики проведения обратной разработки:
анализ обмена данными приложения, с помощью различных анализаторов трафика;
использование режима отладки для поиска нужных участков кода и просмотра данных с которыми работает приложение;
дизассемблирование машинного кода программы (изучение требует довольно много времени);
декомпиляция кода программы для создания исходного кода программы на языке программирования высокого уровня.
Тема дизассмеблирования машинного кода, в целом неразрывно связана с обратной разработкой, но у неспециалиста может попросту отбить какое-либо желание связываться с ним. Связано это с необходимым уровнем подготовки и временем, которое затрачивается на "разборку". Тем не менее, каждый случай может оказаться довольно увлекательным "путешествием" в недра приложения. Поэтому сегодня попробуем наглядно разобрать одно из приложений, чтобы показать, что дизассемблирование - это совсем не страшно и при должном старании и терпении можно прокачать новый скилл.
В качестве подопытного возьмем задание, которое использовалось в бесплатной лаборатории Test lab, где зарегистрировано свыше 30 тысяч участников. Но для большей наглядности программа была скомпилирована без отладочной информации и без использования оптимизаций (gcc -O0). После чего к ней была применена утилита strip с параметром -s, который удаляет всю лишнюю символьную информацию из исполняемого файла, вроде имён переменных и функций.
Рабочим инструментом выступит Radare2 - кроссплатформенный набор утилит для обратной разработки и отладки. Включает в себя инструменты для анализа, дизассемблирования, декомпилирования и патчинга. В отличие от популярного IDA Pro распространяется бесплатно.
Установка
Рекомендуемым разработчиками способом установки и обновления Radare2 является установка из официального git-репозитория. Предварительно в системе должны присутствовать установленные пакеты git, build-essential и make.
$ sudo apt install git build-essential make
$ git clone https://github.com/radareorg/radare2
$ cd radare2
$ sys/install.sh
Запуск установки рекомендуется производить не из под пользователя root, иначе скрипт сам произведёт понижение привилегий.
Далее устанавливаем графическую оболочку для Radare2. Мы будет устанавливать официальный GUI под названием Iaito. Установим пакеты, необходимые для установки Iaito:
$ sudo apt install qttools5-dev-tools qt5-default libqt5svg5-dev
Для дистрибутивов Linux на базе Debian, есть готовые пакеты, ссылки на которые можно взять тут. Скачаем и установим нужную версию пакета:
$ wget https://github.com/radareorg/iaito/releases/download/5.2.2/iaito_5.2.2_amd64.deb
$ sudo dpkg -i iaito_5.2.2_amd64.deb
Теперь установим плагин r2ghidra, который является интеграцией декомпилятора Ghidra для Radare2. Плагин не требует отдельной установки Ghidra, так как содержит в себе всё необходимое. Для установки плагин доступен в качестве r2pm пакета:
$ r2pm update
$ r2pm -ci r2ghidra
Установленный плагин автоматически интегрируется в GUI Iaito. После установки запускаем графическую оболочку и если все сделали правильно, то видим стартовый экран:
Теперь мы можем заняться нашим примером. Суть программы-примера заключается в следующем: при запуске на экране выводится некий токен, необходимый для того, чтобы зафиксировать выполнение задания и приватный SSH ключ. Но что-то пошло не так и в результате ключ выводится в некорректном виде, а токен не принимается в качестве правильного.
Первый запуск программы-примера
Открываем файл в Iaito, оставляем настройки анализа по умолчанию:
Настройки
После того, как Radare2 проанализирует файл, смотрим результат, открывшийся во вкладке Dashboard:
Программа скомпилирована под 64-битную версию Linux, написана на языке C. Слева мы видим список функций, которые Radare2 смог обнаружить. Среди них импортируемые из библиотеки libc функции printf, puts и putchar, выводящие на экран строку по формату и символ.
Функция main – это главная функция программы. Выполнение начинается с неё. Кликнув два раза по её названию, открывается вкладка Disassembly с результатом её дизассемблирования:
Результат
Немного про Ассемблер
Ассемблер - машинно-ориентированный язык программирования низкого уровня. Представляет собой систему обозначений, используемую для представления в удобно читаемой форме программ, записанных в машинном коде. Назван по одноименной утилите, которая транслирует программу в машинный код процессора.
Команды ассемблера
Каждая команда Ассемблера — это команда для процессора. Синтаксис команды состоит из нескольких частей:
Команда - означает какую операцию необходимо выполнить. Например:
mov - команда пересылки данных. Копирует содержимое одного операнда в другой;
lea - вычисляет эффективный адрес операнда-источника и сохраняет его в регистре;
cmp - сравнение двух операндов;
условные и безусловные переходы (jmp, jne, je, jle, …) - безусловные и условные (требуется выполнение условия) переходы к меткам. Например, jump @exit выполняет безусловный переход к метке exit;
nop - однобайтовая команда, которая ничего не выполняет, а только занимает место и время. В основном используется для создание задержки в программе или как заполнитель удаленных инструкций. Например, команду проверки лицензионного ключа во взломанных программах заменяют на "ничего не делать";
и тд
Операнды - то, над чем будут выполняться команды. Операндами могут быть названия регистров, ячейки памяти или служебные части команд.
Комментарий - понятно из названия, что это комментарий для удобства чтения кода. Пишется после точки с запятой.
Метки - обозначение участка кода. Также улучшает читаемость кода, но нужны еще и для перехода к помеченному им участку.
mov ax, 0 ; Помещаем значение 0 в регистр ax
где:
mov - команда (перемещение значения из одного операнда в другой);
ax, 0 - операнды (регистр и значение);
; <текст> - комментарий
Или рассмотрим другой пример как выглядит возведение числа в степень в Ассемблере:
mov ax,2 ; Помещаем значение 2 в регистр AX
mov bx,ax ; Помещаем значение регистра AX в BX
mul bx ; Выполняем дважды командой mul возведение в степень числа 2
mul bx
Это же действие будет выглядеть на языке высокого уровня, например, Си как:
pow (2, 3);
Вернемся к нашему заданию
Для большего понимания логики выполнения программы можно переключиться на вкладку Graph внизу окна. Там мы увидим блоки команд функции, в которой мы находимся, и переходы между ними, построенные Radare2 на основе команд условных и безусловных переходов.
Масштабирование на этой вкладке выполняется сочетаниями клавиш Ctrl+"-" и Ctrl+"+". Можно было бы начать разбираться в работе программы уже с этого места, но есть возможность посмотреть на программу в ещё более “читаемом” виде. Переключаемся на вкладку Decompiler, внизу окна и видим псевдокод, полученный в результате декомпиляции (восстановление до кода на языке, на котором программа была написана, в нашем случае – язык C) средствами встроенного декомпилятора Radare2.
В полученном тексте всё ещё много упоминаний регистров и безусловных переходов. Переключимся на декомпилятор Ghidra, который мы ранее установили. Для этого в правом нижнем углу окна в выпадающем списке выберем “pdg” вместо “pdc”.
Теперь код программы стал практически полностью читаем, за исключением имён переменных.
undefined8 main(void)
{
uint32_t uVar1;
uint32_t uVar2;
uint32_t var_ch;
undefined8 var_8h;
puts("Token:");
fcn.00001189(0x5020, 0x5090);
for (var_8h._0_4_ = 0; (int32_t)var_8h < 0xf; var_8h._0_4_ = (int32_t)var_8h + 1) {
putchar(*(undefined *)((int64_t)(int32_t)var_8h * 8 + 0x5020));
}
puts(0x2c78);
puts("Key:");
printf("\n-----BEGIN RSA PRIVATE KEY-----");
var_ch = 0;
for (var_8h._4_4_ = 0; (int32_t)var_8h._4_4_ < 0xc68; var_8h._4_4_ = var_8h._4_4_ + 2) {
if ((var_8h._4_4_ & 0x7f) == 0) {
putchar(10);
}
uVar1 = (*(uint8_t *)((int32_t)var_8h._4_4_ + *(int64_t *)0x5098) & 0x1f) + 9;
uVar2 = (*(uint8_t *)(*(int64_t *)0x5098 + (int64_t)(int32_t)var_8h._4_4_ + 1) & 0x1f) + 9;
putchar((char)uVar2 + (char)(uVar2 / 0x19) * -0x19 + ((char)uVar1 + (char)(uVar1 / 0x19) * -0x19) * '\x10' ^
*(uint8_t *)((int64_t)(int32_t)var_ch * 8 + 0x5020));
var_ch = var_ch + 1;
if (var_ch == 0xf) {
var_ch = 0;
}
}
putchar(10);
puts("-----END RSA PRIVATE KEY-----");
return 0;
}
В коде мы видим, что сначала выводится строка “Token:”, после чего происходит вызов некой функции с двумя параметрами, после которого идёт цикл с переменной var_8h, которая проходит значения от 0 до 14 включительно и выводит что-то посимвольно, основываясь на адресе памяти 0x5020 и счётчике с множителем 8. Из этого можно сделать вывод, что в памяти, начиная с адреса 0x5020, расположен массив структур из 15 значений размером 8 байт. Также стоит обратить внимание, что адрес 0x5020 передавался в качестве первого параметра в функцию, вызываемую перед этим циклом. Будем для простоты далее называть его “токен”. Далее по коду выводятся строки начала закрытого ключа и в цикле выводится посимвольно закрытый ключ. Внутри цикла вывода ключа идёт повторяющийся цикл по обнаруженному нами ранее массиву структур, используя переменную var_ch. Перед выводом на экран над каждым символом закрытого ключа производится операция исключающего ИЛИ (XOR) с текущим символом токена. После цикла выводится строка, завершающая закрытый SSH ключ. Исходя из того, что выводимый программой токен не является правильным, можно сделать вывод, что что-то происходит не так в ранее обнаруженной нами функции с двумя параметрами fcn.00001189, вызываемой перед выводом токена на экран. Перейдём на неё, дважды кликнув по названию функции в списке слева.
В полученном после декомпиляции коде функции мы видим, что она представляет из себя двойной цикл с параметром, в котором после сравнения двух значений элементов структуры происходит их обмен местами, если одно значение меньше другого. Больше всего это похоже на алгоритм сортировки. В частности, на одну из реализаций сортировки “пузырьком”. Основываясь на информации об алгоритме сортировки “пузырёк” и полученном нами коде, можно сделать вывод, что условие выхода из вложенного цикла написано с ошибкой. Проход осуществляется не до конца массива структур.
_var_18h < (undefined8 *)(arg2 + -8)
Получается, нужно это исправить. Переключимся на вкладку дизассемблера:
В полученном коде мы видим только одну команду вычитания 8:
0x00001211 sub rax, 8
Переключимся в режим графа, чтобы соотнести ассемблерный код с результатом декомпиляции:
Представление в виде графов
Проанализировав логику переходов и соотнеся её с ассемблерным кодом подтверждаем, что нас интересует именно эта область функции.
Теперь нам нужно убрать команду sub rax, 8. Наиболее просто это можно сделать переписав поверх нее команду nop (No Operation) - то есть, заменив команду вычитания командой "ничего не делать".
Для этого, находясь на вкладке дизассемблера, поставим курсор на эту команду и переключимся на вкладку Hexdump:
По относительному адресу команды 0x00001211 убеждаемся, что курсор стоит там, где необходимо. Выделяем 4 байта, начиная с адреса 0x00001211 и справа выберем вкладку “Parsing”. Увидим результат дизассемблирования выделенных байт.
Теперь нужно заменить выделенные байты на 4 байта со значением 90 (шестнадцатиричное значение машинного кода команды nop), но тут мы сталкиваемся с тем, что в Iaito нельзя просто так отредактировать шестнадцатиричное значение по адресу. Список доступных действий мы можем увидеть, нажав на выделенных байтах правую кнопку мыши.
Да, можно воспользоваться сторонним hex-редактором, но это было бы “неспортивно”. Так как мы пробуем выполнить все действия только в рамках функционала Radare2, то будем использовать что есть.
Сначала выберем “Write zeros”. Iaito напомнит нам, что файл открыт в режиме “только для чтения” и предложит переоткрыть его либо в режиме для записи, либо включить режим кэширования. В режиме кэширования все изменения к исходному файлу будут применяться только после выбора пункта меню “File → Commit changes”.
Предупреждение
Выберем режим кэширования, после чего снова попытаемся записать нули. И теперь это у нас получается. На каждом из четырёх байт выберем из контекстного меню пункт “Edit → Increment/Decrement” и добавим значение 144 (десятичную запись шестнадцатиричного числа 90).
Смотрим на получившийся результат:
После внесения изменений не забываем нажать “File → Commit Changes”. Запускаем ещё раз программу dechip, чтобы посмотреть результат наших действий:
Было
Стало
Стоит отметить, что часть наших действий основывалась на предположениях. И не всегда они подтверждаются так быстро и успешно. Для гарантированного успеха нужно более глубоко изучать язык Ассемблера той архитектуры процессоров, реверсом программ для которой Вы хотите заниматься, а также наиболее распространённые алгоритмы.
Заключение
В целом, бесплатный аналог IDA Pro в лице Radare2 является довольно неплохим решением. Однако, официальный GUI Radare2 хоть и позволяет удобно перемещаться между инструментами Radare2 и в части отображения информации удобнее консольной версии, но в то же время он ещё недостаточно доработан и не предоставляет всех возможностей, которые можно реализовать через консоль. Со всеми возможностями консольной версии можно ознакомиться в официальной книге по Radare2.
Что касается обратной разработки, то он оказался совсем не страшным и даже при начальном уровне знания языка Ассемблер можно разбираться в устройстве какого-нибудь простенького приложения. А в Корпоративных лабораторияx Pentestit можно попробовать свои силы не только в реверс-инжинеринге бинарных файлов, но и в деассемблировании Android/IOS приложений.