Внутриигровые боты

В этой главе мы рассмотрим внутриигровых ботов. Сначала познакомимся с инструментами для их разработки. Большая часть этих инструментов нужна для анализа игрового приложения, в которое должен встраиваться бот. Затем мы рассмотрим структуру памяти типичного процесса в ОС Windows. Научимся методам поиска, чтения и записи переменных в работающее игровое приложение. После этого разработаем простого бота для игры Diablo 2, чтобы закрепить полученные знания. В конце главы, мы рассмотрим алгоритмы защиты от внутриигровых ботов.

Инструменты для разработки

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

Язык программирования

В этой главе мы будем использовать только язык C++. Для компиляции и работы с кодом рекомендую вам бесплатную IDE Microsoft Visual Studio вместо открытого набора инструментов MinGW. Проблема в том, что MinGW плохо интегрируется с некоторыми Windows библиотеками (например dbghelp.dll). Вы можете пробовать компилировать примеры этой главы с MinGW, но будьте готовы переключиться на Visual Studio IDE.

Чтобы запустить последнюю версию Visual Studio IDE, обновите браузер Microsoft Edge.

Для доступа к Windows Native API и линковки с системной библиотекой ntdll.dll вам понадобится Windows SDK.

Отладчики

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

Бесплатный отладчик OllyDbg мы будем активно использовать на протяжении всей главы. Простой и понятный интерфейс пользователя является его главным преимуществом. Также OllyDbg предоставляет широкие возможности для анализа Windows приложений без исходного кода. Главный его недостаток заключается в поддержке только 32-битный приложений. Рекомендую вам использовать последнюю версию OllyDbg 2.0.

Отладчик с открытым исходным кодом x64dbg поддерживает и 32-битные, и 64-битные приложения. Некоторые возможности OllyDbg в нём отсутствуют, поэтому часть вычислений вам придётся делать самостоятельно. Я рекомендую использовать x64dbg только для отладки 64-битных приложений и OllyDbg в остальных случаях.

WinDbg – многоцелевой бесплатный отладчик для работы с пользовательскими приложениями, драйверами устройств, системными библиотеками и ядром ОС. Он предоставляет некоторые возможности недоступные в OllyDbg и x64dbg, а также поддерживает 32 и 64-битные приложения. Единственный серьёзный недостаток WinDbg заключается в неудобном пользовательском интерфейсе. Эта проблема частично решается с помощью настройки рабочего окружения, которая делает его визуально похожим на OllyDbg. К сожалению, большинство возможностей WinDbg всё равно будут доступны только из командной строки.

Для настройки рабочего окружения WinDbg выполните следующие действия:

  1. Скачайте архив с настройкой.
  2. Распакуйте полученный архив windbg-workspace-master.zip в папку themes (темы) отладчика. Путь к ней по умолчанию:

C:\Program Files (x86)\Windows Kits\8.1\Debuggers\x64\themes.

3. Среди скопированных файлов найдите и запустите windbg.reg. Затем нажмите кнопку “Yes” в диалоге подтверждения.

После настройки окно WinDbg будет выглядеть как на иллюстрации 3-1.

Иллюстрация 3-1. Главное окно WinDbg после настройки рабочего окружения

Инструменты для анализа памяти

Помимо отладчика нам понадобится приложение для анализа памяти запущенного процесса.

Инструмент с открытым исходным кодом Cheat Engine предоставляет функции сканера памяти, отладчика и Hex-редактора (редактор бинарных файлов). Мы будем использовать Cheat Engine в основном как сканер для поиска адреса переменной в памяти процесса и модификации её значения. Более подробно этот инструмент описан в руководстве пользователя.

HeapMemView – бесплатный инструмент для анализа сегментов динамической памяти (heap, иногда переводится как “куча”), выделенных процессом. HeapMemView имеет две версии: для 32 и 64-битных приложений. В некоторых случаях он будет нам полезен.

Организация памяти процесса

Организация памяти процессов ОС Windows рассмотрена во многих книгах и статьях. Мы изучим только те аспекты этого вопроса, которые имеют отношение к поиску переменных в памяти, а также чтению и записи их значений.

Адресное пространство процесса

Исполняемый EXE-файл и запущенный процесс ОС – это не одно и то же. Файл – это некоторые данные, записанные на устройство хранения информации (например жёсткий диск). Исполняемый файл содержит инструкции (или машинный код), которые выполняет процессор без каких либо дополнительных преобразований.

Когда вы запускаете EXE-файл, для его исполнения ОС нужно выполнить несколько шагов. Во-первых, прочитать его содержимое с устройства хранения и записать в оперативную память (random-access memory или RAM). Благодаря этому процессор получает намного более быстрый доступ к инструкциям из файла, поскольку скорость его интерфейса с RAM на несколько порядков выше чем с любым диском.

Когда содержимое файла записано в оперативную память, ОС загружает туда же все необходимые для его работы динамические библиотеки. После этого шага, процесс готов к выполнению. Поскольку все современные ОС для компьютеров и телефонов многозадачные, несколько процессов могут исполняться параллельно. Параллельность в данном случае не означает одновременность. То есть если у компьютера один процессор с одним ядром, он будет переключаться между процессами. В таком случае говорят о распределении процессорного времени. В многозадачных ОС этим занимается специальная программа планировщик (scheduler). Благодаря ей каждый процесс получает единицы времени (тики или секунды) в зависимости от своего приоритета.

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

Где процесс хранит свои данные? Мы уже знаем, что ОС всегда загружает исполняемые инструкции в оперативную память. В случае данных, сам процесс может свободно выбрать место их хранения: жёсткий диск, оперативная память или даже удалённый компьютер (например игровой сервер подключённый по сети). Большая часть данных, необходимых во время работы процесса копируются в оперативную память для ускорения доступа к ней. Поэтому, именно в RAM мы можем прочитать состояния игровых объектов. Они будут доступны на протяжении всего времени выполнения (runtime) процесса.

Иллюстрация 3-2 демонстрирует элементы типичного процесса. Как правило, он состоит из нескольких модулей. Обязательным из них является EXE, который содержит все инструкции и данные, загруженные из исполняемого файла. Другие модули (обозначенные DLL_1 и DLL_2) соответствуют библиотекам, функции которых вызываются из EXE.

Иллюстрация 3-2. Элементы типичного процесса Windows

Все Windows приложения используют как минимум одну системную библиотеку, которая предоставляет доступ к WinAPI-функциям. Даже если вы не пользуетесь WinAPI явно в своей программе, компилятор вставляет вызовы ExitProcess и VirtualQuery автоматически в ходе компиляции. Они отвечают за корректное завершение процесса и управление его памятью.

Мы рассмотрели исполняемый файл и запущенный процесс. Теперь поговорим о библиотеках с функциями. Они делятся на два типа: динамически подключаемые (dynamic-link libraries или DLL) и статически подключаемые (static libraries). Главное различие между ними заключается во времени разрешения зависимостей. Когда исполняемый файл использует функцию библиотеки, говорят, что он от неё зависит.

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

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

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

Запущенное приложение может использовать несколько алгоритмов в ходе своей работы. Некоторые из них могут выполняться параллельно (так же как процессы в многозадачной ОС). Поток (thread) – это часть машинного кода процесса, которая может выполняться независимо от других частей. Потоки взаимодействуют друг с другом (обмениваются информацией) через разделяемые ресурсы, например файл или область RAM. За выбор потока для исполнения в данный момент отвечает уже знакомый нам планировщик ОС. Как правило, число одновременно работающих потоков определяется числом ядер процессора. Но есть технологии (например hyper-threading от Intel), позволяющие более эффективно использовать мощности процессора и исполнять сразу два потока на одном ядре.

Иллюстрация 3-2 демонстрирует, что модули процесса могут содержать несколько потоков, а могут не содержать ни одного. EXE-модуль всегда имеет главный поток (main thread), который первым получает управление при старте приложения.

Рассмотрим структуру памяти типичного процесса. Иллюстрация 3-3 демонстрирует адресное пространство процесса, состоящего из EXE-модуля и DLL-библиотеки. Адресное пространство – это множество всех доступных процессу адресов памяти. Оно разделено на блоки, называемые сегментами. У каждого из них есть базовый адрес, длина и набор прав доступа (на запись, чтение и исполнение). Разделение на сегменты упрощает задачу контроля доступа к памяти. С их помощью ОС может оперировать блоками памяти, а не отдельными адресами.

Иллюстрация 3-3. Адресное пространство типичного процесса

Процесс на иллюстрации 3-3 имеет три потока (включая главный). У каждого потока есть свой сегмент стека. Стек – это область памяти, организованная по принципу “последним пришёл — первым вышел” (“last in — first out” или LIFO). Она инициализируется ОС при старте приложения и используется для хранения переменных и вызова функций. В стеке сохраняется адрес инструкции, следующей за вызовом. После возврата из функции процесс продолжает своё выполнение с этой инструкции. Также через стек передаются входные параметры функций.

Кроме сегментов стека, у процесса есть несколько сегментов динамической памяти (heap), к которым имеет доступ каждый поток.

У всех модулей процесса есть обязательные сегменты: .text, .data и .bss. Кроме обязательных могут быть и дополнительные сегменты (например .rsrc). Они не представлены на схеме 3-3.

Таблица 3-1 кратко описывает каждый сегмент из иллюстрации 3-3. Во втором столбце приведены их обозначения в отладчике OllyDbg.

Таблица 3-1. Описание сегментов
Сегмент Обозначение в OllyDbg Описание
Стек главного потока Stack of main thread Содержит автоматические переменные (память под которые выделяется при входе в блок области видимости и освобождается при выходе из него), стек вызовов с адресами возврата из функций и их входные параметры.
Динамическая память ID 1 Heap Дополнительный сегмент памяти, который создаётся при переполнении сегмента динамической памяти ID 0.
Динамическая память ID 0 Default heap ОС всегда создаёт этот сегмент при запуске процесса. Он используется по умолчанию для хранения переменных.
Стек потока 2 Stack of thread 2 Выполняет те же функции, что и стек главного потока, но используется только потоком 2.
.text EXE модуля Code Содержит машинный код модуля EXE.
.data EXE модуля Data Содержит статические и не константные глобальные переменные модуля EXE, которые инициализируются значениями при создании.
.bss EXE модуля   Содержит статические и не константные глобальные переменные модуля EXE, которые не инициализируются при создании.
Стек потока 3 Stack of thread 2 То же самое, что и стек потока 2, только используется потоком 3.
Динамическая память ID 2   Дополнительный сегмент памяти, расширяющий сегмент динамической памяти ID 1 при его переполнении.
.text модуля DLL Code Содержит машинный код модуля DLL.
.data модуля DLL Data Содержит статические и не константные глобальные переменные модуля DLL, которые инициализируются значениями при создании.
.bss модуля DLL   Содержит статические и не константные глобальные переменные модуля DLL, которые не инициализируются при создании.
Динамическая память ID 3   Дополнительный сегмент памяти, расширяющий сегмент динамической памяти ID 2 при его переполнении.
TEB потока 3 Data block of thread 3 Содержит блок информации о потоке (Thread Information Block или TIB), также известный как блок контекста потока (Thread Environment Block или TEB). Он представляет собой структуру с информацией о потоке 3.
TEB потока 2 Data block of thread 2 Содержит TEB структуру потока 2.
TEB главного потока Data block of main thread Содержит TEB структуру главного потока.
PEB Process Environment Block Содержит блок контекста процесса (Process Environment Block или PEB). Эта структура данных с информацией о процессе в целом.
Пользовательские данные User Share Data Содержит данные, которые доступны и совместно используются текущим процессом и другим.
Память ядра Kernel memory Область памяти, зарезервированная для нужд ОС.

Предположим, что на иллюстрации 3-3 приведено адресное пространство процесса игрового приложения. В этом случае состояние игровых объектов может находится в сегментах, отмеченных красным цветом.

ОС назначает базовые адреса этих сегментов в момент старта приложения. Эти адреса могут отличаться от запуска к запуску. Кроме того, последовательность сегментов в памяти может также меняться. В то же время некоторые из сегментов, отмеченных синим цветом на иллюстрации 3-3 (например PEB, User Share Data и Kernel memory), имеют неизменный адрес при каждом старте приложения.

Отладчик OllyDbg позволяет прочитать структуру памяти (memory map) запущенного процесса. Иллюстрации 3-4 и 3-5 демонстрируют вывод OllyDbg для приложения, адресное пространство которого приведено на схеме 3-3.

Иллюстрация 3-4. Структура памяти процесса в OllyDbg
Иллюстрация 3-5. Структура памяти процесса в OllyDbg (продолжение)

Таблица 3-2 демонстрирует соответствие между схемой 3-3 и сегментами настоящего процесса из иллюстраций 3-4 и 3-5.

Таблица 3-2. Сегменты процесса
Базовый адрес Сегмент Обозначение в OllyDbg
001ED000 Стек главного потока Stack of main thread
004F0000 Динамическая память ID 1 Heap
00530000 Динамическая память ID 0 Default heap
00ACF000 Стеки вспомогательных Stack of thread N
00D3E000 потоков  
0227F000    
00D50000-00D6E000 Сегменты EXE модуля “ConsoleApplication1”  
02280000-0BB40000 Дополнительные сегменты  
0F230000-2BC70000 динамической памяти  
0F0B0000-0F217000 Сегменты модуля DLL “ucrtbased”  
7EFAF000 TEB вспомогательных Data block of thread N
7EFD7000 потоков  
7EFDA000    
7EFDD000 TEB главного потока Data block of main thread
7EFDE000 PEB главного потока Process Environment Block
7FFE0000 Пользовательские данные User shared data
80000000 Память ядра Kernel memory

Возможно, вы обратили внимание, что OllyDbg не может автоматически идентифицировать все сегменты динамической памяти. С этой задачей лучше справляются отладчик WinDbg и инструмент HeapMemView.

Поиск переменной в памяти

Внутриигровые боты читают состояния объектов из памяти процесса игрового приложения. Эти состояния могут храниться в нескольких переменных, находящихся в разных сегментах. Базовые адреса этих сегментов и смещение переменных внутри них могут меняться от запуска к запуску. Это означает, что абсолютные адреса переменных непостоянны. К сожалению, бот может читать данные из памяти только по абсолютным адресам. Следовательно, он должен уметь искать нужные ему переменные самостоятельно.

Термин “абсолютный адрес” неточен, если мы говорим о модели сегментации памяти x86. x86 – это архитектура процессора, впервые реализованная компанией Intel. Сегодня практически все настольные компьютеры имеют процессоры этой архитектуры. Правильный термин, который следует употреблять – “линейный адрес”. Он вычисляется по следующей формуле:

1 линейный адрес = базовый адрес сегмента + смещение в сегменте

В этой главе мы продолжим использовать термин “абсолютный адрес”, поскольку он интуитивно понятен.

Задачу поиска переменной в памяти процесса можно разделить на три этапа. В результате получится следующий алгоритм:

  1. Найти сегмент, который содержит искомую переменную.
  2. Определить базовый адрес сегмента.
  3. Определить смещение переменной внутри сегмента.

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

Второй шаг алгоритма бот должен всегда выполнять сам. Как мы упоминали ранее, адреса сегментов меняются при старте приложения.

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

Таблица 3-3. Смещение переменных в различных типах сегментов
Тип сегмента Постоянство смещения
.bss Смещение переменной не меняется
.data при перезапуске приложения.
Стек В большинстве случаев смещение переменной не меняется. Но оно зависит от порядка выполнения инструкций (control flow). Если этот порядок меняется, смещение, скорее всего, тоже изменится.
Динамическая память Смещение переменной меняется при перезапуске приложения.

Поиск переменной в 32-битном приложении

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

Приложение ColorPix является 32-битным. Скриншот его окна приведён на иллюстрации 3-6. Попробуем найти в памяти переменную, которая соответствует координате X выделенного на экране пикселя. На иллюстрации 3-6 она подчёркнута красной линией.

Иллюстрация 3-6. Окно приложения ColorPix

Для начала найдём сегмент памяти, в котором хранится переменная. Эту задачу можно разделить на два этапа:

  1. Найти абсолютный адрес переменной с помощью сканера памяти Cheat Engine.
  2. Сравнить найденный адрес с базовыми адресами всех сегментов. Таким образом мы узнаем сегмент, в котором хранится переменная.

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

  1. Запустите 32-битную версию сканера с правами администратора.
  2. Выберите пункт главного меню “File” -> “Open Process”. Вы увидите диалог со списком запущенных процессов (см. иллюстрацию 3-7).
Иллюстрация 3-7. Диалог выбора процесса Cheat Engine
  1. Выберите процесс с именем “ColorPixel.exe” и нажмите кнопку “Open”. В результате имя этого процесса отобразится в верхней части окна Cheat Engine.
  2. Введите значение координаты X, которое вы видите в данный момент в окне ColorPixel, в поле “Value” окна Cheat Engine.
  3. Нажмите кнопку “First Scan”, чтобы найти абсолютный адрес указанного значения координаты X в памяти процесса ColorPixel.

Когда вы нажимаете кнопку “First Scan”, значение в поле “Value” окна Cheat Engine, должно соответствовать тому, что отображает ColorPixel. Координата X изменится, если вы переместите курсор мыши по экрану, поэтому нажать на кнопку будет затруднительно. Воспользуйтесь комбинацией клавиш Shift+Tab, чтобы переключиться на неё и Enter, чтобы нажать.

В левой части окна Cheat Engine вы увидите результаты поиска, как на иллюстрации 3-8.

Иллюстрация 3-8. Результаты поиска в окне Cheat Engine

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

  1. Переместите курсор мыши, чтобы значение координаты X в окне ColorPixel изменилось.
  2. Введите новую координату X в поле “Value” окна Cheat Engine.
  3. Нажмите кнопку “Next Scan”.

После этого в окне результатов должны остаться только две переменные, как на иллюстрации 3-8. В моём случае их абсолютные адреса равны 0018FF38 и 0025246C. У вас они могут отличаться, но это не существенно для нашего примера.

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

1. Запустите отладчик OllyDbg с правами администратора. Путь к нему по умолчанию:
C:\Program Files (x86)\odbg201\ollydbg.exe.

  1. Выберите пункт главного меню “File” -> “Attach”. Вы увидите диалог со списком запущенных 32-битных процессов (см. иллюстрацию 3-9).
Иллюстрация 3-9. Диалог выбора процесса в отладчике OllyDbg
  1. Выберите процесс “ColorPix” в списке и нажмите кнопку “Attach”. Когда отладчик подключится к нему, вы увидите состояние “Paused” в правом нижнем углу окна OllyDbg.
  2. Нажмите комбинацию клавиш Alt+M, чтобы открыть окно, отображающее структуру памяти процесса ColorPix. Это окно “Memory Map” приведено на иллюстрации 3-10.
Иллюстрация 3-10. Окно Memory Map со структурой памяти процесса

Переменная с абсолютным адресом 0018FF38 хранится в сегменте стека главного процесса (“Stack of main thread”), который занимает адреса с 0017F000 по 00190000.

Вторая найденная нами переменная с адресом 0025246C находится в сегменте с базовым адресом 00250000, тип которого неизвестен. Найти его будет труднее чем сегмент стека. Поэтому мы продолжим работу с первой переменной.

Последний шаг поиска – расчёт смещения переменной в сегменте стека. Стек в архитектуре x86 растёт вниз. Это означает, что он начинается с больших адресов и расширяется в сторону меньших. Следовательно, базовый адрес стека равен его верхней границе (в нашем случае это 00190000). Нижняя границе стека может меняться по ходу его увеличения.

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

1 00190000 - 0018FF38 = C8

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

Теперь у нас есть вся необходимая информация, чтобы найти и прочитать координату X в любом запущенном процессе ColorPix. Алгоритм бота, который бы это делал, выглядит следующим образом:

  1. Прочитать базовый адрес сегмента стека главного потока. Этот адрес хранится в TEB сегменте.
  2. Вычесть смещение переменной (всегда равное C8) из базового адреса сегмента стека. В результате получим её абсолютный адрес.
  3. Прочитать значение переменной из памяти процесса ColorPix по её абсолютному адресу.

Корректность первого шага алгоритма мы можем проверить вручную с помощью отладчика OllyDbg. Он позволяет прочитать информацию сегмента TEB в удобном виде. Для этого дважды щёлкните по сегменту, который называется “Data block of main thread”, в окне “Memory Map” отладчика. Вы увидите окно как на иллюстрации 3-11.

Иллюстрация 3-11. Окно OllyDbg с информацией TEB

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

Поиск переменной в 64-битном приложении

Применим наш алгоритм поиска переменной для 64-битного приложения.

Resource Monitor (монитор ресурсов) Windows 7 будет нашим приложением для анализа. Он распространяется вместе с ОС и доступен сразу после её установки. Разрядность Resource Monitor совпадает с разрядностью Windows. Чтобы запустить приложение, откройте меню Пуск (Start) Windows и введите следующую команду в строку поиска:

1 perfmon.exe /res

Иллюстрации 3-12 демонстрирует окно Resource Monitor.

Иллюстрация 3-12. Окно приложения Resource Monitor

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

Прежде всего найдём сегмент, содержащий искомую переменную. Для этого воспользуемся 64-битной версией сканера Cheat Engine. Интерфейс его 64 и 34-битных версий одинаков, поэтому вам нужно выполнить те же действия, что и при анализе приложения ColorPixel.

В моем случае сканер нашёл две переменные с адресами 00432FEC и 00433010. Определим сегменты, в которых они хранятся. Чтобы прочитать структуру памяти процесса с помощью отладчика WinDbg, выполните следующие действия:

1. Запустите 64-битную версию WinDbg с правами администратора. Путь к нему по умолчанию:
C:\Program Files (x86)\Windows ­Kits\8.1\Debuggers\x64\windbg.exe.

2. Выберите пункт главного меню “File” -> “Attach to a Process…”. Откроется окно диалога со списком запущенных 64-разрядных процессов, как на иллюстрации 3-13.

Иллюстрация 3-13. Диалог выбора процесса в отладчике WinDbg
  1. Выберите в списке процесс “perfmon.exe” и нажмите кнопку “OK”.
  2. В командной строке отладчика, расположенной в нижней части окна “Command”, введите текст !address и нажмите Enter. Структура памяти процесса отобразится в окне “Command”, как на иллюстрации 3-14.
Иллюстрация 3-14. Вывод структуры памяти процесса в окне Command

Обе переменные с абсолютными адресами 00432FEC и 00433010 находятся в сегменте динамической памяти с ID 2. Границы этого сегмента: с 003E0000 по 00447000. Смещение первой переменной в сегменте равно 52FEC:

1 00432FEC - 003E0000 = 52FEC

Задача решена.

Для бота алгоритм поиска переменной, хранящей размер свободной памяти ОС в приложении Resource Monitor, выглядит следующим образом:

  1. Прочитать базовый адрес сегмента динамической памяти с ID 2. Чтобы получить доступ к этим сегментам, надо воспользоваться следующими WinAPI-функциями:
    * CreateToolhelp32Snapshot
    * Heap32ListFirst
    * Heap32ListNext
  2. Добавить смещение переменной (в моем случае равное 52FEC) к базовому адресу сегмента. В результате получится её абсолютный адрес.
  3. Прочитать значение переменной из памяти процесса.

Как вы помните, смещение переменной в сегменте динамической памяти обычно меняется при перезапуске приложения. В случае если приложение достаточно простое (как рассматриваемый нами Resource Monitor), порядок выделения динамической памяти может быть одним и тем же при каждом старте программы.

Попробуйте перезапустить Resource Monitor и найти переменную ещё раз. Вы получите то же самое её смещение в сегменте, равное 52FEC.

Выводы

Мы рассмотрели адресное пространство Windows процесса. Затем составили алгоритм поиска переменной в памяти и применили его к 32 и 64-разрядному приложениям. В ходе этого мы познакомились с функциями отладчиков OllyDbg и WinDbg для анализа структуры памяти процесса.

Доступ к памяти процесса

Мы научились вручную искать переменные в памяти процесса. Пришло время написать код, автоматизирующий эту задачу. К сожалению, внутриигровые боты не могут использовать программу-отладчик (например OllyDbg). Вместо этого все необходимые возможности должны быть реализованы в их коде.

Подключение к процессу

Как вы помните, перед началом работы с памятью процесса к нему нужно подключить отладчик. После этого он получает полный доступ к адресному пространству процесса. Мы выполняли это действие через диалог интерфейса пользователя. То же самое должен уметь внутриигровой бот. Рассмотрим, какими WinAPI-функциями он может для этого воспользоваться.

Практически все объекты и ресурсы Windows доступны через их дескрипторы. WinAPI-функция OpenProcess позволяет получить дескриптор работающего процесса. Возникает вопрос: как сообщить ОС, что нас интересует именно процесс игрового приложения? Для этой цели служит идентификатор процесса (process identifier или PID). PID – это уникальный номер, который ОС присваивает каждому процессу при старте. Его мы должны передать в OpenProcess входным параметром. Далее получив дескриптор процесса, мы можем обращаться к его памяти с помощью других WinAPI-функций.

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

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

Представим, что мы разрабатываем пользовательское приложение, например внутриигрового бота. Каким образом оно может взаимодействовать с каким-нибудь Windows объектом? Каждый объект представляет собой структуру, состоящую из заголовка (header) и тела (body). Тело содержит данные, специфичные для каждого типа объектов. Заголовок же включает метаинформацию, которая используется менеджером объектов (Object Manager). Именно он предоставляет доступ к ресурсам ОС через соответствующие им объекты.

Модель безопасности Windows ограничивает процессам доступ к системным объектам и различным действиям, требующих прав администратора. Можно сказать, что менеджер объектов реализует модель безопасности Windows. Согласно ей, процесс должен иметь специальные привилегии, чтобы получить доступ к памяти другого через вызов OpenProcess. Управлять привилегиями процесса можно с помощью специального объекта Windows под названием маркер доступа (access token).

Учитывая модель безопасности Windows, полный алгоритм подключения к процессу через WinAPI-функцию OpenProcess выглядит следующим образом:

  1. Получить дескриптор текущего процесса.
  2. По дескриптору получить маркер доступа текущего процесса.
  3. Предоставить привилегию SE_DEBUG_NAME для маркера доступа. Эта привилегия даёт право отлаживать другие процессы.
  4. Получить дескриптор целевого процесса через вызов OpenProcess.

Приложение, реализующее этот алгоритм, должно быть запущено с правами администратора. Без них невозможно выполнить третий шаг и предоставить текущему процессу привилегию SE_DEBUG_NAME через WinAPI-функцию AdjustTokenPrivileges.

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

Листинг 3-1 демонстрирует код приложения, которое подключается к процессу с заданным PID.

Листинг 3-1. Приложение OpenProcess.cpp
 1 #include <windows.h>
 2 #include <stdio.h>
 3 
 4 BOOL SetPrivilege(HANDLE hToken, LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
 5 {
 6     TOKEN_PRIVILEGES tp;
 7     LUID luid;
 8     if (!LookupPrivilegeValue(NULL, lpszPrivilege, &luid))
 9 
10     {
11         printf("LookupPrivilegeValue error: %u\n", GetLastError());
12         return FALSE;
13     }
14 
15     tp.PrivilegeCount = 1;
16     tp.Privileges[0].Luid = luid;
17 
18     if (bEnablePrivilege)
19         tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
20     else
21         tp.Privileges[0].Attributes = 0;
22 
23     if (!AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(TOKEN_PRIVILEGES),
24                                (PTOKEN_PRIVILEGES)NULL, (PDWORD)NULL))
25     {
26         printf("AdjustTokenPrivileges error: %u\n", GetLastError());
27         return FALSE;
28     }
29 
30     if (GetLastError() == ERROR_NOT_ALL_ASSIGNED)
31     {
32         printf("The token does not have the specified privilege. \n");
33         return FALSE;
34     }
35     return TRUE;
36 }
37 
38 int main()
39 {
40     HANDLE hProc = GetCurrentProcess();
41     HANDLE hToken = NULL;
42 
43     if (!OpenProcessToken(hProc, TOKEN_ADJUST_PRIVILEGES, &hToken))
44         printf("Failed to open access token\n");
45 
46     if (!SetPrivilege(hToken, SE_DEBUG_NAME, TRUE))
47         printf("Failed to set debug privilege\n");
48 
49     DWORD pid = 1804;
50 
51     HANDLE hTargetProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
52     if (hTargetProc)
53         printf("Target process handle = %p\n", hTargetProc);
54     else
55         printf("Failed to open process: %u\n", GetLastError());
56 
57     CloseHandle(hTargetProc);
58     return 0;
59 }

Приложение из листинга 3-1 подключается к процессу с PID равным 1804. Вам нужно заменить его на PID работающего в данный момент процесса. Узнать идентификаторы всех запущенных процессов можно с помощью приложения Task Manager (диспетчер задач). Укажите PID целевого процесса в следующей строке файла OpenProcess.cpp:

1     DWORD pid = 1804;

Каждый шаг алгоритма подключения к процессу выполняется отдельной функцией. Все они вызываются из функции main, которая получает управление сразу при старте приложения. Рассмотрим её код подробнее.

Сначала мы с помощью WinAPI-функции GetCurrentProcess получаем дескриптор текущего процесса и сохраняем его в переменной hProc.

Далее вызывается WinAPI-функция OpenProcessToken, которая возвращает маркер доступа. В неё мы передаём дескриптор hProc и маску доступа TOKEN_ADJUST_PRIVILEGES. Благодаря этой маске мы получаем право менять возвращаемый функцией маркер доступа. Его мы сохраняем в переменной hToken.

Весь код, предоставляющий привилегию SE_DEBUG_NAME маркеру доступа hToken, мы реализовали в отдельной функции SetPrivilege. Она выполняет два действия:

  1. Читает локальный уникальный идентификатор (locally unique identifier или LUID) константы, соответствующей привилегии SE_DEBUG_NAME с помощью WinApI функции LookupPrivilegeValue.
  2. Предоставляет маркеру доступа, переданному входным параметром, привилегию SE_DEBUG_NAME (указанную по LUID) через WinAPI-функцию AdjustTokenPrivileges.

Функция SetPrivilege более детально разбирается в статье.

Последнее действие в функции main – подключение к целевому процессу, дескриптор которого сохраняется в переменной hTargetProc. Для этого мы используем WinAPI-функцию OpenProcess. В неё передаются права доступа PROCESS_ALL_ACCESS и PID процесса для подключения. После этого вся его память становится доступна по дескриптору hTargetProc.

Операции чтения и записи

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

WinAPI-функция ReadProcessMemory читает данные из указанной области памяти целевого процесса и сохраняет их в память вызывающего процесса. Аналогичная ей функция WriteProcessMemory записывает указанные данные в память целевого процесса. Рассмотрим пример использования этих функций.

Тестовое приложение, приведённое в листинге 3-2, записывает шестнадцатеричное значение DEADBEEF по некоторому абсолютному адресу памяти целевого процесса. Затем по этому же адресу происходит чтение. Если запись была успешной, мы прочитаем то же самое значение DEADBEEF.

Листинг 3-2. Приложение ReadWriteProcessMemory.cpp
 1 #include <stdio.h>
 2 #include <windows.h>
 3 
 4 BOOL SetPrivilege(HANDLE hToken, LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
 5 {
 6     // Смотрите реализацию этой функции в листинге 3-1
 7 }
 8 
 9 DWORD ReadDword(HANDLE hProc, DWORD_PTR address)
10 {
11     DWORD result = 0;
12 
13     if (ReadProcessMemory(hProc, (void*)address, &result, sizeof(result), NULL) == 0)
14     {
15         printf("Failed to read memory: %u\n", GetLastError());
16     }
17     return result;
18 }
19 
20 void WriteDword(HANDLE hProc, DWORD_PTR address, DWORD value)
21 {
22     if (WriteProcessMemory(hProc, (void*)address, &value, sizeof(value), NULL) == 0)
23     {
24         printf("Failed to write memory: %u\n", GetLastError());
25     }
26 }
27 
28 int main()
29 {
30     HANDLE hProc = GetCurrentProcess();
31 
32     HANDLE hToken = NULL;
33     if (!OpenProcessToken(hProc, TOKEN_ADJUST_PRIVILEGES, &hToken))
34         printf("Failed to open access token\n");
35 
36     if (!SetPrivilege(hToken, SE_DEBUG_NAME, TRUE))
37         printf("Failed to set debug privilege\n");
38 
39     DWORD pid = 5356;
40     HANDLE hTargetProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
41     if (!hTargetProc)
42         printf("Failed to open process: %u\n", GetLastError());
43 
44     DWORD_PTR address = 0x001E0000;
45     WriteDword(hTargetProc, address, 0xDEADBEEF);
46     printf("Result of reading dword at 0x%llx address = 0x%x\n", address,
47            ReadDword(hTargetProc, address));
48 
49     CloseHandle(hTargetProc);
50     return 0;
51 }

Абсолютный адрес 001E0000 для записи значения DEADBEEF выбран произвольно. Эту область памяти занимает какой-то сегмент. Операция записи данных в него может привести к аварийному завершению целевого процесса. Поэтому в качестве него
не используйте важные системные службы Windows. Лучше всего для нашего теста подойдёт приложение Notepad.

Для запуска приложения ReadWriteProcessMemory.cpp выполните следующие действия:

  1. Запустите Notepad.
  2. С помощью Task Manager прочитайте PID процесса Notepad.
  3. Присвойте этот PID соответствующей переменной в исходном коде приложения ReadWriteProcessMemory.cpp:
1 DWORD pid = 5356;
  1. С помощью отладчика WinDbg прочитайте базовый адрес любого сегмента динамической памяти процесса Notepad. Для этого воспользуйтесь уже знакомой нам командой !address.
  2. Отключите WinDbg от процесса Notepad с помощью команды .detach.
  3. Присвойте базовый адрес сегмента динамической памяти переменной address в функции main:
1 DWORD_PTR address = 0x001E0000;

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

  1. Скомпилируйте приложение ReadWriteProcessMemory.cpp. Разрядность (x86 или x64) полученного EXE-файл должна соответствовать разрядности Notepad. В противном случае наше приложение не сможет к нему подключиться.
  2. Запустите тестовое приложение с правами администратора из командной строки Windows.

После успешного выполнения нашего примера, вы увидите в консоли строку:

1 Result of reading dword at 0x1e0000 address = 0xdeadbeef

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

Обратите внимание на функции-обёртки WriteDword и ReadDword в листинге 3-2. Они скрывают несущественные детали и предоставляют простой интерфейс к WinAPI-функциям WriteProcessMemory и ReadProcessMemory. Их параметры представлены в таблице 3-4.

Таблица 3-4. Параметры функций WriteProcessMemory и ReadProcessMemory
Номер параметра Параметр Описание
1 hProc Дескриптор целевого процесса, к памяти которого идёт обращение.
2 address Абсолютный адрес области памяти для доступа.
3 result Указатель на область памяти текущего процесса, в которую будет сохранён результат вызова ReadProcessMemory.
3 value Указатель на буфер данных, которые будут записаны функцией WriteProcessMemory в память целевого процесса.
4 sizeof(...) Число байт для чтения или записи.
5 NULL Указатель на переменную. Если операция чтения или записи была прервана по какой-то причине, в эту переменную запишется число переданных байт.

Доступ к сегментам TEB и PEB

Мы научились работать с памятью целевого процесса. Но есть одна проблема: доступ на чтение или запись конкретной переменной происходит по её абсолютному адресу. Вопрос в том, как его найти? Мы уже знаем, что его можно вычислить по базовому адресу сегмента, в котором находится эта переменная, и её смещению. Предположим, что мы знаем, какой сегмент следует искать. Как узнать его базовый адрес? К счастью, метаинформацию об адресном пространстве процесса можно найти в его памяти. Например, в специальных сегментах TEB и PEB.

В памяти процесса для каждого потока есть соответствующий ему TEB сегмент. Кроме прочей информации он содержит базовый адрес сегмента стека, выделенного этому потоку. В стеке же хранится большая часть переменных, используемых в потоке. Остальные переменные находятся в сегменте динамической памяти процесса, выделяемом по умолчанию. Его базовый адрес хранится в PEB сегменте. Следовательно, чтобы найти сегменты стека потока и динамической памяти процесса, нам надо найти PEB и соответствующий потоку TEB. Эта задача упрощается тем, что все TEB сегменты содержат базовый адрес PEB. Таким образом, задача сводится к поиску TEB сегмента.

Доступ к TEB текущего процесса

Главный поток 32-битного процесса

Рассмотрим методы доступа к TEB сегменту. Начнём с самого простого варианта этой задачи. Предположим, что у нас есть однопоточное приложение. Как ему получить доступ к TEB своего главного потока? Существует несколько способов.

Самый простой и прямолинейный метод – воспользоваться регистром FS процессора на x86 архитектуре или регистром GS на архитектуре x64. Вообще, процессор предоставляет ОС решать, как использовать эти регистры. Windows хранит в них указатель на TEB сегмент потока, который исполняется в данный момент. Листинг 3-3 демонстрирует чтение регистра FS.

Листинг 3-3. Функция GetTeb
 1 #include <winternl.h>
 2 
 3 PTEB GetTeb()
 4 {
 5     PTEB pTeb;
 6 
 7     __asm {
 8         mov EAX, FS:[0x18]
 9         mov pTeb, EAX
10     }
11     return pTeb;
12 }

В функции GetTeb используются ассемблерные вставки. Эта возможность C++ позволяет добавлять в программу код на языке ассемблера, каждая команда которого соответствует одной инструкции процессора. Другими словами мы спускаемся на самый нижний уровень и оперируем элементарными действиями процессора.

Рассмотрим код GetTeb подробнее. Функция начинается с выделения памяти на стеке для локальной переменной pTeb типа PTEB. Согласно WinAPI документации, тип PTEB – это указатель на структуру, содержащую все данные сегмента TEB. Далее идёт блок с двумя командами на языке ассемблера:

1. Запись в регистр EAX некоторого значения. Оно находится по абсолютному адресу памяти, который рассчитывается по формуле:

1 линейный адрес = базовый адрес из регистра FS + 0x18

2. Запись значение регистра EAX в переменную pTeb.

В результате этих команд базовый адрес регистра TEB оказывается записан в переменную pTeb. Её мы и возвращаем из функции.

Почему GetTeb не может просто вернуть значение регистра FS? Ведь он, по идее, должен указывать на TEB сегмент. Чтобы ответить на этот вопрос, рассмотрим как в Windows происходит доступ к сегментам процесса.

Большинство современных ОС использует защищённый режим процессора (protected processor mode). В этом режиме адресация сегментов происходит через глобальную таблицу дескрипторов (Global Descriptor Table или GDT). В регистрах FS и GS хранится селектор, который является индексом записи в таблице дескрипторов. В этой записи находится базовый адрес сегмента TEB. Запрос к GDT по селектору выполняется аппаратным блоком сегментации (segmentation unit) процессора. Результат этого запроса временно хранится в процессоре и недоступен для приложений или ОС. Таким образом, у Windows нет эффективного способа узнать базовый адрес сегмента TEB. Его можно прочитать из таблицы дескрипторов через WinAPI-функции GetThreadSelectorEntry и Wow64GetThreadSelectorEntry, но этот способ неэффективен из-за накладных расходов. Именно поэтому в TEB сегменте хранится его собственный базовый адрес.

Если вы интересуетесь подробностями, пример использования функции GetThreadSelectorEntry приведён в следующем обсуждении на форуме.

Структура TEB определена в заголовочном файле winternal.h, который распространяется с Windows SDK. Она отличается для разных версий Windows. Поэтому важно, чтобы ваши версии ОС и Windows SDK совпадали. Перед началом работы с TEB структурой всегда уточняйте её поля в заголовочном файле.

Определение структуры TEB из Windows SDK версии 8.1 выглядит следующим образом:

 1 typedef struct _TEB {
 2     PVOID Reserved1[12];
 3     PPEB ProcessEnvironmentBlock;
 4     PVOID Reserved2[399];
 5     BYTE Reserved3[1952];
 6     PVOID TlsSlots[64];
 7     BYTE Reserved4[8];
 8     PVOID Reserved5[26];
 9     PVOID ReservedForOle;  // Windows 2000 only
10     PVOID Reserved6[4];
11     PVOID TlsExpansionSlots;
12 } TEB, *PTEB;

В ней среди прочих есть поле ProcessEnvironmentBlock, которое указывает на структуру PEB. Через него мы можем получить доступ к PEB сегменту.

Главный поток 64-битного процесса

Мы не можем просто заменить регистр FS на GS и использовать функцию GetTeb из листинга 3-3 на 64-разрядной системе. Проблема в том, что компилятор Visual Studio C++ не поддерживает ассемблерные вставки при компиляции 64-разрядных приложений. Вместо них следует использовать встроенные функции компилятора (compiler intrinsics).

Листинг 3-4 демонстрирует функцию GetTeb, переписанную для поддержки обеих архитектур: x86 и x64.

Листинг 3-4. Функция GetTeb для архитектур x86 и x64
 1 #include <windows.h>
 2 #include <winternl.h>
 3 
 4 PTEB GetTeb()
 5 {
 6 #if defined(_M_X64) // x64
 7     PTEB pTeb = reinterpret_cast<PTEB>(__readgsqword(0x30));
 8 #else // x86
 9     PTEB pTeb = reinterpret_cast<PTEB>(__readfsdword(0x18));
10 #endif
11     return pTeb;
12 }

В новом варианте GetTeb используется директива условной компиляции препроцессора. С её помощью перед компиляцией выбирается подходящая реализация функции. Если макрос _M_X64 определён, значит целевая архитектура приложения 64-разрядная. В этом случае вызывается встроенная функция компилятора __readgsqword, которая читает 64-битное значение со смещением 0x30 от базового адреса сегмента TEB (на него указывает регистр GS через селектор). Для 32-разрядной архитектуры вызывается встроенная функция __readfsdword, которая читает 32-битное значение со смещением 0x18 от базового адреса сегмента TEB (на него указывает регистр FS).

Новая реализация функции GetTeb может вызвать вопрос: почему поле структуры TEB с базовым адресом сегмента имеет разные смещения для x86 и x64 архитектур? Чтобы ответить на него, рассмотрим определение структуры NT_TIB, которая используется для представления части TEB, независимой от версии Windows:

 1 typedef struct _NT_TIB {
 2     struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList;
 3     PVOID StackBase;
 4     PVOID StackLimit;
 5     PVOID SubSystemTib;
 6      union
 7      {
 8           PVOID FiberData;
 9           ULONG Version;
10      };
11     PVOID ArbitraryUserPointer;
12     struct _NT_TIB *Self;
13 } NT_TIB;

Поле с базовым адресом сегмента TEB называется Self. До него идут шесть полей, каждое из которых имеет тип PVOID. PVOID – это указатель на область памяти. Его размер зависит от разрядности процессора: 32 бита (или 4 байта) для архитектуры x86 и 64 бита (или 8 байт) для x64. Таким образом, в первом случае поле Self окажется смещено на 24 байта (6 * 4), а во втором на 48 байт (6 * 8). Переведём эти числа в шестнадцатеричную систему счисления и получим 0x18 и 0x30 соответственно.

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

Листинг 3-5. Универсальная версия функции GetTeb
 1 #include <windows.h>
 2 #include <winternl.h>
 3 
 4 PTEB GetTeb()
 5 {
 6 #if defined(_M_X64) // x64
 7     PTEB pTeb = reinterpret_cast<PTEB>(__readgsqword(reinterpret_cast<DWORD>(
 8                                        &static_cast<PNT_TIB>(nullptr)->Self)));
 9 #else // x86
10     PTEB pTeb = reinterpret_cast<PTEB>(__readfsdword(reinterpret_cast<DWORD>(
11                                        &static_cast<PNT_TIB>(nullptr)->Self)));
12 #endif
13     return pTeb;
14 }

Эта реализация функции GetTeb заимствована из статьи. В ней используются уже знакомые нам встроенные функции компилятора __readgsqword и __readfsdword. Мы применяем определение структуры NT_TIB, чтобы прочитать смещение её поля Self, содержащее базовый адрес сегмента TEB. Для этого мы последовательно приводим типы. Общий алгоритм расчёта смещения выглядит следующим образом:

  1. Указатель на нулевой абсолютный адрес, который обозначается литералом nullptr, приводим к типу PNT_TIB с помощью оператора static_cast. Таким образом мы получаем указатель на структуру типа NT_TIB, расположенную по адресу 0.
  2. С помощью оператора доступа к полю -> читаем поле Self структуры NT_TIB.
  3. С помощью операции взятия адреса & читаем абсолютный адрес поля Self. В данном случае абсолютный адрес совпадёт с относительным, поскольку он считается от нуля.
  4. Приведём полученный относительный адрес поля Self к типу DWORD или QWORD (в зависимости от целевой архитектуры) с помощью оператора reinterpret_cast. Это приведение необходимо, так как встроенные функции компилятора ожидают конкретный тип входного параметра.

Версия функции GetTeb из листинга 3-5 позволяет исключить явное указание смещений в коде. Благодаря этому она будет корректно работать для всех версий Windows даже в тех, где эти смещения изменятся.

WinAPI-функции доступа к TEB

Получить доступ к TEB сегменту можно и через WinAPI. Функция NtCurrentTeb реализует тот же алгоритм, что и GetTeb из листинга 3-5. С её помощью можно получить указатель на структуру типа TEB текущего потока. Листинг 3-6 демонстрирует использование NtCurrentTeb.

Листинг 3-6. Пример вызова WinAPI-функции NtCurrentTeb
1 #include <windows.h>
2 #include <winternl.h>
3 
4 PTEB pTeb = NtCurrentTeb();

Теперь все манипуляции над регистрами FS и GS происходят на уровне системной библиотеки ОС. Мы можем рассчитывать на её корректную работу для всех архитектур, поддерживаемых Windows (x86, x64, ARM).

До сих пор мы рассматривали случай однопоточного приложения. Если например нам нужно получить TEB вспомогательного потока из функции main (то есть главного потока), то все рассмотренные выше способы не подходят.

WinAPI-функция NtQueryInformationThread предоставляет доступ к TEB любого потока. Она работает только в контексте вызывающего процесса, т.е. с её помощью вы не сможете прочитать TEB игрового приложения из бота. Но в некоторых случаях NtQueryInformationThread может быть полезна. Листинг 3-7 демонстрирует реализацию GetTeb, которая использует NtQueryInformationThread.

Листинг 3-7. ФункцияGetTeb, вызывающая NtQueryInformationThread
 1 #include <windows.h>
 2 #include <winternl.h>
 3 
 4 #pragma comment(lib,"ntdll.lib")
 5 
 6 typedef struct _CLIENT_ID {
 7     DWORD UniqueProcess;
 8     DWORD UniqueThread;
 9 } CLIENT_ID, *PCLIENT_ID;
10 
11 typedef struct _THREAD_BASIC_INFORMATION {
12     typedef PVOID KPRIORITY;
13     NTSTATUS ExitStatus;
14     PVOID TebBaseAddress;
15     CLIENT_ID ClientId;
16     KAFFINITY AffinityMask;
17     KPRIORITY Priority;
18     KPRIORITY BasePriority;
19 } THREAD_BASIC_INFORMATION, *PTHREAD_BASIC_INFORMATION;
20 
21 typedef enum _THREADINFOCLASS2 {
22     ThreadBasicInformation,
23     ThreadTimes,
24     ThreadPriority,
25     ThreadBasePriority,
26     ThreadAffinityMask,
27     ThreadImpersonationToken,
28     ThreadDescriptorTableEntry,
29     ThreadEnableAlignmentFaultFixup,
30     ThreadEventPair_Reusable,
31     ThreadQuerySetWin32StartAddress,
32     ThreadZeroTlsCell,
33     ThreadPerformanceCount,
34     ThreadAmILastThread,
35     ThreadIdealProcessor,
36     ThreadPriorityBoost,
37     ThreadSetTlsArrayAddress,
38     _ThreadIsIoPending,
39     ThreadHideFromDebugger,
40     ThreadBreakOnTermination,
41     MaxThreadInfoClass
42 } THREADINFOCLASS2;
43 
44 PTEB GetTeb()
45 {
46     THREAD_BASIC_INFORMATION threadInfo;
47     if (NtQueryInformationThread(GetCurrentThread(),
48                                  (THREADINFOCLASS)ThreadBasicInformation,
49                                  &threadInfo, sizeof(threadInfo), NULL))
50     {
51         printf("NtQueryInformationThread return error\n");
52         return NULL;
53     }
54     return reinterpret_cast<PTEB>(threadInfo.TebBaseAddress);
55 }

Параметры функции NtQueryInformationThread приведены в таблице 3-5.

Таблица 3-5. Параметры функции NtQueryInformationThread
Параметр Описание
GetCurrentThread() Дескриптор целевого потока, TEB которого требуется прочитать. В примере используется дескриптор текущего потока.
ThreadBasicInformation Константа типа перечисление (enum) THREADINFOCLASS. Она определяет тип структуры, возвращаемой функцией.
threadInfo Указатель на структуру, в которую функция запишет свой результат.
sizeof(...) Размер структуры с результатом работы функции. В нашем случае – это размер threadInfo.
NULL Указатель на переменную. В неё запишется итоговый размер структуры с результатом (threadInfo).

Чтобы прочитать структуру типа THREAD_BASIC_INFORMATION для заданного потока, мы должны передать в функцию NtQueryInformationThread константу ThreadBasicInformation из перечисления THREADINFOCLASS. К сожалению, эта константа недокументированна. Кроме того, она не определена в заголовочном файле winternl.h. В нём есть только константа ThreadIsIoPending.

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

В нашем новом перечислении THREADINFOCLASS2 не должно быть константы с именем ThreadIsIoPending, иначе она будет конфликтовать с определением из заголовочного файла winternl.h. Поэтому в листинге 3-7 мы переименовали её на _ThreadIsIoPending.

Функция NtQueryInformationThread возвращает структуру данных, тип который зависит от переданного вторым параметром константы. Если мы передаём недокументированную константу ThreadBasicInformation, то тип возвращаемой структуры будет также недокументирован. Поэтому мы должны самостоятельно определить её тип THREAD_BASIC_INFORMATION. Вы можете найти его в уже упомянутой неофициальной документации или скопировать из листинга 3-7.

Обратите внимание на определение структуры THREAD_BASIC_INFORMATION. Базовый адрес сегмента TEB хранится в её поле TebBaseAddress. Она отличается от структуры TEB, с которой мы сталкивались ранее.

Функция NtQueryInformationThread доступна через Native API интерфейс. Она реализована в динамической библиотеке ntdll.dll, которая всегда входит в состав дистрибутива Windows. Эта библиотека активно используется системами ОС. Но, чтобы вызвать её функции из пользовательского приложения, понадобится библиотека импорта ntdll.lib и заголовочный файл winternl.h. Windows SDK предоставляет эти файлы.

Воспользоваться библиотекой импорта можно с помощью директивы pragma:

1 #pragma comment(lib, "ntdll.lib")

Эта строчка добавляет файл ntdll.lib в список библиотек импорта, которым воспользуется компоновщик.

В архиве примеров к этой книге вы можете найти файл TebPebSelf.cpp, в котором приведены все рассмотренные нами способы доступа к TEB и PEB сегментам.

Доступ к TEB целевого процесса

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

Теперь перейдём к реальной практической задаче и рассмотрим методы доступа к сегментам TEB и PEB целевого процесса. В качестве цели воспользуемся любым стандартным Windows приложением.

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

  1. Запустить стандартное Windows приложение (например Notepad). Помните, что его разрядность совпадает с разрядностью Windows.
  2. Прочитайте PID процесса приложения с помощью Task Manager.
  3. Присвойте прочитанный PID переменной pid функции main в коде соответствующего примера:
1 DWORD pid = 5356;
  1. Скомпилируйте пример.
  2. Запустите его из командной строки с правами администратора.

После выполнения приложение напечатает результат в командную строку.

Повторение базового адреса TEB

Начнём с простейшего случая, когда целевой процесс – это однопоточное приложение. При его старте ОС назначает базовый адрес TEB главного потока. Очень часто этот адрес оказывается одним и тем же для 32-разрядных приложений. Воспользуемся этим наблюдением и составим простой алгоритм для чтения TEB сегмента целевого процесса:

  1. Прочитать базовый адрес TEB сегмента главного потока текущего процесса.
  2. Прочитать сегмент по этому же базовому адресу в адресном пространстве целевого процесса.

Листинг 3-8 демонстрирует реализацию этого алгоритма.

Листинг 3-8. Приложение TebPebMirror.cpp
 1 #include <windows.h>
 2 #include <winternl.h>
 3 
 4 BOOL SetPrivilege(HANDLE hToken, LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
 5 {
 6     // Смотрите реализацию этой функции в листинге 3-1
 7 }
 8 
 9 BOOL GetMainThreadTeb(DWORD dwPid, PTEB pTeb)
10 {
11     LPVOID tebAddress = NtCurrentTeb();
12     printf("TEB = %p\n", tebAddress);
13 
14     HANDLE hProcess = OpenProcess(PROCESS_VM_READ, FALSE, dwPid);
15     if (hProcess == NULL)
16         return false;
17 
18     if (ReadProcessMemory(hProcess, tebAddress, pTeb, sizeof(TEB), NULL) == FALSE)
19     {
20         CloseHandle(hProcess);
21         return false;
22     }
23 
24     CloseHandle(hProcess);
25     return true;
26 }
27 
28 int main()
29 {
30     HANDLE hProc = GetCurrentProcess();
31 
32     HANDLE hToken = NULL;
33     if (!OpenProcessToken(hProc, TOKEN_ADJUST_PRIVILEGES, &hToken))
34         printf("Failed to open access token\n");
35 
36     if (!SetPrivilege(hToken, SE_DEBUG_NAME, TRUE))
37         printf("Failed to set debug privilege\n");
38 
39     DWORD pid = 7368;
40 
41     TEB teb;
42     if (!GetMainThreadTeb(pid, &teb))
43         printf("Failed to get TEB\n");
44 
45     printf("PEB = %p StackBase = %p\n", teb.ProcessEnvironmentBlock,
46            teb.Reserved1[1]);
47 
48     return 0;
49 }

После запуска приложения TebPebMirror.cpp, в командной строке будут распечатаны базовые адреса трёх сегментов целевого процесса:

  • TEB
  • PEB
  • Сегмент стека главного потока

Мы использовали уже знакомый нам метод предоставления привилегии SE_DEBUG_NAME для маркера доступа текущего процесса с помощью WinAPI-функций OpenProcessToken и SetPrivilege. После этого вызывается функция GetMainThreadTeb, которая принимает входным параметром PID целевого процесса и возвращает указатель на структуру TEB. Алгоритм GetMainThreadTeb следующий:

  1. Прочитать базовый адрес TEB сегмента текущего потока с помощью вызова NtCurrentTeb.
  2. Получить дескриптор целевого процесса с правами доступа PROCESS_VM_READ. Для этого используется WinAPI-функция OpenProcess.
  3. Прочитать структуру TEB целевого процесса с помощью вызова ReadProcessMemory.

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

Приложение из листинга 3-8 успешно справляется с однопоточными целевыми процессами. Может ли оно работать с многопоточными? Да, но для этого надо немного изменить его код. Приложение должно создавать столько же вспомогательных потоков, сколько имеет целевой процесс. Для каждого потока надо прочитать базовый адрес соответствующего TEB сегмента. Затем через эти адреса можно пытаться получить доступ к сегментам TEB целевого процесса.

Узнать число потоков в целевом процессе можно с помощью отладчика WinDbg или OllyDbg. Достаточно открыть его карту памяти и посчитать число TEB сегментов в ней.

Для всех примеров этой главы важно помнить, что разрядность целевого процесса и вашего приложения должна быть одинаковой. Чтобы выбрать разрядность компилируемого приложения в Visual Studio, укажите желаемую целевую архитектуру в элементе интерфейса “Solution Platforms” (платформы для решения).

Перебор всех потоков целевого процесса

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

WinAPI-функции прохода по списку активных потоков следующие:

  • CreateToolhelp32Snapshot делает снимок текущего состояния системы со всеми запущенными процессам, их потоками, модулями и сегментами динамической памяти. В функцию можно передать PID целевого процесса, тогда в снимок попадёт только он и его ресурсы.
  • Thread32First начинает перебор потоков в указанном снимке состояния системы. Функция записывает результат своей работы в структуру типа THREADENTRY32, переданную входным параметром по указателю. Эта структура содержит информацию о первом потоке в снимке.
  • Thread32Next продолжает перебор потоков в указанном снимке. Имеет те же входные и выходные параметры, что и функция Thread32First.

Приложение TebPebTraverse.cpp из листинга 3-9 демонстрирует алгоритм перебора потоков.

Листинг 3-9. Приложение TebPebTraverse.cpp
 1 #include <windows.h>
 2 #include <tlhelp32.h>
 3 #include <winternl.h>
 4 
 5 #pragma comment(lib,"ntdll.lib")
 6 
 7 typedef struct _CLIENT_ID {
 8     // Смотрите определение этой структуры в листинге 3-7
 9 } CLIENT_ID, *PCLIENT_ID;
10 
11 typedef struct _THREAD_BASIC_INFORMATION {
12     // Смотрите определение этой структуры в листинге 3-7
13 } THREAD_BASIC_INFORMATION, *PTHREAD_BASIC_INFORMATION;
14 
15 typedef enum _THREADINFOCLASS2
16 {
17     // Смотрите определение этой структуры в листинге 3-7
18 }   THREADINFOCLASS2;
19 
20 PTEB GetTeb(HANDLE hThread)
21 {
22     THREAD_BASIC_INFORMATION threadInfo;
23     NTSTATUS result = NtQueryInformationThread(hThread,
24                                     (THREADINFOCLASS)ThreadBasicInformation,
25                                     &threadInfo, sizeof(threadInfo), NULL);
26     if (result)
27     {
28         printf("NtQueryInformationThread return error: %d\n", result);
29         return NULL;
30     }
31     return reinterpret_cast<PTEB>(threadInfo.TebBaseAddress);
32 }
33 
34 void ListProcessThreads(DWORD dwOwnerPID)
35 {
36     HANDLE hThreadSnap = INVALID_HANDLE_VALUE;
37     THREADENTRY32 te32;
38 
39     hThreadSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
40 
41     if (hThreadSnap == INVALID_HANDLE_VALUE)
42         return;
43 
44     te32.dwSize = sizeof(THREADENTRY32);
45 
46     if (!Thread32First(hThreadSnap, &te32))
47     {
48         CloseHandle(hThreadSnap);
49         return;
50     }
51 
52     DWORD result = 0;
53     do
54     {
55         if (te32.th32OwnerProcessID == dwOwnerPID)
56         {
57             printf("\n     THREAD ID = 0x%08X", te32.th32ThreadID);
58 
59             HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE,
60                                         te32.th32ThreadID);
61             PTEB pTeb = GetTeb(hThread);
62             printf("\n     TEB = %p\n", pTeb);
63 
64             CloseHandle(hThread);
65         }
66     } while (Thread32Next(hThreadSnap, &te32));
67 
68     printf("\n");
69     CloseHandle(hThreadSnap);
70 }
71 
72 int main()
73 {
74     DWORD pid = 4792;
75 
76     ListProcessThreads(pid);
77 
78     return 0;
79 }

Это приложение выводит в консоль список потоков целевого процесса. Для каждого из них указывается идентификатор, назначенный ОС (аналог PID для потока), и базовый адрес соответствующего TEB сегмента.

Вся работа приложения происходит в функции ListProcessThreads, в которую передаётся PID целевого процесса. Для создания снимка состояния системы и работы с ним привилегия SE_DEBUG_NAME не требуется. Поэтому при запуске примера будет достаточно предоставить ему только права администратора.

Алгоритм работы функции ListProcessThreads следующий:

  1. Сделать снимок состояния системы через WinAPI вызов CreateToolhelp32Snapshot.
  2. Начать проход по потокам в снимке с помощью функции Thread32First.
  3. Сравнить PID процесса, которому принадлежит последний прочитанный поток, с PID целевого процесса.
  4. Если идентификаторы совпадают, прочитать TEB структуру этого потока с помощью функции GetTeb.
  5. Вывести в консоль полученную информацию о потоке.
  6. Перейти к следующему потоку в снимке состояния системы через вызов Thread32Next. Повторить шаги 3, 4, 5 для каждого потока в снимке.

Метод доступа к TEB из листинга 3-9 надёжен и работает для многопоточных целевых процессов любой разрядности. Применяйте в своих приложениях именно его.

Может быть не совсем понятно, как различать потоки при переборе их функцией Thread32Next. Например, вы ищете TEB главного потока. Структура THREADENTRY32 не содержит идентификатор потока в терминах процесса. Вместо этого в ней есть только глобальный ID, которым пользуется менеджер объектов Windows.

При использовании функции Thread32Next можно полагаться на порядок следования TEB сегментов в адресном пространстве процесса. Другими словами, TEB сегмент с наибольшим базовым адресом соответствует главному потоку (ID которого равен 0). Следующий за ним сегмент с меньшим адресом соответствует потоку с ID 1 в терминах процесса и т.д. Вы можете проверить порядок следования TEB сегментов с помощью отладчика WinDbg.

Доступ к динамической памяти

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

Следующие WinAPI-функции позволяют получить доступ к сегментам динамической памяти:

  • CreateToolhelp32Snapshot уже знакомая нам функция, которая создаёт снимок текущего состояния системы.
  • Heap32ListFirst начинает перебор сегментов динамической памяти, попавших в указанный снимок. Результат работы функции сохраняется в структуре типа HEAPLIST32.
  • Heap32ListNext продолжает перебор сегментов в снимке. Имеет те же входные и выходные параметры, что и функция Heap32ListFirst.

WinaAPI также предоставляет две функции для перебора блоков сегментов динамической памяти: Heap32First и Heap32Next. Мы не будем их использовать в примерах этой главы.

Листинг 3-10 демонстрирует перебор сегментов динамической памяти целевого процесса.

Листинг 3-10. Приложение HeapTraverse.cpp
 1 #include <windows.h>
 2 #include <tlhelp32.h>
 3 
 4 void ListProcessHeaps(DWORD pid)
 5 {
 6     HEAPLIST32 hl;
 7 
 8     HANDLE hHeapSnap = CreateToolhelp32Snapshot(TH32CS_SNAPHEAPLIST, pid);
 9 
10     hl.dwSize = sizeof(HEAPLIST32);
11 
12     if (hHeapSnap == INVALID_HANDLE_VALUE)
13     {
14         printf("CreateToolhelp32Snapshot failed (%d)\n", GetLastError());
15         return;
16     }
17 
18     if (Heap32ListFirst(hHeapSnap, &hl))
19     {
20         do
21         {
22             printf("\nHeap ID: 0x%lx\n", hl.th32HeapID);
23             printf("\Flags: 0x%lx\n", hl.dwFlags);
24         } while (Heap32ListNext(hHeapSnap, &hl));
25     }
26     else
27         printf("Cannot list first heap (%d)\n", GetLastError());
28 
29     CloseHandle(hHeapSnap);
30 }
31 
32 int main()
33 {
34     DWORD pid = 6712;
35 
36     ListProcessHeaps(pid);
37 
38     return 0;
39 }

Это приложение выводит в консоль базовый адрес и флаги каждого сегмента динамической памяти целевого процесса. ID каждого сегмента соответствует его базовому адресу. Флаги важны, поскольку позволяют отличать сегменты друг от друга. Например, сегмент динамической памяти по умолчанию всегда имеет ненулевые флаги.

Функция ListProcessHeaps очень похожа по принципу работы на ListProcessThreads из листинга 3-9. Её алгоритм выглядит следующим образом:

  1. Сделать снимок состояния системы с ресурсами только целевого процесса через вызов CreateToolhelp32Snapshot.
  2. Начать проход по сегментам динамической памяти в снимке с помощью функции Heap32ListFirst.
  3. Вывести в консоль ID и флаги текущего сегмента.
  4. Повторить шаг 3 для всех сегментов в снимке, которые перебираются функцией Heap32ListNext.

Сегменты динамической памяти перебираются в порядке увеличения их ID. Сегмент с меньшим ID будет пройден раньше, чем сегмент с большим. Эта информация может помочь, когда вам понадобится отличить один сегмент от другого.

Выводы

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

Пример бота для Diablo 2

Обзор игры Diablo 2

Мы узнали достаточно, чтобы написать простого внутриигрового бота. Он будет автоматизировать некоторые действия в известной RPG Diablo 2. Её игровой процесс типичен для жанра: игрок должен выполнять квесты, убивать монстров и развивать своего персонажа.

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

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

Иллюстрация 3-15. Скриншот окна Diablo 2

Все параметры персонажа приведены на иллюстрации 3-16. На ней вы видите два открытых внутриигровых окна: левое и правое. В верхней части левого находится общая информация о персонаже: имя Kain, класс Paladin, уровень 70, очки опыта 285160782. Ниже указаны параметры персонажа, влияющие на игровую механику. Например, “Strength” (сила) определяет урон, наносимый противнику при ударе.

Иллюстрация 3-16. Параметры игрового персонажа

Правое окно на иллюстрации 3-16 отображает дерево способностей персонажа. Способности позволяют наносить больше урона противникам. Каждая из них имеет уровень, который определяет её эффективность. Более подробная информация о параметрах и способностях персонажа доступна на Wiki.

В Diablo 2 есть два режима игры: однопользовательский и многопользовательский. Мы будем рассматривать только однопользовательский. В нём вы сможете останавливать игру под отладчиком в любой момент на неограниченное время, чтобы исследовать адресное пространство её процесса. В многопользовательском режиме этому будут мешать тайм-ауты. Если игровой клиент не отвечает какое-то время, сервер его отключает.

Чтобы протестировать нашего бота, вы можете купить игру Diablo 2 на официальном сайте разработчика. Альтернативное решение – воспользоваться бесплатным клоном игры под названием Flaire. В этом случае вам придётся немного изменить код бота самостоятельно. Diablo 2 отличается от своего клона интерфейсом и сложностью. Память процесса оригинальной игры намного сложнее анализировать из-за большого количество вспомогательных библиотек.

Задачи бота

Прежде всего, чётко определим наши цели. Мы не собираемся взламывать игру, то есть нарушать её правила и вмешиваться в механику игрового процесса. Примеры подобных взломов вы можете найти в статьях Jan Miller:

  • extreme-gamerz.org/diablo2/viewdiablo2/hackingdiablo2
  • www.battleforums.com/threads/howtohackd2-edition-2.111214

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

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

  1. Прочитать текущий уровень здоровья игрового персонажа.
  2. Сравнить этот уровень с пороговым значением.
  3. Если здоровье меньше порога, использовать зелье лечения.

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

Исследование памяти процесса Diablo 2

Мы готовы приступить к исследованию памяти процесса Diablo 2. Наша задача – найти переменную, которая хранит значение текущего здоровья персонажа.

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

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

  1. Щёлкните правой кнопкой мыши по иконке “Diablo II” на рабочем столе. В открывшемся меню выберите пункт “Properties” (свойства).
  2. В диалоге “Properties” перейдите на вкладку “Shortcut” (ярлык).
  3. В поле “Target” (объект) добавить параметр “-w”. В результате полная команда запуска приложения будет выглядеть так:
1 "C:\DiabloII\Diablo II.exe" -w

Если вы запустите Diablo 2 через настроенную иконку на рабочем столе, приложение откроется в оконном режиме. Чтобы начать игру, нажмите кнопку “Single player” (одиночная игра) в главном меню и создайте нового персонажа.

Поиск параметров персонажа

Найдём уровень здоровья игрового персонажа в памяти процесса Diablo 2. Для этого воспользуемся сканером Cheat Engine. Он разработан именно для решения подобных задач.

Если вы попробуете найти уровень здоровья по его текущему значению без предварительной настройки Cheat Engine, поиск не даст результата. Вероятнее всего, после первого сканирования вы получите длинный список предполагаемых адресов. При повторном поиске (кнопка “Next Scan”) после изменения уровня здоровья персонажа, список результатов станет пустым.

Прямолинейный подход не заработал. Это совершенно нормально для больших и сложных приложений, как Diablo 2. В памяти процесса находится очень много игровых объектов, причём параметры некоторых из них совпадают. Мы не знаем, как именно они хранятся в памяти. Поэтому будет разумно сначала разобраться с этим вопросом. Если мы сможем найти нужный нам объект в памяти, получить доступ к его параметрам будет очень просто.

Ещё раз обратимся к окну с параметрами игрового персонажа. Значения некоторых из них наверняка уникальны и не встречаются у других игровых объектов. Какие именно? Возможны следующие варианты:

1. Имя персонажа
Очень маловероятно, что есть объект с тем же именем, которое игрок дал своему персонажу. Если это всё-таки произошло, всегда можно создать нового персонажа с другим уникальным именем.

2. Очки опыта
Это длинное положительное целочисленное число. Число такого размера может встретиться в другом объекте только случайно. Если Cheat Engine всё же нашёл несколько потенциальных адресов, очки опыта персонажа очень просто увеличить. Убейте одного-двух монстров и выполните повторное сканирование памяти кнопкой “Next Scan”.

3. Значение выносливости
Это ещё одно длинное число, которое определяет, как долго игрок способен быстро двигаться по карте. Его очень просто уменьшить: для этого достаточно перемещать персонажа вне города.

Из всех вариантов, предлагаю искать очки опыта персонажа. Если вы только начали игру, вам нужно убить нескольких монстров, чтобы этот параметр стал больше нуля. Иллюстрация 3-17 демонстрирует окно Cheat Engine с возможным результатом поиска. Сканер нашёл несколько переменных с одинаковым значением. Только некоторые из них относятся к объекту игрового персонажа. Другие могут быть связаны с интерфейсом игры и выводом информации на экран.

Иллюстрация 3-17. Результаты поиска параметра игрового персонажа

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

Запустите отладчик WinDbg, подключитесь к работающему процессу Diablo 2 и выполните команду !address. Сегменты с найденными параметрами выглядят следующим образом:

1 + 0`003c0000  0`003e0000  0`00020000  MEM_PRIVATE MEM_COMMIT PAGE_READWRITE <unknown>
2 + 0`03840000  0`03850000  0`00010000  MEM_PRIVATE MEM_COMMIT PAGE_READWRITE <unknown>
3 + 0`03850000  0`03860000  0`00010000  MEM_PRIVATE MEM_COMMIT PAGE_READWRITE <unknown>
4 + 0`04f50000  0`04fd0000  0`00080000  MEM_PRIVATE MEM_COMMIT PAGE_READWRITE <unknown>

Отладчик не смог определить тип этих сегментов и указал, что он неизвестен (“unknown”). Мы знаем, что WinDbg умеет корректно определять сегменты стека и динамической памяти. Если тип неизвестен, скорее всего, это не первое и не второе.

Сегменты неизвестного типа может выделять WinAPI-функция VirtualAllocEx. Чтобы это проверить, воспользуемся простым тестовым приложением. Файл VirtualAllocEx.cpp с его исходным кодом есть в архиве с примерами для этой книги. Если вы запустите приложение под отладчиком WinDbg и прочитаете его адресное пространство, вы увидите один сегмент с неизвестным типом. Функция VirtualAllocEx выделяет его и возвращает базовый адрес.

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

Попробуем другой подход. Очевидно, что параметры персонажа меняются, когда игрок совершает действия. Например, после любого перемещения персонажа по карте, его координата изменится. Мы можем следить за такими изменениями в области памяти около найденных нами адресов параметра очков опыта. У Cheat Engine есть возможность отображения области памяти в реальном времени. Чтобы ею воспользоваться, надо открыть окно Memory Viewer (просмотрщик памяти). Для этого выполните следующие шаги:

  1. Выберите один из адресов в списке результатов поиска.
  2. Щёлкните по нему правой кнопкой мыши.
  3. Выберите пункт “Browse this memory region” (просмотреть эту область памяти) в открывшемся меню.

Откроется окно Memory Viewer, как показано на иллюстрации 3-18. Оно разделено на две части. В верхней части выводится область памяти около выбранного адреса в виде дизассемблированного кода. Это значит, что Cheat Engine пытается представить данные в виде инструкций процессора. В нижней части окна отображаются данные той же самой области памяти в шестнадцатеричном формате. Обе части окна Memory Viewer выводят одни и те же данные, но представленные в разном виде.

Нас интересует нижняя половина окна. Данные, соответствующие очкам опыта персонажа, подчёркнуты красным на иллюстрации 3-18. В моём примере персонаж имеет 285161118 очков опыта.

Почему последовательность байт “9E 36 FF 10” равна числу 285161118? Мы запускаем Diablo 2 на процессоре с архитектурой x86, которая имеет порядок байт от младшего к старшему (little-endian byte order). Следовательно, значение из окна Memory Viewer нужно перевернуть, чтобы получить правильно число. Другими словами, последовательность байтов “9E 36 FF 10” надо интерпретировать как “10 FF 36 9E”. Вы можете воспользоваться стандартным приложением Windows Calculator, чтобы перевести число 10FF369E в десятичную систему и получить 285161118.

Иллюстрация 3-18. Окно Memory Viewer сканера Cheat Engine

Окно Memory Viewer позволяет настроить формат вывода данных. Для этого щёлкните правой кнопкой мыши в любом месте нижней половины окна и выберите пункт “Display Type” (тип отображения) в открывшемся меню. Дальше вы можете выбрать нужный вам тип. Однако, я рекомендую всегда пользоваться форматом “Byte hex”, как на иллюстрации 3-18. Другие форматы могут вызвать путаницу, потому что объединяют соседние байты в числа. Когда размер искомых чисел неизвестен, их фрагменты могут объединяться неправильно.

Теперь попробуем проследить изменения данных в областях памяти. Для удобства разместите окна Memory Viewer и Diablo 2 рядом, но без перекрытия, как изображено на иллюстрации 3-19. Это позволит вам одновременно управлять персонажем и следить за изменениями в памяти.

Иллюстрация 3-19. Исследование изменений в памяти процесса Diablo 2

В окне Memory Viewer, приведённом на иллюстрации 3-19, открыта область памяти около адреса 04FC04A4. Это один из адресов, который мы получили при поиске очков опыта персонажа. Вам нужно исследовать области около каждого из них.

Как мы поймём, что нашли объект игрового персонажа в памяти? Предлагаю простое правило: если объект хранит больше параметров персонажа чем другие, то его информация наиболее полная и боту следует использовать именно его. В моём случае этот объект имеет адрес 04FC04A4 и находится последним в списке результатов сканирования Cheat Engine.

Таблица 3-6 демонстрирует параметры, которые мы обнаружили в объекте.

Таблица 3-6. Найденные параметры игрового объекта
Параметр Адрес Смещение Размер Шестнадцатеричное значение Десятичное значение
Здоровье 04FC0490 490 2 40 01 320
Мана 04FC0492 492 2 9D 01 413
Выносливость 04FC0494 494 2 FE 1F 8190
Координата X 04FC0498 498 2 37 14 5175
Координата Y 04FC04A0 4A0 2 47 12 4679
Очки опыта 04FC04A4 4A4 4 9E 36 FF 10 285161118

Эти параметры подчёркнуты красным на иллюстрации 3-19. Чтобы их обнаружить, я выполнял следующие игровые действия:

  1. Оставаться на месте и получать урон от атакующего монстра. В этом случае уменьшается только параметр здоровья по адресу 04FC0490.
  2. Оставаться на месте и использовать любую способность. В этом случае уменьшается запас маны персонажа. Соответствующая переменная находится по адресу 04FC0492.
  3. Перемещаться бегом вне города. При этом действии меняются сразу три параметра: выносливость, координаты X и Y. Если персонаж бегает достаточно долго, его выносливость уменьшится до нуля. Тогда можно отличить в памяти её значение (по адресу 04FC0494) от координат. Если перемещать персонажа только в горизонтальном или вертикальном направлении будет меняться одна из координат (X по адресу 04FC0498 или Y по 04FC04A0).
  4. Убить любого монстра. В результате увеличатся очки опыта персонажа. Адрес соответствующей переменой равен 04FC04A4. Этот параметр легко отличить от уровней здоровья и маны, поскольку они наоборот обычно уменьшаются во время сражения с монстрами.

Что мы узнали нового о параметрах персонажа? Во-первых, уровень здоровья хранится в двухбайтовой переменной. Следовательно, чтобы найти его в памяти, надо указать “2 Byte” в поле “Value Type” (тип значения) окна Cheat Engine перед поиском.

Также мы выяснили, что у некоторых параметров нет четырехбайтового выравнивания. Это означает, что их адреса не кратны четырём. Например, уровень маны по адресу 04FC0492. Чтобы найти значения таких параметров, вам надо убрать галочку “Fast Scan” (быстрое сканирование) в окне Cheat Engine.

Правильная конфигурация Cheat Engine для поиска параметров игрового персонажа приведена на иллюстрации 3-20. Красным подчёркнуты изменённые настройки.

Иллюстрация 3-20. Конфигурация Cheat Engine

Возможно, вы обратили внимание на столбец “Смещение” в таблице 3-6. В нём указаны смещения каждого параметра относительно адреса начала объекта. Рассмотрим, как найти этот адрес в памяти процесса.

Поиск объекта в памяти

Задумаемся над тем, как наш бот будет искать параметр здоровья персонажа в памяти процесса Diablo 2. Эту задачу можно разделить на два этапа:

  1. Найти объект персонажа.
  2. Добавить к адресу объекта постоянное смещение, чтобы получить адрес параметра.

Можем ли мы быть уверены, что смещение параметра будет всегда постоянным? Если приложение написано на C++ или C (обычно именно эти языки применяют для разработки игр), параметры игрового объекта, скорее всего, будут храниться в структуре или классе (особый вид структуры). Структура – это тип, в котором все поля и их порядок жёстко определены. Поэтому при каждом запуске приложения смещение полей структуры от её начала остаётся неизменным.

Мы знаем, как искать игровой объект в памяти приложения с помощью Cheat Engine. К сожалению, наш бот не может пользоваться сканером памяти. Точнее такое решение было бы слишком громоздким. Вместо этого, он должен полагаться на собственные алгоритмы. Поэтому нам нужно найти способ поиска объекта на единственном снимке памяти, который доступен боту через WinAPI-функции.

Прокрутите окно Memory Viewer вверх от переменной с очками опыта в сторону младших адресов. Вы обнаружите имя персонажа, как на иллюстрации 3-21. Четыре байта, подчёркнутых красным, представляют собой строку “Kain”. Обратите внимание, что порядок байтов для строк не перевернут на процессорах с little-endian архитектурой. Причина в том, что внутренняя структура ASCII-строк и массивов с элементами в один байт совпадает. Процессор обрабатывает байтовые массивы поэлементно, то есть читает в свои регистры по одному байту и никаких перестановок не происходит.

Ещё раз посмотрите на иллюстрацию 3-21. Легко заметить, что область памяти в сторону младших адресов от имени персонажа занулена. Предположим, что это признак границы игрового объекта. Можем ли мы проверить эту гипотезу?

Воспользуемся OllyDbg, чтобы поставить точку останова (breakpoint) на адрес переменной с именем персонажа. Когда какой-то код процесса Diablo 2 попытается прочитать или записать значение по этому адресу, процесс остановится и отладчик получит управление. Мы сможем проанализировать этот код и, возможно, найдём признаки начала игрового объекта.

Иллюстрация 3-21. Имя персонажа в памяти процесса

Алгоритм поиска границ объекта с помощью отладчика OllyDbg выглядит следующим образом:

  1. Запустите отладчик с правами администратора и подключитесь к уже запущенному процессу Diablo 2.
  2. Щёлкните правой кнопкой мыши в левом нижнем окне OllyDbg и переключитесь на шестнадцатеричный формат дампа памяти.
  3. Нажмите комбинацию клавиш Ctrl+G, чтобы открыть диалог “Enter expression to follow” (ввести выражение для перехода) для поиска адреса в памяти.
  4. Введите адрес строки с именем персонажа в поле “Enter address expression” (ввести адрес выражения) диалога поиска. В моём случае это адрес 04FC000D. Нажмите кнопку “Follow expression” (перейти к выражению). Теперь курсор в окне с дампом памяти указывает на первый байт строки.
  5. Прокрутите окно дампа памяти вверх, чтобы найти первый ненулевой байт, с которого предположительно начинается объект персонажа. Выделите этот байт щелчком левой кнопки мыши.
  6. Нажмите комбинацию клавиш Shift+F3, чтобы открыть диалог “Set memory breakpoint” для установки точки останова. Выберите в диалоге галочки “Read access” (доступ на чтение) и “Write access” (доступ на запись), чтобы точка останова срабатывала на чтение и запись по выбранному адресу памяти. Нажмите кнопку “OK”.
  7. Нажмите F9, чтобы продолжить выполнение процесса Diablo 2. Он остановится несколько раз. Продолжайте его выполнение по нажатию F9, пока процесс не будет стабильно работать. В этом случае вы увидите состояние “Running” в правом нижнем углу окна отладчика.
  8. Переключитесь на окно Diablo 2. Сразу после этого сработает наша точка останова.
  9. Переключитесь на окно OllyDbg. Оно должно выглядеть так же, как на иллюстрации 3-22.

Дизассемблированный код процесса отображается в левом верхнем окне отладчика. Инструкция процессора с адресом 03668D9F, исполнение которой вызвало срабатывание нашей точки останова, выделена серой линией:

1 CMP DWORD PTR DS:[ESI+4], 4

Эта инструкция сравнивает константу 4 и число типа DWORD, хранящееся по адресу “ESI + 4”. Регистр ESI используется для указания на источник данных в инструкциях процессора. Регистр DS хранит базовый адрес сегмента с данными. Как правило, регистры ESI и DS используются совместно. В правом верхнем окне отладчика отображается текущее значение всех регистров процессора. ESI хранит адрес 04FC0000.

Иллюстрация 3-22. Точка останова на начале объекта игрового персонажа

Изучим дизассемблированный код после инструкции, на которой сработала точка останова. На иллюстрации 3-22 найдите следующий код, начинающийся по адресу 03668DE0:

1 MOV EDI,DWORD PTR DS:[ESI+1B8]
2 CMP DWORD PTR DS:[ESI+1BC],EDI
3 JNE SHORT 03668DFA
4 MOV DWORD PTR DS:[ESI+1BC],EBX

Эти инструкции выглядят как обращения к полям структуры в C++ или C. Константы 1B8 и 1BC – это смещения полей от её начала. Если вы прокрутите дизассемблированный код ниже, вы найдёте ещё несколько подобных обращений. Следовательно, адрес начала структуры, в которой хранятся параметры игрового персонажа, равен 04FC0000, то есть текущему значению регистра ESI.

Теперь мы можем вычислить смещение параметра здоровья от начала структуры:

1 04FC0490 - 04FC0000 = 0x490

Смещение равно 490 в шестнадцатеричной системе счисления.

Следующий вопрос: как бот найдёт адрес начала объекта игрового персонажа в памяти? Мы знаем, что этот объект хранится в сегменте неизвестного (unknown) типа, размер которого 80000 байт в шестнадцатеричной системе. У сегмента есть три флага: MEM_PRIVATE, MEM_COMMIT и PAGE_READWRITE. В адресном пространстве процесса Diablo 2 есть минимум десять сегментов этого же типа, размера и с теми же флагами. Следовательно, мы не можем просто перебрать все сегменты и найти нужный по этим признакам.

Ещё раз рассмотрим первые несколько байт объекта персонажа:

1 00 00 00 00 04 00 00 00 03 00 28 0F 00 4B 61 69 6E 00 00 00

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

Список неизменяемых параметров персонажа следующий:

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

Проверим предположение о неизменных параметрах с помощью Cheat Engine. Запустите сканер и подключитесь к процессу Diablo 2. Выберите пункт “Array of byte” (массив байт) в поле “Value Type”. Затем выберите галочку “Hex” и скопируйте свою последовательность байт в поле “Array of byte”. Ожидаемый результат поиска представлен на иллюстрации 3-23.

Иллюстрация 3-23. Поиск объекта игрового персонажа в памяти процесса Diablo 2

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

Есть альтернативный способ найти уровень здоровья персонажа с помощью Cheat Engine. Он может быть полезен при первоначальном анализе памяти игрового приложения. Cheat Engine предоставляет функцию сканирования указателей (pointer scanning). С её помощью можно найти базовый адрес и смещение переменной после нескольких этапов сканирования памяти процесса. К сожалению, в некоторых случаях эта функция не работает. Подробнее о ней можно узнать в статье.

Реализация бота

Мы собрали всю необходимую информацию, чтобы реализовать нашего внутриигрового бота. Составим подробный алгоритм его работы:

  1. Предоставить привилегию SE_DEBUG_NAME процессу бота.
  2. Подключиться к процессу Diablo 2 для доступа к его памяти.
  3. Искать объект игрового персонажа в адресном пространстве игры.
  4. Вычислить абсолютный адрес параметра здоровья персонажа.
  5. Читать значение параметра в бесконечном цикле. Как только оно опустится ниже 100 пунктов, использовать зелье лечения.

Мы уже рассмотрели реализацию первого шага алгоритма в предыдущем разделе этой главы.

Второй шаг алгоритма можно реализовать двумя способами:

  1. Указать PID целевого процесса в коде бота, как мы делали в предыдущих примерах.
  2. Определять PID динамически по активному в данный момент окну.

Во втором случае важно следить, чтобы в момент запуска бота было активно именно окно Diablo 2. Благодаря этому подходу им будет намного удобнее пользоваться, поскольку его не придётся перекомпилировать с корректным PID целевого процесса перед каждым запуском.

Листинг 3-11 демонстрирует чтение PID и подключение к процессу Diablo 2.

Листинг 3-11. Код подключения к процессу
 1 int main()
 2 {
 3     Sleep(4000);
 4 
 5     HWND wnd = GetForegroundWindow();
 6     DWORD pid = 0;
 7     if (!GetWindowThreadProcessId(wnd, &pid))
 8     {
 9         printf("Error of the pid detection\n");
10         return 1;
11     }
12 
13     HANDLE hTargetProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
14     if (!hTargetProc)
15     {
16         printf("Failed to open process: %u\n", GetLastError());
17     }
18     return 0;
19 }

Перед началом работы мы ждём четыре секунды с помощью WinAPI-функции Sleep. Этого времени должно быть достаточно, чтобы вы успели переключиться на окно Diablo 2.

Для чтения PID процесса мы использовали две новые WinAPI-функции:

  1. GetForegroundWindow возвращает дескриптор активного в данный момент окна.
  2. GetWindowThreadProcessId возвращает PID процесса, который владеет окном, указанным по его дескриптору.

Прочитанный PID активного окна сохраняется в переменную pid.

Третий шаг алгоритма заключается в поиске объекта игрового персонажа в памяти процесса. Для этого предлагаю воспользоваться подходом, описанном в серии видеоуроков. В них рассматривается разработка простого сканера памяти, алгоритм работы которого очень похож на Cheat Engine. Идея заключается в переборе всех сегментов процесса Diablo 2 с помощью WinAPI-функции VirtualQueryEx.

Код для поиска объекта персонажа в памяти процесса приведён в листинге 3-12.

Листинг 3-12. Код поиска игрового объекта в памяти процесса
 1 SIZE_T IsArrayMatch(HANDLE proc, SIZE_T address, SIZE_T segmentSize,
 2                     BYTE array[], SIZE_T arraySize)
 3 {
 4     BYTE* procArray = new BYTE[segmentSize];
 5 
 6     if (ReadProcessMemory(proc, (void*)address, procArray, segmentSize, NULL) != 0)
 7     {
 8         printf("Failed to read memory: %u\n", GetLastError());
 9         delete[] procArray;
10         return 0;
11     }
12 
13     for (SIZE_T i = 0; i < segmentSize; ++i)
14     {
15         if ((array[0] == procArray[i]) && ((i + arraySize) < segmentSize))
16         {
17             if (!memcmp(array, procArray + i, arraySize))
18             {
19                 delete[] procArray;
20                 return address + i;
21             }
22         }
23     }
24 
25     delete[] procArray;
26     return 0;
27 }
28 
29 SIZE_T ScanSegments(HANDLE proc, BYTE array[], SIZE_T size)
30 {
31     MEMORY_BASIC_INFORMATION meminfo;
32     LPCVOID addr = 0;
33     SIZE_T result = 0;
34 
35     if (!proc)
36         return 0;
37 
38     while (1)
39     {
40         if (VirtualQueryEx(proc, addr, &meminfo, sizeof(meminfo)) == 0)
41             break;
42 
43         if ((meminfo.State & MEM_COMMIT) && (meminfo.Type & MEM_PRIVATE)
44             && (meminfo.Protect & PAGE_READWRITE)
45             && !(meminfo.Protect & PAGE_GUARD))
46         {
47             result = IsArrayMatch(proc, (SIZE_T)meminfo.BaseAddress,
48                                   meminfo.RegionSize, array, size);
49 
50             if (result != 0)
51                 return result;
52         }
53         addr = (unsigned char*)meminfo.BaseAddress + meminfo.RegionSize;
54     }
55     return 0;
56 }
57 
58 int main()
59 {
60     // Предоставить SE_DEBUG_NAME привилегию текущему процессу
61 
62     // Подключиться к процессу Diablo 2
63 
64     BYTE array[] = { 0, 0, 0, 0, 0x04, 0, 0, 0, 0x03, 0, 0x28,
65                      0x0F, 0, 0x4B, 0x61, 0x69, 0x6E, 0, 0, 0 };
66 
67     SIZE_T objectAddress = ScanSegments(hTargetProc, array, sizeof(array));
68 
69     return 0;
70 }

Алгоритм прохода по сегментам памяти целевого процесса реализован в функции ScanSegments. Она возвращает указатель на объект персонажа и принимает на вход три параметра:

  1. Дескриптор процесса Diablo 2.
  2. Указатель на искомую последовательность байт.
  3. Размер последовательности.

Алгоритм ScanSegments состоит из следующих шагов:

  1. Прочитать сегмент памяти с базовым адресом равным переменной addr с помощью функции VirtualQueryEx.
  2. Проверить совпадают ли флаги прочитанного сегмента с флагами искомого. Если нет, перейти к следующему сегменту.
  3. Искать последовательность байт, характерную для объекта персонажа в прочитанном сегменте.
  4. Если последовательность найдена, вернуть её абсолютный адрес. Иначе читать следующий сегмент.

Алгоритм поиска последовательности байт в сегменте реализован в функции IsArrayMatch. Он выглядит следующим образом:

  1. Прочитать все данные из указанного сегмента с помощью WinAPI-функции ReadProcessMemory.
  2. Искать в этих данных последовательность путём побайтного сравнения.

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

1 SIZE_T hpAddress = objectAddress + 0x490;

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

Последним действием бот проверяет уровень здоровья персонажа. Если он оказался ниже порогового значения, бот должен использовать зелье лечения. Реализация этой проверки приведена в листинге 3-13.

Листинг 3-13. Код проверки уровня здоровья персонажа
 1 WORD ReadWord(HANDLE hProc, DWORD_PTR address)
 2 {
 3     WORD result = 0;
 4 
 5     if (ReadProcessMemory(hProc, (void*)address, &result, sizeof(result), NULL) == 0)
 6         printf("Failed to read memory: %u\n", GetLastError());
 7 
 8     return result;
 9 }
10 
11 int main()
12 {
13     // Предоставить SE_DEBUG_NAME привилегию текущему процессу
14 
15     // Подключиться к процессу Diablo 2
16 
17     // Искать объект игрового персонажа в памяти процесса Diablo 2
18 
19     // Вычислить абсолютный адрес переменной с уровнем здоровья персонажа
20 
21     ULONG hp = 0;
22 
23     while (1)
24     {
25         hp = ReadWord(hTargetProc, hpAddress);
26         printf("HP = %lu\n", hp);
27 
28         if (hp < 100)
29             PostMessage(wnd, WM_KEYDOWN, 0x31, 0x1);
30 
31         Sleep(2000);
32     }
33     return 0;
34 }

Здоровье персонажа читается в бесконечном while цикле с помощью функции ReadWord, которая представляет собой обёртку для WinAPI вызова ReadProcessMemory. Прочитав значение здоровья, бот выводит его на консоль. Это позволит вам проверить, что параметр найден правильно. Сравните его значение с тем, что выводится в окне Diablo 2. Если уровень здоровья окажется меньше 100, бот симулирует нажатие горячей клавиши “1”. По нему игровой персонаж использует зелье лечения. Для симуляции нажатия клавиши вызывается WinAPI-функция PostMessage.

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

Параметры функции PostMessage описаны в таблице 3-7.

Таблица 3-7. Параметры функции PostMessage
Параметр Описание
wnd Дескриптор окна. Создавший это окно процесс получит сообщение.
WM_KEYDOWN Код сообщения.
0x31 Виртуальный код нажатой клавиши.
0x1 Параметры нажатия. Самый важный из них – число срабатываний нажатия (хранится в битах с 0 по 15).

Полная реализация бота доступна в файле AutohpBot.cpp из архива примеров к этой книге.

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

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

1 BYTE array[] = { 0, 0, 0, 0, 0x04, 0, 0, 0, 0x03, 0, 0x28, 0x0F, 0, 0x4B, 0x61, 0x69\
2 , 0x6E, 0, 0, 0 };
  1. Скомпилируйте бота с новой последовательностью байт.
  2. Запустите Diablo 2 в оконном режиме.
  3. Запустите бота с правами администратора.
  4. В течение четырёх секунд после старта бота переключитесь на окно Diablo 2. После этой задержки, бот подключится к процессу игры и начнёт следить за уровнем здоровья персонажа.
  5. Найдите в игре монстра и получите от него урон так, чтобы здоровье персонажа опустилось ниже 100 пунктов.

В результате бот симулирует нажатие горячей клавиши “1”.

Не забудьте привязать к панели горячих клавиш зелье лечения. Для вызова справки по интерфейсу игры, нажмите клавишу H. Панель “Belt” (пояс) горячих клавиш находится в правой нижней части экрана. Вы можете перенести на неё зелья лечения левой кнопкой мыши.

Дальнейшие улучшения

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

Главная проблема бота в том, что он нажимает только одну горячую клавишу из четырёх доступных. Из-за этого персонаж не будет использовать все зелья лечения, которые у него есть. Чтобы исправить это, перепишем цикл проверки параметра здоровья, как предлагается в листинге 3-14.

Листинг 3-14. Использование всех слотов панели горячих клавиш
 1     ULONG hp = 0;
 2     BYTE keys[] = { 0x31, 0x32, 0x33, 0x34 };
 3     BYTE keyIndex = 0;
 4 
 5     while (1)
 6     {
 7         hp = ReadWord(hTargetProc, hpAddress);
 8         printf("HP = %lu\n", hp);
 9 
10         if (hp < 100)
11         {
12             PostMessage(wnd, WM_KEYDOWN, keys[keyIndex], 0x1);
13             ++keyIndex;
14             if (keyIndex == sizeof(keys))
15                 keyIndex = 0;
16         }
17         Sleep(2000);
18     }

Теперь мы храним список горячих клавиш в байтовом массиве keys. Для его индексации используется переменная keyIndex. Она инкрементируется после каждого применения зелья лечения. При достижении конца массива, keyIndex сбрасывается в ноль. Таким образом бот будет использовать все слоты панели горячих клавиш. Когда зелья лечения в первом ряду панели закончатся, бот перейдёт ко второму ряду и т.д.

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

Сейчас бот симулирует нажатие клавиши с помощью функции PostMessage. Вместо этого он может писать новое значение здоровья персонажа прямо в память процесса Diablo 2. Листинг 3-15 демонстрирует соответствующий код.

Листинг 3-15. Запись нового значения параметра персонажа в память процесса
 1 void WriteWord(HANDLE hProc, DWORD_PTR address, WORD value)
 2 {
 3     if (WriteProcessMemory(hProc, (void*)address, &value, sizeof(value), NULL) == 0)
 4         printf("Failed to write memory: %u\n", GetLastError());
 5 }
 6 
 7 int main()
 8 {
 9     // Предоставить SE_DEBUG_NAME привилегию текущему процессу
10 
11     // Подключиться к процессу Diablo 2
12 
13     // Искать объект игрового персонажа в памяти процесса Diablo 2
14 
15     // Вычислить абсолютный адрес переменной с уровнем здоровья персонажа
16 
17     ULONG hp = 0;
18 
19     while (1)
20     {
21         hp = ReadWord(hTargetProc, hpAddress);
22         printf("HP = %lu\n", hp);
23 
24         if (hp < 100)
25             WriteWord(hTargetProc, hpAddress, 100);
26 
27         Sleep(2000);
28     }
29     return 0;
30 }

Запись нового значения параметра персонажа происходит через WinAPI-функцию WriteProcessMemory. Для удобства работы с ней используется обёртка WriteWord. Теперь если уровень здоровья персонажа становится меньше 100, бот переписывает его значением 100 в памяти процесса. У этого подхода есть один серьёзный недостаток – он нарушает игровую механику. Параметр объекта меняется в обход алгоритмов игры. По этой причине состояние объекта может стать неконсистентным.

Попробуйте протестировать версию бота из листинга 3-15. В большинстве случаев уровень здоровья персонажа не будет меняться после записи нового значения. Причина в том, что приложение хранит этот параметр в нескольких местах (не только в объекте персонажа, который мы нашли). После записи ботом нового значения, у приложения есть несколько несовпадающих переменных для одного и того же параметра. Очевидно, что механика игры не может корректно обработать эту ситуацию и происходят ошибки. Запись данных в память процесса работает только в простых играх без многочисленных копий параметров объектов.

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

  • www.codeproject.com/Articles/4610/Three-Ways-to-Inject-Your-­Code-into-Another-Proces
  • www.codeproject.com/Articles/9229/RemoteLib-DLL-Injection-for-Win-x-NT-Platforms

Идея заключается в том, чтобы заставить игровое приложение исполнять код бота в своём адресном пространстве. Если это удастся, бот сможет вызывать любую функцию игры или её библиотек. В этом случае не нужно симулировать нажатие клавиши. Можно просто напрямую вызвать функцию самой игры типа “UseHealPotion” (использовать зелье лечения). Однако, внедрение кода требует глубокого анализа и реверс-инжиниринга целевого приложения.

Алгоритм нашего бота очень простой. Он автоматизирует использование зелий лечения, и игрок может на них не отвлекаться. Можно ли написать более сложного бота, который бы самостоятельно убивал монстров? Эта задача выполнима. Самым трудным шагом для бота будет поиск объектов монстров в памяти игрового процесса. Рассмотрим возможное решение.

Мы знаем, как и где хранятся координаты X и Y игрового персонажа (см. таблицу 3-6). Это два двухбайтовых числа, следующие друг за другом в памяти. Скорее всего, координаты других игровых объектов хранятся в таком же формате.

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

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

Выводы

Мы реализовали простого внутриигрового бота для Diablo 2. Он использует характерные для своего типа техники взаимодействия с игрой. Рассмотрим его достоинства и недостатки. В принципе, мы можем обобщить их на любого внутриигрового бота.

Преимущества:

  1. Бот получает точную информацию о состоянии игровых объектов. Ошибки и неточности как в кликерах крайне маловероятны.
  2. Есть несколько способов встраивать действия бота в процесс игрового приложения: симулировать действия игрока, писать значения в память процесса, вызывать внутренние функции игры. Можно выбрать наиболее подходящий вариант.
  3. Бот способен очень быстро реагировать на события в игре. Зачастую скорость его реакции выше, чем у игрока.

Недостатки:

  1. Анализ памяти игрового процесса и его дизассемблированного кода требует значительных усилий и времени.
  2. В большинстве случаев бот совместим только с одной версией игры, для которой он разрабатывался. Для новых версий его необходимо адаптировать.
  3. Существует много эффективных средств защиты как от реверс-инжиниринга и отладки, так и от несанкционированного доступа к памяти процесса.

Основной недостаток внутриигровых ботов – это сложность их разработки и сопровождения. Но с другой стороны они очень надёжны в работе.

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

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

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

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

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

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

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

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

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

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

  1. При старте присвоить параметру объекта (например его уровень здоровья) максимально допустимое значение.
  2. В цикле проверять состояние горячей клавиши “1”.
  3. Если пользователь не нажимает клавишу, уменьшать параметр объекта. Иначе – увеличивать.
  4. Если параметр оказался равен 0, завершить приложение.

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

Листинг 3-16. Исходный код тестового приложения
 1 #include <stdio.h>
 2 #include <stdint.h>
 3 #include <windows.h>
 4 
 5 static const uint16_t MAX_LIFE = 20;
 6 static uint16_t gLife = MAX_LIFE;
 7 
 8 int main()
 9 {
10     SHORT result = 0;
11 
12     while (gLife > 0)
13     {
14         result = GetAsyncKeyState(0x31);
15         if (result != 0xFFFF8001)
16             --gLife;
17         else
18             ++gLife;
19 
20         printf("life = %u\n", gLife);
21         Sleep(1000);
22     }
23     printf("stop\n");
24     return 0;
25 }

Уровень здоровья игрового объекта хранится в глобальной переменной 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. Щёлкните правой кнопкой мыши по следующей строке дизассемблированного кода:
1 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. Исходный код бота для тестового приложения
 1 #include <stdio.h>
 2 #include <windows.h>
 3 
 4 BOOL SetPrivilege(HANDLE hToken, LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
 5 {
 6     // См. реализацию этой функции в листинге 3-1
 7 }
 8 
 9 SIZE_T ScanSegments(HANDLE proc)
10 {
11     MEMORY_BASIC_INFORMATION meminfo;
12     LPCVOID addr = 0;
13 
14     if (!proc)
15         return 0;
16 
17     while (1)
18     {
19         if (VirtualQueryEx(proc, addr, &meminfo, sizeof(meminfo)) == 0)
20             break;
21 
22         if ((meminfo.State == MEM_COMMIT) && (meminfo.Type & MEM_IMAGE)
23             && (meminfo.Protect == PAGE_READWRITE)
24             && (meminfo.RegionSize == 0x1000))
25         {
26             return (SIZE_T)meminfo.BaseAddress;
27         }
28         addr = (unsigned char*)meminfo.BaseAddress + meminfo.RegionSize;
29     }
30     return 0;
31 }
32 
33 WORD ReadWord(HANDLE hProc, DWORD_PTR address)
34 {
35     // См. реализацию этой функции в листинге 3-13
36 }
37 
38 void WriteWord(HANDLE hProc, DWORD_PTR address, WORD value)
39 {
40     if (WriteProcessMemory(hProc, (void*)address, &value, sizeof(value), NULL) == 0)
41         printf("Failed to write memory: %u\n", GetLastError());
42 }
43 
44 int main()
45 {
46     // Предоставить SE_DEBUG_NAME привилегию текущему процессу
47 
48     // Подключиться к процессу тестового приложения
49 
50     SIZE_T lifeAddress = ScanSegments(hTargetProc);
51 
52     ULONG hp = 0;
53     while (1)
54     {
55         hp = ReadWord(hTargetProc, lifeAddress);
56         printf("life = %lu\n", hp);
57 
58         if (hp < 10)
59             WriteWord(hTargetProc, lifeAddress, 20);
60 
61         Sleep(1000);
62     }
63     return 0;
64 }

Главное различие ботов для тестового приложения и для 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 можно использовать следующим образом:

 1 int main()
 2 {
 3     if (IsDebuggerPresent())
 4     {
 5         printf("debugger detected!\n");
 6         exit(EXIT_FAILURE);
 7     }
 8 
 9     // Остальной код соответствует функции main из листинга 3-16
10 }

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

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

Листинг 3-18. Защита тестового приложения вызовом IsDebuggerPresent
 1 #include <stdio.h>
 2 
 3 int main()
 4 {
 5     SHORT result = 0;
 6 
 7     while (gLife > 0)
 8     {
 9         if (IsDebuggerPresent())
10         {
11             printf("debugger detected!\n");
12             exit(EXIT_FAILURE);
13         }
14         result = GetAsyncKeyState(0x31);
15         if (result != 0xFFFF8001)
16             --gLife;
17         else
18             ++gLife;
19 
20         printf("life = %u\n", gLife);
21         Sleep(1000);
22     }
23     printf("stop\n");
24     return 0;
25 }

Правильно вызывать 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:

1 if (IsDebuggerPresent())
2 {
3     printf("debugger detected!\n");
4     exit(EXIT_FAILURE);
5 }

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

1 if ( ! IsDebuggerPresent())
2 {
3     printf("debugger detected!\n");
4     exit(EXIT_FAILURE);
5 }

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

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

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

  1. Скачайте архив с плагином с сайта разработчика.
  2. Распакуйте архив в папку установки OllyDbg. По умолчанию это:
1 C:\Program Files (x86)\odbg200
  1. Проверьте путь до папки с плагинами в настройке OllyDbg. Для этого выберите пункт “Options” -> “Options…” главного меню. Откроется диалог “Options” (настройки). В левой его части выберите пункт “Directories” (каталоги). Поле “Plug-in directory” (каталог плагинов) должно соответствовать пути установки OllyDbg (например C:\Program Files (x86)\odbg200).
  2. Перезапустите отладчик.

После этого в главном меню появится новый пункт “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:

 1 BOOL IsDebug()
 2 {
 3     __try
 4     {
 5         CloseHandle((HANDLE)0x12345);
 6     }
 7     __except (GetExceptionCode() == EXCEPTION_INVALID_HANDLE ?
 8               EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
 9     {
10         return TRUE;
11     }
12     return FALSE;
13 }

Для обработки исключения 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 можно сделать очень похожую проверку на наличие отладчика:

 1 BOOL IsDebug()
 2 {
 3     __try
 4     {
 5         DebugBreak();
 6     }
 7     __except (GetExceptionCode() == EXCEPTION_BREAKPOINT ?
 8               EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
 9     {
10         return FALSE;
11     }
12     return TRUE;
13 }

В отличие от 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. Защита тестового приложения методом самоотладки
 1 #include <stdio.h>
 2 #include <stdint.h>
 3 #include <windows.h>
 4 #include <string>
 5 
 6 using namespace std;
 7 
 8 static const uint16_t MAX_LIFE = 20;
 9 static uint16_t gLife = MAX_LIFE;
10 
11 void DebugSelf()
12 {
13     wstring cmdChild(GetCommandLine());
14     cmdChild.append(L" x");
15 
16     PROCESS_INFORMATION pi;
17     STARTUPINFO si;
18     ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));
19     ZeroMemory(&si, sizeof(STARTUPINFO));
20     GetStartupInfo(&si);
21 
22     CreateProcess(NULL, (LPWSTR)cmdChild.c_str(), NULL, NULL, FALSE,
23             DEBUG_PROCESS | CREATE_NEW_CONSOLE, NULL, NULL, &si, &pi);
24 
25     DEBUG_EVENT de;
26     ZeroMemory(&de, sizeof(DEBUG_EVENT));
27 
28     for (;;)
29     {
30         if (!WaitForDebugEvent(&de, INFINITE))
31             return;
32 
33         ContinueDebugEvent(de.dwProcessId,
34                 de.dwThreadId,
35                 DBG_CONTINUE);
36     }
37 }
38 
39 int main(int argc, char* argv[])
40 {
41     if (argc == 1)
42     {
43         DebugSelf();
44     }
45     SHORT result = 0;
46 
47     while (gLife > 0)
48     {
49         result = GetAsyncKeyState(0x31);
50         if (result != 0xFFFF8001)
51             --gLife;
52         else
53             ++gLife;
54 
55         printf("life = %u\n", gLife);
56         Sleep(1000);
57     }
58 
59     printf("stop\n");
60     return 0;
61 }

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

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

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

1     if (argc == 1)
2     {
3         DebugSelf();
4     }

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

Алгоритм функции DebugSelf следующий:

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

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

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

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

1 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 сегменту
 1 #include <stdio.h>
 2 
 3 int main()
 4 {
 5     SHORT result = 0;
 6 
 7     while (gLife > 0)
 8     {
 9         int res = 0;
10         __asm
11         {
12             mov eax, dword ptr fs:[18h]
13             mov eax, dword ptr ds:[eax+30h]
14             movzx eax, byte ptr ds:[eax+2h]
15             mov res, eax
16         };
17         if (res)
18         {
19             printf("debugger detected!\n");
20             exit(EXIT_FAILURE);
21         }
22         result = GetAsyncKeyState(0x31);
23         if (result != 0xFFFF8001)
24             --gLife;
25         else
26             ++gLife;
27 
28         printf("life = %u\n", gLife);
29         Sleep(1000);
30     }
31     printf("stop\n");
32     return 0;
33 }

Сравните наш код и инструкции процессора на иллюстрации 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 сегменту
 1 #include <stdio.h>
 2 
 3 #define CheckDebug() \
 4 int isDebugger = 0; \
 5 { \
 6 __asm mov eax, dword ptr fs : [18h] \
 7 __asm mov eax, dword ptr ds : [eax + 30h] \
 8 __asm movzx eax, byte ptr ds : [eax + 2h] \
 9 __asm mov isDebugger, eax \
10 } \
11 if (isDebugger) \
12 { \
13 printf("debugger detected!\n"); \
14 exit(EXIT_FAILURE); \
15 }
16 
17 int main()
18 {
19     SHORT result = 0;
20 
21     while (gLife > 0)
22     {
23         CheckDebug();
24 
25         result = GetAsyncKeyState(0x31);
26         if (result != 0xFFFF8001)
27             --gLife;
28         else
29             ++gLife;
30     }
31 
32     printf("stop\n");
33 
34     return 0;
35 }

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

 1 int main()
 2 {
 3     SHORT result = 0;
 4 
 5     while (gLife > 0)
 6     {
 7         int res = 0;
 8         __asm
 9         {
10             mov eax, dword ptr fs:[18h]
11             mov eax, dword ptr ds:[eax + 30h]
12             movzx eax, byte ptr ds:[eax + 2h]
13             mov res, eax
14         };
15         if (res)
16         {
17             printf("debugger detected!\n");
18             exit(EXIT_FAILURE);
19         }
20         ...

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

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

 1 #include <winternl.h>
 2 
 3 #define CheckDebug() \
 4 { \
 5 PTEB pTeb = reinterpret_cast<PTEB>(__readgsqword(0x30)); \
 6 PPEB pPeb = pTeb->ProcessEnvironmentBlock; \
 7 if (pPeb->BeingDebugged) \
 8 { \
 9 printf("debugger detected!\n"); \
10 exit(EXIT_FAILURE); \
11 } \
12 }

Не забудьте включить заголовочный файл 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.
Редактирование флага BeingDebugged

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

INT 3

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

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

 1 BOOL IsDebug()
 2 {
 3     __try
 4     {
 5         __asm int 3;
 6     }
 7     __except (GetExceptionCode() == EXCEPTION_BREAKPOINT ?
 8             EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
 9     {
10         return FALSE;
11     }
12     return TRUE;
13 }

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

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

 1 #define CheckDebug() \
 2 bool isDebugger = true; \
 3 __try \
 4 { \
 5     __asm int 3 \
 6 } \
 7 __except (GetExceptionCode() == EXCEPTION_BREAKPOINT ? \
 8           EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) \
 9 { \
10     isDebugger = false; \
11 } \
12 if (isDebugger) \
13 { \
14     printf("debugger detected!\n"); \
15     exit(EXIT_FAILURE); \
16 }

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

 1 #define CheckDebug() \
 2 bool isDebugger = true; \
 3 __try \
 4 { \
 5     __debugbreak(); \
 6 } \
 7 __except (GetExceptionCode() == EXCEPTION_BREAKPOINT ? \
 8           EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) \
 9 { \
10     isDebugger = false; \
11 } \
12 if (isDebugger) \
13 { \
14     printf("debugger detected!\n"); \
15     exit(EXIT_FAILURE); \
16 }

Вы можете найти файл с исходным кодом 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
 1 #include <stdio.h>
 2 #include <stdint.h>
 3 #include <windows.h>
 4 
 5 static const DWORD MAX_DELTA = 1020;
 6 
 7 static const uint16_t MAX_LIFE = 20;
 8 static uint16_t gLife = MAX_LIFE;
 9 
10 int main()
11 {
12     SHORT result = 0;
13 
14     DWORD prevCounter = GetTickCount();
15 
16     while (gLife > 0)
17     {
18         if (MAX_DELTA < (GetTickCount() - prevCounter))
19         {
20             printf("debugger detected!\n");
21             exit(EXIT_FAILURE);
22         }
23         prevCounter = GetTickCount();
24 
25         result = GetAsyncKeyState(0x31);
26         if (result != 0xFFFF8001)
27             --gLife;
28         else
29             ++gLife;
30 
31         printf("life = %u\n", gLife);
32         Sleep(1000);
33     }
34 
35     printf("stop\n");
36 
37     return 0;
38 }

В этом примере мы измеряем время между итерациями цикла 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 счётчика
 1 #include <stdio.h>
 2 #include <stdint.h>
 3 #include <windows.h>
 4 
 5 static const DWORD64 MAX_DELTA = 2650000000;
 6 
 7 static const uint16_t MAX_LIFE = 20;
 8 static uint16_t gLife = MAX_LIFE;
 9 
10 #define ReadRdtsc(result) \
11 { \
12 __asm cpuid \
13 __asm rdtsc \
14 __asm mov dword ptr[result + 0], eax \
15 __asm mov dword ptr[result + 4], edx \
16 }
17 
18 int main()
19 {
20     SHORT result = 0;
21 
22     DWORD64 prevCounter = 0;
23     ReadRdtsc(prevCounter);
24 
25     while (gLife > 0)
26     {
27         DWORD64 counter = 0;
28         ReadRdtsc(counter);
29 
30         if (MAX_DELTA < (counter - prevCounter))
31         {
32             printf("debugger detected!\n");
33             exit(EXIT_FAILURE);
34         }
35         ReadRdtsc(prevCounter);
36 
37         result = GetAsyncKeyState(0x31);
38         if (result != 0xFFFF8001)
39             --gLife;
40         else
41             ++gLife;
42 
43         printf("life = %u\n", gLife);
44         Sleep(1000);
45     }
46 
47     printf("stop\n");
48 
49     return 0;
50 }

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

TSC64.cpp
 1 int main()
 2 {
 3     SHORT result = 0;
 4 
 5     DWORD64 prevCounter = __rdtsc();
 6 
 7     while (gLife > 0)
 8     {
 9         DWORD64 counter = __rdtsc();
10 
11         if (MAX_DELTA < (counter - prevCounter))
12         {
13             printf("debugger detected!\n");
14             exit(EXIT_FAILURE);
15         }
16         prevCounter = __rdtsc();
17 
18         result = GetAsyncKeyState(0x31);
19         if (result != 0xFFFF8001)
20             --gLife;
21         else
22             ++gLife;
23 
24         printf("life = %u\n", gLife);
25         Sleep(1000);
26     }
27 
28     printf("stop\n");
29 
30     return 0;
31 }

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

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

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

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

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

  • http://www.cplusplus.com/forum/windows/96406
  • http://stackoverflow.com/questions/6185975/prevent-user-process-from-being-killed-with-end-process-from-process-explorer/10575889#10575889

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

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

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

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

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

XOR шифр

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

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

Листинг 3-24. Защита данных приложения шифром XOR
 1 #include <stdio.h>
 2 #include <stdint.h>
 3 #include <windows.h>
 4 
 5 using namespace std;
 6 
 7 inline uint16_t maskValue(uint16_t value)
 8 {
 9     static const uint16_t MASK = 0xAAAA;
10     return (value ^ MASK);
11 }
12 
13 static const uint16_t MAX_LIFE = 20;
14 static uint16_t gLife = maskValue(MAX_LIFE);
15 
16 int main(int argc, char* argv[])
17 {
18     SHORT result = 0;
19 
20     while (maskValue(gLife) > 0)
21     {
22         result = GetAsyncKeyState(0x31);
23         if (result != 0xFFFF8001)
24             gLife = maskValue(maskValue(gLife) - 1);
25         else
26             gLife = maskValue(maskValue(gLife) + 1);
27 
28         printf("life = %u\n", maskValue(gLife));
29         Sleep(1000);
30     }
31 
32     printf("stop\n");
33 
34     return 0;
35 }

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

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

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

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

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

Шифр AES

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

Листинг 3-25. Защита данных приложения шифром AES
  1 #include <stdint.h>
  2 #include <stdio.h>
  3 #include <windows.h>
  4 #include <string>
  5 
  6 #pragma comment (lib, "advapi32")
  7 #pragma comment (lib, "user32")
  8 
  9 using namespace std;
 10 
 11 static const uint16_t MAX_LIFE = 20;
 12 static uint16_t gLife = 0;
 13 
 14 HCRYPTPROV hProv;
 15 HCRYPTKEY hKey;
 16 HCRYPTKEY hSessionKey;
 17 
 18 #define kAesBytes128 16
 19 
 20 typedef struct {
 21     BLOBHEADER  header;
 22     DWORD       key_length;
 23     BYTE        key_bytes[kAesBytes128];
 24 } AesBlob128;
 25 
 26 static const BYTE gCipherBlockSize = kAesBytes128 * 2;
 27 static BYTE gCipherBlock[gCipherBlockSize] = {0};
 28 
 29 void CreateContex()
 30 {
 31     if (!CryptAcquireContext(&hProv, NULL, NULL, PROV_RSA_AES, CRYPT_VERIFYCONTEXT))
 32     {
 33         printf("CryptAcquireContext() failed - error = 0x%x\n", GetLastError());
 34     }
 35 }
 36 
 37 void CreateKey(string& key)
 38 {
 39     AesBlob128 aes_blob;
 40     aes_blob.header.bType = PLAINTEXTKEYBLOB;
 41     aes_blob.header.bVersion = CUR_BLOB_VERSION;
 42     aes_blob.header.reserved = 0;
 43     aes_blob.header.aiKeyAlg = CALG_AES_128;
 44     aes_blob.key_length = kAesBytes128;
 45     memcpy(aes_blob.key_bytes, key.c_str(), kAesBytes128);
 46 
 47     if (!CryptImportKey(hProv,
 48                       reinterpret_cast<BYTE*>(&aes_blob),
 49                       sizeof(AesBlob128),
 50                       NULL,
 51                       0,
 52                       &hKey))
 53     {
 54         printf("CryptImportKey() failed - error = 0x%x\n", GetLastError());
 55     }
 56 }
 57 
 58 void Encrypt()
 59 {
 60     unsigned long length = kAesBytes128;
 61     memset(gCipherBlock, 0, gCipherBlockSize);
 62     memcpy(gCipherBlock, &gLife, sizeof(gLife));
 63 
 64     if (!CryptEncrypt(hKey, 0, TRUE, 0, gCipherBlock, &length, gCipherBlockSize))
 65     {
 66         printf("CryptEncrypt() failed - error = 0x%x\n", GetLastError());
 67         return;
 68     }
 69     gLife = 0;
 70 }
 71 
 72 void Decrypt()
 73 {
 74     unsigned long length = gCipherBlockSize;
 75 
 76     if (!CryptDecrypt(hKey, 0, TRUE, 0, gCipherBlock, &length))
 77     {
 78         printf("Error CryptDecrypt() failed - error = 0x%x\n", GetLastError());
 79         return;
 80     }
 81     memcpy(&gLife, gCipherBlock, sizeof(gLife));
 82     memset(gCipherBlock, 0, gCipherBlockSize);
 83 }
 84 
 85 int main(int argc, char* argv[])
 86 {
 87     CreateContex();
 88 
 89     string key("The secret key");
 90 
 91     CreateKey(key);
 92 
 93     gLife = MAX_LIFE;
 94 
 95     Encrypt();
 96 
 97     SHORT result = 0;
 98 
 99     while (true)
100     {
101         result = GetAsyncKeyState(0x31);
102 
103         Decrypt();
104 
105         if (result != 0xFFFF8001)
106             gLife = gLife - 1;
107         else
108             gLife = gLife + 1;
109 
110         printf("life = %u\n", gLife);
111 
112         if (gLife == 0)
113             break;
114 
115         Encrypt();
116 
117         Sleep(1000);
118     }
119     printf("stop\n");
120     return 0;
121 }

Рассмотрим алгоритм работы приложения. Его основные шаги вы можете проследить в функции 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. Проверка целостности данных приложения
 1 #include <stdio.h>
 2 #include <stdint.h>
 3 #include <windows.h>
 4 #include <functional>
 5 
 6 using namespace std;
 7 
 8 static const uint16_t MAX_LIFE = 20;
 9 static uint16_t gLife = MAX_LIFE;
10 
11 std::hash<uint16_t> hashFunc;
12 static size_t gLifeHash = hashFunc(gLife);
13 
14 void UpdateHash()
15 {
16     gLifeHash = hashFunc(gLife);
17 }
18 
19 __forceinline void CheckHash()
20 {
21     if (gLifeHash != hashFunc(gLife))
22     {
23         printf("unauthorized modification detected!\n");
24         exit(EXIT_FAILURE);
25     }
26 }
27 
28 int main(int argc, char* argv[])
29 {
30     SHORT result = 0;
31 
32     while (gLife > 0)
33     {
34         result = GetAsyncKeyState(0x31);
35 
36         CheckHash();
37 
38         if (result != 0xFFFF8001)
39             --gLife;
40         else
41             ++gLife;
42 
43         UpdateHash();
44 
45         printf("life = %u\n", gLife);
46 
47         Sleep(1000);
48     }
49 
50     printf("stop\n");
51 
52     return 0;
53 }

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

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

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

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

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

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

Выводы

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

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