Как заработать очки, даже не запуская игру

    image

    Как-то вечером, сидя за компьютером, я наткнулся на одну инди-игру под названием «Shoot First» (игру можно скачать абсолютно бесплатно с сайта автора, а за донат любого размера вы получите специальную версию с двумя новыми видами оружия и ещё одним видом уровней). Геймплей её довольно незамысловат — игроку необходимо бегать по этажам в поисках прохода на следующий уровень, при необходимости собирая различные предметы (карты, ключи, etc) и попутно убивая встретившихся на его пути врагов. В общем, этакий action roguelike. Несмотря на кажущуюся простоту, игра меня довольно сильно зацепила, и я потратил не один час, пытаясь добраться как можно дальше и заработать как можно больше очков.

    Кстати, об очках. После смерти персонажа и ввода имени игра отображает онлайн-таблицу рекордов:

    image

    Наигравшись вдоволь, я решил разобраться, как она устроена и попытаться обмануть игру, сказав, что я заработал нереальное кол-во очков.

    Как протекал процесс, и что из этого вышло, читайте под катом (осторожно, много скриншотов).

    Первое, что приходит на ум, наверное, любому человеку, который хоть раз занимался нечестным получением денег в сигнл-плеерных играх, это ArtMoney. Что ж, почему бы и нет?

    Запускаем игру, зарабатываем какое-нибудь «необычное» кол-во очков, загружаем процесс в ArtMoney и ищем это самое значение. После долгих мучений мне так и не удалось найти целочисленное значение с набранным мною кол-вом очков, при изменении которого я бы достиг своей цели.

    Что ж, ладно, пойдём другим путём.

    Очевидно, что для получения таблицы рекордов игра лезет в сеть, а взаимодействие с сетью в Windows, как известно, лежит на плечах WinSock, реализация которой находится в WS2_32.dll. Берём в руки WPE Pro (в отличие от, например, Wireshark'а, он умеет перехватывать пакеты конкретного приложения, что в нашем случае гораздо удобнее), указываем процесс нашей игры, умираем и смотрим на результат:

    image

    Как видите, игра шлёт на адрес 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, открываем исполняемый файл игры и видим следующую картину:

    image

    Получается, что с большой долей вероятности игра ничем не защищена.

    Что ж, отлично. Тогда запускаем подопытного в OllyDbg и пытаемся найти место, где игра делает GET-реквесты. В WinSock есть две функции, отвечающие непосредственно за отправку данных — send и WSASend. Переключаемся на модуль нашего исполняемого файла (Alt-E -> Shoot First %version%.exe) и ищем их в списке «Intermodular calls» (right-click по окну CPU -> Search For -> All intermodular calls). Как ни странно, но здесь нет ни одной, ни другой функции. На ум приходит сразу два варианта — разработчик мог скопировать их код из WS2_32.dll напрямую в своё приложение или просто вызывать их из какого-то другого модуля. Второй вариант гораздо проще отследить, так что давайте начнём с него.

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

    image

    Переключаемся на него (Alt-E -> plaidscores.dll) и также ищем вызовы send и WSASend. Находится только один:

    image

    Ставим на него софтварный бряк (left click -> F2), умираем (разумеется, в игре) и… останавливаемся перед вызовом функции send:

    image

    На стеке видны аргументы, самым интересным из которых для нас является Data. Если посмотреть, что находится по этому адресу (right click -> Follow in Dump), то мы увидим уже знакомый нам GET-реквест:

    image

    Таким образом, мы поняли, что отправка данных на сервер осуществляется в модуле plaidscores.dll. Очевидно, что модуль Shoot First %version%.exe должен каким-то образом сообщать dll некоторые данные (как минимум, это всё те же очки, в то время как хеш, например, может генерироваться уже в dll). Вариантов передачи данных тут, конечно, в общем случае целая масса (файлы, реестр, сокеты, etc), но в большинстве случаев разработчики просто вызывают экспортированную функцию из dll с соответствующими аргументами. Смотрим, откуда нас позвали (для этого надо открыть call stack при помощи Alt-K):

    image

    Как видите, для отправки данных exe-модуль зовёт нас из выделенного на скришоте места. Снимаем точку останова с функции send, прыгаем на вызов (right-click -> Show call) и ставим софтварный бряк при помощи F2. Снова умираем и смотрим на обстановку:

    image

    Что мы здесь видим?

    Во-первых, имя экспортированной функции — psSubmit.
    Во-вторых, состояние стека на момент её вызова.

    К сожалению, гарантированно понять, сколько аргументов передаётся экспортированной функции, можно лишь в том случае, если их имена были декорированы (при желании можно почитать об этом, например, тут). Что ж, давайте проверим. Запускаем Dependency Walker, открываем нашу dll и смотрим на список экспортированных функций:

    image

    К сожалению, их имена не декорированы. В таком случае нам придётся проанализировать код перед вызовом функции psSubmit в поисках PUSH'ей. Вероятнее всего, все 4 PUSH'а в case-блоке указанного выше скриншота и есть аргументы нашей исследуемой функции. Посмотрим на них ещё раз:

    image

    С первым и последним аргументами вопросов возникнуть не должно — это имя игрока, этаж, на котором он умер, и 1P / 2P. Скорее всего, один из оставшихся аргументов и есть наша цель — очки. Чтобы понять, какой это конкретно из них, давайте заработаем какое-нибудь их кол-во перед смертью (до этого я умирал без набора очков). Нажимаем F9, выполняем поставленную задачу, умираем и останавливаемся на том же самом месте, но уже с другими данными на стеке:

    image

    Я набрал 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 и попадаем в следующее место:

    image

    В отличие от софтварных, хардварные брейкпоинты останавливаются на инструкции, идущей после выполнения интересующих нас действий, так что смотрим на то, что находится по адресу 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, то мы действительно увидим кол-во наших очков:

    image

    Скажу честно, такого я не ожидал, ведь заработать в игре нецелое кол-во очков просто невозможно (по крайней мере, чисто визуально и по таблице рекордов).

    Для окончательной проверки наших предположений можно воспользоваться каким-нибудь онлайн-сервисом:

    image

    Более того, как вы видите, обращается FLD не только к исследуемому нами значению, но и к последнему неизвестному аргументу. Следовательно, это 8-байтовое число с плавающей точкой.

    Оглядываясь назад, я понимаю, что Art Money мог бы помочь в этой ситуации сразу же, если бы я знал, что искать надо вовсе не целочисленное значение:

    image

    Впрочем, неужели это всё? Пользоваться таким решением не очень-то удобно, так что я принял решение написать отдельную программу, которая будет отправлять 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 и смотрим на стек:

    image

    Визуально всё выглядит точно так же, как и в случае с игрой. Может, мы ошиблись с кол-вом аргументов? Но прежде чем браться за анализ PUSH'ей перед вызовом psSubmit из exe-модуля игры, вспомните, как обычно происходит работа с динамическими библиотеками в Windows. Вы, наверное, не раз слышали, что DllMain — это функция, которая довольно сильно ограничена по тому, что в ней можно делать. Однако очень часто возникает ситуация, когда DLL необходимо инициализировать какие-то данные при запуске, чтобы не делать это постоянно при вызове каждой экспортированной функции. В связи с этим разработчики DLL зачастую предоставляют экспортированную функцию для инициализации (а также нередко и для деинициализации), в которой совершают все необходимые им действия. Посмотрим, нет ли такой функции в plaidScores.dll при помощи Ctrl-N:

    image

    Как видите, она действительно есть. Запускаем игру в OllyDbg, ставим бряк на psInit, смотрим откуда нас вызвали и видим, что, вероятнее всего, она принимает два аргумента:

    image

    Один из них — ссылка, на которую необходимо выполнять 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 и внимательно пробегаемся глазами по списку. Ваше внимание должны привлечь следующие строки:

    image

    Ставим бряки в местах обращения к ним, а также на вызов функции send, передаём огромное кол-во очков и видим, что приложение делает GET-реквест, но после его выполнения понимает, что что-то пошло не так, и действительно обращается к строке с описанием возможной причины ошибки. После этого можно провести ряд экспериментов и наконец выяснить, что максимальное допустимое значение — 2^31 − 1. Результаты экспериментов можно наблюдать на скриншоте:

    image

    Кстати, номер игрока можно сделать больше двух — например, 999P работает отлично.

    Послесловие


    Я ни в коем случае не агитирую ломать игры, подделывать результаты и всячески мешать нормальному игровому процессу (удовольствие от игры в любом случае достигается другими вещами). Этой статьёй я хотел лишь продемонстрировать один из вариантов решения задачи, которая возникла на моём пути. Автору игры я уже написал.

    Надеюсь, что статья показалась кому-то интересной.
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 25

      0
      Для таких «разбирательств» очень полезна IDA — она умеет читать количество параметров для всех функций и показывать код в виде С-подобного кода, что очень сильно упрощает разбор кода. Только с её помощью я смог за неделю вытянуть из одной игры примерно 3к ветвлений сюжета, хранящихся в самой программе в виде чудовищных switch-case — на голом АСМе такие трюки тяжело делаются. Да и дебажить там проще — в любом мете отладки можно нажать F5 и курсор будет на текущей операции псевдокода. Ориентация по памяти тоже очень простая.
        0
        Ради интереса попробовал загрузить plaidscores.dll в IDA. Выбрал функцию psSubmit, нажал F5 и увидел следующую сигнатуру
        int __cdecl psSubmit(int a1, double a2, int a3)
        Это нормально, что IDA приняла const char* за int? Я, конечно, понимаю, что указатели можно хранить и в int'ах, но всё же
          +1
          Дальше по коду будет понятно, что с типам была ошибка и можно вручную поменять тип данных — тогда и касты пропадут.
          Структуры и объекты оно тоже не определяет — приходится самому все это описывать. Но это же мелочи, всё равно упрощение труда значительное получается.
          • UFO just landed and posted this here
              +1
              Статья интересная, много инструментов, без лишних сложностей.

              P.S.
              Скачать IDA и нажать F5 стало так просто :)
              А цена и условия приобретения кусаются, но кого это интересует, мы даже не замечаем что пишем.
                +1
                А при чём здесь цена? На официальном сайте IDA можно скачать бесплатную версию
                  +2
                  Я ждал такого ответа.
                  А на этой версии www.hex-rays.com/products/ida/support/download_freeware.shtml можно использовать hex-rays?
                  Или Ильфак изменил свою позицию, возможно пропустил в СМИ.

                  Расстраивает что люди перестали замечать разницу между лицензией и контрафактом.
                    0
                    Может у автора лицензия персональная или он работает в конторе, где есть такая лицензия? :)
            0
            Спасибо за ссылки на инструментики. Вообще, всегда поражал ход мысли хакера. Интересно, сколько времени в итоге ушло, чтобы взломать очки? Сляпать такую игру из двух дрыгающихся спрайтов и двух синих кирпичей займёт пару-тройку вечеров в любой современной игроделке.
              0
              Суммарно часов пять, наверное. Не всё шло так гладко, как написано в статье — были и попытки разобрать алгоритм генерации хеша, и написание простеньких code cave'ов для автоматизации проверок
              0
              Delphi — это косяк DiE?
                0
                А что, человек не мог написать игру на Delphi?
                  0
                  Теоретически мог, но сомнительно как-то…
                    0
                    Непонятно почему используется такая старая версия DiE. Не хочу сказать, что в данном примере новая чем-то сильнее помогла, просто глаза резануло.
                      0
                      Реальной причины использовать старую версию DiE нет. Просто валялась на компьютере, вот и решил воспользоваться

                      На самом деле, более новые версии DiE конкретно в данном случае ничего нового об исполняемом файле не сообщают
                      0
                      И почему сомнительно? Age Of Wonders написана и как-то никто не умер.
                      А мелкие игры так вообще постоянно на ней делаются, не даром Game Maker дельфевый.
                        0
                        «Космические рейнджеры» написана целиком на дельфях, вообще-то ;)
                    0
                    Я ожидаю в скором времени пост про: «Как играть в игру, не запуская её».
                    Возможно это будет осуществлять внедрением NFC-чипа под кожу…
                      0
                      Конечно исследование очень увлекательно, но мне кажется проще вариант использовать Fiddler (Для HTTP запросов), что бы перехватить запрос и генерировать поддельный используя встроенный Composer или Autoresponder.
                        0
                        Не совсем вас понял. Что именно и каким образом вы хотите подделывать в отправляемых запросах, учитывая, что алгоритм генерации хеша так и не был распарсен?
                          0
                          Прошу прощения упустил момент, что это не просто MD5 от данных
                            0
                            Возможно, это и MD5 от данных, вот только от каких именно и в каком порядке — неизвестно
                              0
                              Как цифровая подпись используется хег от передаваемых данных (параметров) + «соль». Параметров тут немного, а «соль» скорее всего передается вторым параметром в функцию psInit — так проще/надежней использовать единую «соль» и в EXE и в DLL. Осталось перепробовать их комбинации (параметры + «соль») и результат, скорее всего, будет найден.

                              PS
                              Очепятка?

                              В отличие от софтварных, хардварные брейкпоинты останавливаются на инструкции, идущей после выполнения интересующих нас действий, так что смотрим на то, что находится по адресу 0x09F60195.

                              Может все-таки стоит смотреть на адрес 0x09F6018E, т.к. по адресу 0x09F60195 находится собственно вызов функции?
                        0
                        Нельзя просто так взять и поиграть в игру.

                        Спасибо за познавательную статью. Кстати про float, я через ArtMoney уже сталкивался с нецелым количеством патронов. А так же изощрёнными данными моей «Империи» в формате текст. Так что с тех пор делаю с десяток изменений числа, чтобы уж наверняка попасть на нужный.
                        Кстати популярный ход, это побегать не зарабатывая очков, чтобы многие значения поменялись, кроме искомого.
                          0
                          Кстати, рекомендую использовать Cheat Engine вместо ArtMoney. Он, во первых, бесплатный, во вторых — намного более мощный. Может бы вышло и без ollydbg обойтись :)

                          Only users with full accounts can post comments. Log in, please.