Методы защиты от внутриигровых ботов

Мы познакомились с принципами работы внутриигровых ботов. Теперь рассмотрим способы защиты от них. Есть две группы методов защиты:

  • Защита приложения от реверс-инжиниринга.

  • Блокировка алгоритмов бота.

Первая группа методов разрабатывается очень давно: со времён первых версий коммерческого ПО, которое нужно было защищать от нелицензионного распространения. Эти методы хорошо известны, и информацию о них легко найти в Интернете. Их основная задача – усложнить анализ приложения с помощью отладчика и дизассемблера.

Вторая группа методов защищает данные процесса игрового приложения от чтения и записи. Из-за них боту становится сложнее читать состояние объектов и внедрять свои действия.

Некоторые методы защиты можно отнести сразу к обеим группам.

Тестовое приложение

Вспомним архитектуру клиент-сервер современных онлайн-игр. Клиент выполняется на компьютере пользователя и обменивается сообщениями с игровым сервером. Большая часть методов защиты от внутриигровых ботов работает на стороне клиента.

Рассмотрим методы защиты на конкретном примере. Напишем простое приложение, которое будет имитировать игру и менять состояние некоторого объекта. Для контроля за этим состоянием напишем простейшего внутриигрового бота.

Все примеры этого раздела компилировались на Visual Studio C++ под 32 разрядную архитектуру. Некоторые из них могут не заработать или потребуют изменений, если вы соберёте их под 64 разрядную архитектуру или с помощью MinGW.

Алгоритм тестового приложения будет следующим:

  1. При старте присвоить параметру объекта (например его уровень здоровья) максимально допустимое значение.

  2. В цикле проверять состояние горячей клавиши "1".

  3. Если пользователь не нажимает клавишу, уменьшать параметр объекта. Иначе – увеличивать.

  4. Если параметр оказался равен 0, завершить приложение.

Листинг 3-16 демонстрирует исходный код тестового приложения.

Листинг 3-16. Исходный код тестового приложения

#include <stdio.h>
#include <stdint.h>
#include <windows.h>

static const uint16_t MAX_LIFE = 20;
static uint16_t gLife = MAX_LIFE;

int main()
{
    SHORT result = 0;

    while (gLife > 0)
    {
        result = GetAsyncKeyState(0x31);
        if (result != 0xFFFF8001)
            --gLife;
        else
            ++gLife;

        printf("life = %u\n", gLife);
        Sleep(1000);
    }
    printf("stop\n");
    return 0;
}

Уровень здоровья игрового объекта хранится в глобальной переменной gLife. При старте приложения мы присваиваем ей значение константы MAX_LIFE, равное 20.

Вся работа функции main происходит в цикле while. В нём мы проверяем состояние клавиши "1" с помощью WinAPI функции GetAsyncKeyState. Виртуальный код этой клавиши (равный 0x31) передаётся в функцию входным параметром. Если вызов GetAsyncKeyState возвращает состояние "не нажато", переменная gLife уменьшается на единицу. В противном случае – увеличивается также на единицу. После этого идёт односекундная задержка для того, чтобы пользователь успел отпустить клавишу.

Попробуйте скомпилировать тестовое приложение в конфигурации "Debug" (отладка) в Visual Studio и запустить его.

Исследование памяти тестового приложения

Теперь напишем бота для нашего тестового приложения. Его алгоритм будет таким же, как и для игры Diablo 2 из прошлого раздела. Если параметр здоровья опускается ниже 10, бот симулирует нажатие клавиши "1".

Чтобы контролировать параметр здоровья, бот должен читать значение переменной gLife. Очевидно, мы не можем воспользоваться тем же механизмом поиска объекта, который мы применили для Diablo 2. Нам нужно проанализировать адресное пространство тестового приложения и найти подходящий метод доступа к gLife. Хорошая новость заключается в том, что это приложение очень простое и для его изучения нам будет достаточно отладчика OllyDbg.

Чтобы найти сегмент, содержащий переменную gLife выполните следующие шаги:

  1. Запустите отладчик OllyDbg. Нажмите F3, чтобы открыть диалог "Select 32-bit executable" (выберите 32-разрядный исполняемый файл). В диалоге выберите скомпилированное приложение из листинга 3-16. В результате отладчик запустит приложение и остановит его процесс на первой исполняемой инструкции процессора.

  2. Нажмите комбинацию клавиш Ctrl+G, чтобы открыть диалог "Enter expression to follow" (ввести выражение для перехода).

  3. Введите имена EXE модуля и функции main через точку в поле диалога "Enter address expression" (ввести адрес выражения). Должна получиться строка "TestApplication.main". После этого нажмите кнопку "Follow expression" (перейти к выражению). Теперь курсор окна дизассемблера должен указывать на первую инструкцию функции main.

  4. Поставьте точку останова на эту инструкцию нажатием F2.

  5. Начните исполнение процесса нажатием F9. Должна сработать наша точка останова.

  6. Щёлкните правой кнопкой мыши по следующей строке дизассемблированного кода:

    MOV AX,WORD PTR DS:[gLife]

    Позиция курсора должна совпадать с иллюстрацией 3-24.

Иллюстрация 3-24. Точка останова в main функции

  1. Выберите пункт "Follow in Dump" ➤ "Memory address" ("Следить в дампе" ➤ "Адрес памяти") в открывшемся меню. Теперь курсор в окне дампа памяти указывает на переменную gLife. В моём случае она находится по адресу 329000 и имеет значение 14 в шестнадцатеричной системе.

  2. Нажмите комбинацию клавиш Alt+M, чтобы открыть окно "Memory map" (карта памяти).

  3. Найдите сегмент в котором находится переменная gLife. Им окажется .data модуля TestApplication, как на иллюстрации 3-25.

Иллюстрация 3-25. Сегменты модуля TestApplication

Мы выяснили, что переменная gLife хранится в самом начале сегмента .data. Следовательно, её адрес равен базовому адресу сегмента. Если бот найдет .data, он сразу сможет прочитать gLife.

Бот для тестового приложения

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

  1. Предоставить привилегию SE_DEBUG_NAME процессу бота.

  2. Подключиться к процессу тестового приложения.

  3. Искать в памяти сегмент .data, в котором хранится переменная gLife.

  4. Читать переменную в бесконечном цикле. Если её значение оказывается меньше 10, записать вместо него 20.

Исходный код бота приведён в листинге 3-17.

Листинг 3-17. Исходный код бота для тестового приложения

#include <stdio.h>
#include <windows.h>

BOOL SetPrivilege(HANDLE hToken, LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
{
    // См. реализацию этой функции в листинге 3-1
}

SIZE_T ScanSegments(HANDLE proc)
{
    MEMORY_BASIC_INFORMATION meminfo;
    LPCVOID addr = 0;

    if (!proc)
        return 0;

    while (1)
    {
        if (VirtualQueryEx(proc, addr, &meminfo, sizeof(meminfo)) == 0)
            break;

        if ((meminfo.State == MEM_COMMIT) && (meminfo.Type & MEM_IMAGE) && (meminfo.Protect == PAGE_READWRITE) && (meminfo.RegionSize == 0x1000))
        {
            return (SIZE_T)meminfo.BaseAddress;
        }
        addr = (unsigned char*)meminfo.BaseAddress + meminfo.RegionSize;
    }
    return 0;
}

WORD ReadWord(HANDLE hProc, DWORD_PTR address)
{
    // См. реализацию этой функции в листинге 3-13
}

void WriteWord(HANDLE hProc, DWORD_PTR address, WORD value)
{
    if (WriteProcessMemory(hProc, (void*)address, &value, sizeof(value), NULL) == 0)
        printf("Failed to write memory: %u\n", GetLastError());
}

int main()
{
    // Предоставить SE_DEBUG_NAME привилегию текущему процессу

    // Подключиться к процессу тестового приложения

    SIZE_T lifeAddress = ScanSegments(hTargetProc);

    ULONG hp = 0;
    while (1)
    {
        hp = ReadWord(hTargetProc, lifeAddress);
        printf("life = %lu\n", hp);

        if (hp < 10)
            WriteWord(hTargetProc, lifeAddress, 20);

        Sleep(1000);
    }
    return 0;
}

Главное различие ботов для тестового приложения и для Diablo 2 – это реализация функции ScanSegments. Теперь мы можем отличить нужный нам сегмент .data по его флагам и размеру. Эта информация выводится в окне "Memory map" отладчика OllyDbg. Таблица 3-8 поясняет значения флагов.

Таблица 3-8. Значения флагов сегмента .data

Столбец окна "Memory map"

Значение в OllyDbg

Значение в WinAPI

Описание

Type

Img

MEM_IMAGE

Страницы памяти были загружены из исполняемого файла.

Access

RW

PAGE_READWRITE

Страницы памяти доступны для чтения и записи.

MEM_COMMIT

Страницы памяти были выделены на физическом носителе: RAM или файл подкачки на жёстком диске.

Флаг MEM_COMMIT не отображается в OllyDbg, но его можно прочитать с помощью WinDbg.

Чтобы запустить бота, выполните следующие действия:

  1. Запустите тестовое приложение.

  2. Запустите бота с правами администратора.

  3. Переключитесь на консоль с работающим тестовым приложением.

  4. Ждите, пока не увидите сообщение, что переменная gLife стала меньше 10.

Бот перепишет значение gLife, как только оно станет слишко мало.

Защита приложения от реверс-инжиниринга

Сначала рассмотрим методы защиты кода и памяти игрового приложения от исследования. Как показал пример разработки бота для Diablo 2, знание внутренних аспектов работы игры очень важно. К сожалению, абсолютно надёжной защиты не бывает. Лучшее, чего можно достигнуть, – заставить потенциального разработчика бота потратить больше времени на исследование игры. Возможно, этого будет достаточно, чтобы он отказался от своих планов.

WinAPI функции для обнаружения отладчика

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

Рассматриваемые далее методы не защищают память процесса от чтения сканером (например Cheat Engine) или ботом. Они только позволяют обнаружить факт подключения отладчика.

IsDebuggerPresent

WinAPI функция IsDebuggerPresent возвращает значение true, если к вызвавшему её процессу подключён отладчик. IsDebuggerPresent можно использовать следующим образом:

int main()
{
    if (IsDebuggerPresent())
    {
        printf("debugger detected!\n");
        exit(EXIT_FAILURE);
    }

    // Остальной код соответствует функции main из листинга 3-16
}

Мы проверяем присутствие отладчика в начале функции main. Если он обнаружен, процесс тестового приложения завершается вызовом exit. Такой способ использования IsDebuggerPresent неэффективен. Мы обнаружим отладчик только в том случае, если он запускает процесс приложения. Если же подключиться к уже запущенному процессу, мы сможем его отлаживать. В этом случае проверка IsDebuggerPresent уже произошла, а регулярного её повтора нет.

Листинг 3-18 демонстрирует правильный способ использования функции IsDebuggerPresent.

Листинг 3-18. Защита тестового приложения вызовом IsDebuggerPresent

#include <stdio.h>

int main()
{
    SHORT result = 0;

    while (gLife > 0)
    {
        if (IsDebuggerPresent())
        {
            printf("debugger detected!\n");
            exit(EXIT_FAILURE);
        }
        result = GetAsyncKeyState(0x31);
        if (result != 0xFFFF8001)
            --gLife;
        else
            ++gLife;

        printf("life = %u\n", gLife);
        Sleep(1000);
    }
    printf("stop\n");
    return 0;
}

Правильно вызывать IsDebuggerPresent на каждой итерации цикла while (например в его начале). Благодаря этому отладчик будет обнаружен, даже если он подключится к уже работающему приложению.

Как обойти такую защиту? Самый простой способ – манипулировать регистрами процессора в момент проверки. С помощью отладчика мы можем подменить возвращаемое функцией значение, чтобы предотвратить выполнение блока кода с вызовом exit.

Чтобы подменить результат вызова функции IsDebuggerPresent, выполните следующие действия:

  1. Запустите отладчик OllyDbg и приложение из листинга 3-18 под его управлением.

  2. Нажмите комбинацию клавиш Ctrl+N, чтобы открыть окно "Names in TestApplication" (имена в TestApplication). Перед вами таблица символов тестового приложения, в которой указаны все его глобальные переменные, константы и функции.

  3. Введите имя IsDebuggerPresent в окне "Names in TestApplication". При этом переход в списке к соответствующей функции произойдёт автоматически.

  4. Щёлкните левой кнопкой мыши по строчке "&KERNEL32.IsDebuggerPresent" в списке.

  5. Нажмите Ctrl+R, чтобы открыть диалог "Search - References to..." (поиск ссылок на...). Вы увидите список мест в коде приложения, из которых вызывается функция IsDebuggerPresent.

  6. Двойным левым щелчком мыши выберите первую строчку в окне "Search - References to...". Курсор окна дизассемблера перейдёт на вызов IsDebuggerPresent из функции main.

  7. В окне дизассемблера левым щелчком мыши выберите инструкцию TEST EAX,EAX, которая следует за вызовом IsDebuggerPresent. Установите на ней точку останова нажатием F2.

  8. Нажмите F9, чтобы продолжить работу тестового приложения. После этого должна сработать наша точка останова.

  9. Измените значение регистра EAX на 0. Для этого двойным щелчком мыши выберите значение регистра EAX в окне "Registers (FPU)" (регистры). Откроется диалог "Modify EAX" (изменение EAX), как на иллюстрации 3-26. В нём введите значение 0 в ряд "Signed" (знаковый), столбец "EAX". Нажмите кнопку "OK".

  10. Нажмите F9, чтобы приложение работало дальше.

Иллюстрация 3-26. Изменение значения регистра EAX

После изменения значения регистра процессора, тестовое приложение не обнаружит отладчик на текущей итерации цикла while. Однако, проверка IsDebuggerPresent произойдёт на следующей итерации, и OllyDbg будет обнаружен. Поэтому необходимо менять значение регистра вручную перед каждой проверкой, что неудобно.

Другой способ обойти проверку IsDebuggerPresent – модифицировать код тестового приложения. Сделать это можно как в исполняемом файле приложения на диске, так и в памяти уже работающего процесса. Второй способ удобнее в реализации, поэтому рассмотрим его. Как мы уже знаем, OllyDbg позволяет модифицировать память отлаживаемого процесса. Это может быть память любого сегмента: например данных в .data или кода в .text.

Чтобы модифицировать код приложения, выполните следующие действия:

  1. Запустите отладчик OllyDbg и тестовое приложение из листинга 3-18 под его управлением.

  2. Найдите место вызова функции IsDebuggerPresent в коде.

  3. Выберите левым щелчком мыши инструкцию JE SHORT 01371810, следующую сразу за TEST EAX,EAX (см. иллюстрацию 3-27). Нажмите клавишу пробел, чтобы открыть диалог "Assemble" для её редактирования.

  4. Измените инструкцию JE SHORT 01371810 на JNE SHORT 01371810 в диалоге, как показано на иллюстрации 3-27. После этого нажмите кнопку "Assemble".

  5. Нажмите F9, чтобы продолжить работу тестового приложения.

Иллюстрация 3-27. Диалог редактирования инструкции

После этих действий тестовое приложение больше не сможет обнаружить отладчик.

Что означает замена инструкции JE на JNE? Рассмотрим C++ код, соответствующий каждому варианту. Исходная инструкция JE аналогична следующему оператору if:

if (IsDebuggerPresent())
{
    printf("debugger detected!\n");
    exit(EXIT_FAILURE);
}

После замены инструкции на JNE мы получили такой код:

if ( ! IsDebuggerPresent())
{
    printf("debugger detected!\n");
    exit(EXIT_FAILURE);
}

Другими словами, мы инвертировали условие оператора if. Теперь если к тестовому приложению не подключён отладчик, оно завершится с сообщением "debugger detected!" (отладчик обнаружен) в консоль. Если же отладчик подключён, приложение продолжит свою работу.

После перезапуска тестового приложения, модификацию кода придётся повторить. Чтобы этого избежать, можно воспользоваться плагином OllyDumpEx отладчика OllyDbg. Он позволяет сохранить отредактированный код в исполняемый файл.

Для установки плагина OllyDumpEx выполните следующее:

  1. Скачайте архив с плагином с сайта разработчика.

  2. Распакуйте архив в папку установки OllyDbg. По умолчанию это:

    C:\Program Files (x86)\odbg200
  3. Проверьте путь до папки с плагинами в настройке OllyDbg. Для этого выберите пункт "Options" ➤ "Options..." главного меню. Откроется диалог "Options" (настройки). В левой его части выберите пункт "Directories" (каталоги). Поле "Plug-in directory" (каталог плагинов) должно соответствовать пути установки OllyDbg (например C:\Program Files (x86)\odbg200).

  4. Перезапустите отладчик.

После этого в главном меню появится новый пункт "Plug-ins" (плагины). Чтобы воспользоваться возможностью сохранения модифицированного кода приложения в исполняемый файл, выполните следующее:

  1. Выберите пункт главного меню "Plug-ins" ➤ "OllyDumpEx" ➤ "Dump process". Откроется диалог "OllyDumpEx".

  2. В нём нажмите кнопку "Dump" (выгрузить). Откроется диалог "Save Dump to File" (сохранение дампа в память).

  3. Укажите путь к исполняемому файлу для сохранения кода.

После этого на жёстком диске будет создан исполняемый файл с модифицированным кодом приложения. Его можно запустить как обычный EXE-файл. Он будет корректно работать в случае простого приложения. К сожалению, если это большая и сложная игра, она может завершиться с ошибкой после старта из дампа.

В интерфейсе WinAPI есть ещё одна функция для проверки подключенного отладчика – CheckRemoteDebuggerPresent. Она позволяет обнаружить отладчик, подключённый к указанному процессу. CheckRemoteDebuggerPresent может быть полезна, если система защиты и игра работают в разных процессах.

Обе функции CheckRemoteDebuggerPresent и IsDebuggerPresent проверяют данные PEB сегмента. CheckRemoteDebuggerPresent вызывает внутри себя WinAPI функцию NtQueryInformationProcess, которая возвращает структуру типа PROCESS_BASIC_INFORMATION. Её второе поле – это указатель на структуру типа PEB. У PEB есть поле под названием BeingDebugged, значение которого равно 1, если к процессу подключен отладчик. Иначе значение поля равно 0.

CloseHandle

У функции IsDebuggerPresent есть два серьёзных недостатка. Во-первых, её вызовы легко обнаружить в исходном коде приложения и инвертировать условие проверки результата. Во-вторых, достаточно просто изменить значение поля BeingDebugged в PEB сегменте, чтобы предотвратить обнаружение отладчика.

Есть более изящные способы проверки наличия отладчика с помощью WinAPI. Один из них – использование побочного эффекта функции CloseHandle. Обычно CloseHandle вызывается, чтобы сообщить ОС об окончании работы с каким-то объектом. После этого объект может быть удалён, либо к нему могут получить доступ другие процессы. Очевидно, что любое сложное приложение интенсивно использует CloseHandle.

Функция CloseHandle имеет единственный входной параметр: дескриптор объекта. Если переданный дескриптор некорректен, будет сгенерировано исключение (exception) EXCEPTION_INVALID_HANDLE. То же самое произойдёт если процесс вызовет CloseHandle дважды для одного и того же дескриптора. Теперь важный момент – исключение генерируется только тогда, когда к процессу подключен отладчик. Если отладчика нет, исключения не будет, и функция вернёт код ошибки. Таким образом мы можем следить за поведением функции и делать вывод о наличии отладчика.

Для обхода защиты, использующей CloseHandle, потребуется много работы. Прежде всего, надо отследить все вызовы функции. Затем надо отличить места, где с её помощью проверяется наличие отладчика. Во всех этих местах необходимо отредактировать код. Например, заменить вызов функции на NOP (no operation) инструкции процессора.

Пример использования CloseHandle:

BOOL IsDebug()
{
    __try
    {
        CloseHandle((HANDLE)0x12345);
    }
    __except (GetExceptionCode() == EXCEPTION_INVALID_HANDLE ?
              EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
    {
        return TRUE;
    }
    return FALSE;
}

Для обработки исключения EXCEPTION_INVALID_HANDLE мы применили конструкцию try-except, которая отличается от try-catch, определённой в стандарте языка C++. Эта конструкция – расширение для C и C++ от Microsoft, которое является частью механизма Structured Exception Handling (SEH).

Изменим наше тестовое приложение из листинга 3-18. Добавим определение функции IsDebug (приведённое выше) и будем вызывать её вместо IsDebuggerPresent в цикле while. Результат приведён в файле CloseHandle.cpp из примеров к книге. Попробуйте его скомпилировать и протестировать с отладчиками OllyDbg и WinDbg. Приложение успешно обнаруживает WinDbg, но не OllyDbg. Это связано с тем, что OllyDbg имеет встроенный механизм для обхода такого типа защиты.

С помощью WinAPI функции DebugBreak можно сделать очень похожую проверку на наличие отладчика:

BOOL IsDebug()
{
    __try
    {
        DebugBreak();
    }
    __except (GetExceptionCode() == EXCEPTION_BREAKPOINT ?
              EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
    {
        return FALSE;
    }
    return TRUE;
}

В отличие от CloseHandle, DebugBreak всегда генерирует исключение EXCEPTION_BREAKPOINT. Если к приложению подключён отладчик, он обработает это исключение. Это значит, что блок __except приведённого выше кода не получит управление, и функция IsDebug вернёт TRUE. Если же отладчика нет, исключение должно быть обработано приложением. В этом случае мы попадём в блок __except, и функция вернёт значение FALSE.

Проверка на наличие отладчика через DebugBreak обнаруживает и OllyDbg, и WinDbg.

В WinAPI есть функция DebugBreakProcess, которая очень похожа на DebugBreak. Она позволяет сгенерировать исключение EXCEPTION_BREAKPOINT для указанного процесса. Это может быть полезно для реализации защиты, работающей в отдельном процессе.

CreateProcess

Есть метод запрещающий отладку процесса в принципе. Он связан со следующим ограничением ОС Windows: только один отладчик может быть подключён к процессу. Следовательно, если одна часть приложения подключается к другой в качестве отладчика, эта вторая часть становится защищённой. Этот метод известен как самоотладка (self-debugging).

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

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

  2. Родительский процесс отлаживает дочерний. Дочерний выполняет алгоритмы защищаемого приложения.

Мы рассмотрим второй подход. Для создания дочернего процесса воспользуемся WinAPI функцией CreateProcess. Полный код тестового приложения приведён в листинге 3-19.

Листинг 3-19. Защита тестового приложения методом самоотладки

#include <stdio.h>
#include <stdint.h>
#include <windows.h>
#include <string>

using namespace std;

static const uint16_t MAX_LIFE = 20;
static uint16_t gLife = MAX_LIFE;

void DebugSelf()
{
    wstring cmdChild(GetCommandLine());
    cmdChild.append(L" x");

    PROCESS_INFORMATION pi;
    STARTUPINFO si;
    ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));
    ZeroMemory(&si, sizeof(STARTUPINFO));
    GetStartupInfo(&si);

    CreateProcess(NULL, (LPWSTR)cmdChild.c_str(), NULL, NULL, FALSE,
            DEBUG_PROCESS | CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi);

    DEBUG_EVENT de;
    ZeroMemory(&de, sizeof(DEBUG_EVENT));

    for (;;)
    {
        if (!WaitForDebugEvent(&de, INFINITE))
            return;

        ContinueDebugEvent(de.dwProcessId,
                de.dwThreadId,
                DBG_CONTINUE);
    }
}

int main(int argc, char* argv[])
{
    if (argc == 1)
    {
        DebugSelf();
    }
    SHORT result = 0;

    while (gLife > 0)
    {
        result = GetAsyncKeyState(0x31);
        if (result != 0xFFFF8001)
            --gLife;
        else
            ++gLife;

        printf("life = %u\n", gLife);
        Sleep(1000);
    }

    printf("stop\n");
    return 0;
}

Иллюстрация 3-28 демонстрирует взаимодействие родительского и дочернего процессов.

Иллюстрация 3-28. Взаимодействие родительского и дочернего процессов

Приложение из листинга 3-19 запускается в два этапа. Сначала пользователь щёлкает по иконке рабочего стола, и приложение запускается без параметров командной строки. В этом случае следующее if условие будет истинным:

    if (argc == 1)
    {
        DebugSelf();
    }

Параметр argv функции main – это указатель на строку параметров командной строки. argc хранит их количество. Когда приложение запущено без параметров командной строки, argc равен 1, а строка argv содержит только имя запускаемого файла. Поэтому условие if истинно, и приложение вызовет функцию DebugSelf. Её алгоритм следующий:

  1. Прочитать параметры командной строки и добавить к ним "x". Этот параметр сообщает дочернему процессу, что он был запущен из родительского:

    wstring cmdChild(GetCommandLine());
    cmdChild.append(L" x");
  2. Создать дочерний процесс с помощью вызова CreateProcess. В эту функцию мы передаём флаг DEBUG_PROCESS, который означает что новый процесс будет отлаживаться родительским. Также мы передаём флаг CREATE_NEW_CONSOLE, благодаря которому у дочернего процесса будет отдельная консоль. В ней вы сможете прочитать вывод нашего приложения.

  3. Запустить бесконечный цикл for, в котором будем обрабатывать все события дочернего процесса.

Попробуйте запустить приложение из листинга 3-19 и подключиться к нему отладчиками OllyDbg и WinDbg. Ни одному из них это не удастся.

Наше тестовое приложение демонстрирует метод самоотладки в максимально простом и лаконичном виде. Его защиту очень просто обойти. Для этого достаточно запустить приложение из командной строки, передав параметром символ "x":

TestApplication.exe x

В этом случае приложение запустится без самоотладки, и к нему можно будет подключиться.

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

Есть более надёжные техники обмена информацией между родительским и дочерним процессом, чем параметры командной строки. Они описаны в официальной документации Microsoft.

Операции с регистрами для обнаружения отладчиков

Все техники обнаружения отладчиков, использующие WinAPI функции, имеют серьёзный недостаток: очень просто отследить места их вызовов. Даже если вы используете подход с CloseHandle, и ваше приложение имеет тысячи вызовов этой функции, такую защиту можно обойти за предсказуемое время. Есть несколько техник, лишённых этого недостатка. Они основаны на манипуляции регистрами процессора. Доступ к этим регистрам можно получить через ассемблерные вставки или встроенные функции компилятора. Преимущество такого подхода в том, что анализ таблицы символов не поможет в поиске проверок на наличие отладчика. Из-за этого их намного сложнее обнаружить.

Флаг BeingDebugged

Рассмотрим, как функция IsDebuggerPresent устроена внутри. Мы знаем, что она проверяет данные PEB сегмента. Возможно, мы могли бы повторить её алгоритм.

Выполните следующие шаги для исследования функции IsDebuggerPresent:

  1. Запустите отладчик OllyDbg.

  2. Запустите из него тестовое приложение из листинга 3-18.

  3. Найдите место вызова функции IsDebuggerPresent из main. Поставьте на нём точку останова. Продолжайте исполнение приложения.

  4. Когда сработает точка останова нажмите F7, чтобы перейти к инструкциям функции IsDebuggerPresent.

В окне дизассемблера OllyDbg вы увидите код как на иллюстрации 3-29.

Иллюстрация 3-29. Инструкции функции IsDebuggerPresent

Рассмотрим каждую из четырёх инструкций функции IsDebuggerPresent:

  1. Прочитать в регистр EAX базовый адрес TEB сегмента, соответствующего текущему потоку. Как мы уже знаем, регистр FS всегда указывает на сегмент TEB, а по смещению 0x18 в нём лежит собственный адрес.

  2. Прочитать базовый адрес сегмента PEB в регистр EAX. Он хранится по смещению 0x30 в регистре TEB.

  3. Прочитать значение флага BeingDebugged со смещением 0x2 из сегмента PEB в EAX регистр. По его значению можно определить наличие отладчика.

  4. Вернуться из функции.

Повторим рассмотренный алгоритм в коде нашего тестового приложения. Результат приведён в листинге 3-20.

Листинг 3-20. Обнаружение отладчика через прямой доступ к PEB сегменту

#include <stdio.h>

int main()
{
    SHORT result = 0;

    while (gLife > 0)
    {
        int res = 0;
        __asm
        {
            mov eax, dword ptr fs:[18h]
            mov eax, dword ptr ds:[eax+30h]
            movzx eax, byte ptr ds:[eax+2h]
            mov res, eax
        };
        if (res)
        {
            printf("debugger detected!\n");
            exit(EXIT_FAILURE);
        }
        result = GetAsyncKeyState(0x31);
        if (result != 0xFFFF8001)
            --gLife;
        else
            ++gLife;

        printf("life = %u\n", gLife);
        Sleep(1000);
    }
    printf("stop\n");
    return 0;
}

Сравните наш код и инструкции процессора на иллюстрации 3-29. Они почти одинаковы. Единственное отличие в последней инструкции. В нашем коде значение флага BeingDebugged присваивается переменной res. Сразу после ассемблерной вставки она проверяется в if условии.

Если вы поместите такую ассемблерную вставку и проверку на отладчик в нескольких местах приложения, их будет труднее найти чем вызовы функции IsDebuggerPresent. Можем ли мы в этом случае избежать дублирования кода? Это хороший вопрос. Если в следующих версиях Windows поменяется структура TEB или PEB сегмента, исправление придётся вносить в каждую копию ассемблерной вставки.

Есть несколько способов избежать дублирования кода. Очевидно, что в нашем случае мы не можем просто поместить его в обычную C++ функцию. Она обязательно попадёт в таблицу символов, по которой легко отследить все места её вызовов.

Можно вынести код ассемблерной вставки в C++ функцию и пометить её ключевым словом __forceinline. Такая функция называется встроенной. Компилятор будет вставлять её код в места вызовов. К сожалению, __forceinline игнорируется в нескольких случаях:

  1. Приложение компилируется в конфигурации "Debug" (отладка).

  2. Если встраиваемая функция содержит рекурсивные вызовы, т.е. вызывает саму себя.

  3. Если встраиваемая функция делает вызов alloca.

Ключевое слово __forceinline работает только в конфигурации сборки "Release" (релиз), что может быть неудобно. В этом случае выходной исполняемый файл не содержит отладочной информации.

Альтернативное решение заключается в использовании макроса препроцессора. Компилятор вставляет тело макроса в каждое место исходного кода, где упоминается его имя. В этом случае поведение компилятора не зависит от конфигурации сборки.

Листинг 3-21 демонстрирует проверку флага BeingDebugged с помощью ассемблерной вставки, завёрнутой в макрос препроцессора.

Листинг 3-21. Обнаружение отладчика через прямой доступ к PEB сегменту

#include <stdio.h>

#define CheckDebug() \
int isDebugger = 0; \
{ \
__asm mov eax, dword ptr fs : [18h] \
__asm mov eax, dword ptr ds : [eax + 30h] \
__asm movzx eax, byte ptr ds : [eax + 2h] \
__asm mov isDebugger, eax \
} \
if (isDebugger) \
{ \
printf("debugger detected!\n"); \
exit(EXIT_FAILURE); \
}

int main()
{
    SHORT result = 0;

    while (gLife > 0)
    {
        CheckDebug();

        result = GetAsyncKeyState(0x31);
        if (result != 0xFFFF8001)
            --gLife;
        else
            ++gLife;
    }

    printf("stop\n");

    return 0;
}

Обратите внимание на использование макроса CheckDebug в функции main. Это выглядит как обычный вызов функции. Однако, поведения макроса и функции кардинально отличаются. Ещё на этапе обработки препроцессором файла с исходным кодом, который идёт до этапа компиляции, main будет преобразована следующим образом:

int main()
{
    SHORT result = 0;

    while (gLife > 0)
    {
        int res = 0;
        __asm
        {
            mov eax, dword ptr fs:[18h]
            mov eax, dword ptr ds:[eax + 30h]
            movzx eax, byte ptr ds:[eax + 2h]
            mov res, eax
        };
        if (res)
        {
            printf("debugger detected!\n");
            exit(EXIT_FAILURE);
        }
        ...

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

Как вы помните, ассемблерные вставки не работают при компиляции 64-разрядных приложений на Visual Studio C++. В этом случае можно переписать макрос CheckDebug следующим образом:

#include <winternl.h>

#define CheckDebug() \
{ \
PTEB pTeb = reinterpret_cast<PTEB>(__readgsqword(0x30)); \
PPEB pPeb = pTeb->ProcessEnvironmentBlock; \
if (pPeb->BeingDebugged) \
{ \
printf("debugger detected!\n"); \
exit(EXIT_FAILURE); \
} \
}

Не забудьте включить заголовочный файл winternl.h, в котором определены структуры TEB и PEB, а также указатели на них (PTEB и PPEB).

Защита, приведённая в листинге 3-21, выглядит достаточно надёжной. Так ли это, и сможем ли мы её обойти? На самом деле это совсем несложно. Вместо того, чтобы искать в коде проверки и инвертировать if условия, мы можем просто изменить флаг BeingDebugged в PEB сегменте. Для этого выполните следующие шаги:

  1. Запустите отладчик OllyDbg.

  2. Из него запустите тестовое приложение из листинга 3-21.

  3. Нажмите Alt+M, чтобы открыть карту памяти процесса. В ней найдите сегмент "Process Environment Block" (PEB).

  4. Дважды щёлкните левой кнопкой мыши по сегменту PEB. Откроется окно "Dump - Process Environment Block". В нём найдите значение флага "BeingDebugged".

  5. Щёлкните левой кнопкой мыши по флагу "BeingDebugged", чтобы его выделить. Нажмите Ctrl+E – откроется диалог "Edit data at address..." (редактирование данных по адресу).

  6. Измените значение поля "HEX+01" с "01" на "00" и нажмите кнопку "OK", как изображено на иллюстрации 3-30.

Иллюстрация 3-30. Диалог редактирования флага "BeingDebugged"

Если вы продолжите выполнение, приложение не обнаружит подключённый отладчик. Обход этой защиты очень прост. Поэтому рассмотрим более надёжный метод.

INT 3

Как вы помните, WinAPI функция DebugBreak позволяет обнаружить отладчик по тому, кто обрабатывает сгенерированное ею исключение. Исследуем инструкции этой функции и попробуем повторить их с помощью ассемблерной вставки. Для этого выполните уже рассмотренные нами шаги, когда мы исследовали IsDebuggerPresent. Если вы сделаете всё правильно, то обнаружите, что функция DebugBreak состоит из единственной инструкции процессора INT 3. Именно она генерирует исключение EXCEPTION_BREAKPOINT.

Перепишем функцию IsDebug так, чтобы она использовала инструкцию INT 3 вместо вызова DebugBreak:

BOOL IsDebug()
{
    __try
    {
        __asm int 3;
    }
    __except (GetExceptionCode() == EXCEPTION_BREAKPOINT ?
            EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
    {
        return FALSE;
    }
    return TRUE;
}

Чтобы усложнить поиск вызовов функции IsDebug, мы могли бы применить ключевое слово __forceinline в её определении. Однако, в этом случае компилятор его проигнорирует. Дело в том, что обработчик __try/__except неявно выделяет блок памяти с помощью функции alloca. Как вы помните, это нарушает условие использования __forceinline.

Правильным решением будет использовать макрос:

#define CheckDebug() \
bool isDebugger = true; \
__try \
{ \
    __asm int 3 \
} \
__except (GetExceptionCode() == EXCEPTION_BREAKPOINT ? \
          EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) \
{ \
    isDebugger = false; \
} \
if (isDebugger) \
{ \
    printf("debugger detected!\n"); \
    exit(EXIT_FAILURE); \
}

Для 64-разрядного приложения воспользуемся встроенной функцией компилятора __debugbreak():

#define CheckDebug() \
bool isDebugger = true; \
__try \
{ \
    __debugbreak(); \
} \
__except (GetExceptionCode() == EXCEPTION_BREAKPOINT ? \
          EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) \
{ \
    isDebugger = false; \
} \
if (isDebugger) \
{ \
    printf("debugger detected!\n"); \
    exit(EXIT_FAILURE); \
}

Вы можете найти файл с исходным кодом Int3.cpp тестового приложения, защищенного этим методом, в архиве примеров к книге. Чтобы обойти эту защиту, вам придётся найти все if проверки в коде и инвертировать их.

У OllyDbg есть функция поиска инструкций процессора в памяти отлаживаемого процесса. Для этого нажмите Ctrl+F в окне дизассемблера и в открывшемся диалоге введите значение "INT3". После этого нажмите кнопку "Search" (поиск).

В машинном коде инструкция INT 3 представляется шестнадцатеричным числом 0xCC. В результате поиска OllyDbg вы получите список инструкций, содержащих 0xCC в своем коде операции (opcode). Далеко не все из этих инструкций являются INT 3, но вам придётся их проверить.

Очевидно, рассмотренная нами защита не идеальна. Но для её преодоления придётся потратить много времени и усилий.

Проверка таймера

Во время отладки приложения, пользователь часто останавливает его выполнение. Обычно это нужно, чтобы проверить значения переменных, прочитать дамп памяти или дизассемблированный код. На этой особенности строится достаточно надёжная защита. Идея заключается в том, чтобы измерять время между контрольными точками в коде приложения. Если остановок выполнения не было, это время будет относительно небольшим (порядка миллисекунд). В противном случае можно с уверенностью утверждать, что приложение работает под отладчиком.

WinAPI функции

Есть несколько WinAPI функций, которые позволяют прочитать текущее время:

  1. GetTickCount – возвращает количество миллисекунд с момента запуска ОС.

  2. GetLocalTime – возвращает текущее время с учётом настройки часового пояса.

  3. GetSystemTime – возвращает текущее всемирное координированное время (UTC).

Вы можете использовать любую из этих функций для замеров времени между контрольными точками. Листинг 3-22 демонстрирует решение с использованием GetTickCount.

Листинг 3-22. Замер времени между контрольными точками приложения с помощью GetTickCount

#include <stdio.h>
#include <stdint.h>
#include <windows.h>

static const DWORD MAX_DELTA = 1020;

static const uint16_t MAX_LIFE = 20;
static uint16_t gLife = MAX_LIFE;

int main()
{
    SHORT result = 0;

    DWORD prevCounter = GetTickCount();

    while (gLife > 0)
    {
        if (MAX_DELTA < (GetTickCount() - prevCounter))
        {
            printf("debugger detected!\n");
            exit(EXIT_FAILURE);
        }
        prevCounter = GetTickCount();

        result = GetAsyncKeyState(0x31);
        if (result != 0xFFFF8001)
            --gLife;
        else
            ++gLife;

        printf("life = %u\n", gLife);
        Sleep(1000);
    }

    printf("stop\n");

    return 0;
}

В этом примере мы измеряем время между итерациями цикла while. Если остановок не было, каждая итерация длится чуть больше одной секунды. Большую часть этого времени занимают вызовы Sleep (1000 миллисекунд) и printf. Если задержка оказывается больше константы MAX_DELTA, равной 1020 миллисекунд, скорее всего, была остановка. В этом случае приложение завершается.

Для тестирования примера выполните следующие действия:

  1. Запустите отладчик OllyDbg.

  2. Запустите из него приложение из листинга 3-22.

  3. Начните выполнение процесса нажатием F9.

  4. Остановите процесс нажатием F12.

  5. Продолжите выполнение процесса по F9.

Приложение завершит свою работу с сообщением в консоль "debugger detected!" (отладчик обнаружен).

Чтобы обойти эту защиту, надо найти вызовы GetTickCount в коде приложения с помощью таблицы символов. Затем будет достаточно инвертировать проверку в операторе if.

Счётчики процессора

Текущее время можно читать не только с помощью WinAPI функций. У процессора есть несколько аппаратных счётчиков. Один из них Time Stamp Counter (TSC), который считает количество тактовых сигналов (или циклов) с момента старта процессора. Его значение можно прочитать с помощью ассемблерных инструкций или встроенной функции компилятора.

Листинг 3-23 демонстрирует использование счётчика TSC для замеров времени между контрольными точками приложения.

Листинг 3-23. Замер времени между контрольными точками приложения с помощью TSC

#include <stdio.h>
#include <stdint.h>
#include <windows.h>

static const DWORD64 MAX_DELTA = 2650000000;

static const uint16_t MAX_LIFE = 20;
static uint16_t gLife = MAX_LIFE;

#define ReadRdtsc(result) \
{ \
__asm cpuid \
__asm rdtsc \
__asm mov dword ptr[result + 0], eax \
__asm mov dword ptr[result + 4], edx \
}

int main()
{
    SHORT result = 0;

    DWORD64 prevCounter = 0;
    ReadRdtsc(prevCounter);

    while (gLife > 0)
    {
        DWORD64 counter = 0;
        ReadRdtsc(counter);

        if (MAX_DELTA < (counter - prevCounter))
        {
            printf("debugger detected!\n");
            exit(EXIT_FAILURE);
        }
        ReadRdtsc(prevCounter);

        result = GetAsyncKeyState(0x31);
        if (result != 0xFFFF8001)
            --gLife;
        else
            ++gLife;

        printf("life = %u\n", gLife);
        Sleep(1000);
    }

    printf("stop\n");

    return 0;
}

Для 64-разрядного приложения функция main будет выглядеть следующим образом:

int main()
{
    SHORT result = 0;

    DWORD64 prevCounter = __rdtsc();

    while (gLife > 0)
    {
        DWORD64 counter = __rdtsc();

        if (MAX_DELTA < (counter - prevCounter))
        {
            printf("debugger detected!\n");
            exit(EXIT_FAILURE);
        }
        prevCounter = __rdtsc();

        result = GetAsyncKeyState(0x31);
        if (result != 0xFFFF8001)
            --gLife;
        else
            ++gLife;

        printf("life = %u\n", gLife);
        Sleep(1000);
    }

    printf("stop\n");

    return 0;
}

Алгоритм этой проверки точно такой же, как и в примере из листинга 3-22. Отличие только в способе замера времени и величине константы MAX_DELTA. В данном случае мы измеряем не миллисекунды, а тактовые сигналы процессора. Каждая итерация цикла длится примерно два с половиной миллиона циклов. Из-за этого пороговое значение MAX_DELTA получилось намного больше.

Обойти эту защиту труднее. Необходимо найти в коде приложения все инструкции rdtsc и выяснить, есть ли после каждой из них проверка на временную задержку. Если проверка есть, её надо инвертировать.

Защита приложения от ботов

В ОС Windows есть механизм Security Descriptors (SD) (дескрипторы безопасности) для ограничения доступа к системным объектам (например процессам). Он подробно описан в статье.

Следующие примеры демонстрируют использование SD:

В них приложение защищается с помощью Discretionary Access Control List (DACL) (дискреционный список контроля доступа). К сожалению, механизм SD не может защитить приложение, если к нему пытается получить доступ процесс, запущенный с правами администратора. В большинстве случаев пользователь, запускающий бота, имеет эти права. Поэтому мы не можем полагаться на ОС в вопросе защиты данных приложения и должны реализовывать собственные механизмы.

Надёжная система защиты должна решать две задачи:

  1. Сокрытие данных от сканеров памяти (например Cheat Engine).

  2. Проверка корректности данных для предотвращения их несанкционированного изменения.

Сокрытие данных

Рассмотрим техники сокрытия данных от сканеров памяти.

XOR шифр

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

XOR представляет собой самый простой алгоритм шифрования. Листинг 3-24 демонстрирует его использование.

Листинг 3-24. Защита данных приложения шифром XOR

#include <stdio.h>
#include <stdint.h>
#include <windows.h>

using namespace std;

inline uint16_t maskValue(uint16_t value)
{
    static const uint16_t MASK = 0xAAAA;
    return (value ^ MASK);
}

static const uint16_t MAX_LIFE = 20;
static uint16_t gLife = maskValue(MAX_LIFE);

int main(int argc, char* argv[])
{
    SHORT result = 0;

    while (maskValue(gLife) > 0)
    {
        result = GetAsyncKeyState(0x31);
        if (result != 0xFFFF8001)
            gLife = maskValue(maskValue(gLife) - 1);
        else
            gLife = maskValue(maskValue(gLife) + 1);

        printf("life = %u\n", maskValue(gLife));
        Sleep(1000);
    }

    printf("stop\n");

    return 0;
}

Функция maskValue шифрует данные при первом вызове и дешифрует при повторном. Чтобы получить зашифрованное значение, мы используем операцию XOR (также известную как "исключающее ИЛИ") над данными и ключом. В качестве ключа используется константа MASK. Для расшифровки значения переменной gLife, maskValue вызывается повторно.

Если вы запустите приложение и попробуйте найти переменную gLife по её значению с помощью Cheat Engine, вам это не удастся. Однако, если значение константы MASK известно, задача значительно упрощается. Всё что вам нужно, это вручную или с помощью стандартного калькулятора Windows рассчитать зашифрованное значение gLife и задать его сканеру. В этом случае поиск даст результат.

Наша реализация шифра XOR упрощена в целях демонстрации подхода. Если вы планируете использовать её для защиты своих приложений, её следует доработать. Прежде всего будет полезно поместить алгоритм шифрования в шаблон класса (template) C++. Для этого класса следует определить арифметические операторы и присваивание. Тогда вы сможете шифровать данные неявно, и код будет выглядеть намного компактнее. Например так:

XORCipher<int> gLife(20);
gLife = gLife - 1;

Ещё одним улучшением будет генерация случайного ключа шифрования в конструкторе шаблона класса. Благодаря этому его будет труднее найти и применить для сканирования памяти.

AES шифр

Даже с нашими улучшениями шифр XOR крайне прост для взлома. Чтобы надёжно защитить данные вашего приложения, понадобится более криптостойкий шифр. WinAPI предоставляет ряд криптографических функций. Среди них есть достаточно современный шифр AES. Попробуем применить его для нашего тестового приложения, как демонстрирует листинг 3-25.

Листинг 3-25. Защита данных приложения шифром AES

#include <stdint.h>
#include <stdio.h>
#include <windows.h>
#include <string>

#pragma comment (lib, "advapi32")
#pragma comment (lib, "user32")

using namespace std;

static const uint16_t MAX_LIFE = 20;
static uint16_t gLife = 0;

HCRYPTPROV hProv;
HCRYPTKEY hKey;
HCRYPTKEY hSessionKey;

#define kAesBytes128 16

typedef struct {
    BLOBHEADER  header;
    DWORD       key_length;
    BYTE        key_bytes[kAesBytes128];
} AesBlob128;

static const BYTE gCipherBlockSize = kAesBytes128 * 2;
static BYTE gCipherBlock[gCipherBlockSize] = {0};

void CreateContex()
{
    if (!CryptAcquireContext(&hProv, NULL, NULL, PROV_RSA_AES, CRYPT_VERIFYCONTEXT))
    {
        printf("CryptAcquireContext() failed - error = 0x%x\n", GetLastError());
    }
}

void CreateKey(string& key)
{
    AesBlob128 aes_blob;
    aes_blob.header.bType = PLAINTEXTKEYBLOB;
    aes_blob.header.bVersion = CUR_BLOB_VERSION;
    aes_blob.header.reserved = 0;
    aes_blob.header.aiKeyAlg = CALG_AES_128;
    aes_blob.key_length = kAesBytes128;
    memcpy(aes_blob.key_bytes, key.c_str(), kAesBytes128);

    if (!CryptImportKey(hProv,
                      reinterpret_cast<BYTE*>(&aes_blob),
                      sizeof(AesBlob128),
                      NULL,
                      0,
                      &hKey))
    {
        printf("CryptImportKey() failed - error = 0x%x\n", GetLastError());
    }
}

void Encrypt()
{
    unsigned long length = kAesBytes128;
    memset(gCipherBlock, 0, gCipherBlockSize);
    memcpy(gCipherBlock, &gLife, sizeof(gLife));

    if (!CryptEncrypt(hKey, 0, TRUE, 0, gCipherBlock, &length, gCipherBlockSize))
    {
        printf("CryptEncrypt() failed - error = 0x%x\n", GetLastError());
        return;
    }
    gLife = 0;
}

void Decrypt()
{
    unsigned long length = gCipherBlockSize;

    if (!CryptDecrypt(hKey, 0, TRUE, 0, gCipherBlock, &length))
    {
        printf("Error CryptDecrypt() failed - error = 0x%x\n", GetLastError());
        return;
    }
    memcpy(&gLife, gCipherBlock, sizeof(gLife));
    memset(gCipherBlock, 0, gCipherBlockSize);
}

int main(int argc, char* argv[])
{
    CreateContex();

    string key("The secret key");

    CreateKey(key);

    gLife = MAX_LIFE;

    Encrypt();

    SHORT result = 0;

    while (true)
    {
        result = GetAsyncKeyState(0x31);

        Decrypt();

        if (result != 0xFFFF8001)
            gLife = gLife - 1;
        else
            gLife = gLife + 1;

        printf("life = %u\n", gLife);

        if (gLife == 0)
            break;

        Encrypt();

        Sleep(1000);
    }
    printf("stop\n");
    return 0;
}

Рассмотрим алгоритм работы приложения. Его основные шаги вы можете проследить в функции main:

  1. Создать контекст для криптографического алгоритма с помощью функции CreateContex. Это обёртка над WinAPI функцией CryptAcquireContext. Контекст представляет собой комбинацию двух компонентов: контейнер ключей и Cryptography Service Provider (CSP) (криптопровайдер). Контейнер содержит все ключи, принадлежащие пользователю. CSP – это программный модуль, реализующий криптографический алгоритм.

  2. Добавить ключ шифрования в CSP с помощью функции CreateKey. Функция принимает в качестве входного параметра строку со значением ключа. Из неё создается структура BLOB (расшифровывается как Binary Large Object, т.е. двоичный большой объект). Эта структура передаётся в CSP с помощью WinAPI вызова CryptImportKey.

  3. Инициализировать переменную gLife и зашифровать её функцией Encrypt. Внутри себя она вызывает WinAPI функцию CryptEncrypt. Зашифрованное значение сохраняется в глобальном байтовом массиве gCipherBlock. При этом значение переменной gLife зануляем, чтобы сканер памяти не смог её найти.

  4. Перед каждым использованием переменной gLife расшифровываем её значение функцией Decrypt, которая вызывает внутри себя WinAPI функцию CryptDecrypt. После работы с gLife мы снова её шифруем.

В чём преимущество шифра AES по сравнению с XOR? На самом деле алгоритм поиска зашифрованного значения в памяти одинаков в обоих случаях:

  1. Восстановить ключ шифрования.

  2. Применить ключ для шифровки текущего значения переменной.

  3. Искать зашифрованное значение в памяти процесса с помощью сканера.

XOR шифр работает намного быстрее, но его проще взломать. Для этого есть два варианта: перебор всех возможных ключей или поиск ключа в памяти процесса. В некоторых случаях первый подход будет быстрее и проще. Для шифра AES есть только один вариант – поиск ключа в памяти. Чтобы взломать его перебором, понадобится значительное время. Поэтому стойкость защиты определяется только тем, насколько хорошо спрятан ключ. Надёжным решением может быть генерация нового ключа при каждом запуске приложения.

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

Оба шифра XOR и AES скрывают данные приложения от сканирования. Это значит, что боту будет сложно найти информацию об объектах в памяти процесса. Однако, это не помешает ему писать произвольные данные в память. В некоторых случаях это может стать уязвимостью.

Проверка корректности данных

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

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

Хеширование очень похоже на шифрование. Алгоритм берёт исходные данные и конвертирует их в другое представление. Различие заключается в том, что шифрование обратимо, т.е. данные можно расшифровать и получить исходное значение. Операция же хеширования необратима. Благодаря этому свойству алгоритмы хеширования работают намного быстрее.

Проверка целостности данных с помощью хеширования приведена в листинге 3-26.

Листинг 3-26. Проверка целостности данных приложения

#include <stdio.h>
#include <stdint.h>
#include <windows.h>
#include <functional>

using namespace std;

static const uint16_t MAX_LIFE = 20;
static uint16_t gLife = MAX_LIFE;

std::hash<uint16_t> hashFunc;
static size_t gLifeHash = hashFunc(gLife);

void UpdateHash()
{
    gLifeHash = hashFunc(gLife);
}

__forceinline void CheckHash()
{
    if (gLifeHash != hashFunc(gLife))
    {
        printf("unauthorized modification detected!\n");
        exit(EXIT_FAILURE);
    }
}

int main(int argc, char* argv[])
{
    SHORT result = 0;

    while (gLife > 0)
    {
        result = GetAsyncKeyState(0x31);

        CheckHash();

        if (result != 0xFFFF8001)
            --gLife;
        else
            ++gLife;

        UpdateHash();

        printf("life = %u\n", gLife);

        Sleep(1000);
    }

    printf("stop\n");

    return 0;
}

В этом примере мы добавили вспомогательную переменную gLifeHash, которая хранит хэшированное значение gLife. Для вычисления хеша используется функция hash из стандартной библиотеки шаблонов (STL) стандарта C++11.

На каждой итерации while цикла мы сравниваем хэшированное и текущее значение переменной gLife в функции CheckHash. Если они различаются, мы делаем вывод о несанкционированном изменении переменной. После проверки мы работаем с gLife точно так же, как и раньше. Затем пересчитываем её хеш с помощью функции UpdateHash и назначаем новое значение gLifeHash.

Попробуйте скомпилировать и запустить этот пример. Если вы модифицируйте значение переменной gLife с помощью сканера Cheat Engine, приложение завершит свою работу.

Обойти такую защиту возможно. Для этого бот должен одновременно модифицировать переменные gLife и gLifeHash. Но здесь есть подводные камни. Во-первых, хэшированное значение не так-то просто обнаружить. Если алгоритм известен, вы можете рассчитать хеш исходного значения и найти его с помощью сканера памяти. В большинстве случаев алгоритм неизвестен. Чтобы его восстановить надо проанализировать дизассемблированный код приложения. Во-вторых, необходимо выбрать правильный момент для модификации. Если запись нового значения происходит во время проверки if в функции CheckHash, изменение будет обнаружено.

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

Надёжнее всего данные игры будут защищены от несанкционированного изменения, если они хранятся на стороне сервера. В этом случае клиент получит их только для визуализации текущего состояния игры. Изменение этих данных ботом повлияет на картинку в окне приложения, но их копия на стороне сервера останется неизменной. Можно ожидать, что эта копия всегда будет корректна. Если данные клиента в какой-то момент будут отличаться, их можно восстановить из копии.

Выводы

Мы рассмотрели методы защиты памяти процесса игрового приложения. Большинство из них можно реализовать с помощью WinAPI функций. Однако, в некоторых случаях операции с регистрами позволят лучше скрыть алгоритм защиты от исследования.

Мы познакомились с методами защиты от отладки и сканирования памяти, а также с техниками предотвращения несанкционированного изменения данных приложения.

Last updated