Как-то вечером, сидя за компьютером, я наткнулся на одну инди-игру под названием «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%.exe должен каким-то образом сообщать 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 очков, и значение одного из аргументов действительно изменилось — теперь на его месте красуется 0x40220000. На 9 в hex'е это не очень-то смахивает, так что давайте проведём ещё несколько экспериментов:
Кол-во очков в игре — значение аргумента
6 — 0x40180000
7 — 0x401C0000
8 — 0x40200000
9 — 0x40220000
10 — 0x40240000
Как видите, значения увеличиваются неравномерно, так что гарантированно провести обратную конвертацию прямо сейчас у нас не получится. Но давайте хотя бы проверим, что при изменении этого значения перед вызовом psSubmit игра действительно думает, что мы набрали другое кол-во очков, и отправляет на сервер поддельные данные. Умираем без зарабатывания очков, останавливаемся перед вызовом psSubmit и изменяем (left click -> Ctrl-E) значение соответствующего аргумента на, предположим, 0x40220000, т.е. 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 и попадаем в следующее место:
В отличие от софтварных, хардварные брейкпоинты останавливаются на инструкции, идущей после выполнения интересующих нас действий, так что смотрим на то, что находится по адресу 0x09F60195. Здесь можно увидеть инструкцию 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 <boost/scope_exit.hpp>
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <cstdlib>
#include <iostream>
#include <sstream>
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 <boost/scope_exit.hpp>
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <cstdlib>
#include <iostream>
#include <sstream>
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 работает отлично.
Послесловие
Я ни в коем случае не агитирую ломать игры, подделывать результаты и всячески мешать нормальному игровому процессу (удовольствие от игры в любом случае достигается другими вещами). Этой статьёй я хотел лишь продемонстрировать один из вариантов решения задачи, которая возникла на моём пути. Автору игры я уже написал.
Надеюсь, что статья показалась кому-то интересной.
Комментарии (24)
alexf2000
01.05.2015 23:32Спасибо за ссылки на инструментики. Вообще, всегда поражал ход мысли хакера. Интересно, сколько времени в итоге ушло, чтобы взломать очки? Сляпать такую игру из двух дрыгающихся спрайтов и двух синих кирпичей займёт пару-тройку вечеров в любой современной игроделке.
NikitaTrophimov Автор
01.05.2015 23:35Суммарно часов пять, наверное. Не всё шло так гладко, как написано в статье — были и попытки разобрать алгоритм генерации хеша, и написание простеньких code cave'ов для автоматизации проверок
robert_ayrapetyan
02.05.2015 00:02Delphi — это косяк DiE?
NikitaTrophimov Автор
02.05.2015 00:04А что, человек не мог написать игру на Delphi?
robert_ayrapetyan
02.05.2015 00:08Теоретически мог, но сомнительно как-то…
priv8v
02.05.2015 00:21Непонятно почему используется такая старая версия DiE. Не хочу сказать, что в данном примере новая чем-то сильнее помогла, просто глаза резануло.
NikitaTrophimov Автор
02.05.2015 01:27Реальной причины использовать старую версию DiE нет. Просто валялась на компьютере, вот и решил воспользоваться
На самом деле, более новые версии DiE конкретно в данном случае ничего нового об исполняемом файле не сообщают
AllexIn
02.05.2015 08:01И почему сомнительно? Age Of Wonders написана и как-то никто не умер.
А мелкие игры так вообще постоянно на ней делаются, не даром Game Maker дельфевый.
EINSAM_KONSTANTIN
02.05.2015 15:10Я ожидаю в скором времени пост про: «Как играть в игру, не запуская её».
Возможно это будет осуществлять внедрением NFC-чипа под кожу…
Rylov
02.05.2015 22:01Конечно исследование очень увлекательно, но мне кажется проще вариант использовать Fiddler (Для HTTP запросов), что бы перехватить запрос и генерировать поддельный используя встроенный Composer или Autoresponder.
NikitaTrophimov Автор
02.05.2015 22:04Не совсем вас понял. Что именно и каким образом вы хотите подделывать в отправляемых запросах, учитывая, что алгоритм генерации хеша так и не был распарсен?
Rylov
03.05.2015 00:08Прошу прощения упустил момент, что это не просто MD5 от данных
NikitaTrophimov Автор
03.05.2015 00:20Возможно, это и MD5 от данных, вот только от каких именно и в каком порядке — неизвестно
eldarmusin
04.05.2015 16:27Нельзя просто так взять и поиграть в игру.
Спасибо за познавательную статью. Кстати про float, я через ArtMoney уже сталкивался с нецелым количеством патронов. А так же изощрёнными данными моей «Империи» в формате текст. Так что с тех пор делаю с десяток изменений числа, чтобы уж наверняка попасть на нужный.
Кстати популярный ход, это побегать не зарабатывая очков, чтобы многие значения поменялись, кроме искомого.
mrThe
06.05.2015 14:34Кстати, рекомендую использовать Cheat Engine вместо ArtMoney. Он, во первых, бесплатный, во вторых — намного более мощный. Может бы вышло и без ollydbg обойтись :)
andreili
Для таких «разбирательств» очень полезна IDA — она умеет читать количество параметров для всех функций и показывать код в виде С-подобного кода, что очень сильно упрощает разбор кода. Только с её помощью я смог за неделю вытянуть из одной игры примерно 3к ветвлений сюжета, хранящихся в самой программе в виде чудовищных switch-case — на голом АСМе такие трюки тяжело делаются. Да и дебажить там проще — в любом мете отладки можно нажать F5 и курсор будет на текущей операции псевдокода. Ориентация по памяти тоже очень простая.
NikitaTrophimov Автор
Ради интереса попробовал загрузить plaidscores.dll в IDA. Выбрал функцию psSubmit, нажал F5 и увидел следующую сигнатуру
Это нормально, что IDA приняла const char* за int? Я, конечно, понимаю, что указатели можно хранить и в int'ах, но всё жеandreili
Дальше по коду будет понятно, что с типам была ошибка и можно вручную поменять тип данных — тогда и касты пропадут.
Структуры и объекты оно тоже не определяет — приходится самому все это описывать. Но это же мелочи, всё равно упрощение труда значительное получается.
resetnow
Скорее всего, это попытка «угадать» тип. Название функции нашлось в таблице экспорта, а тип восстанавливался по ассемблерному коду.
Dywar
Статья интересная, много инструментов, без лишних сложностей.
P.S.
Скачать IDA и нажать F5 стало так просто :)
А цена и условия приобретения кусаются, но кого это интересует, мы даже не замечаем что пишем.
NikitaTrophimov Автор
А при чём здесь цена? На официальном сайте IDA можно скачать бесплатную версию
Dywar
Я ждал такого ответа.
А на этой версии www.hex-rays.com/products/ida/support/download_freeware.shtml можно использовать hex-rays?
Или Ильфак изменил свою позицию, возможно пропустил в СМИ.
Расстраивает что люди перестали замечать разницу между лицензией и контрафактом.
priv8v
Может у автора лицензия персональная или он работает в конторе, где есть такая лицензия? :)