Скомпилированное приложение является «чёрным ящиком». Чтобы туда заглянуть, восстановить алгоритм работы применяется реверс‑инжиниринг. Это непростой навык с высоким порогом входа. В статье мы попробуем взять дизассемблер, несложную задачку и пойдём в бой. Материал будет полезен тем, кому хочется с чего-то начать и погрузиться в тему реверса.
В ходе нашего погружения разберем, какие инструменты использовать, с какой стороны подходить к решению подобных задач, разберём различные теоретические моменты. Для углублённого изучения будут ссылки на дополнительную литературу.
Задача
В качестве примера для разбора мы взяли задачу с платформы Codeby Games из раздела Реверс-инжиниринг – файлы. Называется: "Шифрование или что-то в этом роде, не знаю". Чтобы получить доступ к платформе – нужно зарегистрироваться. К самой платформе мы не имеем никакого отношения.
Решением задачи является строка (флаг), которую будем добывать.
Скачиваем бинарный файл “task3.exe” из раздела “Файлы” и приступаем.
В статье будут применяться бесплатные инструменты:
Редактор исполняемых файлов CFF Explorer
Дизассемблер IDA Freeware
Редактор Microsoft Visual Studio Community и компилятор MSVC
Первичный запуск
Все исполняемые файлы, скачанные из интернета, рекомендуем всегда сначала запускать внутри виртуальной машины. Это позволит первично обезопасить себя от вредоносной активности незнакомого приложения.
Также перед запуском рекомендуем анализировать исполняемые файлы специальными утилитами на предмет вредоносности. В случае установки анализаторов локально делать это также необходимо внутри виртуальной машины, так как некоторые из них автоматически запускают образцы, которые были приняты на анализ.
Рекомендуем анализаторы
VirusTotal (Есть бесплатная версия)
Hybrid Analysis (Есть бесплатная версия)
AnyRun (Есть бесплатная версия)
PEStudio (Есть бесплатная версия)
Capa (Бесплатно)
Современное вредоносное ПО часто взаимодействует с сетью как для доставки очередной "стадии вредоносности", так и для распространения в локальной сети. Рекомендуем либо целиком отключать виртуальную машину от сети, либо, если для исследуемого приложения требуется подключение в интернет, использовать VPN изнутри виртуальной машины. Это не даст образцу распространиться в сети вашей хостовой машины.
Теперь, когда обо всем предупредили, можно приступать к работе.
Запускаем приложение!
Перед нами классическое консольное приложение, которое запрашивает строку:
Пробуем ввести флаг и убеждаемся, что удача не на нашей стороне:
Но, теперь мы знаем, что проверка флага каким-то образом связана со строками INPUT YOUR FLAG: и INCORRECT!!!.
Запускаем дизассемблер IDA и открываем исполняемый файл. IDA предлагает использовать оставленную автором отладочную информацию:
Наличие отладочной информации всегда сильно упрощает дальнейший анализ.
Немного об отладочной информации
На сайте Касперского отладочные символы определяются, как набор специальных символов, который генерируется при компиляции программы и содержит сведения о расположении переменных и функций в итоговом бинарном файле, а также другую служебную информацию. Такой набор данных позволяет производить пошаговую отладку программы с исходным кодом или исследовать сторонний код.
Существует множество форматов отладочных символов. Чаще всего вам придется сталкиваться с этими:
PDB – Program Data Base, используется в продуктах, собранных с использованием компиляторов, разработанных компанией Microsoft.
DWARF (Debugging With Arbitrary Record Format), STABS (Symbol Table and String Table), COFF (Common Object File Format), ELF (Executable and Linkable Format) используются в основном в продуктах, собранных с использованием open-source компиляторов.
Mach-O – Mach Objecton macOS and iOS, используется только в продуктах, собранных различными компиляторами, для целевых ОС MacOs или iOS.
Как видим, отладочная информация может распространяться в различных форматах, отличающихся в зависимости от используемого компилятора, целевой операционной системы и желания разработчика.
Не стоит пугаться такому списку, потому что суть одна и та же. Разберем чуть подробнее, что хранят в себе DWARF и PDB – самые часто используемые форматы.
Информация DWARF обычно хранится в отдельном файле, который затем связывается (линкуется) с исполняемым файлом.
Основная информация, хранящаяся в файле DWARF:
имена файлов исходного кода и номера строк
информацию о глобальных и локальных переменных и их типах
информацию о функциях и подфункциях
граф вызовов программы
Информация PDB обычно также хранится в отдельном файле, который затем связывается с исполняемым файлом.
Основная информация, хранящаяся в файле PDB:
информацию об именах и типах переменных
информацию о функциях
информацию о местоположении строк кода
информацию о стеке вызовов
О том, как управлять своей жизнью, и самому включать отладочную информацию на примере отладчика gdb и исполняемого ELF-файла, советуем прочитать эти две статьи:
The GDB developer's GNU Debugger tutorial, Part 1: Getting started with the debugger
The GDB developer’s GNU Debugger tutorial, Part 2: All about debuginfo
В Visual Studio отладочная информация включается при наличии флага линковщика /DEBUG. Отключить можно в настройках проекта, поставив полю Linker -> Debugging -> Generate debug info значение NO.
Также в настройках линковщика можно отключить создание pdb файла, очистив поле Linker -> Debugging -> Generate Program Database File.
Если не отключить создание PDB-файла, в собранном исполняемом файле в секции данных будет лежать путь до этого файла.
Копаем глубже:
Больше о том, как исследователи используют отладочную информацию можно почитать в статье Utilizing Debugging Information of Applications in Memory Forensics.
Крис Касперски в статье Защита игр от взлома рекомендовал никогда не оставлять отладочную информацию в скомпилированной программе.
Начинаем анализировать
Теперь, когда есть поверхностное знание, что такое отладочная информация начнем анализ. Для этого откроем IDA, с использованием отладочной информации. Перед нами функция main.
С отладочной информацией дизассемблер сразу понял, что перед нами не набор бессмысленных переменных. По модификаторам <Тип данных> ptr
понимаем, что flag
, check
и i
это указатели на данные определенных типов.
Спускаемся ниже по коду к адресу 401598. По синтаксису команды mov [rbp+check+4], 7ABh видим, что в память по адресам check, check+4, check+8 и т.д. помещаются какие-то числа.
Копаем глубже
Больше о квадратных скобках в операндах команд и вообще о типах адресации можно почитать в статье Операнды в языке ассемблера.
Так как данные помещаются в check+N
по регулярному смещению 4, делаем вывод, что check является указателем на массив. Этих команд 25 штук, поэтому можем предположить, что массив хранит в себе 25 элементов длиной 4 байта. Если это верно, то далее, когда массив будет использоваться в циклах, скорее всего операции будут проводиться с двадцатью пятью элементами.
Отвлечемся немного и ответим на пару вопросов.
А почему каждая строка начинается с “.text”?
Исполняемый файл имеет множество участков, называемых секциями. Они имеют различное назначение. Код в исполняемых файлах хранится в секции .text. У этой секции есть специальные права на исполнение. Чтобы в этом убедиться, откроем исполняемый файл в редакторе CFF Explorer.
Загрузив файл в CFF Explorer, видим вкладки в левой части рабочего пространства:
Сначала идут заголовки, потом таблицы: импорта, экспорта и TLS. Ниже различные модули для работы с исполняемыми файлами, включенные в CFF Explorer.
Информация о секциях хранится в заголовке Section Headers. Откроем его:
Копаем глубже
О структуре исполняемых файлов Windows подробне прочитать в статье Часть 1. Обзор PE формата и других статьях цикла.
Статьи покрывают: из чего состоит исполняемый файл Windows, как искать секции, что такое импортируемые и экспортируемые функции и так далее многое другое.
А выжимка есть на хабре PE (Portable Executable): На странных берегах. Для начала ее вполне хватит.
Нас интересует поле Characteristics. Здесь содержится информация о данных, хранящихся в секции. У секции .text Characteristics имеет значение 60500060. Старшая 6 говорит о том, что у секции есть характеристики:
IMAGE_SCN_MEM_READ и IMAGE_SCN_MEM_EXECUTE – секция может быть исполнена как код
IMAGE_SCN_MEM_READ – секция может быть прочитана
Копаем глубже
Все возможные характеристики можно посмотреть в документации Microsoft.
А почему после .text следует “040…”?
У исполняемого файла есть параметр ImageBase – смещение относительно адреса в памяти, куда была загружена программа. Обычно это 0x40000. Этот параметр хранится в заголовке Optional Header:
Так почему же main()
не начинается с 400000? Потому что это адрес, где лежит весь исполняемый файл, в том числе заголовки. Заголовки находятся как раз над сегментом кода. Поэтому нас интересуют еще два поля Optional Header:
BaseOfCode – смещение относительно начала загруженного исполняемого файла (ImageBase). Здесь начинается сегмент кода text. В этой части размещается код начальной работы процесса: проверка сигнатуры исполняемого файла, получение аргументов main, StartupInfo для процесса и так далее.
AddressOfEntryPoint – смещение относительно ImageBase, откуда начинает исполняться программа. Здесь начинается функция mainCRTStartup, которая выполняется до человеческого main и является точкой входа библиотеки времени выполнения Си (C Runtime). Помимо прочего, здесь инициализируются статические элементы кода, а так же переменные argc, argv и envp. А еще, в этой функции помимо всего остального, инициализируются статические элементы (с пометкой static), если они используются в программе.
В нашем случае AddressOfEntryPoint равен 1510. Посмотрим на него в IDA. В IDA этот адрес будет равен ImageBase+AddressOfEntryPoint, т.е. 401510:
Здесь как раз видим вызов __tmainCRTStartup
, находящийся выше AddressOfEntryPoint, в котором инициализируется StartupInfo.
Дважды нажав на эту функцию, перейдем внутрь нее и немного пролистаем до момента, пока не увидим вызов функции main
(call main):
Здесь же видим, что в функцию main передаются привычные нам аргументы: argc, argv и envp. Это происходит всегда, даже когда разработчик приложения напрямую никак не взаимодействует с этими переменными в исходном коде.
Теперь вы знаете, как найти функцию main
самостоятельно и как она вызывается на самом деле, а так же что означают числа рядом с .text в дизассемблере.
Если отладочной информации нет
Давайте посмотрим, как будет выглядеть код без отладочной информаци. Откроем экземпляр IDA без использования отладочной информации. Увидим функцию main, только в менее понятном виде.
Дизассемблер не заметил, что перед ним массив. Поэтому нам нужно методом пристального взгляда определить границы массива: переменную, начиная с которой переменные размещаются регулярно с каким-то фиксированным смещением. В нашем случае это var_70
:
Затем укажем дизассемблеру наличие массива. Для этого дважды кликнем по первой переменной, с которой начинается массив. Перед нами откроется окно редактирования переменных стека:
Далее выделим все необходимые переменные и с помощью горячей клавиши '*' создадим массив:
Затем переименуем созданный массив с помощью горячей клавиши 'N':
В результате этих манипуляций, код будет выглядеть практически так же красиво, как и при наличии отладочной информации. Но это потребовало от нас определенных усилий.
В реверсе очень важно уметь находить подобные структуры, это сильно помогает при дальнейшем анализе.
Наконец-то реверс
Вернемся к коду с отладочной информацией и продолжим.
Перейдем в раздел Strings (shift+F12) и видим уже знакомую строку, которая приглашает ввести флаг:
Дважды кликаем по ней в открывшемся окне и попадаем в секцию, где хранятся инициализированные данные приложения, предназначенные только для чтения – .rdata
:
Чтобы посмотреть, где используется текущий адрес, жмем Ctrl+X и получаем список перекрестных ссылок. Механизм называется XRefs или перекрестные ссылки.
Переходим по единственной перекрестной ссылке:
Перейдя по ссылке, видим, что строка используется чуть ниже заполнения массива check
(адрес 401640) в функции puts
(вывод в стандартный поток вывода, в данном случае – консоль):
Ниже (адрес 40165D) замечаем вызов scanf
(форматированный ввод из стандартного потока ввода) с параметрами %s и указателем flag
. Значит, flag
– строка, вводимая пользователем.
Спускаемся еще ниже и видим зануление значения по адресу i
(адрес 401662) и на следующей строке безусловный переход на метку loc_4016b2
. Метка – название для адреса в секции кода. Переходим по этой метке, дважды нажав на loc_4016b2
.
Здесь (адрес 4016B2) мы видим сравнение i
с числом 24 (18h), условный переход на метку loc_40166B
, который сработает, если i
будет меньше или равно 24. Если i
будет больше 24, переход не произойдет и будет вызвана функция puts(“GOOD JOB, HACKER”)
:
Перейдя по метке loc_40166B
, видим, что в этом участке кода производятся манипуляции с массивами flag
(адрес 401670) и check
(адрес 401691):
Первым делом значение i
помещается в регистр eax
и расширяется до rax
командой cdqe
(адреса 40166B и 40166E). Физически eax
– младшая половина регистра rax
:
Копаем глубже
Подробнее о регистрах можно узнать в статье Регистры процессора.
Строкой ниже (адрес 401670) в eax
помещается значение, находящееся по адресу flag+rax
c помощью команды movzx
(rax
в два раза больше eax
, поэтому используется movzx
, где zx
означается, что данные, находящиеся в eax
дополняются нулями). Сопоставив эту строку с двумя предыдущими, понимаем, что в rax
помещается i-ый элемент массива flag
.
По адресу 401678 от регистра rax
остается только младший байт, и расширяется до знакового четырех байтового числа командой movsx eax, al
. Позже будет понятно зачем.
Далее для содержимого eax
вызывается функция enc
(строки 401682-401687). Параметр функции передается в регистре ecx
. Кликнув дважды enc
попадаем в ее тело:
Благодаря отладочным символам, видим, что функция возвращает тип int
–четырёхбайтовое знаковое число и имеет соглашение о вызовах cdecl
, а значит ее результат, возвращаемый в return будет храниться в регистре eax
. При этом, параметр letter
, передаваемый в функцию – четырёхбайтовое знаковое целое. Именно поэтому ранее мы видели расширение до четырёхбайтового целого.
Копаем глубже
На эту тему на хабре есть статья Соглашения о вызовах.
Расширение до четырех байт нужно для проверки модифицированного flag[i]
, имеющего однобайтовый тип с некоторым четырёхбайтовым элементом check
.
В функции видим, что ecx
сохраняется в переменную letter
(адрес 401574), а затем, строкой ниже, значение переменной сохраняется в eax
.
Следующей строкой со значением регистра eax
производят операцию xor eax, 0BADh
– побитового исключающего ИЛИ с числом 0x0BAD.
На этом тело функции заканчивается, значит, результатом ее исполнения является:
xor(flag[i], 0x0BAD)
Вернемся к коду, вызывающему эту функцию:
После вызова функции enc
видим (адрес 401682), что результат ее выполнения, хранящийся в eax
, увеличивается на 0x24 и для полученного значения снова вызывается функция enc
.
В результате второго выполнения enc
со значением flag[i]
произведено действие:
xor(xor(flag[i], 0x0BAD+0x24),0x0BAD)
Разберемся далее построчно:
40168C: результат второго выполнения enc сохраняется в
edx
40168E и 401691: значение i помещается в
rax
.401693: в
eax
сохраняется значениеcheck[rax*4]
, что означает сохранение вeax check[i*4]
401697-401699: полученное значение для i-го элемента флага сравнивается с
check[i*4]
, и если значения равны, происходит переход на меткуloc_4016AE
Переходим на метку и видим (адрес 4016AE) увеличение значения i
на единицу:
После увеличения значения i
снова попадаем на сравнение с 24. Так мы понимаем, что попали в цикл.
Подтвердить это предположение можно перейдя в графическое представление кода, нажав на пробел. Метка, на которой мы сейчас находимся на скриншоте подсвечена желтым:
В IDA зеленые стрелки означают верный результат при сравнении, красные – неверный, синие – безусловный переход (может быть как признаком команды jmp
, так и вовсе отсутствия команды j*).
Обратив внимание на условный переход jle
на метку loc_40166B
, понимаем – чтобы на нее не переходить и добиться вывода GOOD JOB, HACKER, необходимо пройти целиком весь цикл.
Алгоритм проверки флага
В результате обратной разработки функции проверки введенного флага, имеем уравнение, в котором неизвестным являетсяflag[i]
:
(flag[i] ^ 0x0BAD + 36) ^ 0x0BAD = check[i]
Так как xor является обратимой функцией, получим flag[i]
из check[i]
:
int flag[i] = (check[i] ^ 0x0BAD – 36) ^ 0x0BAD
Далее остается только взять младший байт от flag[i]
. Возьмем значения элементов массива из дизассемблированного кода и напишем простую программу на C:
int main()
{
int check[25];
char flag[25];
int i;
check[0] = 1983;
check[1] = 1963;
check[2] = 1952;
check[3] = 1953;
check[4] = 1982;
check[5] = 1973;
check[6] = 87;
check[7] = 70;
check[8] = 1969;
check[9] = 1967;
check[10] = 80;
check[11] = 1979;
check[12] = 93;
check[13] = 1979;
check[14] = 1967;
check[15] = 109;
check[16] = 73;
check[17] = 1964;
check[18] = 72;
check[19] = 111;
check[20] = 1979;
check[21] = 84;
check[22] = 108;
check[23] = 1966;
check[24] = 89;
for (int i = 0; i <= 24; i++)
{
flag[i] = (char)(((check[i] ^ 0x0BAD) - 36) ^ 0x0BAD);
}
printf(flag);
}
В результате выполнения получаем флаг, который вставляем в консоль и убеждаемся в его правильности.
В заключение скажем, что можно было так не страдать, сразу нажать волшебную кнопку F5, декомпилировать код и получить что-то вроде этого:
Однако, понимания в реверсе это не добавит. IDA не всегда декомпилирует во что-то читабельное (особенно при отсутствии отладочной информации), и все равно нужно сидеть над ассемблером и изучать, кто на ком стоял.
Заключение
В статье мы решили несложную задачу с применением реверс-инжиниринга. Познакомились с дизассемблером IDA и некоторыми его возможностями. Посмотрели, как выглядит код без отладочной информацией, и что в таком случае делать. Сделали теоретические отступления на тему секций и адресов.