Pull to refresh

Реверс-инжиниринг эмулятора NES в игре для GameCube

Reading time 20 min
Views 5.9K
Original author: jamchamb
image

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

В дополнение к играм NES/Famicom, которые можно получить внутри игры, можно загружать новые игры NES с карты памяти.

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

Введение — объекты консоли NES


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

Расположив этот объект в своём доме и взаимодействуя с ним, можно запустить эту единственную игру. На рисунке ниже показаны Excitebike и Golf.


Также существует общий объект «NES Console», в котором нет никаких встроенных игр. Его можно купить у Редда, а иногда получить через случайные события, например, прочитав на городской доске объявлений, что консоль закопана в случайной точке города.


Этот объект выглядит как консоль NES, на которой нет никаких картриджей.


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


Выяснилось, что этот объект на самом деле пытается просканировать карту памяти на наличие специально сконструированных файлов, содержащих ROM-образы для NES! Эмулятор NES, используемый для запуска встроенных игр, похоже, является полным стандартным эмулятором NES для GameCube, и способен запустить большинство игр.

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

Поиск загрузчика ROM на карте памяти


Ищем меню разработчика


Изначально я хотел найти код, активирующий различные меню разработчика, такие как меню выбора карты или меню выбора игры для эмулятора NES. Меню «Forest Map Select», благодаря которой можно легко мгновенно загружать разные локации игры, было найти довольно просто — я всего лишь поискал строку «FOREST MAP SELECT», появляющуюся в верхней части экрана (его можно увидеть в разных видео и на скриншотах в Интернете).

В «FOREST MAP SELECT» есть перекрёстные ссылки данных на функцию select_print_wait, которая ведёт к куче других функций, также имеющих префикс select_*, в том числе и к функции select_init. Они оказались функциями, управляющими меню выбора карты.

Функция select_init ведёт к другой интересной функции под названием game_get_next_game_dlftbl. Эта функция связывает вместе все другие меню и «сцены», которые можно запустить: экран с логотипом Nintendo, главный экран, меню выбора карты, меню эмулятора NES (Famicom) и так далее. Она запускается в начале основной процедуры игры, находит, какую функцию инициализации сцены должна запустить, и находит её запись в структуре табличных данных под названием game_dlftbls. Эта таблица содержит ссылки на функции обработки различных сцен, а также некоторые другие данные.


Внимательное изучение первого блока функции показало, что он загружает функцию «next game init», а затем начинает сравнивать её с серией известных функций init:

  • first_game_init
  • select_init
  • play_init
  • second_game_init
  • trademark_init
  • player_select_init
  • save_menu_init
  • famicom_emu_init
  • prenmi_init


Один из указателей функций, которые он ищет, — это famicom_emu_init, который отвечает за запуск эмулятора NES/Famicom. Принудительно присвоив в отладчике Dolphin результату game_get_next_game_init значение famicom_emu_init или select_init, я смог отобразить специальные меню. Следующим шагом будет определение того, как эти указатели задаются нормальным способом во время выполнения программы. Единственное, что делает функция game_get_next_game_init — это загрузка значения по смещению 0xC первого аргумента в game_get_next_game_dlftbl.

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

  • Когда игра запускается обычным способом, она выполняет следующую последовательность действий:
    • first_game_init
    • second_game_init
    • trademark_init
    • play_init
  • player_select_init задаёт для следующего init значение select_init. Этот экран должен позволять выбрать игрока сразу после выбора карты, но, похоже, он работает неправильно.

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

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

Game_play_fbdemo_wipe_proc обрабатывает переходы между сценами, такие как очистка экрана и затемнение.

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

Обработка объектов-консолей


На самом деле переключиться на эмулятор обработчик перехода экрана заставляют функции-обработчики объектов мебели для консолей NES. Когда игрок взаимодействует с одной из консолей, вызывается aMR_FamicomEmuCommonMove.

При вызове функции r6 содержит значение индекса, соответствующее числам в названиях файлов игр NES в famicom.arc:

  • 01_nes_cluclu3.bin.szs
  • 02_usa_balloon.nes.szs
  • 03_nes_donkey1_3.bin.szs
  • 04_usa_jr_math.nes.szs
  • 05_pinball_1.nes.szs
  • 06_nes_tennis3.bin.szs
  • 07_usa_golf.nes.szs
  • 08_punch_wh.nes.szs
  • 09_usa_baseball_1.nes.szs
  • 10_cluclu_1.qd.szs
  • 11_usa_donkey3.nes.szs
  • 12_donkeyjr_1.nes.szs
  • 13_soccer.nes.szs
  • 14_exbike.nes.szs
  • 15_usa_wario.nes.szs
  • 16_usa_icecl.nes.szs
  • 17_nes_mario1_2.bin.szs
  • 18_smario_0.nes.szs
  • 19_usa_zelda1_1.nes.szs

(.arc — это проприетарный формат файловых архивов.)

Когда r6 не равна нулю, она передаётся в вызове aMR_RequestStartEmu. При этом срабатывает переход к эмулятору.


Однако если r6 равна нулю, то вместо этого вызывается функция aMR_RequestStartEmu_MemoryC. Присвоив в отладчике значение 0, я получил сообщение «I don’t have any software». Я не сразу вспомнил, что нужно проверить объект «NES Console», чтобы убедиться, что он обнуляет значение r6, но так и оказалось — нулевой индекс используется для объекта консоли без картриджа.

Хотя aMR_RequestStartEmu просто сохраняет значение индекса в какую-то структуру данных, aMR_RequestStartEmu_MemoryC выполняет гораздо более сложные операции…


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

aMR_GetCardFamicomCount вызывает famicom_get_disksystem_titles, который затем вызывает memcard_game_list, и тут всё становится очень интересно.

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


Функции принимает решение о том, загружать или не загружать файл, в зависимости от результатов проверки нескольких строк. Во-первых, она проверяет наличие строк «GAFE» и «01», которые являются идентификаторами игры и компании. 01 обозначает Nintendo, «GAFE» — это Animal Crossing. Думаю, это расшифровывается как «GameCube Animal Forest English».

Затем она проверяет строки «DobutsunomoriP_F_» и «SAVE». В этом случае первая строка должна совпадать, но не вторая. Оказалось, что «DobutsunomoriP_F_SAVE» — это название файла, в котором хранятся данные встроенных игр для NES. Поэтому будут загружены все файлы, кроме этого, с префиксом «DobutsunomoriP_F_».

Воспользовавшись отладчиком Dolphin, чтобы пропустить сравнение строки с «SAVE» и заставив игру хитростью считать, что мой файл «SAVE» можно спокойно загружать, я получил после использования консоли NES это меню:


Я ответил «Yes» и попытался загрузить файл сохранения как игру, после чего впервые увидел встроенный экран сбоя игры:


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

Первое, что я попробовал сделать — попытался найти, где название игры считывается из файла карты памяти. Поискав строку «FEFSC», которая присутствовала в сообщении «Would you like to play <name>?», я нашёл смещение, по которому она считывалась из файла: 0x642. Я скопировал файл сохранения, изменил имя файла на «DobutsunomoriP_F_TEST», изменил байты по смещению 0x642 на «TESTING» и импортировал изменённую сохранёнку, после чего нужное мне имя отобразилось в меню.

После добавления ещё нескольких файлов в этом формате в меню появилось ещё несколько вариантов выбора:


Загрузка ROM-файла


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

Оказалось, что мне нужна famicom_rom_load. Она управляет загрузкой ROM, или с карты памяти, или из внутренних ресурсов игры.


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

Значение контрольной суммы


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

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

Копирование ROM


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


Если определённое 16-битное целое значение, считанное с карты памяти, не равно нулю, то вызывается функция, проверяющая в буфере заголовок сжатия. Он проверяет наличие проприетарных форматов сжатия Nintendo, ища в начале буфера «Yay0» или «Yaz0». Если одна из этих строк найдена, то вызывается функция распаковки. В противном случае выполняется простая функция копирования памяти. В любом из случаев после этого обновляется переменная под названием nesinfo_data_size.

Ещё один намёк на контекст здесь заключается в том, что ROM-файлы для встроенных игр NES используют сжатие «Yaz0», и эта строка присутствует в заголовках их файлов.

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

В найденном мной пути выполнения кода буфер ROM располагается сразу после этого 32-байтного буфера заголовка.


Этой информации достаточно, чтобы попытаться создать рабочий ROM-файл. Я просто взял один из других файлов сохранений Animal Crossing и отредактировал его в hex-редакторе, чтобы заменить имя файла на DobutsunomoriP_F_TEST и очистить все области, куда я хотел вставить данные.

Для тестового прогона я использовал ROM игры Pinball, которая уже есть в игре, и вставил его содержимое после 32-байтного заголовка. Вместо вычисления значения контрольной суммы я установил точки останова так, чтобы просто пропустить calcSum, а также наблюдать за результатами других проверок, которые могут привести к ветвлению, пропускающему процесс загрузки ROM.

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



Сработало! Присутствовали небольшие графические баги, связанные с параметрами Dolphin, которые влияли на используемый эмулятором NES графический режим, но в целом игра выполнялась просто отлично. (В более новых сборках Dolphin она должна работать по умолчанию.)

Чтобы убедиться, что другие игры тоже запустятся, я попробовал записать несколько других ROM, которых не было в игре. Battletoads запустилась, но переставала работать после текста заставки (после дальнейших настроек мне удалось сделать её играбельной). С другой стороны, Mega Man работал идеально:


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

Формат файлов внешних ROM


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

  • Контрольная сумма
  • Имя файла сохранения
  • Заголовок файла ROM
  • Неизвестный буфер, копируемый без всякой обработки
  • Текстовый комментарий, иконка и загрузчик баннера (для создания нового файла сохранения)
  • Загрузчик ROM


Контрольная сумма


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

checksum = 0
for byte_val in new_data_tmp:
    checksum += byte_val
    checksum = checksum % (2**32)  # keep it 32 bit

checkbyte = 256 - (checksum % 256)
new_data_tmp[-1] = checkbyte

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

Имя файла


Повторюсь, имя файла сохранения должно начинаться с «DobutsunomoriP_F_» и заканчиваться на что-то, не содержащее «SAVE». Это имя файла копируется пару раз, и в одном случае буква «F» заменяется на «S». Это будет названием файлов сохранений для игры NES («DobutsunomoriP_S_NAME»).

Заголовок ROM


В память загружается непосредственная копия 32-байтного заголовка. Часть значений в этом заголовке используется для определения способа обработки последующих разделов. В основном это некие 16-битные значения размера и упакованные биты параметров.

Если оттрассировать указатель, копируемый заголовком, по всему пути до начала функции, и найти позицию его аргумента, то сигнатура функции ниже покажет, что на самом деле он имеет тип MemcardGameHeader_t*.

memcard_game_load(unsigned char *, int, unsigned char **, char *, char *, MemcardGameHeader_t *, unsigned char *, unsigned long, unsigned char *, unsigned long)

Неизвестный буфер


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

Баннер, иконка и комментарий


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

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

  1. Использовать значение по умолчанию
  2. Копировать из раздела баннера/иконки/комментария в файле ROM
  3. Копировать из альтернативного буфера

Значения кода по умолчанию приводят к тому, что иконка или баннер грузятся с ресурса на диске, а имени файла сохранения и комментарию (текстовому описанию файла) присваиваются значения «Animal Crossing» и «NES Cassette Save Data». Вот как это выглядит:


Второе значение кода просто копирует название игры из файла ROM (некая альтернатива «Animal Crossing»), а затем пытается найти в комментарии файла строку "] ROM" и заменить её на "] SAVE". По-видимому, файлы, которые хотела выпускать Nintendo, должны были иметь формат названий «Game Name [NES] ROM» или что-то подобное.

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

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

ROM


Если внимательно посмотреть на скриншот memcard_game_load копирования ROM, то можно заметить, что 16-битное значение, проверяемое на равенство нулю, смещено влево на 4 бита (умноженные на 16), а затем используется как размер функции memcpy, если сжатие не обнаружено. Это ещё одно значение размера, присутствующее в заголовке.

Если размер не равен нулю, то данные ROM проверяются на сжатие, а затем копируются.

Неизвестный буфер и поиск багов


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

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

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

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

Обработчики меток информации NES


Я вернулся к famicom_rom_load. После загрузки ROM с карты памяти или диска вызывается несколько функций:

  • nesinfo_tag_process1
  • nesinfo_tag_process2
  • nesinfo_tag_process3

Отследив место, в которое копируется неизвестный буфер, я убедился, что эта задача выполняется этими функциями. Они начинаются с вызова nesinfo_next_tag, которая выполняет простой алгоритм:

  • Проверяет, соответствует ли заданный указатель указателю в nesinfo_tags_end. Если он меньше, чем nesinfo_tags_end или nesinfo_tags_end равен нулю, то он проверяет наличие в заголовке указателя строки «END».

    • Если «END» достигнут, или указатель поднялся до или выше nesinfo_tags_end, то функция возвращает ноль (null).
    • В противном случае байт по смещению 0x3 указателя прибавляется к 4 и к текущему указателю, после чего возвращается значение.

Это говорит нам, что есть какой-то формат метки из трёхбуквенного названия, значения размера данных и самих данных. Результатом является указатель на следующую метку, поскольку текущая метка пропускается (cur_ptr + 4 пропускает трёхбуквенное название и один байт, а size_byte пропускает данные).

Если результат не равен нулю, то функция обработки меток выполняет серию сравнений строк, чтобы выяснить, какую метку нужно обрабатывать. Некоторые из названий меток, проверяемых в nesinfo_tag_process1: VEQ, VNE, GID, GNO, BBR и QDS.


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

К счастью, есть множество подробных отладочных сообщений, выводимых при обнаружении меток. Все они на японском, поэтому их сначала необходимо декодировать из Shift-JIS и перевести. Например, сообщение для QDS может гласить «Загрузка области сохранения диска» или «Поскольку это первый запуск, создаём область сохранения диска». Сообщения для BBR гласят «загрузка резервной копии батареи» или «поскольку это первый запуск, выполняем очистку».

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

Существует также метка «HSC» с отладочным сообщением, говорящим, что она обрабатывает рекорды очков. Она получает смещение в ROM от своих данных метки, а также исходное значение рекорда очков. Эти метки можно использовать, чтобы указывать место в памяти игры NES для хранения рекордов очков, возможно для их сохранения и восстановления в дальнейшем.

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

Охота на баги


Большинство меток, приводящих к манипуляциям с памятью, не очень полезны для эксплойтов, потому что у всех них есть значения максимального смещения и размера, задаваемые в виде 16-битных целых чисел. Этого достаточно для работы с 16-битным адресным пространством NES, но не хватит для записи полезных целевых значений, таких как указатели на функции или адреса возврата в стеке в 32-битном адресном пространстве GameCube.

Однако есть несколько случаев, когда значения смещений размеров, передаваемых memcpy, могут превышать 0xFFFF.

QDS

QDS загружает 24-битное смещение из своих данных метки, а также 16-битное значение размера.

Хорошо здесь то, что смещение используется для вычисления адреса назначения операции копирования. Базовый адрес cмещения — это начало загруженных данных, источник копирования находится в файле ROM карты памяти, а размер задаётся 16-битным значением размера из метки.

24-битное значение имеет максимальное значение 0xFFFFFF, что намного больше необходимого для записи за пределами загруженных данных ROM. Однако тут есть определённые проблемы…

Первая заключается в том, что хотя максимальное значение размера равно 0xFFFF, изначально оно используется для обнуления раздела памяти. Если значение размера слишком высоко (не намного больше 0x1000), то это обнулит метку «QDS» в коде игры.

И в этом заключается проблема, потому что nesinfo_tag_process1 на самом деле вызывается дважды. В первый раз она получает какую-то информацию о пространстве, необходимом ей для подготовки к данным сохранений. Метки QDS и BBR не обрабатываются полностью при первом выполнении. После первого выполнения подготавливается место для данных сохранений, и функция вызывается снова. На этот раз метки QDS и BBR обрабатываются полностью, но если строки названий меток вычищены из памяти, то сопоставить метки заново невозможно!

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

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

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

Однако использованная здесь реализация malloc проверяет определённый байтовый паттерн (0x7373) в начале следующего и предыдущего блоков, которыми она будет манипулировать при вызове free. Если она не находит эти байты, то вызывает OSPanic и игра зависает.


Не имея возможности повлиять на то, чтобы эти байты присутствовали в каком-то целевом местоположении, невозможно выполнять запись сюда. Другими словами, невозможно записывать что-то в произвольное место, не имея возможности записывать что-то рядом с этим местом. Может быть какой-то способ сделать так, чтобы значение 0x73730000 хранилось в стеке прямо перед адресом возврата и место, на которое ссылается значение, которое мы хотим записать в адрес назначения (он тоже будет проверяться, как будто это указатель на блок кучи), но этого сложно добиться и использовать это в эксплойте.

nesinfo_update_highscore

Ещё одна функция, касающаяся меток QDS, BBR и HSC — это nesinfo_update_highscore. Значения размеров меток QDS, BBR и OFS (offset, смещение) используются для вычисления смещения, в которое нужно выполнять запись, а метка HSC включает запись в это место. Эта функция выполняется для каждого кадра, обрабатываемого эмулятором NES.

Максимальное значение смещения для каждой метки в этом случае, даже для QDS, равно 0xFFFF. Однако во время цикла обработки меток значения размеров из меток BBR и QDS на самом деле накапливаются. Это значит, что несколько меток можно использовать для вычисления практически любого значения смещения. Ограничением является количество меток, которые можно уместить в раздел данных меток ROM в файле на карте памяти, а он тоже имеет максимальный размер 0xFFFF.

Базовый адрес, к которому прибавляется смещение — это 0x800C3180, буфер данных сохранений. Этот адрес намного ниже, чем данные ROM, что даёт нам больше свободы в выборе места для записи. Например, достаточно просто будет переписать адрес возврата в стеке по адресу 0x812F95DC.

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

bzero(nintendo_hi_0, ((offset_sum + 0xB) * 4) + 0x40)


Со значением смещения, которое я пытался вычислить, это привело к тому, что были очищены 0x48D91EC (76 386 796) байт памяти, из-за чего игра зрелищно сбойнула.

Метка PAT


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

Большинство обработчиков меток в nesinfo_tag_process2 никогда не запускается, потому что они работают только тогда, когда указатель nesinfo_rom_start не равен нулю. Ничто в коде не присваивает этому указателю ненулевое значение. Он инициализируется с нулевым значением и никогда больше не используется. При загрузке ROM задаётся только nesinfo_data_start, поэтому это выглядит мёртвым кодом.

Однако есть одна метка, которая всё-таки может работать при ненулевом nesinfo_rom_start: PAT. Это самая сложная метка в функции nesinfo_tag_process2.


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

Каждый буфер данных метки PAT имеет 8-битный код типа, 8-битный размер патча и 16-битное значение смещения, за которыми следуют данные патча.

  • Если код равен 2, то значение смещения прибавляется к текущей сумме смещений.
  • Если код равен 9, то смещение сдвигается вверх на 4 бита и прибавляется к текущей сумме смещений.
  • Если код равен 3, то сумма смещений сбрасывается до 0.

Максимальный размер информационной метки NES равен 255, то есть наибольший размер патча PAT равен 251 байтам. Однако допускается использование нескольких меток PAT, то есть можно патчить больше 251 байта, а также патчить несмежные места.

Пока у нас есть серия подметок PAT с кодом 2 или с кодом 9, смещение указателя назначения продолжает накапливаться. При копировании данных патча оно обнуляется, но если использовать нулевой размер патча, то этого можно избежать. Понятно, что это можно использовать для вычисления какого-нибудь произвольного смещения с нулевым указателем nesinfo_rom_start с помощью множества меток PAT.

Однако существует ещё две проверки значений кодов…

  • Если код находится между 0x80 и 0xFF, то он прибавляется к 0x7F80, а затем смещается вверх на 16 бит. Далее он прибавляется к 16-битному значению смещения и используется как адрес назанчения для патча.

Это позволяет нам назначать адрес назначения для патча в интервале от 0x80000000 до 0x807FFFFF! Именно там в памяти находится основная часть кода Animal Crossing. Это значит, что мы можем патчить сам код Animal Crossing с помощью меток метаданных ROM из файла на карте памяти.

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

В качестве быстрой проверки я создал патч, включающий «zuru mode 2» (режим разработчика игры, описанный в моей предыдущей статье) при загрузке пользователем ROM с карты игры. Выяснилось, что чит-комбо из клавиш активирует только режим «zuru mode 1», который не имеет доступа к тем функциям, которые есть у режима 2. С помощью этого патча благодаря карте памяти мы сможем получить полный доступ к режиму разработчика на настоящем железе.


Метки патча будут обрабатываться при загрузке ROM.


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


Работает!

Формат информационных меток патча


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

000000 5a 5a 5a 00 50 41 54 08 a0 04 6f 9c 00 00 00 7d >ZZZ.PAT...o....}<
000010 45 4e 44 00 >END.<


  • ZZZ \x00: игнорируемая метка начала. 0x00 — это размер её буфера данных: ноль.
  • PAT \x08 \xA0 \x04 \x6F\x9C \x00\x00\x00\x7D: патчит 0x80206F9C в 0x0000007D.
    • 0x08 — это размер буфера метки.
    • 0xA0 при прибавлении к 0x7F80 становится 0x8020, то есть верхними 16 битами адреса назначения.
    • 0x04 — это размер данных патча (0x0000007D).
    • 0x6F9C — это нижние 16 бит адреса назначения.
    • 0x0000007D — это данные патча.
  • END \x00: метка маркера конца.

Если вы хотите самостоятельно поэкспериментировать с созданием патчера или файлов сохранений ROM, то по адресу https://github.com/jamchamb/ac-nesrom-save-generator я выложил очень простой код для генерации файлов. Патч наподобие показанного выше можно сгенерировать следующей командой:

$ ./patcher.py Patcher /dev/null zuru_mode_2.gci -p 80206F9c 0000007D

Выполнение произвольного кода


Благодаря этой метке можно добиться выполнения произвольного кода в Animal Crossing.

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

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

Чтобы понять, как можно очистить кэш, я начал изучать связанные с кэшем функции из документации GameCube SDK, и обнаружил ICInvalidateRange. Эта функция объявляет недействительными кэшированные блоки инструкций по указанному адресу в памяти, что позволяет выполнять изменённую память инструкций с обновлённым кодом.

Однако без возможности запуска первоначального кода мы по-прежнему не сможем вызвать ICInvalidateRange. Для успешного выполнения кода нам понадобится ещё один трюк.

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

Эмулятор NES активно использует my_malloc для выделения и освобождения памяти под связанные с ROM NES данные, поэтому я был уверен, что он будет несколько раз запущен примерно в то же время, что и обрабатываются метки PAT.

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

Разработчик фан-проекта Dōbutsu no Mori e+ по имени Cuyler написал такой загрузчик на ассемблере PowerPC и продемонстрировал его использование для инъекции нового кода в этом видео: https://www.youtube.com/watch?v=BdxN7gP6WIc. (Dōbutsu no Mori e+ была последней итерацией Animal Crossing на GameCube, обладавшей наибольшим количеством обновлений. Выпущена только в Японии.) Патч загружает некий код, позволяющий игроку создавать любые объекты вводом их ID по буквам и нажатием кнопки Z.


Благодаря этому можно загружать моды, читы и homebrew в обычной копии Animal
Crossing на настоящем GameCube.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+19
Comments 3
Comments Comments 3

Articles