Перейти к содержанию

Тренировка на покемонах (Введение в реверс-инжиниринг)#

Материал к занятию

Если вам нужна верситя mGBA для MacOs или Linux, то ищите здесь

В этом руководстве рассматривается процесс реверс-инжиниринга игры.
В качестве примера используется игра для Game Boy Advance — Pokémon Mystery Dungeon: Red Rescue Team, однако большинство описываемых концепций применимы и к другим играм.

https://ratcatcher.ru/media/alg/lec/prac_pok/debug-env.png

О чём это руководство?#

В этом руководстве пошагово рассматриваются следующие темы:

  • Настройка среды для обратной разработки.
  • Основы ассемблерного кода.
  • Использование инструмента обратной разработки Ghidra для анализа ассемблерного кода.
  • Работа с отладочными функциями эмулятора mGBA для изучения памяти игры и её отладки в процессе выполнения.
  • Стратегии обратной разработки для выявления игровых механик и данных.

Какие навыки необходимы для прохождения этого руководства?#

Понадобятся базовые знания программирования (в идеале — на C или C++), а также понимание следующих концепций:

  • Указатели
  • Структуры
  • Побитовые операции
  • Шестнадцатеричные числа (система счисления по основанию 16)
  • Навыки базового использования эмулятора для запуска игр
  • Интерес к реверс-инижиниргу игр!

Настройка среды для реверс-инжиниринга#

Для прохождения этого руководства понадобятся следующие приложения:

  • Ghidra — инструмент реверс-инжиниринга, используемый для статического анализа кода.
  • mGBA — эмулятор Game Boy Advance с функциями отладки и просмотра памяти.
  • ROM-образ игры Pokémon Mystery Dungeon: Red Rescue Team — именно эту игру мы будем анализировать. Обязательно используйте версию для США/NTSC, так как другие версии (например, EU/PAL) содержат отличия, влияющие на расположение данных и функций.

Установка Ghidra#

Ghidra — это бесплатный инструмент с открытым исходным кодом для реверс-инжиниринга, разработанный Агентством национальной безопасности США (NSA). Ghidra может анализировать бинарные данные, такие как ROM-образ игры, и преобразовывать их в ассемблерный код и даже в декомпилированный C-код, который можно прочитать и изучить в процессе реверс-инжиниринга. Такой тип анализа выполняется без запуска игры и называется статическим анализом кода (в отличие от динамического анализа, когда игра отлаживается в процессе выполнения).

https://ratcatcher.ru/media/alg/lec/prac_pok/ghidra.png

Ghidra — отличный инструмент для анализа кода и дизассемблирования, подходящий для энтузиастов: он полностью бесплатный и при этом функционально насыщенный. Существуют платные альтернативы, например, IDA, которая считается отраслевым стандартом для профессионального реверс-инжиниринга. Однако полная версия IDA стоит тысячи долларов и недоступна для многих любителей, а бесплатной версии не хватает функций, необходимых для нашей задачи.

Чтобы установить Ghidra, следуйте инструкциям на официальном сайте Ghidra.

Если вы используете современную версию macOS, перед созданием проекта потребуется дать разрешение на запуск декомпилятора Ghidra. Перейдите в папку Ghidra/Features/Decompiler/os/mac_x86_64, щёлкните правой кнопкой мыши по исполняемому файлу decompile, выберите Открыть и подтвердите запуск, когда macOS предупредит о невозможности проверить разработчика.

После установки запустите Ghidra — вы увидите следующий экран.

https://ratcatcher.ru/media/alg/lec/prac_pok/ghidra-start.png

Создадим новый проект с использованием ROM-образа Red Rescue Team:

  1. Перейдите в File > New Project…
    Тип проекта по умолчанию — Non-Shared Project. Нажмите Next.

  2. Укажите имя проекта и каталог для его сохранения, затем нажмите Finish.

  3. Перейдите в File > Import File…
    Найдите и выберите ROM-файл Red Rescue Team на вашем компьютере.

  4. Выберите архитектуру команд (Language), которую Ghidra будет использовать для анализа бинарного файла.
    Game Boy Advance использует набор инструкций ARMv4T в формате little-endian.
    В поле выбора языка выполните поиск по слову v4t и выберите вариант с пометкой little в колонке Endian:
    ARM:LE:32:v4t.

https://ratcatcher.ru/media/alg/lec/prac_pok/armv4t.png

5 Нажмите Options… и установите Base Address в значение 08000000.

6 Нажмите OK, чтобы Ghidra начала анализировать бинарный файл.

7 По завершении Ghidra покажет отчёт с некоторыми деталями об инспекции. Нажмите OK для продолжения.

8 Дважды кликните по ROM в Ghidra, чтобы открыть просмотр кода.

9 Ghidra предложит проанализировать бинарный файл. Нажмите Yes.

10 Используйте настройки анализа по умолчанию и нажмите Analyze.

11 Подождите, пока Ghidra завершит анализ — это может занять пару минут.

12 После завершения анализа проверьте, что Ghidra настроена правильно: нажмите клавишу g, введите значение 80450E0, затем нажмите OK.

Вы должны увидеть экран, похожий на изображение ниже.
На этом этапе Ghidra готова к началу реверс-инжиниринга игры.

https://ratcatcher.ru/media/alg/lec/prac_pok/ghidra-setup.png

Особенности настройки#

Как узнать, что нужно использовать язык ARM:LE:32:v4t, если бы я вам не подсказал? Сначала найдите, какой процессор используется в GBA — это ARM7TDMI (например, в статье на Wikipedia). Зная процессор, можно узнать, какую архитектуру он поддерживает: документация ARM указывает, что ARM7TDMI использует архитектуру ARMv4T.

Что касается порядка байт (endianness), большинство ARM-процессоров являются bi-endian и поддерживают как little endian, так и big endian. На практике большинство ARM-программ используют little endian. Если при выборе little endian в Ghidra получается нечитаемый дизассемблированный код, попробуйте big endian — возможно, вывод станет лучше. Обратите внимание, что некоторые процессоры, например, с набором инструкций PowerPC (например, Wii), преимущественно используют big endian.


Настройка mGBA с Red Rescue Team#

Помимо Ghidra, необходимо настроить эмулятор GBA и пройти начальную часть игры.

  1. Скачайте последнюю версию mGBA для вашей операционной системы с официального сайта mGBA.
  2. Установите mGBA, следуя инструкциям по установке.
  3. Запустите mGBA и выберите File(Файл) > Load ROM (Загрузить ROM). Найдите и откройте ROM-файл Red Rescue Team.
  4. При необходимости настройте кнопки управления через Preferences > Controllers (**PИнструменты -> Настройки -> Клавиатура**P).
  5. Просмотрите вступительную катсцену игры, затем начните новую игру.
  6. В начале игры вам предстоит пройти тест на определение личности, который выберет вашего игрового персонажа. Отвечайте честно или нет — игра присвоит вам покемона.
  7. Выберите имя для себя и покемона-напарника .
  8. После выбора персонажей начнётся катсцена, после которой вы попадёте в первый подземелье — Tiny Woods.
  9. Как только вы войдёте в Tiny Woods, вы готовы начать реверс-инжиниринг игры.

https://ratcatcher.ru/media/alg/lec/prac_pok/mgba-setup.png

После настройки Ghidra и самой игры следующим шагом будет открыть игру и изучить её код. Для этого потребуется умение читать ассемблерный код, чему будет посвящён следующий раздел.

Введение в ассемблер#

Ассемблер (assembly или ASM) — это низкоуровневый язык программирования, который тесно соответствует машинным инструкциям (двоичному коду), выполняемым процессором. По сравнению с языками высокого уровня, такими как C, ассемблер использует меньше абстракций. Например, вызов функции в C — это одна строка кода, а в ассемблере эта операция соответствует нескольким строкам. Позже мы рассмотрим конкретные примеры.

В этом руководстве даётся краткое введение в ассемблер, чтобы вы могли начать работу. Для краткости некоторые детали опущены. Если вас интересует более подробное руководство по ассемблеру GBA, ознакомьтесь с руководством Tonc — Whirlwind Tour of ARM Assembly. Помимо ассемблерного гида, у Tonc есть подробная документация по устройству консоли GBA.

Разные процессоры используют разные языки ассемблера в зависимости от набора поддерживаемых ими инструкций (instruction set). Все процессоры поддерживают минимальный набор инструкций, необходимых для работы компьютера, но дополнительные инструкции служат своего рода сокращениями, позволяя программе компилироваться в меньшее количество строк кода и работать быстрее. Это достигается за счёт усложнения аппаратной части процессора и увеличения размера инструкций. В данном руководстве будет использоваться набор инструкций THUMB, так как именно он используется в коде Red Rescue Team. В дальнейшем слово «ассемблер» в этом руководстве будет обозначать «язык ассемблера набора инструкций THUMB» — это распространённое сокращение в контексте конкретной игры.

Кроме THUMB, процессор GBA поддерживает ещё один набор инструкций — ARM, который содержит более сложные инструкции, позволяющие писать код короче. Однако набор ARM выходит за рамки данного руководства, хотя основные концепции можно применять и к любому ассемблерному языку.

Вот простой пример инструкции присваивания в ассемблере:

movs r0,#0x0

Эта команда присваивает регистру r0 значение 0 (#0x0). Регистры — что это такое? Давайте поговорим об этом сначала.

Регистры#

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

В процессорах ARM есть несколько регистров, которые обозначаются как rX, где X — номер регистра, начиная с нуля. В процессоре GBA всего 16 регистров: от r0 до r15, каждый из которых может хранить до 32 бит (4 байта) данных, всего — 512 бит (64 байта).

r15 — особый регистр, называемый счётчиком команд (Program Counter, PC). В нём хранится адрес следующей инструкции для выполнения. После выполнения инструкции значение PC автоматически увеличивается на 2 (поскольку большинство инструкций занимает 2 байта), что обеспечивает последовательное выполнение кода. Можно изменить значение PC вручную — в этом случае процессор «прыгает» к новому адресу и начинает выполнять код оттуда. Это используется для условных операторов, циклов и вызова функций.

Некоторые другие регистры также имеют специальные имена и функции, о которых мы поговорим позже.

Память#

Объём регистров ограничен, поэтому если программе нужно сохранить больше данных, чем помещается в регистрах, данные записываются в основную память — RAM (оперативную память). Работа с RAM медленнее, чем с регистрами, но память значительно больше по объёму. В GBA доступно 288 КБ RAM для обычных операций, а также выделена дополнительная память для специальных функций, например, для ввода-вывода.

ПЗУ (ROM)#

Файл ROM (память только для чтения), с которого загружается игра, по сути представляет собой массив байт. В нём хранятся ассеты — спрайты, аудио, значения данных, используемые логикой игры (например, сколько здоровья восстанавливает предмет), и сам код игры.

При работе с данными ROM обычно обращаются к ним по адресу (смещению) с начала ROM, начиная с нуля. Например, первый байт ROM имеет адрес 0x0, пятый — 0x4. Адреса почти всегда записываются в шестнадцатеричной системе счисления (префикс 0x указывает на это). Шестнадцатеричная система также часто используется в реверс-инжиниринге.

Когда GBA загружает ROM, данные ROM размещаются в памяти в диапазоне адресов от 0x08000000 до 0x09FFFFFF. Этот участок памяти часто называют «ROM», хотя технически он находится в оперативной памяти. В игре доступ к данным ROM осуществляется с прибавлением смещения 0x08000000. Например, данные по адресу 0x4 в ROM доступны по адресу 0x08000004. В контексте ROM адреса 0x4 и 0x08000004 считаются взаимозаменяемыми.

При настройке Ghidra ранее вы указывали базовый адрес ROM — 0x08000000. Это соответствует месту загрузки ROM в памяти GBA и помогает Ghidra лучше анализировать данные.

Инструкции#

Инструкции служат для обработки данных в регистрах и памяти. Процессор выполняет инструкции последовательно, следуя значению счётчика команд (PC).

Каждая инструкция кодируется в 2 или 4 байта. В таком виде она называется машинным кодом (binary code или machine code). Инструменты реверс-инжиниринга преобразуют эти байты в более понятный человеку вид — ассемблерный код.

Операция присваивания (Assignment)#

Пример инструкции присваивания:

movs r0,#0x0

Эта инструкция состоит из трёх частей:

https://ratcatcher.ru/media/alg/lec/prac_pok/movs.png

Мнемоника: сокращённое имя операции, которую нужно выполнить. Инструкция movs присваивает значение регистру. Иногда вместо неё используется просто mov.

Приёмник (Destination): регистр, в который будет записано значение. В этой инструкции это регистр r0.

Источник (Source): место, откуда берётся значение для записи. В данном случае — это константное значение 0; в ассемблерной терминологии это называется непосредственным значением (immediate value).

Объединяя все три части, эта инструкция присваивает регистру r0 значение 0, затирая предыдущее значение.

Также в качестве источника может выступать другой регистр. Например, следующая инструкция скопирует значение из регистра r1 в r0:

mov r0,r1

Tip

Дополнительная буква «s» в мнемонике movs используется в некоторых языках ассемблера для различения типов операций присваивания, которые устанавливают флаги или автоматически увеличивают регистры, ускоряя распространённые операции, например, присваивание строк. В ассемблере THUMB такого различия нет, поэтому movs и mov используются взаимозаменяемо. Эта буква «s» также встречается в других мнемониках.

Арифметика#

Рассмотрим другой тип инструкции. Ниже показана инструкция сложения.

adds r0,r1,#0x1

Эта инструкция прибавляет 1 к значению в регистре r1 и сохраняет результат в регистр r0.

r0 — регистр назначения, как и раньше. Для операции сложения требуется два операнда — источниками выступают r1 и непосредственное значение #0x1. Можно складывать либо регистр с непосредственным значением, либо два регистра. Если используется непосредственное значение, оно всегда должно быть последним — это связано с особенностями аппаратной реализации инструкции.

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

adds r0,#0x1

Эта инструкция прибавляет 1 к значению в регистре r0 и сохраняет сумму в r0. Иными словами, она увеличивает значение в r0 на единицу.

Другие доступные арифметические операции включают вычитание, умножение, отрицание, побитовые операции AND/OR/XOR/NOT, а также логические и арифметические (беззнаковые/знаковые) сдвиги влево и вправо. Некоторые из этих операций более ограничены, чем сложение — например, не поддерживают непосредственные значения, хотя все они имеют схожую структуру. Полный список поддерживаемых инструкций можно найти в документации для разработчиков ARM.

Tip

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

Загрузка/сохранение в память#

Регистры могут хранить лишь ограниченное количество значений, поэтому основная часть данных программы хранится в памяти. Напомним, что память (также называемая основной памятью или ОЗУ) — это место, где хранится большинство данных, поскольку регистры имеют ограниченную ёмкость.

Вот инструкция, которая сохраняет данные в память.

str r0,[r1,#0x4]

Мнемоника str означает «store register» — сохранение значения из регистра в память.
r0 — исходный регистр, содержащий значение для записи в основную память.
Значения в скобках r1 (регистр) и #0x4 (непосредственное значение или регистр) складываются, и сумма используется как адрес в памяти, куда будет сохранено значение. Непосредственное значение может быть 0, чтобы сохранить значение напрямую по адресу из r1.

Например, если r0 равно 3, а r1 — 0x2000000, то значение 3 будет записано по адресу 0x2000004 (0x2000000 + 4). Поскольку значение регистра занимает 4 байта, оно сохранится в адресах памяти от 0x2000004 до 0x2000007 включительно.

Альтернативные инструкции strh и strb используются для сохранения младших 2 байт (halfword) и 1 байта соответственно из исходного регистра.

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

ldr r0,[r1,#0x4]

Мнемоника ldr означает «load register» — загрузка значения в регистр.
Эта команда загружает данные по адресу, который получается сложением значения регистра r1 и непосредственного смещения 4, и сохраняет загруженное значение в r0. Аналогично str, существуют инструкции для загрузки половинных слов (ldrh) и отдельных байтов (ldrb).

Кроме загрузки по адресу из регистра, ldr может также загружать жёстко заданные значения, прописанные в ассемблерном коде. В Ghidra такие значения обозначаются как DAT_<address>, где <address> — адрес значения в ROM.

ldr r0,[DAT_08090fe8]
...
DAT_08090fe8
  02000010

Эта инструкция загрузит значение 0x2000010 в регистр r0.

Ghidra для удобства отмечает данные значениями в формате DAT_<address>. На самом деле инструкция ldr содержит 10-битное смещение от адреса инструкции в ROM до адреса данных.

Переходы (Branches)#

До этого момента все инструкции выполнялись последовательно, строка за строкой. Процесс такой:

  1. Выполнить инструкцию по адресу 0x8000000.
  2. Увеличить счетчик команд (PC) на 2 (размер инструкции в байтах).
  3. Выполнить инструкцию по адресу 0x8000002.
  4. Увеличить PC на 2.
  5. Выполнить инструкцию по адресу 0x8000004.
  6. И так далее.

Переходы (branches), также известные как условные переходы или прыжки, — это инструкции, которые устанавливают счетчик команд в заданное значение. Это вызывает переход к указанной инструкции, изменяя порядок выполнения программы.

Ниже показана инструкция безусловного перехода.

b LAB_08090fde

LAB_08090fde — это метка (label). Метка указывает на конкретную инструкцию в памяти; в данном случае метка ссылается на инструкцию по адресу 0x8090FDE в ROM.

Продолжая предыдущий пример, если инструкция перехода находится по адресу 0x8000002:

  1. Выполнить инструкцию по адресу 0x8000000.
  2. Увеличить счетчик команд до 0x8000002.
  3. Выполнить инструкцию b LAB_08090fde по адресу 0x8000002.
  4. Инструкция перехода устанавливает счетчик команд в 0x8090FDE.
  5. Выполнить инструкцию по адресу 0x8090FDE.
  6. Увеличить счетчик команд до 0x8090FE0.
  7. Выполнить инструкцию по адресу 0x8090FE0.
  8. И так далее.

После перехода счетчик команд продолжает увеличиваться и инструкции выполняются последовательно.

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

Кроме инструкции b, существуют и другие безусловные инструкции перехода для особых случаев:

  • bl: инструкция перехода для вызова функций (будет рассмотрено позже).
  • bx: переход к адресу, который хранится в регистре.

Условные переходы#

Возможны инструкции перехода, которые выполняются только при выполнении определённого условия. Если условие не выполняется, инструкция условного перехода пропускается, а счётчик команд увеличивается и программа продолжает выполнение следующей инструкции.

Условный переход обычно состоит из двух инструкций. Пример:

cmp r0,#0x1
beq LAB_08090f14

Если в этом наборе инструкций значение в регистре r0 равно 1, программа перейдёт к метке LAB_08090f14. Если r0 не равен 1, переход пропускается, и выполняется следующая инструкция.

Инструкция cmp используется для подготовки условного перехода путём сравнения двух значений. Первым всегда является регистр, а вторым — либо непосредственное значение, либо другой регистр.

Все условные инструкции перехода начинаются с буквы b и заканчиваются расширением (условным кодом), указывающим условие для перехода. В данном случае eq означает, что переход будет выполнен, если сравниваемые значения равны.

Поддерживаются все основные операторы сравнения:

  • Равно: beq
  • Не равно: bne
  • Больше: bgt, bhi
  • Больше или равно: bge, bcs
  • Меньше: blt, bcc
  • Меньше или равно: ble, bls

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

Высокоуровневые языки, такие как C, используют условные конструкции (if / else if / else) и циклы (while / do while / for), которые при компиляции обычно преобразуются в условные переходы на языке ассемблера.

Tip

Внутри процессора инструкция cmp устанавливает четыре 1-битовых флага условий: C, N, V и Z. Каждая условная инструкция перехода проверяет определённые флаги, чтобы решить, выполнять переход или нет. Например, инструкция beq выполнит переход, если флаг Z равен 1.

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

Функции#

Концептуально функции в ассемблере работают аналогично функциям в языках высокого уровня. Функцию можно вызвать (call), после чего она выполнится, а затем управление вернётся к коду, который её вызвал. Функции могут принимать параметры и возвращать значения.

Рассмотрим, как вызывается функция:

bl FUN_080450f8

bl — специальная команда перехода, используемая для вызова функций. В данном случае программа перейдёт к первой инструкции функции FUN_080450f8. Перед переходом текущее значение счётчика команд (program counter) сохраняется в регистр ссылки (link register, lr), который соответствует r14 в процессоре GBA. Значение регистра ссылки будет использовано в конце функции, чтобы вернуть управление обратно к месту вызова функции.

Пример простой функции:

FUN_080450f8

ldr r0,[r0,#0x0]
bx lr

По умолчанию Ghidra даёт функциям имена по адресу памяти, с которого они начинаются. Эта функция начинается по адресу 0x80450F8, поэтому её имя — FUN_080450f8.

Большинство функций состоит из трёх частей: пролог, тело и эпилог. Пролог и эпилог содержат стандартные операции подготовки и завершения выполнения функции, а тело — основную логику.

В приведённой функции:

  • Пролога нет, так как функция достаточно простая.
  • Тело содержит инструкцию ldr r0, [r0, #0x0].
  • Эпилог состоит из инструкции bx lr, которая устанавливает pc равным значению в lr, что приводит к возврату в место вызова функции.

После возврата из функции в вызывающую, счётчик команд (pc) увеличивается как обычно, и выполнение программы продолжается со следующей инструкции после вызова функции.

Аргументы/параметры функции#

Для передачи аргументов в функцию значения записываются в регистры перед её вызовом. Регистрs r0r3 используются для передачи первых четырёх аргументов. Если аргументов больше четырёх, остальные помещаются в область памяти, называемую стеком (об этом будет рассказано позже).

В приведённом коде значение записывается в регистр r0 для передачи его в функцию FUN_080450f8 в качестве аргумента.

...
adds r0,r4,#0x0
bl FUN_080450f8

После входа в функцию она может использовать переданный аргумент из регистра r0.

FUN_080450f8

ldr r0,[r0,#0x0]
...

Возвращаемые значения#

Если функция должна вернуть значение, возвращаемое значение записывается в регистр r0 непосредственно перед возвратом из функции. Вызывающая функция может использовать это значение из r0 по своему усмотрению.

В функции FUN_080450f8 тело функции присваивает значение регистру r0 перед возвратом с помощью инструкции bx.

FUN_080450f8

ldr r0,[r0,#0x0]
bx lr

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

bl FUN_080450f8
cmp r0,#0x1
...

Стек вызовов#

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

Регистры r0–r3 и r12 считаются временными регистрами (scratch registers) и не сохраняются функцией, если используются. Регистры r4–r11сохраняемые регистры (preserved registers), значения которых функция должна сохранить и восстановить. Регистр lr также сохраняется, если функция вызывает другие функции.

Поскольку функция может вызывать другую функцию, которая в свою очередь вызывает ещё одну и так далее, каждая функция должна правильно сохранять и восстанавливать значения регистров. Для этого используется специальное место в памяти — стек вызовов (call stack).

Стек вызовов, или просто стек, — это специальная область памяти для сохранения значений регистров при вызове функций. Также в стеке хранятся локальные переменные, если регистров недостаточно. Стек работает по принципу «последним пришёл — первым вышел» (LIFO).

В большинстве игр для GBA стек располагается в памяти по адресам от 0x03000000 до 0x03007FFF. Верхушка стека изначально находится по адресу 0x03007FFF. При помещении значений в стек (push) верхушка стека сдвигается вниз (адрес уменьшается). Адрес верхушки стека хранится в регистре r13, который называется указателем стека (stack pointer, sp).

Основная задача пролога функции — сохранить значения регистров в стеке. Значения регистров помещаются в стек по адресу sp с помощью инструкции push. При этом sp уменьшается, отражая новую верхушку стека.

Ниже пример пролога, сохраняющего значение регистра.

push { lr }

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

В эпилоге функции сохранённые значения регистров восстанавливаются с помощью инструкции pop. Это соответствует приведённому выше прологу.

pop { r1 }
bx r1

Инструкция pop удаляет значение с вершины стека и присваивает его указанному регистру. Кроме того, pop увеличивает значение sp, чтобы переместить вершину стека за извлечённые элементы.

Когда регистр lr помещается в стек с помощью push, его обычно извлекают в r0 или r1 в эпилоге, чтобы последующая инструкция bx могла вернуться к вызывающей функции. Если функция уже использует r0 для возврата значения, то адрес возврата извлекается в r1; в противном случае — в r0.

Если нужно сохранить и восстановить другие регистры, пролог и эпилог могут выглядеть следующим образом:

push { r4, r5, r6, r7, lr }
...
pop { r4, r5, r6, r7 }
pop { r1 }
bx r1

Вместе с lr будут сохранены и восстановлены значения регистров r4r7.

Помимо сохранения и восстановления значений регистров, стек используется ещё для нескольких других целей.

Локальные переменные в стеке#

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

sub sp,#0x1c
...
str r0,[sp,#0x4]
...
add sp,#0x1c

В прологе функции из регистра sp вычитается 0x1c, чтобы выделить место для локальных переменных.
Функция сохраняет и загружает локальные переменные из стека, используя смещение от sp.
В эпилоге функции выделенное под локальные переменные место освобождается путём прибавления значения обратно к sp.

В Ghidra локальным переменным в стеке присваиваются имена вида local_24, основанные на их положении в стеке, а не отображается прямое смещение от sp.
Число получается вычитанием смещения переменной (0x4) из общего размера области локальных переменных (0x1c), после чего результат преобразуется в десятичное значение:
0x1c - 0x4 = 0x18 = 24.

Аргументы функции в стеке#

Для передачи аргументов в функцию выделено четыре регистра. Если аргументов больше четырёх, дополнительные передаются, записываясь в stack. Также stack используется для передачи больших типов данных, таких как struct и array.

Ниже приведён пример передачи пяти аргументов в функцию.

// Prologue
sub sp,#0x4
...
// Body
str r2,[sp,#0x0]
adds r0,r5,#0x0
adds r1,r4,#0x0
mov r2,r9
adds r3,r6,#0x0
bl FUN_08073b78
...
// Epilogue
add sp,#0x4

Как и с локальными переменными в stack, пролог вычитает из sp, чтобы выделить место в стеке для передачи аргумента функции через стек. Когда вызывается функция FUN_08073b78, регистры r0r3 используются для четырёх аргументов, а стек применяется для последнего аргумента (установленного с помощью str r2,[sp,#0x0]). Эпилог добавляет к sp, очищая выделенное место в стеке.

Выпускаем Гидру!#

Ghidra — мощный инструмент для реверс-инжиниринга с богатым набором функций. В этом разделе рассматриваются базовые приёмы работы с Ghidra, чтобы помочь вам начать читать ассемблерный код.

Интерфейс Ghidra выглядит следующим образом:

https://ratcatcher.ru/media/alg/lec/prac_pok/ghidra(1).png

В рабочем пространстве Ghidra есть две основные области, которые мы будем использовать.

  • Центр — окно listing. Здесь отображается ассемблерный код.
  • Окно справа — декомпилятор, который анализирует ассемблерный код выбранной функции и преобразует его в C.

В интерфейсе Ghidra есть и другие окна, но в этом руководстве они рассматриваться не будут. Их можно закрыть, чтобы увеличить пространство для окна listing.

Для демонстрации воспользуемся функцией по адресу 0x80450E0. Чтобы перейти к этому адресу, нажмите g и введите 80450E0. Немного пролистайте вниз, чтобы увидеть всю функцию, которая выглядит следующим образом:

https://ratcatcher.ru/media/alg/lec/prac_pok/ghidra-example-function.png

Давайте разберёмся, что происходит на экране.

https://ratcatcher.ru/media/alg/lec/prac_pok/ghidra-example-function-labeled.png

  • Имя функции говорит само за себя.
  • В ассемблерный код можно добавлять комментарии. Пояснительный комментарий (plate comment) занимает несколько строк. Ghidra автоматически добавляет такие комментарии для обозначения функций, и вы сами можете добавлять их (об этом поговорим позже).

  • Раздел «references to function» показывает все места в ассемблерном коде, откуда вызывается текущая функция. Список представлен в формате «имя-функции:адрес-инструкции». В данном случае Ghidra нашла 380 вызовов FUN_080450e0, один из которых — в функции FUN_0803edf0 с инструкцией bl по адресу 0x803EE08.

  • «Assembly instructions» — это собственно ассемблерный код, а также метки для переходов и жёстко заданные значения данных.

  • «Hex data» содержит сырые шестнадцатеричные значения из ROM-файла, соответствующие ассемблерным инструкциям. Например, инструкция push { lr } в начале FUN_080450e0 представлена в ROM значением 00 b5.

  • «Addresses» показывает адреса или смещения каждой инструкции относительно начала файла. Так, инструкция push { lr } в начале FUN_080450e0 находится по адресу 0x80450E0 в ROM.

  • В разделе «branches» показаны стрелки, обозначающие переходы внутри функции: начало стрелки — инструкция перехода, конец стрелки — инструкция, на которую устанавливается pc.

  • «References to labels» — это список всех переходов к каждой метке с указанием адреса инструкции перехода. Например, метка LAB_080450ea используется инструкцией bne по адресу 0x80450E4.

  • В окне декомпилятора отображается декомпилированный код функции на C.

Декомпилятор#

Декомпилятор — это инструмент, который пытается преобразовать ассемблерный код в код на языке C. Поскольку C — язык более высокого уровня, иногда читать декомпилированный код на C проще и быстрее, чем анализировать ассемблер при изучении работы функции.

Однако, учитывая наличие декомпилятора, может возникнуть вопрос: зачем учиться читать ассемблер, если его можно декомпилировать в C?

  • Код на C генерируется автоматически, а не пишется вручную, поэтому зачастую он хуже читается, чем исходный ассемблер.
  • Декомпилятор не совершенен и иногда не может корректно обработать функцию. Например, с таблицами переходов (jump tables) у него часто возникают проблемы.
  • При отладке игры в реальном времени вы будете пошагово выполнять именно ассемблерный код, а не C.
  • В зависимости от целей реверс-инжиниринга зачастую требуется работать именно с ассемблером, а не с декомпилированным кодом. Например, если нужно создать патч к игре путём изменения кода, делать это придётся через ассемблер (если только сообщество реверс-инженеров не разработало инструменты для внедрения скомпилированного кода на C в ROM — что встречается редко).

Рассматривайте декомпилятор как один из инструментов в наборе, а не как единственный способ. Скорее всего, вы будете переключаться между чтением ассемблера и декомпилированного кода на C в зависимости от текущей задачи.

Навигация#

В Ghidra есть несколько способов перемещения по коду.

  • Как показано ранее, нажатие клавиши g (Go To…) позволяет перейти к конкретному адресу в ROM.
  • Двойной клик по функции, метке или ссылке на функцию/метку перемещает к соответствующему месту в ROM.
  • На панели инструментов вверху окна Ghidra есть кнопки для перехода назад и вперёд. Эти кнопки отслеживают вашу историю переходов, например, при использовании Go To… или кликах по функциям и меткам. Наведите курсор на кнопки, чтобы увидеть горячие клавиши (они могут отличаться в зависимости от ОС).

Выделение#

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

  • Клик по строке ассемблера выделит соответствующую строку декомпилированного кода на C и наоборот.
  • Средний клик по символу выделит все его вхождения. Например, средний клик по r1 в ассемблере выделит все остальные места использования r1.
  • Правый клик по переменной в декомпиляторе с выбором Highlight > Forward Slice или Highlight > Backward Slice выделит все переменные, данные которых связаны с выбранной (по потоку данных вперёд или назад).

Комментирование#

Вы можете добавлять комментарии к ассемблерному коду, щёлкнув правой кнопкой мыши по инструкции и выбрав в контекстном меню пункт Comments.
Доступны варианты размещения комментария в нескольких местах: в конце строки (EOL), перед строкой (pre-line), после строки (post-line) и в виде блока (plate comments).

По умолчанию клавиша ; открывает интерфейс для добавления комментария в конце строки (EOL), а другие типы комментариев можно назначить на горячие клавиши в Edit > Tool Options > Key Bindings.

Ниже пример комментария в конце строки (EOL).

https://ratcatcher.ru/media/alg/lec/prac_pok/eol-comment.png

Настоятельно рекомендуется активно использовать комментарии. В ассемблере нет описательных имён переменных, поэтому легко запутаться, если не оставлять себе заметки.

Переименование переменных#

Как только вы поймёте, что делает функция, метка перехода, значение в стеке или данные, вы можете переименовать их, щёлкнув правой кнопкой мыши по метке и выбрав Edit label (горячая клавиша L).
Также можно редактировать имена переменных в декомпиляторе.

https://ratcatcher.ru/media/alg/lec/prac_pok/rename-label.png

Иногда дизассемблер Ghidra автоматически назначает регистрам метки параметров. Это редко бывает полезно, поскольку регистры часто переиспользуются для многих переменных в течение функции.
Примером может служить функция FUN_08043af4.

https://ratcatcher.ru/media/alg/lec/prac_pok/register-labels.png

Чтобы удалить эти метки, перейдите в меню Edit > Tool Options…, затем выберите Options > Listing Fields > Operands Field и снимите галочку с Markup Register Variable References.

https://ratcatcher.ru/media/alg/lec/prac_pok/register-labels-disable.png

Таблицы переходов#

Jump tables создают сложности для дизассемблера Ghidra и требуют доработки для повышения читаемости.

Пример использования jump table — функция FUN_08048f28.

https://ratcatcher.ru/media/alg/lec/prac_pok/jump-table.png

Сам jump table Ghidra распознаёт правильно, но логика в операторах case дизассемблируется некорректно — вместо кода отображаются сырые данные в байтах.

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

https://ratcatcher.ru/media/alg/lec/prac_pok/jump-table-code-fixed.png

Когда строка кода дизассемблируется вручную, Ghidra дизассемблирует также другие строки кода, доступные из этой начальной строки, включая переходы по ветвлениям. Для jump table это значит, что будет дизассемблирован весь код выбранного case.

Вам нужно будет аналогично дизассемблировать остальные case-ветки. Рекомендуется дизассемблировать каждый case отдельно, а не все сразу — это снижает риск случайного дизассемблирования данных, например, жестко заданных значений.

Заметки по разделу

К этому моменту мы изучили достаточно функций Ghidra, чтобы начать анализировать ассемблерный код игры. Ghidra предлагает множество других полезных инструментов для реверс-инжиниринга, поэтому вы можете продолжить исследовать её самостоятельно и найти те функции, которые окажутся наиболее полезными для вас.

Ghidra отлично подходит для статического анализа кода (когда игра не запущена). Хотя этого часто бывает достаточно, полезно дополнить анализ изучением кода в реальном времени во время работы игры. В следующем разделе мы сосредоточимся на отладке с помощью mGBA.

Отладка с помощью mGBA#

При реверс-инжиниринге часто полезно изучать состояние игры во время её работы — этот процесс называется динамическим анализом. Он включает:
- Просмотр значений в памяти,
- Пошаговое выполнение ассемблерного кода,
- Анализ регистров процессора.

В этом разделе мы кратко рассмотрим некоторые возможности отладки, которые предоставляет mGBA.

Средство прросомтра памяти (Memory viewer)#

Просмотрщик памяти позволяет просматривать и редактировать значения в основной памяти. Доступ к просмотрщику памяти можно получить через Tools -> Game state views -> View Memory… (Инструменты-> Просмотр состояния игры -> Просмотр памяти), что откроет окно, выглядящее как на изображении ниже. Обратите внимание, что вы можете выбрать View Memory… несколько раз, чтобы открыть несколько окон просмотрщика памяти.

https://ratcatcher.ru/media/alg/lec/prac_pok/memory-viewer.png

На изображении выше просмотрщик памяти показывает значения памяти с адресов от 0x0 до 0x2CF. Данные в байтах отображаются в табличном формате, где первая цифра адреса представляет собой столбец, а кратные 0x10 значения - строки. Например, строка 0x10 содержит значения с адресов 0x10 до 0x1F, а адрес 0x14 содержит значение 0x66. По умолчанию отображаются отдельные байты, но можно изменить вид на полуслова (2 байта) или слова (4 байта), чтобы отображать более крупные значения, такие как 4-байтовые указатели.

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

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

Согласно карте памяти GBA, адреса около 0x0 — это внутренние значения BIOS, используемые ядром системы GBA, поэтому они не представляют особого интереса для наших целей. Вы можете перейти к любому адресу в памяти, введя адрес в поле Inspect Address (Найти адрес) в правом верхнем углу.

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

Далее перейдите по адресу 0x200419E. В этой области хранятся дополнительные значения, связанные с покемоном игрока и напарника. Измените значение по адресу 0x200419E на 08, и вы увидите, что здоровье игрока (HP) изменится в игре в соответствии с введённым значением.

https://ratcatcher.ru/media/alg/lec/prac_pok/edit-memory.png

Средство просмотра памяти обладает и другими функциями, такими как сохранение/загрузка значений в больших объёмах, но я не буду рассматривать их в этом руководстве. Вы можете изучить эти функции самостоятельно.

Поиск в памяти#

Просмотр поиска по памяти позволяет искать в памяти конкретные значения. К нему можно получить доступ через меню Tools > Game state views > Search memory…, (Инструменты-> Просмотр состояния игры -> Поиск в памяти) после чего откроется окно, показанное ниже.

https://ratcatcher.ru/media/alg/lec/prac_pok/memory-search.png

Поиск по памяти — полезный инструмент для нахождения адресов важных значений в игре. Например, с его помощью можно определить, по какому адресу хранится HP игрока.

Чтобы выполнить поиск значения, введите искомое значение, настройте параметры поиска и нажмите New Search. Давайте поищем текущее значение HP игрока, которое ранее мы установили равным 8.

https://ratcatcher.ru/media/alg/lec/prac_pok/memory-search-new.png

8 — маленькое значение, поэтому оно часто встречается в памяти. Можно было бы просмотреть каждый адрес из результатов поиска и проверить, какой из них соответствует HP игрока, но есть более эффективный способ.

Не закрывая окно поиска по памяти, вернитесь в игру и немного походите, пока HP не увеличится до 9. Затем измените тип поиска на Changed by value, задайте значение поиска равным 1 и нажмите Search Within. Это ограничит поиск теми адресами памяти, которые были найдены в предыдущем поиске, и отфильтрует те значения, которые увеличились на 1 с момента предыдущего поиска. Так как предыдущий поиск был по значению 8, теперь будет найдено изменение с 8 на 9.

https://ratcatcher.ru/media/alg/lec/prac_pok/memory-search-changed.png

Теперь, когда поиск сузился до нескольких значений, можно попробовать изменить значения по каждому из этих адресов, чтобы найти, какой из них контролирует HP игрока. Обратите внимание, что можно выбрать результат поиска и использовать Open in Memory Viewer для открытия просмотрщика памяти по выбранному адресу. Вы обнаружите, что правильный адрес — 0x200419E.

Tip

Для настройки приведённого выше примера мы вручную изменили HP игрока на 8, что требует предварительного знания адреса HP игрока. Если бы вы не знали этот адрес заранее, можно было бы понизить HP игровыми способами, например, найти врага и позволить ему атаковать вас.

Отлдачик#

В mGBA отладчик — это консоль, которая предоставляет несколько полезных функций для отладки, включая установку точек останова (breakpoints) и точек наблюдения (watchpoints) для приостановки выполнения игры, пошаговое выполнение ассемблерного кода во время работы и просмотр значений регистров. Отладчик открывается через меню Tools -> Open Debugger Console… (Инструменты -> Открыть консоль отладки ), что откроет окно, показанное ниже:

https://ratcatcher.ru/media/alg/lec/prac_pok/debugger.png

Как указано в строке ввода консоли, вы можете использовать команду h (help) для просмотра всех доступных команд. В этом руководстве будут рассмотрены несколько команд, чтобы познакомить вас с процессом отладки.

Навигация в окне отладки#

Начнём с остановки выполнения игры в том месте кода, где она сейчас находится. Нажмите кнопку Break в правом нижнем углу — это приостановит игру и приведёт к выводу отладчиком примерно такого сообщения:

https://ratcatcher.ru/media/alg/lec/prac_pok/debugger-break.png

На изображении выше показан типичный вид текущего состояния игры, когда отладчик приостанавливает выполнение. В верхней части находятся текущие значения всех 16 регистров, включая указатель стека (r13), регистр возврата (r14) и счётчик команд (r15). В нижней части отображается ассемблерная инструкция, которая будет выполняться следующей, вместе с соответствующими шестнадцатеричными данными для инструкции и адресом, по которому она хранится.

Вы можете использовать команду n (next), чтобы выполнить текущую инструкцию и остановиться на следующей.

https://ratcatcher.ru/media/alg/lec/prac_pok/debugger-next.png

Также можно выполнить несколько инструкций подряд с помощью команды trace . Например, trace 5 эквивалентна выполнению команды n 5 раз подряд. При этом trace выводит сокращённые отображения состояния игры для каждой промежуточной инструкции, чтобы сэкономить место.

https://ratcatcher.ru/media/alg/lec/prac_pok/debugger-trace.png

Для выхода из режима отладки используйте команду c (continue). Это приведёт к продолжению выполнения игры.

Если нужно повторить последнюю выполненную команду, просто нажмите Enter без ввода новой команды. Это удобно для команд типа n.

Также mGBA сохраняет историю введённых команд, к которой можно обратиться с помощью стрелок вверх/вниз, как в обычном терминале.

Чтение/запись памяти#

The memory viewer can be used while the debugger has paused the game, though it is sometimes convenient to read/write memory values directly with the debugger console.

The read command uses the format r/

, where is 1, 2, or 4 bytes. For example, to read the player HP value from earlier, use the command r/1 0x200419E. The address can be expressed in either decimal or hex; the standard 0x prefix denotes a hex value.

https://ratcatcher.ru/media/alg/lec/prac_pok/debugger-read.png

Обратите внимание, что большинство команд консоли отладчика при выполнении приостанавливают игру, если она ещё не была приостановлена.

Запись значений в память осуществляется с помощью команды формата:

w/<размер> <адрес> <значение>

Например, команда

w/1 0x200419E 20

установит здоровье игрока (HP) равным 20. Как и адрес, значение для записи можно задавать в десятичном или шестнадцатеричном формате.

Также существует команда

w/r

которая позволяет записать значение в регистр вместо памяти.

Брикпойнты#

Подобно отладчикам в языках высокого уровня, брикпойнт останавливает выполнение программы, если достигается определённая строка кода. Это полезно для проверки, вызывается ли конкретная строка при выполнении действия в игре, или для остановки выполнения программы в определённой функции с целью её отладки.

Брейкпойнт можно установить с помощью команды

b <адрес>

Например, чтобы установить брейкпойнт в начале функции FUN_08044b28, используйте команду

b 0x8044B28

После этого выполнение игры приостановится, чтобы продолжить работу, используйте команду

c

https://ratcatcher.ru/media/alg/lec/prac_pok/debugger-add-breakpoint.png

После установки брикпойнта попробуйте передвигать персонажа в игре. Это вызовет выполнение функции FUN_08044b28 и приведёт к срабатыванию брейкпойнта, приостановив выполнение игры.

https://ratcatcher.ru/media/alg/lec/prac_pok/debugger-hit-breakpoint.png

Точно так же, как при ручном нажатии Break, теперь можно пошагово выполнять инструкции и просматривать значения регистров после каждой инструкции. Для продолжения игры используйте команду

c

Если продолжать движение в игре, вы заметите, что брейкпойнт срабатывает каждый раз при выполнении действия — это полезно для понимания назначения функции.

Для удаления брейкпойнта используйте команду

d <индекс>

где <индекс> — номер брейкпойнта, который нужно удалить. Индекс выводится при добавлении брейкпойнта и при его срабатывании. Например, для брейкпойнта #1 команда d 1 удалит этот брейкпойнт.

Команда listb выведет список всех текущих брейкпойнтов с их индексами.

Точка наблюдения#

Точки наблюдения (watchpoints) останавливают выполнение программы при обращении к определённому адресу в памяти — при чтении или записи. Это удобно для поиска части кода, которая изменяет определённое значение, или для отслеживания неизвестного значения.

Для установки точки наблюдения используется команда

watch <адрес>

Есть варианты для наблюдения только за чтением —

watch/r,

только за записью —

watch/w,

или за изменением значения —

watch/c.

Как и с брейкпоинтами, watchpoints удаляются командой

d <индекс>,

а список текущих точек наблюдения выводится командой

listw.

Для демонстрации поставим точку наблюдения на значение здоровья игрока по адресу 0x200419E:

https://ratcatcher.ru/media/alg/lec/prac_pok/debugger-hit-watchpoint.png

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

Если продолжить выполнение игры командой c, точка наблюдения будет срабатывать снова и снова. Повторите это несколько раз, и вы заметите, что значение HP постоянно устанавливается в одно и то же значение. Это может быть полезной информацией, но при частом срабатывании наблюдения играть становится невозможно, так как выполнение постоянно останавливается.

Чтобы решить эту проблему, удалите текущую точку наблюдения и создайте точку наблюдения изменения значения с помощью команды:

watch/c 0x200419E

Эта точка наблюдения будет останавливать выполнение программы только если значение HP изменится.

Теперь, когда точка наблюдения срабатывает не постоянно, вы можете ходить по игре, пока здоровье не восстановится естественным образом. В момент восстановления HP выполнение игры будет приостановлено.

https://ratcatcher.ru/media/alg/lec/prac_pok/debugger-change-watchpoint.png

Точка наблюдения выводит как новое, так и предыдущее значение по адресу. На скриншоте выше значение HP изменилось с 8 на 9.

Обратите внимание, что инструкция, отображаемая точкой наблюдения, — это инструкция после той, которая обращалась к отслеживаемому адресу. То есть инструкция, записывающая значение HP, находится по адресу 0x8074C28.

Если посмотреть адрес 0x8074C28 в Ghidra, то вы увидите инструкцию strh, которая используется для установки значения HP, в функции FUN_08074b54. Это указывает на то, что функция FUN_08074b54 отвечает за пассивное восстановление HP.

Сохранение состояния#

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

В mGBA доступно десять слотов для сохранённых состояний. Для сохранения или загрузки можно использовать пункты меню в File или команды save [1-10] и load [1-10].

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

Стратегии реверс-инжиниринга#

Имея знания ассемблера, инструменты Ghidra и mGBA, мы обладаем всем необходимым для начала реверс-инжиниринга игры. Последний раздел этого руководства посвящён стратегиям реверс-инжиниринга — как находить, где находится нужная функциональность в ROM и в памяти.

Раздел про mGBA уже рассмотрел несколько стратегий с использованием поиска по памяти и точек наблюдения (watchpoints), поэтому обязательно ознакомьтесь с ним, если ещё не сделали этого.

Определение источника значения через анализ ассемблерного кода#

Один из способов определить местоположение конкретного значения — это проследить связанные значения по ассемблерному коду, чтобы выяснить их источник. Это может привести вас к нужному значению.

Для демонстрации давайте найдём в коде место, которое определяет, сколько очков здоровья (HP) восстанавливает определённый лечебный предмет. В игре исследуйте подземелье, пока не найдёте на этаже синюю ягоду под названием Oran Berry. Возможно, на текущем этаже её не окажется; если вы уже осмотрели весь этаж и не нашли её, найдите лестницу, чтобы подняться на следующий уровень и продолжить поиск.

https://ratcatcher.ru/media/alg/lec/prac_pok/oran-berry.png

Как только вы найдёте Oran Berry, подойдите к ней, чтобы подобрать. Эта ягода восстанавливает 100 HP (до максимума), а наша задача в этом примере — изменить значение так, чтобы она восстанавливала только 10 HP. Для этого нужно знать, где в коде игры хранится значение 100.

Чтобы наблюдать, сколько именно восстанавливает ягода, вам нужно иметь низкий уровень HP. Введите в консоли отладчика команду w/1 0x200419E 1, чтобы установить HP равным 1. Нажмите B, чтобы открыть меню, и перейдите в раздел Предметы, чтобы увидеть Oran Berry в инвентаре. Создайте сохранение состояния, чтобы иметь возможность вернуться к этой точке, так как вам предстоит несколько раз съесть ягоду, чтобы отследить, что происходит в коде при её употреблении. После создания сохранения выберите ягоду и съешьте её — она восстановит все HP игрока.

https://ratcatcher.ru/media/alg/lec/prac_pok/oran-berry-normal.png

Теперь, когда мы увидели, как обычно работает Oran Berry, загрузите сохранённое состояние и используйте команду watch/c 0x200419E, чтобы установить watchpoint на HP игрока. Съешьте ягоду снова — это вызовет срабатывание watchpoint.

https://ratcatcher.ru/media/alg/lec/prac_pok/eat-oran-berry.png

Watchpoint остановился по адресу 0x8077CD6, поэтому перейдите к адресу 0x8077CD6 в Ghidra. Посмотрите на предыдущую инструкцию — там находится strh r0,[r4,#0xe], которая записывает новое значение HP. Согласно watchpoint, текущее значение r0 равно 0x65 (101 в десятичной системе). Это совпадает с лечением Oran Berry на 100 HP плюс 1 HP у игрока. Позже в коде есть проверка, которая ограничивает HP игрока их максимальным значением, но текущее превышение пока не мешает.

Также стоит обратить внимание на две предыдущие строки ассемблера.

mov r3,r10
add r0,r2,r3

Значение 0x65 в регистре r0 — это сумма r2 и r3. Смотрим на текущее состояние игры по watchpoint: r2 соответствует текущему HP игрока (1), а r3 — количеству восстановления Oran Berry, равному 0x64 (100). Перед сложением значение 0x64 из r3 было скопировано из регистра r10.

Теперь найдём, где значение 0x64 было присвоено r10. Средним кликом по r10 выделяем все его использования и прокручиваем функцию вверх. Есть ссылка на r10 по адресу 0x8077C8C с командой mov r3,r10, но она не изменяет r10, поэтому её можно пропустить. Ближе к началу функции находится нужная нам команда по адресу 0x8077C54mov r10,r2.

https://ratcatcher.ru/media/alg/lec/prac_pok/trace-oran-berry.png

Значение 0x64 в регистре r10 пришло из r2, но между этой точкой и началом функции больше нет ссылок на r2. Помните, что регистры r0-r3 используются для передачи аргументов в функцию. Это означает, что r2 — третий параметр функции FUN_08077c44, а значение 0x64 пришло из кода, который вызвал эту функцию. Следующий шаг — найти место вызова этой функции.

Прокрутите вниз до конца функции FUN_08077c44, где находится инструкция bx по адресу 0x8077DD6, обозначающая конец функции. Установите точку останова (breakpoint) в mGBA по адресу 0x8077DD6 и продолжите выполнение программы до срабатывания точки останова. Обратите внимание, что перед этим сработает watchpoint по здоровью игрока (HP) — здесь происходит ограничение HP максимальным значением.

https://ratcatcher.ru/media/alg/lec/prac_pok/oran-berry-end-function.png

Точка останова показывает, что в регистре r0 находится значение 0x804838D. Поскольку код собирается вернуть управление из функции FUN_08077cc4, именно по этому адресу произойдет возврат и здесь же была сделана вызов функции. Однако, если перейти к адресу 0x804838D в Ghidra, то этот участок не отмечен как ассемблерный код.

https://ratcatcher.ru/media/alg/lec/prac_pok/oran-berry-missing-assembly.png

Иногда Ghidra не распознаёт некоторые участки кода как ассемблерные автоматически. Выделите строку с адресом 0x8048364 (сразу после последней уже дизассемблированной функции) и нажмите F12, чтобы вручную дизассемблировать этот блок как THUMB-ассемблер. Ghidra расшифрует эту строку и последовательно проследит переходы и вызовы функций, дизассемблируя следующие строки, включая строку с адресом 0x804838D.

https://ratcatcher.ru/media/alg/lec/prac_pok/oran-berry-fixed-assembly.png

Теперь вызов функции FUN_08077cc44 виден по адресу 0x8048388. Мы искали место, где регистру r2 присваивается значение 0x64, поэтому рассмотрим строки перед вызовом функции. Набор инструкций начинается с адреса 0x8048378.

ldr r2,[DAT_08048394]
mov r3,#0x0
ldrsh r2,[r2,r3]

Здесь в регистр r2 загружается адрес из значения данных DAT_08048394, а затем загружается само значение по этому адресу в r2. Посмотрите на адрес 0x8048394, чтобы увидеть, что значение DAT_08048394 равно 0x80F4FB6. Это находится в ROM, что многообещающе — обычно ROM нельзя изменять, значит с большой вероятностью именно здесь хранится значение 0x64.

Учитывая этот адрес, вернитесь в mGBA и перейдите в память по адресу 0x80F4FB6 в просмотрщике памяти. Там вы найдёте значение 0x64, что подтверждает, что это место хранения количества восстанавливаемого HP для Oran Berry.

https://ratcatcher.ru/media/alg/lec/prac_pok/oran-berry-found.png

Чтобы проверить найденный адрес с количеством восстановления HP, загрузите своё сохранение заново, измените значение 0x64 по адресу 0x80F4FB6 в просмотрщике памяти на 0x0A (0x0A = 10 в десятичной системе), и съешьте Oran Berry снова, чтобы увидеть, сколько HP он теперь восстанавливает. Удалите ранее установленные watchpoint и breakpoint, чтобы они больше не срабатывали, так как с ними работа завершена.

https://ratcatcher.ru/media/alg/lec/prac_pok/oran-berry-changed.png

Успех! Мы нашли, что количество восстановления HP для Oran Berry хранится по адресу 0x80F4FB6. Технически, если изменить значение в байте 0xF4FB6 в бинарном файле игры, можно создать минимальный ROM-хак, который ослабит эффект лечения Oran Berry. Создание ROM-хаков выходит за рамки этого руководства, но это демонстрирует часть процесса создания подобных модификаций.

Warning

Обратите внимание, что изменённые значения в ПЗУ в mGBA не будут восстановлены, если вы загрузите сохранённое состояние. Если вы хотите восстановить значения ПЗУ, вы можете либо изменить их вручную, либо перезагрузить ПЗУ в эмуляторе.

Анализ кода напрямую#

Возможно, это очевидно, но чтение ассемблера вперёд помогает понять, как работает логика игры.

Давайте рассмотрим ту же функцию, что и в предыдущем разделе (восстановление HP игрока ягодой), и попробуем найти код, который ограничивает HP игрока максимальным значением. Перейдите к адресу 0x8077CD6 в Ghidra.

https://ratcatcher.ru/media/alg/lec/prac_pok/read-assembly-forwards.png

Мы видели, что инструкция по адресу 0x8077CD4strh r0,[r4,#0xe] — устанавливает HP игрока равным сумме текущего HP и количества лечения. По адресу 0x8077CDC находится условный переход ble, который проверяет, меньше ли r0 (вычисленное значение лечения) чем r1.

Альтернативно, можно посмотреть декомпилированный код, где это же условие представлено так: вычисленное значение — это iVar3, а HP игрока — это *(short *)(iVar4 + 0xe). Если условие не выполняется, то в HP игрока записывается другое значение (инструкция strh r0,[r4,#0xe] по адресу 0x8077CE0).

Tip

Поскольку доступ к запасу здоровья игрока осуществляется с помощью смещения, это указывает на то, что запас здоровья игрока находится в структуре, которая, вероятно, содержит и другие значения, связанные с игроком. В этой структуре запас здоровья игрока находится по смещению 0xe.

Хотя мы могли бы отследить значения регистров r1 и r12 в ассемблере, возможно, будет проще снова установить точку останова (breakpoint) или наблюдения (watchpoint) в этом месте и посмотреть, какими будут значения во время работы игры.

https://ratcatcher.ru/media/alg/lec/prac_pok/eat-oran-berry(1).png

Регистры r1 и r12 равны максимальному запасу здоровья игрока. Это означает, что код проверяет, превышает ли сумма количества лечения и текущего здоровья игрока максимальное здоровье, и в случае превышения ограничивает здоровье игрока максимальным значением. Вы можете пошагово пройтись по инструкциям с помощью команды n, чтобы увидеть это в действии.

Для дополнительного подтверждения можно изменить инструкцию strh по адресу 0x8077CE0, чтобы она не ограничивала здоровье игрока. В Ghidra видно, что шестнадцатеричное значение этой инструкции — E0 81. В mGBA перейдите к адресу 0x8077CE0 в просмотрщике памяти, где вы найдете те же байты E0 81. Измените оба этих байта на 00, что превратит инструкцию в no-op (операцию, которая ничего не делает).

https://ratcatcher.ru/media/alg/lec/prac_pok/no-op-instruction.png

Теперь давайте попробуем съесть ягоду.

https://ratcatcher.ru/media/alg/lec/prac_pok/remove-hp-cap.png

Игра теперь сообщает, что восстановлено 100 единиц здоровья, и вы могли заметить, что индикатор текущего здоровья (HUD) быстро мигнул, показывая значение больше максимального запаса здоровья, но затем HP сразу сбросился обратно к максимуму. Очевидно, в коде есть ещё одна проверка, которая не даёт здоровью игрока превысить максимум.

Однако факт того, что игра показала восстановление 100 HP, подтверждает, что рассматриваемый нами участок кода действительно отвечает за ограничение лечения по максимуму. Если захотите, можно добавить ещё одну точку наблюдения (watchpoint) на запас здоровья игрока и повторить аналогичный процесс, чтобы найти и отключить другие проверки.

Проверка значений вблизи известного значения#

После того как вы нашли адрес значения в памяти, вероятно, что соседние значения тоже связаны между собой и могут представлять собой часть структуры или массива данных. Мы можем использовать просмотрщик памяти для изучения других значений ОЗУ рядом с известным значением, а также попробовать изменить их, чтобы найти новые интересные данные.

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

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

https://ratcatcher.ru/media/alg/lec/prac_pok/memory-viewer-nearby.png

Значение, напрямую связанное с вашим запасом здоровья (HP), — это максимальное количество HP. Посмотрите на HUD в игре, где максимальное HP отображается справа от текущего. Попробуйте найти это значение в просмотрщике памяти.

Искать далеко не придётся. Два байта после текущего HP находится число, совпадающее с вашим максимальным HP — по адресу 0x20041A0. Попробуйте изменить это значение, и вы увидите, что максимальное HP изменится на HUD, что подтверждает, что 0x20041A0 — это адрес максимального HP игрока.

Рядом также находится число, отвечающее за уровень игрока (на HUD обозначено как «Lv»), который равен 5, если вы только начали игру. Найдите в просмотрщике памяти значение 5 и попробуйте изменить его, чтобы проверить, меняется ли уровень игрока на HUD. Правильный адрес скрыт в спойлере ниже.

Адрес Уровня игрока

0x2004199

Похожий способ — выполнить действие в игре и наблюдать, как меняются значения в памяти. Это может быть перемещение, атака и так далее. Для демонстрации нажмите кнопку Start в игре, чтобы войти в режим, где можно менять направление взгляда, не двигаясь. Повернитесь несколько раз, наблюдая за просмотрщиком памяти, и вы увидите, что значение по адресу 0x20041D6 изменяется. Другие видимые значения при этом не меняются, что говорит о том, что 0x20041D6 содержит направление, в котором смотрит игрок (кодируется как перечисление).

Также можно использовать метод проб и ошибок: просто проходить по адресам памяти, изменять их и смотреть, влияет ли это на что-то в игре. Например, если сделать так, вы в итоге попадёте на адрес 0x2004238. Установите там значение 1, и персонаж игрока заснёт — это указывает, что этот адрес отвечает за состояние сна.

https://ratcatcher.ru/media/alg/lec/prac_pok/memory-viewer-asleep.png

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

Поиск текста#

Большие блоки текста в памяти часто появляются только один или несколько раз, что облегчает их поиск. Например, нажмите кнопку «Пуск», чтобы изменить направление, в котором вы смотрите, а затем повернитесь лицом к партнёру. Нажмите A, чтобы поговорить с партнёром.

https://ratcatcher.ru/media/alg/lec/prac_pok/dialogue.png

Этого диалога достаточно для поиска. Обратите внимание, что специальные символы, такие как апострофы, не всегда могут быть закодированы как обычные символы ASCII, поэтому по возможности избегайте их. Давайте поищем в памяти фразу «going for it». Выберите «Текст» в качестве типа и включите поиск в ПЗУ.

https://ratcatcher.ru/media/alg/lec/prac_pok/text-search.png

Адрес 03007a80 находится в стеке, поэтому его можно игнорировать. Для наших целей значения 0a4e4944 и 0c4e4944 расположены в областях ПЗУ, которые дублируют часть ROM с префиксом 08XXXXXX, поэтому их тоже можно не учитывать. Остаются только 0202e7af и 084e4944.

Tip

Регионы памяти a000000-bffffff и c000000-dffffff известны как «состояния ожидания» (согласно карте памяти GBA), которые используются процессором для обработки инструкций.

084e4944 — единственный оставшийся результат поиска в пределах ПЗУ, поэтому можно сделать вывод, что именно здесь в ПЗУ хранится эта строка диалога. 0202e7af находится в ОЗУ, скорее всего, именно здесь текст хранится во время отображения в диалоговом окне.

Стратегии создания цепочки#

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

Например, если вы хотите понять более абстрактный механизм, например, как работает ИИ в игре, можно следовать таким шагам:

  1. Найти запас здоровья игрока в памяти с помощью поиска изменений в памяти.
  2. Найти врага, который будет вас атаковать, и установить watchpoint на адрес запаса здоровья игрока, чтобы определить, какая часть кода отвечает за нанесение урона.
  3. Проследить ассемблерный код назад от места нанесения урона к тому месту, где игра решает, что атака врага должна нанести урон. Может помочь сравнение путей выполнения для атак с уроном и без него с использованием breakpoints. Возможно, вы найдёте проверку уникального идентификатора атаки для определения её эффекта.
  4. Отследить ассемблерный код назад и/или использовать watchpoints, чтобы найти место, где устанавливается идентификатор атаки, которую враг использует. Скорее всего, это решение принимается ИИ врага — ваша точка входа в код ИИ.
  5. Проследить ассемблерный код вперёд, чтобы понять, как работает ИИ врага.

Использование существующих ресурсов#

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

Обратите внимание, что ресурсы по взлому и реверс-инжинирингу игр обычно разрознены и неструктурированы — информация разбросана по Google Docs/Sheets, репозиториям GitHub, серверам Discord, постам на Reddit, форумам, вики и другим источникам.

Одной из отправных точек для поиска материалов по реверс-инжинирингу игр является сайт Data Crystal, который содержит неплохой набор документов по реверсу различных игр, а также ссылки на внешние ресурсы. Например, вот страница Data Crystal для игры Red Rescue Team.

Также можно использовать Google (или ваш любимый поисковик) и искать по ключевым словам вроде «<название игры> hacking». Обращайте внимание на активные сообщества по взлому и реверс-инжинирингу, такие как Discord-серверы или сабреддиты.

Некоторые сообщества реверс-инжиниринга идут дальше и поддерживают проект по ручной декомпиляции (decomp) или дизассемблированию игры — это проект, использующий исходный код или структурированный ассемблерный код для сборки бинарника игры, который точно совпадает с оригинальным ROM-файлом.

Поскольку такие проекты охватывают весь код игры, они обычно содержат большое количество подписанной информации о различных частях игры. Например, для Red Rescue Team существует такой проект ручной декомпиляции.

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

Заключение#

К настоящему моменту вы создали среду для реверс-инжиниринга, изучили основы сборки и некоторые инструменты для реверс-инжиниринга, а также рассмотрели несколько стратегий для обнаружения функций в игре. Теперь вы готовы погрузиться в код вашей любимой игры для GBA и посмотреть, что вы сможете найти. Имейте в виду, что процесс реверс-инжиниринга не всегда прост и требует изобретательности и терпения, но этот навык можно развить с помощью практики и упорства. Удачи!

Источники и Материалы (благодарности)#

  1. Документации для разработчиков ARM
  2. Карта памяти GBA
  3. Data Crystal
  4. Decompilation of Pokémon Mystery Dungeon: Red Rescue Team.