Pull to refresh

«Pimp my game», или как «прокачивать» игры без API

Reading time 10 min
Views 14K
image

Думаю, многие из вас хотя бы раз в своей жизни занимались модификацией любимой компьютерной игры. Это могло быть редактирование ресурсов (например, моделей персонажей и объектов из GTA), написание разнообразных скриптов (к примеру, планировщик задач для Dwarf Fortress, запускаемый при помощи DFHack) или разработка модов для своего сервера Minecraft, которые работали под управлением Minecraft Forge.

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

Сидя однажды вечером за одной из своих любимых игр под названием UnReal World (далее — URW), я в очередной раз столкнулся с невероятно неудобным для меня поведением. Естественное желание практически любого игрока при встрече с более-менее сильным врагом — моментально сохраниться, чтобы не потерять всё, чего он достиг на данный момент (да, некоторые сочтут это «хамством» / «читом» / etc, но о вкусах не спорят). Проблема заключается в том, что сохраниться-загрузиться по-быстрому в URW просто не получится. При сохранении персонажа автоматически выполняется выход в главное меню, а выйти не сохранившись, чтобы вернуться к предыдущей точке сохранения, можно лишь убив процесс игры через taskmgr. В итоге в большинстве случаев сложные бои на поздних этапах игры (когда уже действительно жаль терять всё то, что накопил за долгое время) заканчивались безумными комбинациями и заученными наизусть сочетаниями клавиш.

И тут я задумался. А что мешает нам добавить своё собственное меню быстрой загрузки и сохранения? Вооружившись отладчиком, редактором и анализаторами PE-файлов, я принялся за работу.

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

Несколько слов о самой игре. UnReal World — компьютерная игра, разрабатываемая с 1992 г. двумя финскими разработчиками, и продолжающая своё развитие и в наши дни. Игра является представителем жанра roguelike и заставит Вас окунуться в жизнь северных племён времён позднего железного века. На момент написания статьи URW абсолютно бесплатен (впрочем, так было не всегда), а за донат любого размера Вы получите видео с благодарностями, снятое авторами игры, и, если заплатите больше определённой суммы, получите т.н. lifetime membership, который даст Вам доступ к скрытому разделу форума с бета-релизами и прочими плюшками. В общем, игра действительно стоит того, чтобы в неё сыграть.

Если Вы внимательно читали предыдущие статьи, то уже наверняка знаете, с чего мы начнём исследование бинарника. Верно, с анализа исполняемого файла urw.exe утилитами DiE и PEiD:

image

image

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

Теперь перейдём к проверке на использование технологии ASLR. Загружаем urw.exe в PE Tools, нажимаем на кнопку «Optional Header» и видим уже знакомое нам по предыдущим статьям значение опции «DLL Flags» — 0x8140:

image

Меняем его на 0x8100 (почему именно так объясняется, например, тут) и нажимаем на две кнопки «Ok», заставляя таким образом исполняемый файл загружаться каждый раз по одному и тому же адресу, равному Image Base (в нашем случае это 0x00400000).

Загружаем urw.exe в OllyDbg и продумываем порядок действий:
  • Найти процедуру загрузки персонажа и выяснить, как с ней необходимо взаимодействовать
  • Найти процедуру сохранения персонажа и так же выяснить, как с ней необходимо взаимодействовать
  • Назначить открытие меню «Quick save-load» на какой-нибудь незанятый хоткей и отрисовать его

Начнём с первого пункта.

Создаём персонажа, сохраняемся и, выйдя в главное меню, пытаемся загрузить его. Ваше внимание должно упасть на сообщение «Loading %character_name%»:

image

Давайте найдём обращения к данной строке в дизассемблированном листинге:

image

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

image

Предполагая, что вся эта процедура ответствена за загрузку персонажа, прыгаем на её вызов при помощи окна с call stack'ом

image

и видим, что никакого «окружения» она для своей работы, судя по всему, не требует:

image

Перейдём ко второму пункту — поиску процедуры, занимающейся сохранением персонажа.

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

image

Теперь ищем строку «Saving your character...» в «Referenced text strings» модуля urw:

image

Ставим бряк на инструкции, которая обращается к данной строке, и снова пытаемся сохранить персонажа:

image

Далее прыгаем на место вызова текущей процедуры

image

и смотрим на «окружение»:

image

Как видите, перед вызовом процедуры сохранения персонажа на стек заносится число 1, которое, вероятнее всего, является её аргументом. Если заглянуть в тело процедуры, то мы действительно увидим обращения к EBP+8 в паре мест (по адресу EBP лежит старое значение регистра EBP, по адресу EBP+4 — адрес инструкции, которой необходимо передать управление после завершения работы текущей процедуры, с адреса EBP+8 начинаются аргументы, если они, разумеется, есть, а по отрицательным смещениям, как правило, находятся локальные переменные):

image

image

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

Не менее важно также обратить внимание на инструкцию ADD ESP,4, которая следует сразу же за вызовом процедуры сохранения персонажа. Она «подчищает» место в стеке, которое было занято аргументами процедуры (в данном случае, всего одним). Требования на тему того, кто должен выполнять «очистку» стека, определяются разнообразными calling convention'ами.

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

К счастью, в одной из предыдущих версий из игры было «вырезано» меню «расширенных команд», которое раньше вызывалось по вводу символа '#'. Теперь при попытке его активации игра выдаёт следующее сообщение:

image

Чем не идеальное место для патчинга?

Ищем отсылки к строке «Extended commands menu has been removed»

image

и прыгаем на единственную из них:

image

Нажимаем левой кнопкой мыши на начало процедуры и жмём Ctrl-R, чтобы найти места, из которых она вызывается:

image

Отлично, такое обращение всего одно. Прыгаем на него и оказываемся в одном из case'ов switch-блока:

image

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

Очевидно, меню это должно вписываться в остальную визуальную составляющую игры, как это сделано, например, с меню «Fishing»:

image

Обращений к строке «Fishing options» слишком много, так что давайте попробуем найти отсылки к «Retrieve a net»:

image

Прыгаем на указанную инструкцию и попадаем в один из case'ов:

image

Ставим бряк, пытаемся открыть меню «Fishing» и смотрим, откуда нас позвали:

image

image

Ставим бряк на начало данной процедуры, нажимаем F9 в OllyDbg и снова пытаемся активировать меню рыболовства. В результате пошаговой отладки можно предположить, что процедура 0x004FC530 отвечает за получение текста для очередного пункта меню из уже увиденного нами ранее switch-блока, а процедура, находящаяся по адресу 0x004BEF10, ответствена за отрисовку пунктов меню:

image

image

Arg1 обозначает ASCII-код символа, который необходимо ввести для выбора соответствующего пункта меню, а Arg2 отвечает за его название.

За отрисовку самого меню с его названием отвечает процедура 0x004BF3B0, которая принимает в качестве аргумента заголовок меню. Также по какой-то причине значение по адресу 0xAE16068 меняется на 1 перед вызовом данной процедуры и на 0 после него:

image

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

Процедура 0x004BF3B0 вернёт управление вызвавшему её коду только после выбора одного из вариантов меню или нажатия клавиши Esc, положив в регистр EAX число, обозначающее ASCII-код введённого символа (в случае Esc им будет ноль).

Давайте продумаем код нашего патча:

; Отрисовываем пункты меню
PUSH "Save"
PUSH 0x53 ; 'S'
CALL 0x004BEF10 ; Процедура отрисовки пунктов меню, найденная нами ранее
ADD ESP,8

PUSH "Load"
PUSH 0x4C ; 'L'
CALL 0x004BEF10 ; Процедура отрисовки пунктов меню, найденная нами ранее
ADD ESP,8

; Отрисовываем само меню с заголовком
PUSH "Quick save-load"
MOV DWORD PTR DS:[0xAE16068],1
CALL 0x004BF3B0
ADD ESP,4
MOV DWORD PTR DS:[0xAE16068],0

; Проверяем, что ввёл пользователь
CMP EAX,53
JE save ; Если это 'S'
CMP EAX,4C
JNZ exit ; Если это не 'L'
JMP load

save:
PUSH 1 ; Включить вывод сообщений о начале и окончании сохранения персонажа
CALL 0x005030E0 ; Процедура сохранения персонажа, найденная нами ранее
ADD ESP,4
JMP exit

load:
CALL 0x0050CB90 ; Процедура загрузки персонажа, найденная нами ранее
JMP exit

exit:
; Прыгаем на default-case switch-блока, которому передаётся управление
; после выполнения кода, выполняющегося по вводу символа '#'
JMP 0x0050C8C1

Ищем место для нашего code cave'а в конце образа исполняемого файла. Я решил начать его с адреса 0x0051039B:

image

Вот, что получилось:

0051039B   .  53 61 76 65 00                                    ASCII "Save",0
005103A0   .  4C 6F 61 64 00                                    ASCII "Load",0
005103A5   .  51 75 69 63 6B 20 73 61 76 65 2D 6C 6F 61 64 00   ASCII "Quick save-load",0
005103B5      68 9B035100                                       PUSH urw.0051039B              ;  ASCII "Save"
005103BA      6A 53                                             PUSH 53
005103BC      E8 4FEBFAFF                                       CALL urw.004BEF10
005103C1      83C4 08                                           ADD ESP,8
005103C4      68 A0035100                                       PUSH urw.005103A0              ;  ASCII "Load"
005103C9      6A 4C                                             PUSH 4C
005103CB      E8 40EBFAFF                                       CALL urw.004BEF10
005103D0      83C4 08                                           ADD ESP,8
005103D3      68 A5035100                                       PUSH urw.005103A5              ;  ASCII "Quick save-load"
005103D8      C705 6860E10A 01000000                            MOV DWORD PTR DS:[AE16068],1
005103E2      E8 C9EFFAFF                                       CALL urw.004BF3B0
005103E7      83C4 04                                           ADD ESP,4
005103EA      C705 6860E10A 00000000                            MOV DWORD PTR DS:[AE16068],0
005103F4      83F8 53                                           CMP EAX,53
005103F7      74 07                                             JE SHORT urw.00510400
005103F9      83F8 4C                                           CMP EAX,4C
005103FC      75 13                                             JNZ SHORT urw.00510411
005103FE      EB 0C                                             JMP SHORT urw.0051040C
00510400      6A 01                                             PUSH 1
00510402      E8 D92CFFFF                                       CALL urw.005030E0
00510407      83C4 04                                           ADD ESP,4
0051040A      EB 05                                             JMP SHORT urw.00510411
0051040C      E8 7FC7FFFF                                       CALL urw.0050CB90
00510411    ^ E9 ABC4FFFF                                       JMP urw.0050C8C1

Давайте добавим безусловный прыжок на наш code cave в case-блок, отвечающий за обработку открытия меню расширенных команд:

image

Нажимаем '#' в игре и…

image

Что это такое? Судя по всему, после открытия предыдущего меню список пунктов не очистился, а лишь расширился новыми. Давайте ещё раз взглянем на то, как происходит отрисовка меню «Fishing» и обратим внимание на то, что в случае нашего меню мы не вызываем ещё как минимум одну процедуру — 0x004BED40. Возможно, именно она ответствена за cleanup, ведь её вызов находится прямо перед отрисовкой всех пунктов:

image

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

image

Чтобы не «сдвигать» все инструкции и не менять адреса в уже написанном code cave, давайте добавим вызов этой процедуры в код case-блока

image

и попробуем ввести символ '#' ещё раз:

image

Отлично!

Балуемся с save-load'ом

image

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

В процессе тестов приходим в деревню

image

и снова нажимаем # — S:

image

Куда делись люди?

Видимо, процедура сохранения игрового мира содержит также код cleanup'а игровых объектов, т.к. не была рассчитана на использование вне контекста выхода в главное меню. Из этой ситуации можно выкрутиться двумя путями — разобраться, где именно находится код очистки (а вложенных вызовов там не мало, уж поверьте), или схитрить и выполнять вместо обычного сохранения сохранение с последующей загрузкой. Предлагаю остановиться на последнем варианте. Изменяем наш code cave, занопив инструкцию JMP 0x00510411, находящуюся по адресу 0x0051040A:

0051039B   .  53 61 76 65 00                                   ASCII "Save",0
005103A0   .  4C 6F 61 64 00                                   ASCII "Load",0
005103A5   .  51 75 69 63 6B 20 73 61 76 65 2D 6C 6F 61 64 00  ASCII "Quick save-load",0
005103B5      68 9B035100                                      PUSH urw.0051039B               ;  ASCII "Save"
005103BA      6A 53                                            PUSH 53
005103BC      E8 4FEBFAFF                                      CALL urw.004BEF10
005103C1      83C4 08                                          ADD ESP,8
005103C4      68 A0035100                                      PUSH urw.005103A0               ;  ASCII "Load"
005103C9      6A 4C                                            PUSH 4C
005103CB      E8 40EBFAFF                                      CALL urw.004BEF10
005103D0      83C4 08                                          ADD ESP,8
005103D3      68 A5035100                                      PUSH urw.005103A5               ;  ASCII "Quick save-load"
005103D8      C705 6860E10A 01000000                           MOV DWORD PTR DS:[AE16068],1
005103E2      E8 C9EFFAFF                                      CALL urw.004BF3B0
005103E7      83C4 04                                          ADD ESP,4
005103EA      C705 6860E10A 00000000                           MOV DWORD PTR DS:[AE16068],0
005103F4      83F8 53                                          CMP EAX,53
005103F7      74 07                                            JE SHORT urw.00510400
005103F9      83F8 4C                                          CMP EAX,4C
005103FC      75 13                                            JNZ SHORT urw.00510411
005103FE      EB 0C                                            JMP SHORT urw.0051040C
00510400      6A 01                                            PUSH 1
00510402      E8 D92CFFFF                                      CALL urw.005030E0
00510407      83C4 04                                          ADD ESP,4
0051040A      90                                               NOP
0051040B      90                                               NOP
0051040C      E8 7FC7FFFF                                      CALL urw.0050CB90
00510411    ^ E9 ABC4FFFF                                      JMP urw.0050C8C1

Здорово! Объекты всё равно пропадают, но тут же появляются из-за последующей загрузки.

Давайте попытаемся сохранить наши изменения на диск:

image

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

Давайте вычислим верхнюю «границу», взглянув на информацию о секциях urw.exe в PE Tools:

image

Virtual Offset (0x00001000) + Raw Size (0x0010F400) + Image Base (0x00400000) = 0x00510400

Т.е. выделенные инструкции «вылезли» за «границу»:

image

NOP'ы, разумеется, можно убрать, однако у нас и без них остаётся ещё 20 байт. Ещё 5 байт мы можем позаимствовать перед нашим code cave'ом:

image

Но что делать с остальными 15 байтами? Можно уменьшить размер строк, отвечающих за пункты меню и его заголовок, сделав их, например, равными «S», «L» и «S&L», но это будет не очень элегантным решением.

Вполне возможно, что в середине дизассемблированного листинга тоже есть места, в которые можно добавить свой собственный код. Это могут быть нули, которые мы обычно находили в конце образа исполняемого файла, NOP'ы или, например, INT3 инструкции, следующие друг за другом между телами процедур. Как раз-таки последний случай и наблюдается в urw.exe. Например,

image

Давайте разнесём строки и обработку сохранения и загрузки по таким местам:

00409FBA   .  53 61 76 65 00                                    ASCII "Save",0
00409FBF   .  4C 6F 61 64 00                                    ASCII "Load",0
00409FC4   .  51 75 69 63 6B 20 73 61 76 65 2D 6C 6F 61 64 00   ASCII "Quick save-load",0

005007A2      6A 01           PUSH 1
005007A4      E8 37290000     CALL urw.005030E0
005007A9    ^ E9 C6F5FFFF     JMP urw.004FFD74

004FFD74      83C4 04          ADD ESP,4
004FFD77    ^ E9 16FFFFFF      JMP urw.004FFC92

004FFC92      E8 F9CE0000      CALL urw.0050CB90
004FFC97      E9 25CC0000      JMP urw.0050C8C1

0050C438   > \BE 20C75300               MOV ESI,urw_.0053C720     ;  ASCII "Extended commands"; Case 23 ('#') of switch 0050C3FB
0050C43D   .  E8 FE28FBFF               CALL urw.004BED40
0050C442      E9 4F3F0000               JMP urw.00510396
0050C447   .  E9 75040000               JMP urw.0050C8C1

00510396      68 BA9F4000                                       PUSH urw.00409FBA                   ;  ASCII "Save"
0051039B      6A 53                                             PUSH 53
0051039D      E8 6EEBFAFF                                       CALL urw.004BEF10
005103A2      83C4 08                                           ADD ESP,8
005103A5      68 BF9F4000                                       PUSH urw.00409FBF                   ;  ASCII "Load"
005103AA      6A 4C                                             PUSH 4C
005103AC      E8 5FEBFAFF                                       CALL urw.004BEF10
005103B1      83C4 08                                           ADD ESP,8
005103B4      68 C49F4000                                       PUSH urw.00409FC4                   ;  ASCII "Quick save-load"
005103B9      C705 6860E10A 01000000                            MOV DWORD PTR DS:[AE16068],1
005103C3      E8 E8EFFAFF                                       CALL urw.004BF3B0
005103C8      83C4 04                                           ADD ESP,4
005103CB      C705 6860E10A 00000000                            MOV DWORD PTR DS:[AE16068],0
005103D5      83F8 53                                           CMP EAX,53
005103D8    ^ 0F84 C403FFFF                                     JE urw.005007A2
005103DE      83F8 4C                                           CMP EAX,4C
005103E1    ^ 0F85 B0F8FEFF                                     JNZ urw.004FFC97
005103E7    ^ E9 A6F8FEFF                                       JMP urw.004FFC92

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

Послесловие


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

Спасибо за внимание, и снова надеюсь, что статья оказалась кому-нибудь полезной.
Tags:
Hubs:
+27
Comments 2
Comments Comments 2

Articles