Хабрахабр

Снова EA, снова NFS, снова баги. Чиним

Привет, Хабр! С вами снова спидраннинг коммьюнити NFS. И мы снова чиним старенькую игрушку — NFS Most Wanted. Я уже рассказывал о починке багов в своих предыдущихстатьях, а сегодня хотел был пойти с вами немного глубже в дебри дизассемблирования. Заинтересовавшихся прошу под кат.

Предыстория

Когда-то давно, когда EA издавала хорошие NFS, вышла одна из известнейших гоночных игр — Most Wanted. Увы, написана она была не так хорошо, как продавалась, и периодически падала. Конечно, обычный человек на это обращает мало внимания — ну вылетела разок за прохождение, ничего страшного. А вот нам это создает огромные проблемы: сколько потенциальных рекордов было убито случайными падениями без внятных симптомов. Все закончилось тем, что KuruHS лично попросил меня разобраться в ситуации. Отказаться я не смог.

Что имеем

IDA — для дизассемблирования
Cheat Engine — для редактирования памяти и инструкций
Visual Studio — для отладки (Trace Points, оказались весьма удобной вещью)

У нас есть куча дампов. Приличная куча, гигабайт на 10. С них мы и начнем — проанализируем, на каких инструкциях падает игра. А падает она довольно рандомно, хотя некоторые закономерности прослеживаются. За время решеняи проблем мы нашли несколько потенциально опасных мест, которые иногда крашат игру. Например:

в функции вычисления хэша строки. Видимо, разработчики не ожидали получить null-pointer в этом месте, поэтому не добавили проверку на него. Из-за этого в редких случаях игра падала. Фикс довольно банальный — прыгнуть в первый пустой кусок экзешника, да сделать test edi, edi. Потом jz retun и jmp откуда прыгали изначально.

Другой похожий случай нашёлся в процедуре по адресу
0х0057D105 mov edx, [ecx] ; я так и не смог понять, что конкретно она делает

Разработчики снова не ожидали получить там null pointer, поэтому игра падала. Фикс абсолютно идентичен предыдущему.

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

Ладно, нет времени ныть, надо реверсить. Несколько вечеров за разборами этого мусора принесли свои плоды: код, хоть все еще и не читаемый, стал более понятен. Судя по всему, эта подсистема работает по стандартной схеме: грабастаем некоторое количество памяти сразу, разбивая на блоки, храним их в двусвязном списке; по требованию выдаём свободные участки, а если таковых нету — пытаемся взять у системы еще. Ах, 2005-ый, когда операции с памятью были достаточно дорогими, чтобы ей разбрасываться как попало…

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

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

Pure virtual function call.

Та самая ошибка, что проиллюстрирована в шапке. Люди, знакомые с С++ сразу поймут, в чем проблема. Однако без исходного кода все становится значительно сложнее. Ситуацию усложняет CRT, который, аки партизан, упорно не хочет генерировать дампы, если вылавливает этот тип ошибки.

Purecall означает, что код попытался вызвать «чисто виртуальную функцию» (виртуальную функцию класса, не имеющую реализации). Без сомнений, этого сделать у него не получается, поэтому единственное, на что он решается — сообщить об этом пользователю и завершиться с кодом 0. В итоге вроде бы все и хорошо с кодом, а на деле все плохо.

Спасибо Microsoft за замечательную функцию — _set_purecall_handler, которая позволяет заменить обработчик purecall'ов. Ищем в экзешнике упоминания/ссылки, находим саму функцию. Теперь осталось написать свой обработчик и не забыть установить его как хэндлер. Для этого нам нужно найти достаточно большой кусок неиспользуемого кода в самом экзешнике, который мы сможем перезаписать на наш код. Недолгий поиск показал, что это будет функция _CxxThrowException (ссылок на нее не было найдено). Беспощадно записываем все ее тело nop'ами и начинаем творить поверх нее:

Вот так будет выглядеть псевдокод новых процедур:

new_handler: xor eax, eax ; return *(0); mov eax, [eax] ; моментально валит игру ret
set_handler: push new_handler call _set_purecall_handler ; _set_purecall_handler(new_handler); add esp, 4 ; cdecl, восстанавливаем стек ret 

Компилируем (в моем случае руками вбиваем в Cheat Engine) и вставляем в код:

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

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

0043E005 call dword ptr [edx+80h] 

Ничего не могу сказать, кроме как: «да, это вызов виртуальной функции». Первая же мысль — а что, если без него? Выпиливаем его nop'ами, тестируем — вроде живем. Игра работает как надо. Побочных эффектов нет. Собираем патч, отсылаем на тестирование. Через день прилетает дамп, где та же процедура падает несколькими байтами ниже. Выпиливаю и ее — игра начинает падать. Все ведет к тому, что нужно думать над более серьезным решением. Но в голову ничего не лезет, поэтому откладывается на неопределенный срок.

За ночь я успел все обдумать, и пришёл к выводу. Вы скажете, что С++ не умеет в рантайме определять тип объекта? А я скажу, что может. И очень просто — по адресу виртуальной таблицы в памяти. Изучив дампы, я пришел к выводу, что периодически в процедуру прилетает неправильный класс (vtbl @ 0x00890970), а значит мы можем отловить эту ситуацию:

 cmp edx, 00890970h jnz good_class xor eax, eax jmp return
good_class: call dword ptr[edx+80h] jmp continue

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

Лирика

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

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

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

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

убираем по максимуму «лишние» проверки и пытаемся насильно вызвать эту функцию. Запускаем тестирвоание и наконец-то получаем неправильный класс на вход. Ждём, пока отладчик студии допечатает весь текст, игра отвисает и… продолжает работать. Ура!


Скриншот мыльный, ибо запись со стрима

Заключение

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

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

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

Спасибо за внимание!

Теги
Показать больше

Похожие статьи

Кнопка «Наверх»
Закрыть