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

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

xchps57_2b_ibgxjlohfxlrmr5y.png

Предыстория


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

Что имеем


iiatddonw5fdjhpx1_rrnro0bcw.png

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

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

vxyvv1jw47c5u8vkjrxnldlxzro.png

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

unkghhzml3dsnt7_zatbmatbkdk.png

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

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

sgfmn8nkxvnjaqvwoic4r6bhkxk.png

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

bq_as5aopykjs8fn80gf-cxevhs.png

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

1y27zfayohre3bzupcxhvc_hdc0.png

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

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

Pure virtual function call.


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

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

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

xoagcqurfprw6do_qu61p7acpee.png

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

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) и вставляем в код:

3dtso7rf9nuvvuf-mipjdbfdajq.png

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

qtrrokqrmqn4ljbf1gwtxg_r8lk.png

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

0043E005  call        dword ptr [edx+80h] 


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

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


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


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

icunxpc8s5tblyte-hwsz-ruxha.png

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


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

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

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

90xetsjhcoykjzidkzc7petygoi.png

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

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

Заключение


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

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

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

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

© Habrahabr.ru