Как заработать очки, даже не запуская игру
Как-то вечером, сидя за компьютером, я наткнулся на одну инди-игру под названием «Shoot First» (игру можно скачать абсолютно бесплатно с сайта автора, а за донат любого размера вы получите специальную версию с двумя новыми видами оружия и ещё одним видом уровней). Геймплей её довольно незамысловат — игроку необходимо бегать по этажам в поисках прохода на следующий уровень, при необходимости собирая различные предметы (карты, ключи, etc) и попутно убивая встретившихся на его пути врагов. В общем, этакий action roguelike. Несмотря на кажущуюся простоту, игра меня довольно сильно зацепила, и я потратил не один час, пытаясь добраться как можно дальше и заработать как можно больше очков.
Кстати, об очках. После смерти персонажа и ввода имени игра отображает онлайн таблицу рекордов:
Наигравшись вдоволь, я решил разобраться, как она устроена и попытаться обмануть игру, сказав, что я заработал нереальное кол-во очков.
Как протекал процесс, и что из этого вышло, читайте под катом (осторожно, много скриншотов).Первое, что приходит на ум, наверное, любому человеку, который хоть раз занимался нечестным получением денег в сигнл-плеерных играх, это ArtMoney. Что ж, почему бы и нет?
Запускаем игру, зарабатываем какое-нибудь «необычное» кол-во очков, загружаем процесс в ArtMoney и ищем это самое значение. После долгих мучений мне так и не удалось найти целочисленное значение с набранным мною кол-вом очков, при изменении которого я бы достиг своей цели.
Что ж, ладно, пойдём другим путём.
Очевидно, что для получения таблицы рекордов игра лезет в сеть, а взаимодействие с сетью в Windows, как известно, лежит на плечах WinSock, реализация которой находится в WS2_32.dll. Берём в руки WPE Pro (в отличие от, например, Wireshark’а, он умеет перехватывать пакеты конкретного приложения, что в нашем случае гораздо удобнее), указываем процесс нашей игры, умираем и смотрим на результат:
Как видите, игра шлёт на адрес teknopants.com GET-реквесты вида
/games/shootfirst/score12.php? alltime=15&monthly=15&weekly=15&daily=15&name=%name%&score=%score%&data=Floor%20%floor%%20%5b%player%P%5d&hash=%hash%
, где %name% — это имя игрока, %score% — кол-во очков, %floor% — этаж, на котором погиб игрок, %player% — номер игрока (за одним компьютером может играть одновременно два человека — 1P и 2P соответственно) и %hash% — хеш, необходимый, очевидно, для проверки корректности отправляемых данных.
Обратите внимание, что GET-реквест одновременно содержит информацию о том, какие данные необходимо получить (параметры alltime, weekly и daily), и о том, какие данные необходимо добавить (параметры name, score, data и hash).
Понятное дело, что просто так поменять в отправляемом GET-реквесте кол-во заработанных очков нельзя — для этого нам также потребуется сгенерировать новый хеш. Решать задачу путём проведения экспериментов практически бессмысленно, так что пора взяться за ещё один инструмент — на этот раз OllyDbg.
Но перед тем, как загрузить процесс в OllyDbg, давайте проверим, не запакована ли наша игра. Берём DiE, открываем исполняемый файл игры и видим следующую картину:
Получается, что с большой долей вероятности игра ничем не защищена.
Что ж, отлично. Тогда запускаем подопытного в OllyDbg и пытаемся найти место, где игра делает GET-реквесты. В WinSock есть две функции, отвечающие непосредственно за отправку данных — send и WSASend. Переключаемся на модуль нашего исполняемого файла (Alt-E → Shoot First %version%.exe) и ищем их в списке «Intermodular calls» (right-click по окну CPU → Search For → All intermodular calls). Как ни странно, но здесь нет ни одной, ни другой функции. На ум приходит сразу два варианта — разработчик мог скопировать их код из WS2_32.dll напрямую в своё приложение или просто вызывать их из какого-то другого модуля. Второй вариант гораздо проще отследить, так что давайте начнём с него.
Смотрим на директорию с игрой на предмет каких-то дополнительный динамических библиотек. Находим одну, которая уже по названию намекает на то, что наши поиски будут недолгими:
Переключаемся на него (Alt-E → plaidscores.dll) и также ищем вызовы send и WSASend. Находится только один:
Ставим на него софтварный бряк (left click → F2), умираем (разумеется, в игре) и… останавливаемся перед вызовом функции send:
На стеке видны аргументы, самым интересным из которых для нас является Data. Если посмотреть, что находится по этому адресу (right click → Follow in Dump), то мы увидим уже знакомый нам GET-реквест:
Таким образом, мы поняли, что отправка данных на сервер осуществляется в модуле plaidscores.dll. Очевидно, что модуль Shoot First %version%.dll должен каким-то образом сообщать dll некоторые данные (как минмум, это всё те же очки, в то время как хеш, например, может генерироваться уже в dll). Вариантов передачи данных тут, конечено, в общем случае целлая масса (файлы, реестр, сокеты, etc), но в большинстве случаев разработчики просто вызывают экспортированную функцию из dll с соответствующими аргументами. Смотрим, откуда нас позвали (для этого надо открыть call stack при помощи Alt-K):
Как видите, для отправки данных exe-модуль зовёт нас из выделенного на скришоте места. Снимаем точку останова с функции send, прыгаем на вызов (right-click → Show call) и ставим софтварный бряк при помощи F2. Снова умираем и смотрим на обстановку:
Что мы здесь видим?
Во-первых, имя экспортированной функции — psSubmit.Во-вторых, состояние стека на момент её вызова.
К сожалению, гарантированно понять, сколько аргументов передаётся экспортированной функции, можно лишь в том случае, если их имена были декорированы (при желании можно почитать об этом, например, тут). Что ж, давайте проверим. Запускаем Dependency Walker, открываем нашу dll и смотрим на список экспортированных функций:
К сожалению, их имена не декорированы. В таком случае нам придётся проанализировать код перед вызовом функции psSubmit в поисках PUSH’ей. Вероятнее всего, все 4 PUSH’а в case-блоке указанного выше скриншота и есть аргументы нашей исследуемой функции. Посмотрим на них ещё раз:
С первым и последним аргументами вопросов возникнуть не должно — это имя игрока, этаж, на котором он умер, и 1P / 2P. Скорее всего, один из оставшихся аргументов и есть наша цель — очки. Чтобы понять, какой это конкретно из них, давайте заработаем какое-нибудь их кол-во перед смертью (до этого я умирал без набора очков). Нажимаем F9, выполняем поставленную задачу, умираем и останавливаемся на том же самом месте, но уже с другими данными на стеке:
Я набрал 9 очков, и значение одного из аргументов действительно изменилось — теперь на его месте красуется 0×40220000. На 9 в hex’е это не очень-то смахивает, так что давайте проведём ещё несколько экспериментов:
Кол-во очков в игре — значение аргумента6 — 0×401800007 — 0×401C00008 — 0×402000009 — 0×4022000010 — 0×40240000
Как видите, значения увеличиваются неравномерно, так что гарантированно провести обратную конвертацию прямо сейчас у нас не получится. Но давайте хотя бы проверим, что при изменении этого значения перед вызовом psSubmit игра действительно думает, что мы набрали другое кол-во очков, и отправляет на сервер поддельные данные. Умираем без зарабатывания очков, останавливаемся перед вызовом psSubmit и изменяем (left click → Ctrl-E) значение соответствующего аргумента на, предположим, 0×40220000, т.е. 9 очков. Нажимаем F9 и наблюдаем, что наше поддельное значение действительно отправилось на сервер.
Теперь у нас остались нерешёнными две проблемы:
В каком формате хранятся заработанные очки Зачем нужен ещё один аргумент, который во всех проведённых экспериментах был равен нулю Второй пункт не сказать, чтобы проблема, но никто ведь не любит, когда то, что он изучает, не поддаётся объяснению, верно? Однако давайте пока остановимся на первом пункте.
Раз plaidscores.dll формирует GET-реквест с «читаемым» кол-вом очков, а получает на вход «закодированный» вариант, она знает, как выполнить необходимое нам преобразование (в принципе, его знает и exe-модуль, раз он может отображать кол-во очков игроку). В связи с этим мы можем прямо сейчас взяться за изучение алгоритма декодирования, но что если есть способ проще? Мы забыли, что у нас есть хеш, который безумно напоминает MD5. Вспоминая, что в WinAPI есть функция для получения MD5-хеша (и некоторых других видов) для переданных ей данных, можно предположить, что игра просто вызывает её для получения этого хеша, так что мы сможем понять, от чего именно игра берёт хеш. Если такой вызов и есть, то он должен находиться в plaidscores.dll, ведь, как мы видели, в psSubmit передаются лишь четыре аргумента, каждый из которых не очень-то напоминает MD5-хеш. Функция, о которой идёт речь, называется CryptGetHashParam (на самом деле, там целый ряд функций, которые необходимо позвать одна за другой, но всё ведёт именно к ней), так что давайте поищем её среди «Intermodular calls». К сожалению, такой функции не нашлось.
Что ж, ничего — снова останавливаемся перед вызовом psSubmit, прыгаем внутрь этой функции по нажатию F7 и ставим хардварный бряк на область памяти с нашими очками. Для этого ищем адрес, по которому хранятся очки, в «Memory Dump» (можно воспользоваться Ctrl-G) → right-click по первому байту → Breakpoint → Hardware, on access → Dword. Нажимаем F9 и попадаем в следующее место:
В отличие от софтварных, хардварные брейкпоинты останавливаются на инструкции, идущей после выполнения интересующих нас действий, так что смотрим на то, что находится по адресу 0×09F60195. Здесь можно увидеть инструкцию FLD:
Pushes the source operand onto the FPU register stack. The source operand can be in singleprecision, double-precision, or double extended-precision floating-point format
Так вот оно что! Получается, значение вовсе не закодировано, а всего лишь представлено в виде числа с плавающей точкой! Если посмотреть на регистр ST0, то мы действительно увидим кол-во наших очков:
Скажу честно, такого я не ожидал, ведь заработать в игре нецелое кол-во очков просто невозможно (по крайней мере, чисто визуально и по таблице рекордов).
Для окончательной проверки наших предположений можно воспользоваться каким-нибудь онлайн-сервисом:
Более того, как вы видите, обращается FLD не только к исследуемому нами значению, но и к последнему неизвестному аргументу. Следовательно, это 8-байтовое число с плавающей точкой.
Оглядываясь назад, я понимаю, что Art Money мог бы помочь в этой ситуации сразу же, если бы я знал, что искать надо вовсе не целочисленное значение:
Впрочем, неужели это всё? Пользоваться таким решением не очень-то удобно, так что я принял решение написать отдельную программу, которая будет отправлять GET-реквест с заданными пользователем данными (для упрощения кода я убрал некоторые проверки):
#include
#define WIN32_LEAN_AND_MEAN
#include
#include
typedef void (__cdecl *submit_proc_t)(const char*, double, const char*);
int main () { HMODULE scores_dll = LoadLibraryA («PlaidScores.dll»); if (scores_dll == NULL) { std: cerr << "Unable to load DLL \n"; return EXIT_FAILURE; } BOOST_SCOPE_EXIT_ALL(scores_dll) { FreeLibrary(scores_dll); };
submit_proc_t submit_proc = (submit_proc_t)GetProcAddress (scores_dll, «psSubmit»); if (submit_proc == NULL) { std: cerr << "Unable to find submit procedure \n"; return EXIT_FAILURE; }
std: cout << "Enter your name: "; std::string name; std::getline(std::cin, name);
std: cout << "Enter scores count: "; int scores; std::cin >> scores;
std: cout << "Enter floor: "; int floor; std::cin >> floor;
std: cout << "Enter player number: "; int player_number; std::cin >> player_number;
std: ostringstream osstr; osstr << "Floor " << floor << "[" << player_number << "P]";
submit_proc (name.c_str (), scores, osstr.str ().c_str ());
std: cout << "Done \n"; } Запускаем, взволнованно смотрим на таблицу рекордов, и… Ничего не происходит.На первый взгляд выглядит всё так же, как и в случае с вызовом plaidscores.dll из игры. Что же пошло не так? Давайте попробуем разобраться.
Загружаем наш исполняемый файл в OllyDbg, ставим бряк на psSubmit и смотрим на стек:
Визуально всё выглядит точно так же, как и в случае с игрой. Может, мы ошиблись с кол-вом аргументов? Но прежде чем браться за анализ PUSH’ей перед вызовом psSubmit из exe-модуля игры, вспомните, как обычно происходит работа с динамическими библиотеками в Windows. Вы, наверное, не раз слышали, что DllMain — это функция, которая довольно сильно ограничена по тому, что в ней можно делать. Однако очень часто возникает ситуация, когда DLL необходимо инициализиовать какие-то данные при запуске, чтобы не делать это постоянно при вызове каждой экспортированной функции. В связи с этим разработчики DLL зачастую предоставляют экспортированную функцию для инициализации (а также нередко и для деинициализации), в которой совершают все необходимые им действия. Посмотрим, нет ли такой функции в plaidScores.dll при помощи Ctrl-N:
Как видите, она действительно есть. Запускаем игру в OllyDbg, ставим бряк на psInit, смотрим откуда нас вызвали и видим, что, вероятнее всего, она принимает два аргумента:
Один из них — ссылка, на которую необходимо выполнять GET-реквест (http://teknopants.com/games/shootfirst/score12.php), а другой является строкой »5hoo7first12».
Основываясь на новых данных, немного изменим исходный код нашей программы:
#include
#define WIN32_LEAN_AND_MEAN
#include
#include
typedef void (__cdecl *init_proc_t)(const char*, const char*); typedef void (__cdecl *submit_proc_t)(const char*, double, const char*);
int main () { HMODULE scores_dll = LoadLibraryA («PlaidScores.dll»); if (scores_dll == NULL) { std: cerr << "Unable to load DLL \n"; return EXIT_FAILURE; } BOOST_SCOPE_EXIT_ALL(scores_dll) { FreeLibrary(scores_dll); };
init_proc_t init_proc = (init_proc_t)GetProcAddress (scores_dll, «psInit»); if (init_proc == NULL) { std: cerr << "Unable to find init procedure \n"; return EXIT_FAILURE; }
submit_proc_t submit_proc = (submit_proc_t)GetProcAddress (scores_dll, «psSubmit»); if (submit_proc == NULL) { std: cerr << "Unable to find submit procedure \n"; return EXIT_FAILURE; }
std: cout << "Enter your name: "; std::string name; std::getline(std::cin, name);
std: cout << "Enter scores count: "; int scores; std::cin >> scores;
std: cout << "Enter floor: "; int floor; std::cin >> floor;
std: cout << "Enter player number: "; int player_number; std::cin >> player_number;
std: ostringstream osstr; osstr << "Floor " << floor << "[" << player_number << "P]";
init_proc («http://teknopants.com/games/shootfirst/score12.php»,»5hoo7first12»); submit_proc (name.c_str (), scores, osstr.str ().c_str ());
std: cout << "Done \n"; } Запускаем и наслаждаемся — на этот раз результат появился в таблице рекордов.Как узнать, какое кол-во очков максимальное? Тут варианта два — либо автор производит необходимые проверки прямо в dll, либо на сервере. В обоих случаях проще всего поискать в модуле строки с похожим содержимым, так что делаем right-click по окну CPU -> Search for → All referenced text strings и внимательно пробегаемся глазами по списку. Ваше внимание должны привлечь следующие строки:
Ставим бряки в местах обращения к ним, а также на вызов функции send, передаём огромное кол-во очков и видим, что приложение делает GET-реквест, но после его выполнения понимает, что что-то пошло не так, и действительно обращается к строке с описанием возможной причины ошибки. После этого можно провести ряд экспериментов и наконец выяснить, что максимальное допустимое значение — 2^31 − 1. Результаты экспериментов можно наблюдать на скриншоте:
Кстати, номер игрока можно сделать больше двух — например, 999P работает отлично.
Послесловие Я ни в коем случае не агитирую ломать игры, подделывать результаты и всячески мешать нормальному игровому процессу (удовольствие от игры в любом случае достигается другими вещами). Этой статьёй я хотел лишь продемонстрировать один из вариантов решения задачи, которая возникла на моём пути. Автору игры я уже написал.Надеюсь, что статья показалась кому-то интересной.