Думаю, многие из вас хотя бы раз в своей жизни занимались модификацией любимой компьютерной игры. Это могло быть редактирование ресурсов (например, моделей персонажей и объектов из 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:
Ни о каких паковщиках и проекторах сообщено не было, в связи с чем можно предположить, что снимать нам на этот раз ничего не придётся.
Теперь перейдём к проверке на использование технологии ASLR. Загружаем urw.exe в PE Tools, нажимаем на кнопку «Optional Header» и видим уже знакомое нам по предыдущим статьям значение опции «DLL Flags» — 0x8140:
Меняем его на 0x8100 (почему именно так объясняется, например, тут) и нажимаем на две кнопки «Ok», заставляя таким образом исполняемый файл загружаться каждый раз по одному и тому же адресу, равному Image Base (в нашем случае это 0x00400000).
Загружаем urw.exe в OllyDbg и продумываем порядок действий:
- Найти процедуру загрузки персонажа и выяснить, как с ней необходимо взаимодействовать
- Найти процедуру сохранения персонажа и так же выяснить, как с ней необходимо взаимодействовать
- Назначить открытие меню «Quick save-load» на какой-нибудь незанятый хоткей и отрисовать его
Начнём с первого пункта.
Создаём персонажа, сохраняемся и, выйдя в главное меню, пытаемся загрузить его. Ваше внимание должно упасть на сообщение «Loading %character_name%»:
Давайте найдём обращения к данной строке в дизассемблированном листинге:
Очень похоже на то! Ставим бряк, выходим в главное меню и пытаемся снова загрузить нашего персонажа:
Предполагая, что вся эта процедура ответствена за загрузку персонажа, прыгаем на её вызов при помощи окна с call stack'ом
и видим, что никакого «окружения» она для своей работы, судя по всему, не требует:
Перейдём ко второму пункту — поиску процедуры, занимающейся сохранением персонажа.
Порядок действий, в принципе, аналогичен предыдущему этапу. Сначала смотрим, что происходит в момент сохранения персонажа и обращаем внимание на то, что в этом случае игра так же пишет соответствующее сообщение:
Теперь ищем строку «Saving your character...» в «Referenced text strings» модуля urw:
Ставим бряк на инструкции, которая обращается к данной строке, и снова пытаемся сохранить персонажа:
Далее прыгаем на место вызова текущей процедуры
и смотрим на «окружение»:
Как видите, перед вызовом процедуры сохранения персонажа на стек заносится число 1, которое, вероятнее всего, является её аргументом. Если заглянуть в тело процедуры, то мы действительно увидим обращения к EBP+8 в паре мест (по адресу EBP лежит старое значение регистра EBP, по адресу EBP+4 — адрес инструкции, которой необходимо передать управление после завершения работы текущей процедуры, с адреса EBP+8 начинаются аргументы, если они, разумеется, есть, а по отрицательным смещениям, как правило, находятся локальные переменные):
Беглым просмотром можно догадаться, что значение этого аргумента отвечает за необходимость вывода сообщений о начале и окончании загрузки.
Не менее важно также обратить внимание на инструкцию ADD ESP,4, которая следует сразу же за вызовом процедуры сохранения персонажа. Она «подчищает» место в стеке, которое было занято аргументами процедуры (в данном случае, всего одним). Требования на тему того, кто должен выполнять «очистку» стека, определяются разнообразными calling convention'ами.
Итак, мы разобрались с процедурами загрузки и сохранения игрового мира, так что давайте перейдём к следующему этапу — поиску незанятого хоткея и отрисовке нашего меню.
К счастью, в одной из предыдущих версий из игры было «вырезано» меню «расширенных команд», которое раньше вызывалось по вводу символа '#'. Теперь при попытке его активации игра выдаёт следующее сообщение:
Чем не идеальное место для патчинга?
Ищем отсылки к строке «Extended commands menu has been removed»
и прыгаем на единственную из них:
Нажимаем левой кнопкой мыши на начало процедуры и жмём Ctrl-R, чтобы найти места, из которых она вызывается:
Отлично, такое обращение всего одно. Прыгаем на него и оказываемся в одном из case'ов switch-блока:
Что ж, место для внедрения нашего патча мы нашли. Теперь давайте подумаем, как мы будем отрисовывать меню быстрой загрузки и сохранения.
Очевидно, меню это должно вписываться в остальную визуальную составляющую игры, как это сделано, например, с меню «Fishing»:
Обращений к строке «Fishing options» слишком много, так что давайте попробуем найти отсылки к «Retrieve a net»:
Прыгаем на указанную инструкцию и попадаем в один из case'ов:
Ставим бряк, пытаемся открыть меню «Fishing» и смотрим, откуда нас позвали:
Ставим бряк на начало данной процедуры, нажимаем F9 в OllyDbg и снова пытаемся активировать меню рыболовства. В результате пошаговой отладки можно предположить, что процедура 0x004FC530 отвечает за получение текста для очередного пункта меню из уже увиденного нами ранее switch-блока, а процедура, находящаяся по адресу 0x004BEF10, ответствена за отрисовку пунктов меню:
Arg1 обозначает ASCII-код символа, который необходимо ввести для выбора соответствующего пункта меню, а Arg2 отвечает за его название.
За отрисовку самого меню с его названием отвечает процедура 0x004BF3B0, которая принимает в качестве аргумента заголовок меню. Также по какой-то причине значение по адресу 0xAE16068 меняется на 1 перед вызовом данной процедуры и на 0 после него:
Обратите внимание, что в случае данных процедур ответственность за 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:
Вот, что получилось:
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-блок, отвечающий за обработку открытия меню расширенных команд:
Нажимаем '#' в игре и…
Что это такое? Судя по всему, после открытия предыдущего меню список пунктов не очистился, а лишь расширился новыми. Давайте ещё раз взглянем на то, как происходит отрисовка меню «Fishing» и обратим внимание на то, что в случае нашего меню мы не вызываем ещё как минимум одну процедуру — 0x004BED40. Возможно, именно она ответствена за cleanup, ведь её вызов находится прямо перед отрисовкой всех пунктов:
Несмотря на то, что перед вызовом данной процедуры осуществляется занесение в стек значения регистра EBX, оно вовсе не является аргументом данной процедуры, в чём можно убедиться, заглянув в её реализацию:
Чтобы не «сдвигать» все инструкции и не менять адреса в уже написанном code cave, давайте добавим вызов этой процедуры в код case-блока
и попробуем ввести символ '#' ещё раз:
Отлично!
Балуемся с save-load'ом
и замечаем, что на первый взгляд всё работает так, как и должно.
В процессе тестов приходим в деревню
и снова нажимаем # — S:
Куда делись люди?
Видимо, процедура сохранения игрового мира содержит также код 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
Здорово! Объекты всё равно пропадают, но тут же появляются из-за последующей загрузки.
Давайте попытаемся сохранить наши изменения на диск:
Печально осознавать, но инструкции, добавленные нами ближе к концу образа исполняемого файла, вылезли за его «физические границы», как это уже было в одной из предыдущих статей.
Давайте вычислим верхнюю «границу», взглянув на информацию о секциях urw.exe в PE Tools:
Virtual Offset (0x00001000) + Raw Size (0x0010F400) + Image Base (0x00400000) = 0x00510400
Т.е. выделенные инструкции «вылезли» за «границу»:
NOP'ы, разумеется, можно убрать, однако у нас и без них остаётся ещё 20 байт. Ещё 5 байт мы можем позаимствовать перед нашим code cave'ом:
Но что делать с остальными 15 байтами? Можно уменьшить размер строк, отвечающих за пункты меню и его заголовок, сделав их, например, равными «S», «L» и «S&L», но это будет не очень элегантным решением.
Вполне возможно, что в середине дизассемблированного листинга тоже есть места, в которые можно добавить свой собственный код. Это могут быть нули, которые мы обычно находили в конце образа исполняемого файла, NOP'ы или, например, INT3 инструкции, следующие друг за другом между телами процедур. Как раз-таки последний случай и наблюдается в urw.exe. Например,
Давайте разнесём строки и обработку сохранения и загрузки по таким местам:
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 с новым меню быстрой загрузки и сохранения.
Послесловие
Лень — одно из самых раздражающих качеств практически в любом человеке и сфере деятельности. Не ленитесь потратить немного времени на решение проблемы, с которой Вы встречаетесь уже не первый раз — возможно, это сэкономит Вам гораздо больше времени в будущем.
Спасибо за внимание, и снова надеюсь, что статья оказалась кому-нибудь полезной.
Комментарии (2)
janekprostojanek
16.07.2015 14:37Отличная статья. Люблю рогалики, в Катаклизме такое решение пришлось бы к месту… Хотя это и читерство :)
И реверс тоже люблю. Интересная статья.
Evengard
Я бы вместо поиска место в exe-шнике сделал бы DLL-proxy, в остальном очень интересно. Правда в современных условиях отрисовать такое меню не будет так просто, как мне кажется… Боюсь сработает только в старых играх такой простой метод без реверса всего механизма отрисовки менюшек.