История взлома классической игры на Dendy или Contra со спрэдганом в начале +24


Раз уж прошлая моя статья, к моему большому удивлению, вас заинтересовала. Я решил дополнить её результат, хакнутую версию игры "Contra (J) [T+Rus_Chronix]", небольшим функционалом, заодно показав "code injection" на NES. В этот раз я сделаю так, чтоб игроки начинали игру с прокачанным Spreadgun, для его получения в игре нужно подобрать иконку "S", а за ней "R".



Все заинтересовавшиеся welcome под кат.


Традиционно:


скучная и нудная видеозапись процесса

И традиционно распланируем последовательность действий.


  1. Найти нужные адреса
  2. Выяснить значение прокачанного Spreadgun
  3. Выяснить что пишет в эти адреса в начале игры
  4. Переписать ROM
    • Easyway — поменять значение базового оружия на прокачанный спредган
    • Hardway — использовать полноценную code injection, если легкий путь не получится
  5. сохранить результат в новый файл

Для поиска адресов используем ранее описанный метод, но помня что жизни оказались в соседних адресах будем искать оружие только у первого игрока в надежде, что второй игрок будет рядом. Открыв окно "Ram watch" после начала первого уровня ищем неизвестное значение. Я полагаю что базовое оружие имеет значение 0, но не знаю этого наверняка.
Бегаем по уровню, не забегая вперёд, стреляем во все стороны и отсеиваем меняющиеся значения. Оружие пока не менялось.


Давайте воспользуемся ещё одной опцией окна, а точнее в поле "Compare To/By" выделив радиоточку "Number of Changes" поставьте в поле 0. Тип сравнения конечно "Equals to". Оружие всё ещё не менялось.


Так перескакивая с варианта "равно предыдущему значению", на вариант "количество изменений равно 0" можно дойти до примерно 10000 адресов. Когда дальнейшие отсеивания будут сокращать список адресов слишком незначительно или не сокращать вовсе, можно выйти достаточно далеко вперёд, чтоб выбить первое оружие.


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


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


С усилителем оружия есть некоторый риск того, что он поменяет не значение самого оружия, а флаг в другом месте в памяти, но будем надеяться что авторы игры экономили память и инструкции. И, мне повезло бонус "R" поменял само значение оружия и адрес оказался в координате AA16. (Я могу ошибаться, но я неоднократно мониторил оружие героя в разных версиях игры Contra, вроде везде этот адрес был AA16).


Ещё я обратил внимание, что бонус "R" увеличил значение в адресе на 1610 или 1016, то есть увеличил на 1 первую цифру шестнадцатеричного числа.


После рестарта в окне "Ram watch" видно что базовое значение действительно 0016, бонус "M" увеличил вторую цифру на 1, а бонус "R" первую.


Можно идти за спредганом, он точно встретится в этом уровне, а можно меняя значение адреса смотреть какие числа, какое оружие создают. Эмпирическим путём я выяснил что, 0116 это "Machinegun", 0216 это "Fire", 0316 это "Spreadgun", а 0416 это "Laser". При вводе других значений во вторую цифру возникают различные глюки.


Сбросив игру и введя в адрес значение 1x16, (где "x" любой из допустимых вариантов) перед подбором "Rapid" бонуса можно выяснить, что повторный подбор бонуса ничего не меняет.


Теперь можно перезапустить игру, начать игру за двоих и попробовать менять соседние с AA16 адреса. (Их два, поиск не будет долгим) Постреляв вторым игроком я очень быстро выяснил, что оружие второго игрока и правда хранится рядом в адресе AB16. И вот мы знаем интересующие нас адреса и значение которое следует туда положить, пора выяснить, что пишет в эти адреса.


Бросив брейкпоинт на запись этого адреса, я выяснил, что запись туда происходит несколько раз и один из них после заставки. Делает эту запись следующий код:


Address Opcode Mnemonic Arguments A X
C307 A2 28 LDX #$28 ?? ??
C309 A9 00 LDA #$00 ?? 28 or 29 or… or F0
C30B 95 00 STA $00,X 00 28 or 29 or… or F0
C30D E8 INX 00 28 or 29 or… or F0
C30E E0 F0 CPX #$F0 00 29 or 30 or… or F0
C310 D0 F9 BNE $C30B 00 29 or 30 or… or F0

Если внимательно прочитать игра здесь зануляет диапазон адресов с 002816 по 00F016, очевидно оба интересующих нас адреса в диапазоне. А значит лёгкого пути не будет. Придется использовать "Code Injection" и простейшее решение что я вижу выяснить откуда мы попадаем сюда, перенаправить выполнение в какое нибудь свободное от кода и данных место в памяти, написать там свою версию цикла зануляющую весь диапазон кроме адресов 00AA16 и 00AB16 и возвращающую каретку исполнения обратно. Кстати это наиболее классическая версия инъекции. Так-же можно предположить, что попадаем мы сюда из инструкции JSR(Jump to SubRoutine), это легко проверить с помощью стека.


Как работает стек

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


Отладчик эмулятора не показывает значение регистра "Stack Pointer", вместо этого он показывает адрес на который регистр ссылается и прямо сейчас это адрес 01F216, нехитрые вычисления показывают, что последние попавшие в стек данные это, C316 и C216, что могло бы навести на мысль об адресе C3C216 но 6502 процессор типа "Little Endian" и потому при выполнении инструкций и сохранении адреса в памяти или стеке первым записывается менее значимый байт. И если на вершине стека действительно лежит адрес это адрес C2C316. И это адрес последнего аргумента инструкции JSR, если опять же это вообще адрес. Проверить очень легко, достаточно посмотреть что записано на два байта выше адреса C2C316.


Address Opcode Mnemonic Arguments
C2C1 20 07 C3 JSR $C307

Как видите это инструкция JSR в адрес C30716, а значит предположение о субрутине верно.


Теперь нужно правильно написать код инъекции, найти для него подходящее место, записать его на это место и перенаправить инструкцию JSR на эту инъекцию.


Сайты со справочными материалами

Опкоды, инструкции, много информации про 6502 assembly.


Для этого очень удобно воспользоваться блокнотом, у меня Visual Studio Code за него. Стиль написания у всех свой, лично я первой пишу инструкцию JSR с её адресом, чтоб знать где менять и полным опкодом, чтоб знать что менять.


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


C2C1:20 07 C3  JSR $C307

A2 28     LDX #$28
A9 00     LDA #$00
95 00     STA $00,X
E8        INX
E0 F0     CPX #$F0
D0 F9     BNE $C30B

C312:A2 07     LDX #$07

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


C312:A2 07     LDX #$07

A2 28     LDX #$28
A9 00     LDA #$00
95 00     STA $00,X
E8        INX
E0 AA     CPX #$AA
D0 F9     BNE -7
A9 13     LDA #$13
95 00     STA $00,X
E8        INX
E0 AC     CPX #$AC
D0 F9     BNE -7
A9 00     LDA #$00
95 00     STA $00,X
E8        INX
E0 F0     CPX #$F0
D0 F9     BNE -7

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


Если вы не смогли прочитать этот код

Здесь тот-же цикл, но разбитый на три части, в первом цикле мы сравниваем регистр X со значением AA16, поскольку нам нужно занулить все адреса до адреса 00AA16, после в регистр A кладём 1316, значение прокачанного спредгана и вторым циклом пишем его до адреса 00AC16 начиная с которого остаток диапазона следует опять занулить. Возвращаем в регистр A ноль и зануляем остаток диапазона.


Крайне важно в конце дописать инструкцию возврата из инъекции.


E0 F0     CPX #$F0
D0 F9     BNE -7
4C 12 C3  JMP $C312

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


4C 12 C3  JMP $C312

A2 28 A9 00 95 00 E8 E0 AA D0 F9 A9 13 95 00 E8 E0 AC D0 F9 A9 00 95 00 E8 E0 F0 D0 F9 4C 12 C3 

И подсчитав опкоды легко выяснить что их 3210. Следовательно нужно найти 2016 ничем не занятых адресов на РОМе. Как правило ничем не занятые адреса это большие пространства одинаковых значений, чаще всего нулей или FF16, Как раз такой большой кусок и нужно найти, он должен быть не меньше 3510 адресов, чтоб был некоторый запас.


В "Hex Editor" у меня такой диапазон нашёлся в B29E16-BFFF16. Использовать для инъекций начало подобных свободных участков может быть опасно, потому советую писать код инъекций в его конце. Наиболее удобный адрес для начала инъекции BFE016, но это адрес в памяти консоли, чтоб выяснить где он находится в РОМ файле нужно кликнуть по нему правой кнопкой мыши и выбрать пункт "Go Here In ROM File".


Теперь можно скопипастить сюда весь опкод (32 значения). Последний штрих поменять инструкцию


C2C1:20 07 C3  JSR $C307

на прыжок в адрес инъекции у меня это BFE016.


C2C1:20 E0 BF  JSR $BFE0

Разумеется найдя истинное место инструкции на РОМе.


Ответ на вопрос невнимательных профессионалов.

В стек попадает адрес инструкции JSR потому независимо от места прыжка там будет тот же адрес возврата, а из инъекции мы возвращаемся в код инструкцией JMP никак не влияющей на стек. Таким образом инструкция RTS срабатывает в том же месте что и без инъекции, и возвращает в то же место что и без инъекции. В итоге эта инъекция никак не ломает работу стека.


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




К сожалению, не доступен сервер mySQL