Специальные техники
В этой главе мы рассмотрим специальные техники разработки игровых ботов. Они применяются в особых случаях для обхода некоторых видов защит от кликеров и внутриигровых ботов.
Сначала мы познакомимся с эмуляцией стандартных устройств ввода: клавиатуры и мыши. Затем перейдём к более сложной технике перехвата вызовов процесса игрового приложения к WinAPI библиотекам.
Эмуляция устройств ввода
Рассмотрим технику эмуляции устройств ввода. Этот подход применяется для обхода защит от кликеров, которые проверяют состояние клавиатуры. Алгоритм работы таких защит подробно разобран во второй главе.
Когда мы используем вместо клавиатуры или мыши эмулятор, у ОС нет возможности обнаружить подмену. Симулируемые эмулятором события (например, нажатия клавиш) будут обрабатываться ОС точно так же, как и для настоящей клавиатуры. Поэтому защите игрового приложения будет намного сложнее различать действия бота и игрока.
Инструменты для разработки
Прежде всего нам следует выбрать устройство, которое будет выполнять роль эмулятора. Рассмотрим основные требования к нему:
- Невысокая цена.
- Средства разработки (IDE и компилятор) должны быть бесплатны.
- Среда разработки должна предоставлять библиотеки для эмуляции устройств ввода.
- Должна быть доступная подробная документация.
Плата Arduino удовлетворяет всем перечисленным требованиям. Кроме того, Arduino — это одна из лучших аппаратных платформ, чтобы познакомиться с разработкой программ для встраиваемых систем.
Следующий вопрос, который следует решить: какую версию платы Arduino выбрать? Чтобы ответить на него, изучим возможности средств разработки. Arduino IDE предоставляет библиотеки для эмуляции клавиатуры и мыши. Согласно документации, некоторые версии плат их не поддерживают. Следовательно, нам они не подойдут. Нас устроят следующие модели: Leonardo, Micro и Due.
Мы выбрали аппаратную платформу. Теперь самое время установить средства разработки для неё. Компания производитель плат Arduino предоставляет бесплатную IDE с интегрированным C++ компилятором и библиотеками для поддержки периферии. Скачайте её с официального сайта и установите.
Теперь установим драйвер для работы с платой Arduino. Для этого нужна программа установки из каталога Arduino IDE. Её путь по умолчанию: C:\Program Files (x86)\Arduino\drivers. В каталоге drivers есть две программы: dpinst-amd64.exe для 64-разрядной версии Windows и dpinst-x86.exe для 32-разрядной. Выберите подходящую вам и перед её запуском подключите плату к компьютеру с помощью USB кабеля.
После установки драйвера выполните заключительные шаги конфигурации в Arduino IDE:
- Прочитайте модель вашей платы. Для этого в главном меню выберите пункт “Tools” -> “Get Board Info” (“Инструменты” -> “Информация о плате”). Проверьте, что в пункте меню “Tools” -> “Board:…” (“Инструменты” -> “Плата:…”) модель указана правильно.
- Укажите порт подключения платы в пункте главного меню “Tools”->”Port:…” (“Инструменты” -> “Порт:…”).
Теперь Arduino IDE настроена и готова к работе.
Самой по себе платы Arduino недостаточно для эмуляции устройств ввода. Мы должны написать для неё программу, которая посылала бы ОС события о симулируемых действиях. Со стороны компьютера этой программой будет управлять бот-кликер, написанный на языке AutoIt. Для такого взаимодействия понадобится набор AutoIt скриптов CommAPI.
Эмуляция клавиатуры
Есть два варианта реализации бота, использующего эмулятор устройства ввода.
В первом случае все алгоритмы бота реализованы в программе, работающей на плате Arduino. После её загрузки на устройство, всё готово к работе. Бот запускается автоматически, как только вы подключите плату к компьютеру через USB. Такая архитектура лучше всего подходит для “слепых” ботов, которые нажимают кнопки, не проверяя состояние игровых объектов. К сожалению, программа, запущенная на Arduino не имеет доступа к WinAPI-интерфейсу. Следовательно, она не сможет прочитать данные из процесса игрового приложения или устройства вывода.
Если ваш бот должен реагировать на игровые события, следует выбрать второй вариант реализации. В этом случае его алгоритмы запускаются и работают на компьютере. Программа платы Arduino отвечает только за симуляцию событий устройства ввода. В такой схеме бот имеет полный доступ к WinAPI и может читать состояние игровых объектов. После принятия решения, он отправляет плате Arduino команду на симуляцию нужного действия.
Мы рассмотрим пример второго варианта реализации бота. Он более надёжен и универсален.
Интерфейс взаимодействия платы и бота может быть любым: Ethernet, UART, I2C, SPI. Предлагаю остановиться на самом простом варианте, не требующем дополнительного оборудования кроме самой платы и USB провода. Речь идёт об интерфейсе UART (Universal Asynchronous Receiver-Transmitter).
Листинг 5-1 демонстрирует программу keyboard.ino для платы Arduino. Она симулирует события клавиатуры. При этом из UART-интерфейса читается код клавиши, которую требуется нажать.
keyboard.ino 1 #include <Keyboard.h>
2
3 void setup()
4 {
5 Serial.begin(9600);
6 Keyboard.begin();
7 }
8
9 void loop()
10 {
11 if (Serial.available() > 0)
12 {
13 int incomingByte = Serial.read();
14 Keyboard.write(incomingByte);
15 }
16 }
В этой программе мы используем библиотеку Keyboard, которую предоставляет Arduino IDE. Она позволяет генерировать события нажатия клавиш. Подключённый по USB компьютер получает их через интерфейс HID (Human Interface Device). Он является современным стандартом взаимодействия с устройствами ввода.
В первой строке программы мы включаем заголовок Keyboard.h. В нём создаётся глобальный объект Keyboard класса Keyboard_. Все возможности библиотеки доступны через его методы.
В нашей программе всего две функции: setup и loop. Возможно, вы помните, что в любом C++ приложении обязательно должна быть ещё функция main. Она генерируется IDE во времени компиляции. В ней выполняется два действия: однократный вызов setup и цикличный вызов loop. Прототипы обеих этих функций предопределены, и поменять их нельзя.
Кроме Keyboard мы используем глобальный объект Serial. Он предоставляет доступ к интерфейсу UART. Для инициализации обоих объектов в функции setup вызываются методы begin. Для Serial этот метод принимает входным параметром скорость передачи данных между компьютером и платой, которая в нашем случае равна 9600 бит/c. У метода begin объекта Keyboard нет входных параметров. Сразу после его вызова плата начинает эмулировать клавиатуру.
После выполнения функции setup Arduino плата готова принимать команды по UART интерфейсу и симулировать нажатия соответствующих клавиш. За это отвечает код функции loop. Её алгоритм состоит из трёх шагов:
- С помощью метода
availableобъектаSerialпроверить, были ли получены данные по UART интерфейсу. Они сохраняются во входном буфере платы, размер которого 64 байта. Метод возвращает количество принятых байт. Если передачи не было, вернётся значение ноль. - Прочитать один байт из входного буфера UART с помощью метода
readобъектаSerial. Байт интерпретируется как ASCII код клавиши, нажатие которой следует симулировать. - Симулировать нажатие клавиши через HID-интерфейс с помощью метода
writeобъектаKeyboard. Подключённый по USB компьютер обработает его как событие обычной клавиатуры.
Чтобы скомпилировать программу keyboard.ino и загрузить её на плату, откройте её в Arduino IDE и нажмите комбинацию клавиш Ctrl+U.
Мы подготовили плату. Теперь разработаем AutoIt скрипт, который будет ею управлять. Он должен посылать через UART интерфейс ASCII коды клавиш. Функции работы с UART предоставляет ОС через WinAPI. Доступ к ним из языка AutoIt могут значительно упростить обёртки CommAPI. Скачайте и скопируйте их в каталог вашего скрипта. Проверьте, что все необходимые файлы на месте:
CommAPI.au3CommAPIConstants.au3CommAPIHelper.au3CommInterface.au3CommUtilities.au3
Листинг 5-2 демонстрирует использование обёрток CommAPI. Приведённый в нём скрипт печатает строку “Hello world!” в окне Notepad. Для симуляции нажатий клавиш он использует плату Arduino с загруженной на неё программой из листинга 5-1.
ControlKeyboard.au3 1 #include "CommInterface.au3"
2
3 func ShowError()
4 MsgBox(16, "Error", "Error " & @error)
5 endfunc
6
7 func OpenPort()
8 local const $iPort = 7
9 local const $iBaud = 9600
10 local const $iParity = 0
11 local const $iByteSize = 8
12 local const $iStopBits = 1
13
14 $hPort = _CommAPI_OpenCOMPort($iPort, $iBaud, $iParity, $iByteSize, $iStopBits)
15 if @error then
16 ShowError()
17 return NULL
18 endif
19
20 _CommAPI_ClearCommError($hPort)
21 if @error then
22 ShowError()
23 return NULL
24 endif
25
26 _CommAPI_PurgeComm($hPort)
27 if @error then
28 ShowError()
29 return NULL
30 endif
31
32 return $hPort
33 endfunc
34
35 func SendArduino($hPort, $command)
36 _CommAPI_TransmitString($hPort, $command)
37 if @error then ShowError()
38 endfunc
39
40 func ClosePort($hPort)
41 _CommAPI_ClosePort($hPort)
42 if @error then ShowError()
43 endfunc
44
45 $hWnd = WinGetHandle("[CLASS:Notepad]")
46 WinActivate($hWnd)
47 Sleep(200)
48
49 $hPort = OpenPort()
50
51 SendArduino($hPort, "Hello world!")
52
53 ClosePort($hPort)
Общий алгоритм скрипта состоит из следующих шагов:
- Переключиться на окно Notepad с помощью AutoIt функции
WinActivate. - Установить последовательное соединение (serial communication) по интерфейсу UART с платой Arduino, используя функцию
OpenPort. - Отправить команду набора строки “Hello world!” на плату с помощью функции
SendArduino. - Закрыть последовательное соединение функцией
ClosePort.
Рассмотрим подробнее работу функций OpenPort, SendArduino и ClosePort.
Функция OpenPort устанавливает соединение и подготавливает плату Arduino к взаимодействию. Она возвращает дескриптор соединения. В ней происходят следующие вызовы CommAPI:
-
_CommAPI_OpenCOMPortустанавливает последовательное соединение с указанными параметрами. Из нихiParity,iByteSizeиiStopBitsодинаковы для Arduino плат всех моделей. ПараметрiBaudзадаёт скорость передачи данных. Она должна соответствовать скорости, переданной в методbeginобъектаSerialв программе платы. ПараметрiPortопределяет номер последовательного порта (COM порта), через который плата подключена к компьютеру. На самом деле подключение происходит по USB, а COM порт эмулируется. Уточнить номер порта можно в пункте меню “Tools” -> “Port:…” (“Инструменты” -> “Порт:…”) Arduino IDE. Например, если там указан COM7, параметрiPortдолжен быть равен 7. -
_CommAPI_ClearCommErrorвозвращает код ошибки при передаче данных. Через второй необязательный параметр функции возвращается текущее состояние подключённого устройства. В нашем случае он не используется. Функция вызывается для сброса флага ошибки на стороне платы. Это действие очень важно, поскольку передача данных будет заблокирована до тех пор, пока флаг ошибки взведён. -
_CommAPI_PurgeCommотменяет все текущие операции по передаче данных, а также очищает входной и выходной буферы подключённого устройства. После завершения работы этой функции Arduino готова принимать команды по UART.
Функция SendArduino представляет собой обёртку над вызовом _CommAPI_TransmitString, который передаёт указанную строку по UART интерфейсу.
Функция ClosePort закрывает соединение по переданному в неё дескриптору.
Вспомогательная функция ShowError нужна для отладки. Она выводит сообщение с кодом ошибки, которая может произойти на любом этапе установки соединения.
Чтобы протестировать скрипт, выполните следующие действия:
- Подключите Arduino плату с загруженной на неё программой
keyboard.inoк компьютеру с помощью USB кабеля. - Запустите приложение Notepad.
- Запустите скрипт
ControlKeyboard.au3.
В результате в окне Notepad будет набран текст “Hello world!”.
Сочетание клавиш
Разработанная нами программа keyboard.ino успешно справляется с симуляцией нажатия одной клавиши за раз. Однако в некоторых играх может понадобится симулировать сочетание клавиш, например Ctrl+Z. В этом случае одного байта для передачи команды будет недостаточно. Кроме кода основной клавиши нужно отправлять код клавиши-модификатора. Таким образом, программа должна уметь читать два байта из входного буфера UART интерфейса.
Рассмотрим методы объекта Serial. Раньше мы использовали read, но с его помощью можно прочитать только один байт из входного буфера UART. Есть альтернативный метод readBytes, который читает последовательность байт указанной длины. Первым параметром в него передаётся массив, в который будут сохранены данные. Вторым – его размер. Метод возвращает количество прочитанных байтов. Оно может отличаться от значения второго параметра, если буфер содержит меньше данных.
Задумаемся над вопросом: достаточно ли будет передавать только коды модификатора и клавиши? На самом деле, если по какой-то причине приём данных на плате начнётся с середины команды, возникнут серьёзные сложности. Второй байт этой команды будет интерпретирован как первый. Первый же байт следующей команды – как второй. В результате будет симулировано нажатие не той клавиши, которую ожидает управляющий скрипт. Из-за возникшего сдвига все последующие команды также выполнятся неверно.
Возможна ли ситуация, когда плата получает очередную команду не с начала? Если мы подключаем устройство до запуска управляющего скрипта, это маловероятно. Однако такая ситуация возможна, если плата перезагрузится например из-за отошедшего USB разъёма или ошибки драйвера Windows.
Проблему можно решить с помощью преамбулы. Преамбула – это предопределённое значение, которое сигнализирует о начале команды. Для неё мы выделим первый байт сообщения. Теперь мы легко отличим начало передачи. Если программа Arduino получила первый байт и он отличается от преамбулы, значит команда читается со сдвигом и её лучше проигнорировать.
По сути мы разработали простейший протокол для передачи команд эмулятору по UART интерфейсу. В таблице 5-1 приведены значения каждого байта в сообщении.
| Номер байта | Значение |
|---|---|
| 1 | Преамбула. |
| 2 | Код клавиши-модификатора. |
| 3 | Код основной клавиши. |
Рассмотрим пример команды для симуляции нажатия Alt+Tab. В этом случае управляющий скрипт отправляет три байта:
1 0xDC 0x82 0xB3
Первый из них (0xDC) – это преамбула. Дальше идёт код клавиши-модификатора 0x82, который соответствует Alt. Последний байт 0xB3 – это код клавиши Tab.
Листинг 5-3 демонстрирует Arduino программу, поддерживающую наш протокол.
keyboard-combo.ino 1 #include <Keyboard.h>
2
3 void setup()
4 {
5 Serial.begin(9600);
6 Keyboard.begin();
7 }
8
9 void pressKey(char modifier, char key)
10 {
11 Keyboard.press(modifier);
12 Keyboard.write(key);
13 Keyboard.release(modifier);
14 }
15
16 void loop()
17 {
18 static const char PREAMBLE = 0xDC;
19 static const uint8_t BUFFER_SIZE = 3;
20
21 if (Serial.available() > 0)
22 {
23 char buffer[BUFFER_SIZE] = {0};
24 uint8_t readBytes = Serial.readBytes(buffer, BUFFER_SIZE);
25
26 if (readBytes != BUFFER_SIZE)
27 return;
28
29 if (buffer[0] != PREAMBLE)
30 return;
31
32 pressKey(buffer[1], buffer[2]);
33 }
34 }
В программе появилась новая функция pressKey. Кроме этого, алгоритм loop стал сложнее. Мы читаем принятую команду из входного буфера UART с помощью метод readBytes объекта Serial. Для проверки её корректности используем операторы if. Первый из них сравнивает длину команды с ожидаемой. Второй — соответствие её первого байта и преамбулы. Если любая из проверок не проходит, обработка команды прекращается.
Симуляция нажатия сочетания клавиш происходит в функции pressKey. У неё два входных параметра: код модификатора и клавиши. Чтобы нажать и удерживать модификатор, используется метод press объекта Keyboard. Затем симулируется нажатие основной клавиши с помощью метода write. После этого модификатор отпускается вызовом release.
Управляющий AutoIt скрипт также должен поддерживать новый протокол передачи команд. Его исправленная версия приведена в листинге 5-4.
ControlKeyboardCombo.au3 1 #include "CommInterface.au3"
2
3 func ShowError()
4 MsgBox(16, "Error", "Error " & @error)
5 endfunc
6
7 func OpenPort()
8 local const $iPort = 7
9 local const $iBaud = 9600
10 local const $iParity = 0
11 local const $iByteSize = 8
12 local const $iStopBits = 1
13
14 $hPort = _CommAPI_OpenCOMPort($iPort, $iBaud, $iParity, $iByteSize, $iStopBits)
15 if @error then
16 ShowError()
17 return NULL
18 endif
19
20 _CommAPI_ClearCommError($hPort)
21 if @error then
22 ShowError()
23 return NULL
24 endif
25
26 _CommAPI_PurgeComm($hPort)
27 if @error then
28 ShowError()
29 return NULL
30 endif
31
32 return $hPort
33 endfunc
34
35 func SendArduino($hPort, $modifier, $key)
36 local $command[3] = [0xDC, $modifier, $key]
37
38 _CommAPI_TransmitString($hPort, _
39 StringFromASCIIArray($command, 0, UBound($command), 1))
40
41 if @error then ShowError()
42 endfunc
43
44 func ClosePort($hPort)
45 _CommAPI_ClosePort($hPort)
46 if @error then ShowError()
47 endfunc
48
49 $hWnd = WinGetHandle("[CLASS:Notepad]")
50 WinActivate($hWnd)
51 Sleep(200)
52
53 $hPort = OpenPort()
54
55 SendArduino($hPort, 0x82, 0xB3)
56
57 ClosePort($hPort)
Единственное отличие здесь от скрипта ControlKeyboard.au3 в функции SendArduino. Теперь вместо строки символов, которые передаются последовательно, она передаёт команду из трёх байтов: преамбула, модификатор и клавиша. Для отправки данных используется та же CommAPI функция _CommAPI_TransmitString. Сложность заключается в том, что она ожидает входным параметром строку. Команда же представляет собой байтовый массив. Его можно преобразовать в строку с помощью стандартной функции AutoIt StringFromASCIIArray.
Для тестирования Arduino программы и скрипта выполните следующие шаги:
- Загрузите программу
keyboard-combo.inoна Arduino плату. - Откройте несколько окон на компьютере.
- Запустите скрипт
ControlKeyboardCombo.au3.
Скрипт будет симулировать нажатие сочетания клавиш Alt+Tab и переключаться между открытыми окнами.
Эмуляция мыши
С помощью платы Arduino можно эмулировать не только клавиатуру, но и мышь.
Все библиотеки Arduino IDE рассчитаны на разработку устройств на основе платы. Например, уже знакомая нам библиотека Keyboard. С её помощью мы могли бы собрать и запрограммировать свою собственную клавиатуру. Но вместо этого мы использовали её для эмуляции настоящего устройства. Keyboard отлично подошла для решения этой задачи.
У Arduino IDE есть библиотека Mouse. Она аналогична Keyboard, но служит для разработки сходных с мышью устройств (например трекболы или джойстики). Mouse хорошо справляется со своей основной целью, но для эмуляции мыши её использовать неудобно.
Проблема в том, что библиотека оперирует относительными координатами курсора. Чем продиктовано такое решение? Представьте, что вы разрабатываете свою мышь на основе платы Arduino. Её перемещения по столу читаются с помощью светодиода-сенсора. Этот сенсор может сообщить на сколько единиц расстояния произошёл сдвиг относительно прошлого положения устройства. Значение сдвига посылается на компьютер через HID интерфейс, и ОС отрисовывает курсор в новой позиции экрана. Абсолютные координаты в эту схему не укладываются, поскольку светодиод-сенсор не способен установить расположение мыши относительно какой-либо точки стола.
Для нашей цели эмуляции устройства абсолютные координаты были бы удобнее. По ним управляющий AutoIt скрипт читает пиксели экрана. Он знает, в какой именно точке нужно совершить щелчок мыши. Поэтому было бы естественно для скрипта указывать именно абсолютные координаты экрана.
У этой проблемы есть два возможных решения:
- На стороне управляющего скрипта – реализовать алгоритм для расчёта относительных координат целевой точки.
- На стороне программы платы – исправить библиотеку Mouse так, чтобы она работала с абсолютными координатами.
Сообщество пользователей Arduino уже решило задачу модификации библиотеки Mouse. Необходимые для этого изменения описаны в статье. К сожалению, это решение подходит только для Arduino IDE старой версий 1.0. В ней библиотеки Keyboard и Mouse были объединены в одну под название HID.
Чтобы исправить библиотеку Mouse в новых версиях IDE, выполните следующие действия:
- Скачайте файл
Mouse.cppиз архива примеров к этой книге. - Скопируйте его с заменой в каталог Arduino IDE. Путь по умолчанию должен быть
C:\Program Files (x86)\Arduino\libraries\Mouse\src.
Также вы можете исправить файл Mouse.cpp самостоятельно. Для этого объявите макрос ABSOLUTE_MOUSE_MODE и измените часть массива _hidReportDescriptor следующим образом:
1 #define ABSOLUTE_MOUSE_MODE
2
3 static const uint8_t _hidReportDescriptor[] PROGMEM = {
4 ...
5 #ifdef ABSOLUTE_MOUSE_MODE
6 0x15, 0x01, // LOGICAL_MINIMUM (1)
7 0x25, 0x7F, // LOGICAL_MAXIMUM (127)
8 0x75, 0x08, // REPORT_SIZE (8)
9 0x95, 0x03, // REPORT_COUNT (3)
10 0x81, 0x02, // INPUT (Data,Var,Abs)
11 #else
12 0x15, 0x81, // LOGICAL_MINIMUM (-127)
13 0x25, 0x7f, // LOGICAL_MAXIMUM (127)
14 0x75, 0x08, // REPORT_SIZE (8)
15 0x95, 0x03, // REPORT_COUNT (3)
16 0x81, 0x06, // INPUT (Data,Var,Rel)
17 #endif
В массиве _hidReportDescriptor перечислены данные, которые плата может отправить и получить от компьютера. Другими словами в нём описан протокол передачи данных. Благодаря ему компьютер может взаимодействовать со всем HID устройствами единообразно.
Если макрос ABSOLUTE_MOUSE_MODE объявлен, протокол будет изменён в двух местах:
- Значение байта
LOGICAL_MINIMUMс ID равным 0x15 изменено с -127 (0x81 в шестнадцатеричной системе) на 1. Таким образом мы задали минимально допустимое значение координаты курсора. Для относительной координаты оно может быть отрицательным, но не абсолютной. - Значение байта
INPUTс ID равным 0x81 изменено с 0x06 на 0x02. Это означает, что теперь будут передаваться абсолютные координаты, а не относительные.
Чтобы переключиться обратно в режим относительных координат, просто удалите или закомментируйте объявление макроса ABSOLUTE_MOUSE_MODE:
1 #define ABSOLUTE_MOUSE_MODE
Программа mouse.ino из листинга 5-5 симулирует нажатие кнопки мыши в указанной точке экрана.
mouse.ino 1 #include <Mouse.h>
2
3 void setup()
4 {
5 Serial.begin(9600);
6 Mouse.begin();
7 }
8
9 void click(signed char x, signed char y, char button)
10 {
11 Mouse.move(x, y);
12 Mouse.click(button);
13 }
14
15 void loop()
16 {
17 static const char PREAMBLE = 0xDC;
18 static const uint8_t BUFFER_SIZE = 4;
19
20 if (Serial.available() > 0)
21 {
22 char buffer[BUFFER_SIZE] = {0};
23 uint8_t readBytes = Serial.readBytes(buffer, BUFFER_SIZE);
24
25 if (readBytes != BUFFER_SIZE)
26 return;
27
28 if (buffer[0] != PREAMBLE)
29 return;
30
31 click(buffer[1], buffer[2], buffer[3]);
32 }
33 }
Алгоритмы программ mouse.ino и keyboard-combo.ino из листинга 5-3 очень похожи. Теперь мы получаем от управляющего AutoIt скрипта команду, состоящую не из трёх байт, а из четырёх. Её формат приведён в таблице 5-2.
| Номер байта | Значение |
|---|---|
| 1 | Преамбула. |
| 2 | Координата X-точки, в которой следует симулировать нажатие кнопки. |
| 3 | Координата Y-точки. |
| 4 | Код кнопки мыши, которая будет нажата. |
Получив команду по UART интерфейсу, мы проверяем её длину и корректность первого байта преамбулы. Если оба условия выполнены, вызываем функцию click. Для симуляции действий мыши используется глобальный объект Mouse. Он инициализируется с помощью метода begin точно так же, как и Keyboard. Перед тем как нажать кнопку, необходимо переместить курсор в заданную координату. Для этого вызываем метод move объекта Mouse, в который передаём координаты X и Y целевой точки. Затем с помощью метода click симулируем нажатие в текущей позиции курсора.
Внимательный читатель заметит, что максимально допустимые значения координат X и Y ограничены числом 127. В шестнадцатеричном виде оно равно 0x7F. Это максимальное целое положительное число со знаком, которое может быть передано в одном байте. Это ограничение продиктовано протоколом HID. Обратите внимание на значение байта LOGICAL_MAXIMUM в массиве _hidReportDescriptor:
1 0x25, 0x7f, // LOGICAL_MAXIMUM (127)
Получается, что максимальные координаты, на которые может переместить курсор Arduino плата, равны 127×127. Однако разрешение современных мониторов значительно превышает эти числа. Перекладка координат HID устройства в координаты монитора происходит на уровне ОС. Придётся повторить её в нашем управляющем AutoIt скрипте, чтобы правильно спозиционировать курсор.
Итак, скрипт знает абсолютные координаты точки экрана, в которой следует симулировать нажатие кнопки мыши. Задача заключается в том, чтобы перевести эти координаты в шкалу Arduino платы.
Формулы перевода координат выглядят следующим образом:
1 Xa = 127 * X / Xres
2 Ya = 127 * Y / Yres
Значения переменных приведены в таблице 5-3.
| Переменные | Значение |
|---|---|
| Xa, Ya | Координаты X и Y в шкале Arduino. |
| X, Y | Координаты X и Y в шкале экрана. |
| Xres, Yres | Разрешение экрана в пикселях. |
Рассмотрим пример перевода координат с помощью формул. Предположим, что разрешение нашего экрана 1366×768. Управляющий скрипт симулирует нажатие кнопки мыши в точке с координатами экрана X = 250 и Y = 300. Тогда ему надо отправить плате Arduino такие координаты:
1 Xa = 127 * 250 / 1366 = 23
2 Ya = 127 * 300 / 768 = 49
Координата X = 23 в шестнадцатеричном виде равна 0x17, а Y = 49 равна 0x31. Команда целиком будет выглядеть следующим образом:
1 0xDC 0x17 0x31 0x1
Листинг 5-6 демонстрирует управляющий скрипт для программы mouse.ino.
ControlMouse.au3 1 #include "CommInterface.au3"
2
3 func ShowError()
4 MsgBox(16, "Error", "Error " & @error)
5 endfunc
6
7 func OpenPort()
8 local const $iPort = 8
9 local const $iBaud = 9600
10 local const $iParity = 0
11 local const $iByteSize = 8
12 local const $iStopBits = 1
13
14 $hPort = _CommAPI_OpenCOMPort($iPort, $iBaud, $iParity, $iByteSize, $iStopBits)
15 if @error then
16 ShowError()
17 return NULL
18 endif
19
20 _CommAPI_ClearCommError($hPort)
21 if @error then
22 ShowError()
23 return NULL
24 endif
25
26 _CommAPI_PurgeComm($hPort)
27 if @error then
28 ShowError()
29 return NULL
30 endif
31
32 return $hPort
33 endfunc
34
35 func GetX($x)
36 return (127 * $x / 1366)
37 endfunc
38
39 func GetY($y)
40 return (127 * $y / 768)
41 endfunc
42
43 func SendArduino($hPort, $x, $y, $button)
44 local $command[4] = [0xDC, GetX($x), GetY($y), $button]
45
46 _CommAPI_TransmitString($hPort, _
47 StringFromASCIIArray($command, 0, UBound($command), 1))
48
49 if @error then ShowError()
50 endfunc
51
52 func ClosePort($hPort)
53 _CommAPI_ClosePort($hPort)
54 if @error then ShowError()
55 endfunc
56
57 $hWnd = WinGetHandle("[CLASS:MSPaintApp]")
58 WinActivate($hWnd)
59 Sleep(200)
60
61 $hPort = OpenPort()
62
63 SendArduino($hPort, 250, 300, 1)
64
65 ClosePort($hPort)
Этот скрипт очень похож на ControlKeyboardCombo.au3 из листинга 5-4. Теперь в функцию SendArduino передаются четыре параметра: дескриптор порта, координаты курсора X и Y, код кнопки для нажатия. Кроме этого появились две новые функции: GetX и GetY. Они переводят соответствующие координаты из шкалы экрана в шкалу Arduino платы.
Для тестирования эмулятора мыши выполните следующие шаги:
- Загрузите программу
mouse.inoна Arduino плату. - Запустите приложение Paint. Переключитесь в нём на инструмент Brush (кисть).
- Запустите скрипт
ControlMouse.au3.
Скрипт симулирует щелчок левой кнопки мыши в точке с абсолютными координатами 250×300 в окне Paint. В ней должна появиться чёрная точка.
Эмуляция клавиатуры и мыши
Мы разработали программы для Arduino платы, чтобы эмулировать клавиатуру и мышь по отдельности. Такое решение хорошо работает, если для управления персонажем в игре требуется только одно из устройств ввода. Если же нужны оба, вам придётся купить две платы, запрограммировать их и сделать так, чтобы бот управлял обоими. Это неудобно. Намного лучше будет совместить функции эмуляции клавиатуры и мыши в одном устройстве. HID интерфейс это позволяет. Единственная сложность заключается в протоколе передачи данных по UART. Нам потребуется его расширить.
Прежде всего программа платы должна понять, какое именно действие требует выполнить управляющий AutoIt-скрипт. Назначим каждому из возможных действий код. Например, как предложено в таблице 5-4.
| Код | Действие |
|---|---|
| 0x1 | Нажатие клавиши без модификатора. |
| 0x2 | Нажатие клавиши с модификатором. |
| 0x3 | Щелчок мыши. |
В команде код действия должен идти сразу после байта преамбулы. Благодаря этому программа сможет правильно интерпретировать оставшиеся данные. Если код равен 0x1 или 0x2, применяется алгоритм симуляции нажатия клавиши из программы keyboard-combo.ino (листинг 5-3). В случае кода 0x3, отрабатывает алгоритм программы mouse.ino (листинг 5-5).
Листинг 5-7 демонстрирует программу для платы, которая поддерживает новый формат команд.
keyboard-mouse.ino 1 #include <Mouse.h>
2 #include <Keyboard.h>
3
4 void setup()
5 {
6 Serial.begin(9600);
7 Keyboard.begin();
8 Mouse.begin();
9 }
10
11 void pressKey(char key)
12 {
13 Keyboard.write(key);
14 }
15
16 void pressKey(char modifier, char key)
17 {
18 Keyboard.press(modifier);
19 Keyboard.write(key);
20 Keyboard.release(modifier);
21 }
22
23 void click(signed char x, signed char y, char button)
24 {
25 Mouse.move(x, y);
26 Mouse.click(button);
27 }
28
29 void loop()
30 {
31 static const char PREAMBLE = 0xDC;
32 static const uint8_t BUFFER_SIZE = 5;
33 enum
34 {
35 KEYBOARD_COMMAND = 0x1,
36 KEYBOARD_MODIFIER_COMMAND = 0x2,
37 MOUSE_COMMAND = 0x3
38 };
39
40 if (Serial.available() > 0)
41 {
42 char buffer[BUFFER_SIZE] = {0};
43 uint8_t readBytes = Serial.readBytes(buffer, BUFFER_SIZE);
44
45 if (readBytes != BUFFER_SIZE)
46 return;
47
48 if (buffer[0] != PREAMBLE)
49 return;
50
51 switch(buffer[1])
52 {
53 case KEYBOARD_COMMAND:
54 pressKey(buffer[3]);
55 break;
56
57 case KEYBOARD_MODIFIER_COMMAND:
58 pressKey(buffer[2], buffer[3]);
59 break;
60
61 case MOUSE_COMMAND:
62 click(buffer[2], buffer[3], buffer[4]);
63 break;
64 }
65 }
66 }
Для выбора симулируемого действия в зависимости от полученного кода, мы используем оператор switch в функции loop. Этот оператор проверяет значение второго байта команды. Он определяет, какая из функций будет вызвана для обработки оставшихся байт. Для удобства в операторе switch мы используем константы с кодами команд: KEYBOARD_COMMAND (0x1), KEYBOARD_MODIFIER_COMMAND (0x2) и MOUSE_COMMAND (0x3).
Возможно, вы заметили, что в случае команды на нажатие клавиши управляющий скрипт передаёт лишние данные. Метод readBytes объекта Serial всегда читает пять байтов (это константа BUFFER_SIZE) из входного буфера UART. Но используются из них только три в случае нажатия без модификатора или четыре – с модификатором. Можно ли оптимизировать эти накладные расходы и не передавать лишние данные? Предположим, что мы исправили управляющий скрипт. В результате этого длина команды зависит от кода действия, указанного во втором байте. Проблема в том, что мы должны передать в метод readBytes число байт для чтения из входного буфера UART. Но на момент его вызова, эта информация неизвестна. Поэтому нам придётся воспользоваться другим методом объекта Serial.
Метод readBytesUntil позволяет читать байты из входного буфера до тех пор, пока не встретится символ ограничитель или терминатор. Ограничитель – это предопределённое значение, которое сигнализирует об окончании передачи. Такой подход выглядит перспективным. Единственный вопрос, на который осталось ответить: какой ограничитель выбрать? Если вы задумаетесь над ним, то придёте к выводу, что однозначного ответа нет. Ограничитель, как и преамбула, – это один байт. Его значение не должно встречаться в данных команды. То есть отпадают все значения, которые могут принять координаты позиций курсора мыши (от 0x00 до 0x7F) и коды клавиш (от 0x00 до 0xFF). К сожалению, код клавиши может быть любым из диапазона значений, помещающихся в один байт. Поэтому нельзя гарантировать уникальность ограничителя. Мы могли бы увеличить его длину до двух байт. Это бы решило проблему, но тогда мы ничего не выиграем от команд переменной длины. Нам придётся передавать столько же байт, а иногда и больше, как и в случае с командами одинаковой длины.
Объект Serial предоставляет ещё один метод – read. Он читает все байты, находящиеся во входном буфере UART. С его помощью можно было бы решить нашу проблему, но только в том случае, если управляющий скрипт будет делать задержки между командами. Длительности каждой задержки должно быть достаточно, чтобы программа Arduino успела прочитать буфер. В противном случае в буфер будут попадать несколько команд за раз и различить их окажется проблематично. Этот подход ненадёжен, поскольку скрипт может генерировать запросы к плате очень часто.
В результате мы приходим к выводу, что накладные расходы, связанные с одинаковой длиной команд, приемлемы. Ими мы расплачиваемся за надёжную передачу данных.
Листинг 5-8 демонстрирует управляющий скрипт для программы keyboard-mouse.ino.
ControlKeyboardMouse.au3 1 #include "CommInterface.au3"
2
3 func ShowError()
4 MsgBox(16, "Error", "Error " & @error)
5 endfunc
6
7 func OpenPort()
8 local const $iPort = 10
9 local const $iBaud = 9600
10 local const $iParity = 0
11 local const $iByteSize = 8
12 local const $iStopBits = 1
13
14 $hPort = _CommAPI_OpenCOMPort($iPort, $iBaud, $iParity, $iByteSize, $iStopBits)
15 if @error then
16 ShowError()
17 return NULL
18 endif
19
20 _CommAPI_ClearCommError($hPort)
21 if @error then
22 ShowError()
23 return NULL
24 endif
25
26 _CommAPI_PurgeComm($hPort)
27 if @error then
28 ShowError()
29 return NULL
30 endif
31
32 return $hPort
33 endfunc
34
35 func SendArduinoKeyboard($hPort, $modifier, $key)
36 if $modifier == NULL then
37 local $command[5] = [0xDC, 0x1, 0xFF, $key, 0xFF]
38 else
39 local $command[5] = [0xDC, 0x2, $modifier, $key, 0xFF]
40 endif
41
42 _CommAPI_TransmitString($hPort, _
43 StringFromASCIIArray($command, 0, UBound($command), 1))
44
45 if @error then ShowError()
46 endfunc
47
48 func GetX($x)
49 return (127 * $x / 1366)
50 endfunc
51
52 func GetY($y)
53 return (127 * $y / 768)
54 endfunc
55
56 func SendArduinoMouse($hPort, $x, $y, $button)
57 local $command[5] = [0xDC, 0x3, GetX($x), GetY($y), $button]
58
59 _CommAPI_TransmitString($hPort, _
60 StringFromASCIIArray($command, 0, UBound($command), 1))
61
62 if @error then ShowError()
63 endfunc
64
65 func ClosePort($hPort)
66 _CommAPI_ClosePort($hPort)
67 if @error then ShowError()
68 endfunc
69
70 $hPort = OpenPort()
71
72 $hWnd = WinGetHandle("[CLASS:MSPaintApp]")
73 WinActivate($hWnd)
74 Sleep(200)
75
76 SendArduinoMouse($hPort, 250, 300, 1)
77
78 Sleep(1000)
79
80 $hWnd = WinGetHandle("[CLASS:Notepad]")
81 WinActivate($hWnd)
82 Sleep(200)
83
84 SendArduinoKeyboard($hPort, Null, 0x54) ; T
85 SendArduinoKeyboard($hPort, Null, 0x65) ; e
86 SendArduinoKeyboard($hPort, Null, 0x73) ; s
87 SendArduinoKeyboard($hPort, Null, 0x74) ; t
88
89 Sleep(1000)
90
91 SendArduinoKeyboard($hPort, 0x82, 0xB3) ; Alt+Tab
92
93 ClosePort($hPort)
В этом скрипте мы реализовали две отдельные функции для симуляции действий клавиатуры и мыши. SendArduinoKeyboard отправляет на плату команду для нажатия клавиши. Её алгоритм почти такой же, как у функции SendArduino из скрипта ControlKeyboardCombo.au3 (листинг 5-4). Отличие в формате команды: появился второй байт с кодом действия. Также мы дополняем байтовый массив на выдачу до необходимой длины в пять байтов с помощью константного значения 0xFF. Если нажатие симулируется без модификатора, то третий байт сообщения также заменяется на 0xFF.
Функция SendArduinoMouse отправляет команду для симуляции щелчка мыши. Единственное её отличие от аналога из скрипта ControlMouse.au3 (листинг 5-6) – добавлен код действия во втором байте.
Чтобы протестировать скрипт ControlKeyboardMouse.au3, выполните следующие действия:
- Загрузите программу
keyboard-mouse.inoна Arduino-плату. - Запустите приложение Paint.
- Запустите приложение Notepad.
- Запустите скрипт.
Скрипт последовательно выполнит три действия:
- Щелчок левой кнопкой мыши в окне Paint.
- Набор строки “Test” в окне Notepad.
- Переключение между открытыми окнами с помощью комбинации клавиш Alt+Tab.
Может возникнуть вопрос: почему мы использовали константное значение 0xFF для дополнения команд до нужной длины? Разумнее было бы подставлять 0x00. Это решение продиктовано особенностью AutoIt-функции StringFromASCIIArray, с помощью которой мы конвертируем массив в строку. Она обрабатывает значение 0x00 как ограничитель строки. Другими словами, результирующая строка будет обрезана до этого символа. Эта особенность означает, что все наши команды не должны содержать нулевых байтов. Следовательно, мы не сможем симулировать нажатие клавиши с кодом 0x00.
Выводы
Мы рассмотрели технику эмуляции клавиатуры и мыши с помощью платы Arduino. AutoIt скрипт, в котором реализована вся логика бота, может управлять ею через UART интерфейс. Таким образом совмещаются возможности анализа изображения на экране и симуляции действий устройств ввода. Благодаря этому вашего кликера будет невозможно обнаружить с помощью защит, основанных на проверке состояния клавиатуры и мыши.
Перехват данных на уровне ОС
В третьей главе мы рассмотрели методы чтения состояний объектов из памяти процесса игрового приложения. Хорошо продуманная защита может значительно усложнить их применение. В этом случае имеет смысл попробовать альтернативный подход, который заключается в подмене или модификации системных библиотек. Это позволит вам изменить точку перехвата данных. Теперь состояния объектов будут читаться не из памяти процесса, а из используемых им DLL-библиотек. Проконтролировать их намного труднее. Высока вероятность, что система защиты с этим не справится.
Инструменты для разработки
Нам предстоит активная работа с WinAPI-функциями и системными библиотеками. Для этой задачи лучше всего подойдёт язык C++. Для компиляции примеров воспользуемся Visual Studio IDE. Инструкцию по её установке вы найдёте в третьей главе.
Есть несколько решений с открытым исходным кодом для перехвата вызовов WinAPI. Первое из них называется DLL Wrapper Generator (генератор обёрток DLL). Мы будем использовать его, чтобы создавать обёртки для системных библиотек.
Для установки генератора выполните следующие шаги:
- Скачайте архив со скриптами со страницы проекта на Github.
- Скачайте и установите Python версии 2.7.
Второе решение, которым мы воспользуемся, называется Deviare. Это фреймворк для перехвата вызовов DLL-библиотек.
Чтобы установить Deviare, сделайте следующее:
- Скачайте архив с уже собранными исполняемыми файлами и библиотеками фреймворка.
- Скачайте архив с исходным кодом той же версии.
- Распакуйте оба архива в разные каталоги.
Список сборок Deviare доступен на Github странице проекта. Ещё раз проверьте, что версии скачанной сборки и исходного кода совпадают.
Тестовое приложение
Чтобы продемонстрировать методы перехвата WinAPI-вызовов, понадобится какое-то целевое приложение. Предлагаю воспользоваться программой, разработанной нами в разделе “Методы защиты от внутриигровых ботов” третьей главы. Немного изменённая версия её исходного кода приведена в листинге 5-9.
1 #include <stdio.h>
2 #include <stdint.h>
3 #include <windows.h>
4 #include <string>
5
6 static const uint16_t MAX_LIFE = 20;
7 volatile uint16_t gLife = MAX_LIFE;
8
9 int main()
10 {
11 SHORT result = 0;
12
13 while (gLife > 0)
14 {
15 result = GetAsyncKeyState(0x31);
16 if (result != 0xFFFF8001)
17 --gLife;
18 else
19 ++gLife;
20
21 std::string str(gLife, '#');
22 TextOutA(GetDC(NULL), 0, 0, str.c_str(), str.size());
23
24 printf("life = %u\n", gLife);
25 Sleep(1000);
26 }
27 printf("stop\n");
28 return 0;
29 }
Алгоритм работы приложения не изменился. Каждую секунду значение глобальной переменной gLife уменьшается на единицу, если клавиша “1” не была нажата. В противном случае gLife увеличивается на один. Теперь вместо вывода на консоль с помощью функции printf, мы делаем WinAPI-вызов TextOutA. Он печатает строку, переданную в качестве входного параметра, в левом верхнем углу экрана. В нашем случае строка состоит из символов решётки, число которых соответствует значению переменной gLife.
Зачем мы изменили функцию вывода информации? Наша цель заключается в перехвате WinAPI-вызовов. Функция printf предоставляется не WinAPI, а библиотекой времени выполнения языка C. В этой библиотеке реализованы низкоуровневые функции, описанные в стандарте языка. Доступ к ним возможен как из приложений, написанных на C, так и C++. Конечно, техника перехвата вызовов подойдёт и для случая с printf. Но для примера будет интереснее разобрать вариант именно с WinAPI-функцией. Поэтому мы используем TextOutA.
Согласно документации WinAPI, функция TextOutA реализована в системной библиотеке gdi32.dll. Эта информация пригодится нам в дальнейшем.
Скомпилируйте приложение на Visual Studio под 32-разрядную платформу и запустите, чтобы проверить его работу.
Загрузка DLL-библиотек
Перед тем как разбираться с техниками перехвата WinAPI-вызовов, рассмотрим взаимодействие приложения и используемой им DLL-библиотеки.
Когда мы запускаем какое-то приложение, загрузчик программ Windows (PE-загрузчик) читает содержимое исполняемого файла в оперативную память. Точнее в область памяти нового процесса. Загруженный код называется EXE-модулем. Стандартным форматом исполняемых файлов в Windows является PE. Он определяет структуру данных (известную как PE-заголовок), которая хранится в начале файла. Она содержит всю необходимую информацию для запуска приложения. Список используемых DLL библиотек является её частью.
На следующем шаге PE-загрузчик ищет файлы необходимых DLL-библиотек на жёстком диске. Их содержимое читается с диска и записывается в память процесса запускаемого приложения. Загруженный код одной библиотеки называется DLL модулем. Было бы логично размещать DLL-модули по одним и тем же адресам при каждом запуске приложения. К сожалению, всё не так просто. Эти адреса выбираются случайно механизмом Windows под названием Address Space Load Randomization (ASLR). Он защищает ОС от некоторых видов вредоносного ПО. Минус такого подхода в том, что компилятор не может использовать статические адреса для вызова функций библиотек из EXE модуля.
Проблема решается с помощью Import Table (таблица импорта). Кроме неё есть так называемая Thunk Table (таблица переходов). Эти таблицы часто путают. Рассмотрим подробнее их внутреннее устройство.
Import Table представляет собой массив структур типа IMAGE_IMPORT_DESCRIPTOR:
1 typedef struct _IMAGE_IMPORT_DESCRIPTOR {
2 DWORD OriginalFirstThunk;
3 DWORD TimeDateStamp;
4 DWORD ForwarderChain;
5 DWORD Name;
6 DWORD FirstThunk;
7 } IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;
Каждая такая структура соответствует одной DLL-библиотеке. В поле Name хранится имя её файла. Число OriginalFirstThunk на самом деле является указателем на первый элемент массива структур типа IMAGE_THUNK_DATA:
1 typedef struct _IMAGE_IMPORT_BY_NAME {
2 WORD Hint;
3 BYTE Name[1];
4 } IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
5
6 typedef struct _IMAGE_THUNK_DATA {
7 union {
8 PDWORD Function;
9 PIMAGE_IMPORT_BY_NAME AddressOfData;
10 } u1;
11 } IMAGE_THUNK_DATA, *PIMAGE_THUNK_DATA;
Ключевое слово union в определении IMAGE_THUNK_DATA говорит о том, что данные могут интерпретироваться двумя способами:
- Как указатель типа
PDWORDна функцию в памяти запущенного процесса. - Как указатель на структуру типа
IMAGE_IMPORT_BY_NAME, которая содержит порядковый номер функции в библиотеке и её символьное имя.
Поле FirstThunk структуры IMAGE_IMPORT_DESCRIPTOR указывает на первый элемент массива, известного как Import Address Table (таблица импорта адресов) или IAT. PE-загрузчик перезаписывает её адресами функций из соответствующей загруженной DLL-библиотеки. Более подробно структура Import Table описана в русской и английской статьях.
Import Table является частью PE-заголовка. В ней хранится общая информация о требуемых DLL библиотеках. Всё содержимое PE-заголовка загружается в сегмент памяти процесса с правами только на чтение. Thunk Table является частью исполняемого кода. Она содержит переходы (thunk) на импортируемые функции. Эти переходы представляют собой ассемблерные инструкции JMP. Thunk Table загружается в .text сегмент с правами на чтение и исполнение. В этом же сегменте хранится исполняемый код приложения. Import Address Table, на которую указывает FirstThunk элементов Import Table, помещается в сегмент .idata с правами на чтение и запись.
Некоторые компиляторы генерируют код, не использующий Thunk Table. Благодаря этому удаётся избежать накладных расходов, связанных с дополнительным JMP переходом. Код, сгенерированный компилятором MinGW, использует Thunk Table. В этом случае схема вызова импортируемой функции TextOutA будет соответствовать иллюстрации 5-1.
TextOutA из приложения, скомпилированного MinGWАлгоритм вызова выглядит следующим образом:
- Процессор переходит к инструкции ассемблера
CALL 40839C. Она выполняет вызов функции. При этом адрес возврата из неё помещается в стек, а управление передаётся элементу Thunk Table по адресу 40839C. - Элемент Thunk Table содержит единственную инструкцию
JMP. Она выполняет безусловный переход на функциюTextOutAмодуляgdi32.dll, загруженном в память исполняемого процесса. Линейный адрес функции извлекается из Import Address Table. Для доступа к ней используется регистр DS, указывающий на сегмент.idata. Для расчёта адреса элемента Import Address Table используется сдвиг (в нашем случае равный 0x278):
1 DS + 0x278 = 0x422000 + 0x278 = 0x422278
3. Процессор выполняет код TextOutA. Последняя инструкция функции — это RETN. Она извлекает адрес возврата из стека и осуществляет переход на инструкцию, следующую сразу за CALL в EXE модуле, откуда начинался вызов.
Компилятор Visual C++ генерирует код, который не использует Thunk Table. Схема вызова функции TextOutA в этом случае выглядит как на иллюстрации 5-2. Алгоритм этого вызова следующий:
- Процессор выполняет инструкцию ассемблера
CALL DWORD PTR DS:[0x10C]. В ней происходит чтение линейного адреса функции из Import Address Table. Затем на стек помещается адрес возврата. После этого управление передаётся в функциюTextOutAмодуляgdi32.dll. - Процессор выполняет код
TextOutA. Возврат из неё в EXE-модуль происходит по инструкцииRETN.
TextOutA из приложения, скомпилированного Visual C++Техники перехвата вызовов WinAPI
Игровые приложения взаимодействуют с ОС и её ресурсами через системные DLL-библиотеки. Например, чтобы вывести на экран текст, вызывается функция TextOutA или аналогичная. Перехватив этот вызов, мы узнаем текст, который приложение пытается вывести. Такой подход чем-то напоминает перехват данных устройства вывода. Только теперь мы получаем эти данные до того, как они будут отображены на экране.
Инструмент API Monitor, применявшийся во второй главе, хорошо демонстрирует принцип перехвата вызовов WinAPI. Все функции WinAPI, которые вызывал анализируемый процесс, выводятся в окне приложения “Summary”. Мы можем реализовать бота, который будет вести себя как API Monitor. Но вместо вывода перехваченных вызовов, он должен симулировать действия игрока.
Рассмотрим на примерах две наиболее известные и используемые техники перехвата вызовов.
Proxy DLL
Идея первой техники заключается в подмене Windows-библиотеки. Мы могли бы подготовить DLL-библиотеку, которая выглядит так же как системная с точки зрения PE-загрузчика. В этом случае она будет загружена в память процесса приложения. Игра будет взаимодействовать с подложной библиотекой точно так же, как если бы это была системная. Благодаря этому наш код будет получать управление при каждом вызове функции из неё. Подложная библиотека называется proxy DLL.
В большинстве случаев надо перехватывать несколько определённых вызовов WinAPI. Все остальные функции замещаемой системной библиотеки нам не интересны и должны работать как обычно. Кроме того при подмене DLL-библиотек помните важное правило: процесс должен вести себя с proxy DLL точно так же, как и с оригинальной библиотекой. В противном случае нельзя гарантировать его корректную работу. Эти два обстоятельства наводят на мысль, что proxy DLL должна уметь перенаправлять в оригинальную библиотеку все вызовы приложения.
Когда процесс игрового приложения вызывает функцию из proxy DLL, наш код получает управление. Он может симулировать действия пользователя или просто читать для бота состояния игровых объектов. После этого обязательно надо передать управление WinAPI-функции, выполнение которой ожидает приложение. В противном случае оно просто завершится с ошибкой или продолжит работу в не консистентном состоянии, то есть его данные окажутся не согласованы.
Итак, если мы не собираемся перехватывать какую-то функцию WinAPI, мы просто перенаправляем её вызов в системную библиотеку. В противном случае сначала отрабатывает наш код, и только потом управление передаётся в системную библиотеку. Это означает, что она должна быть загружена в адресное пространство процесса. Иначе код оригинальных WinAPI-функций будет недоступен. Очевидно, что PE-загрузчик ничего не знает про замещённую библиотеку. Он загрузил proxy DLL, и на этом его работа выполнена. Оригинальную библиотеку должна загружать proxy DLL с помощью WinAPI-функции LoadLibrary.
Иллюстрация 5-3 демонстрирует схему вызова WinAPI-функции TextOutA через proxy DLL в случае компиляции приложения на Visual C++.
TextOutA через proxy DLLАлгоритм вызова функции следующий:
- PE-загрузчик загружает proxy DLL вместо системной библиотеки
gdi32.dll. При этом он записывает линейные адреса всех функций, экспортируемых proxy DLL, в Import Address Table модуля EXE. - Исполнение кода модуля EXE достигает точки вызова функции
TextOutA. Дальше отрабатывает стандартный алгоритм вызова функции из импортируемой DLL. ИнструкцияCALLсохраняет адрес возврата на стеке и передаёт управление по адресу из Import Address Table. Единственное отличие в том, что управление получает не системная библиотека, а подменяющая её proxy DLL. - В proxy DLL есть Thunk Table, элемент которой получает управление от
CALLинструкции EXE-модуля. Именно линейные адреса элементов Thunk Table записываются PE-загрузчиком в Import Address Table модуля EXE. - Инструкция
JMPэлемента Thunk Table выполняет безусловный переход в обёртку для функцииTextOutAпод названиемTextOutA_wrapper, которая реализована в proxy DLL. В ней отрабатывает код бота. - В конце кода обёртки находится инструкция
CALL, которая сохраняет на стеке адрес возврата и передаёт управление оригинальной функцииTextOutAиз модуляgdi32.dll. - После отработки оригинальной функции
TextOutA, она возвращает управление в обёрткуTextOutA_wrapperчерез инструкциюRETN. - Инструкция
RETNв обёртке возвращает управление обратно в EXE-модуль.
Может возникнуть вопрос: как proxy DLL узнаёт линейные адреса функций, которые экспортируются системной библиотекой gdi32.dll? В обычной ситуации эти адреса читаются PE-загрузчиком. Но сейчас мы не можем его задействовать, ведь он загружает только proxy DLL. Опять же эта задача должна выполняться proxy DLL самостоятельно. WinAPI-вызов GetProcAddress возвращает линейный адрес функции из указанного модуля по её имени или порядковому номеру.
Ещё один момент остаётся неясным. Что нужно сделать, чтобы PE-загрузчик выбрал proxy DLL вместо системной библиотеки? У Windows есть механизм поиска динамических библиотек. Пути всех системных DLL хранятся в реестре и только по ним происходит поиск. Мы не можем просто подменить системную библиотеку в каталоге Windows. Скорее всего, её используют многие сервисы и службы ОС. Велика вероятность, что система окажется неработоспособной после такой подмены. Кроме того, оригинальная библиотека должна храниться в месте, известном всем её клиентам, поскольку все вызовы proxy DLL должны перенаправляться в неё. Правильное решение заключается в том, чтобы поместить proxy DLL в каталог приложения, вызовы которого мы собираемся перехватывать. Чтобы механизм защиты Windows не мешал загрузке библиотеки, его надо отключить. Для этого достаточно отредактировать реестр.
Преимущества использования proxy DLL перед другими техниками перехвата WinAPI-вызовов следующие:
- Очень просто сгенерировать proxy DLL с помощью существующих бесплатных утилит.
- Подмена библиотеки происходит только для одного конкретного приложения. Все остальные системные сервисы и службы используют оригинальную DLL.
- Защитить приложение от такого перехвата вызовов может быть сложно.
Недостатки подхода proxy DLL:
- Некоторые системные библиотеки невозможно подменить (например
kernel32.dll). Причина этого ограничения в том, что обе WinAPI-функцииLoadLibraryиGetProcAddressпредоставляютсяkernel32.dll. Это значит, что они должны быть доступны в момент, когда proxy DLL загружает системную библиотеку.
Пример использования proxy DLL
Применим технику перехвата WinAPI-вызовов с помощью proxy DLL на практике. Напишем бота для нашего тестового приложения, который будет поддерживать значение gLife больше десяти. Для простоты встроим алгоритм бота в код proxy DLL.
Первая задача заключается в том, чтобы сгенерировать proxy DLL с заглушками для каждой WinAPI-функции из замещаемой системной библиотеки gdi32.dll. В этом нам поможет скрипт DLL Wrapper Generator. Для его запуска выполните следующие шаги:
- Скопируйте 32-разрядную версию системной библиотеки
gdi32.dllв каталог скрипта-генератора. Она находится в каталогеC:\Windows\system32на 32-разрядной Windows или вC:\Windows\SysWOW64для 64-разрядной. - Запустите скрипт-генератор из командной строки CMD:
1 python Generate_Wrapper.py gdi32.dll
Будет создан Visual Studio проект с исходным кодом proxy DLL в подкаталоге gdi32.
Теперь реализуем алгоритм бота в сгенерированной proxy DLL. Для этого выполните следующее:
- В Visual Studio откройте файл проекта gdi32. Его формат устарел, поэтому Visual Studio предложит обновление до актуальной версии. Для этого нажмите кнопку “OK” в диалоге “Upgrade VC++ Compiler and Libraries” (обносить компилятор VC++ и библиотеки).
- В файле проекта
gdi32.cppизмените путь до системной библиотекиgdi32.dllв вызовеLoadLibrary. Вам нужна строчка номер 10, которая выглядит следующим образом:
1 mHinstDLL = LoadLibrary( "ori_gdi32.dll" );
Замените строку “ori_gdi32.dll” на корректный путь библиотеки в вашей системе. В случае 64-разрядной Windows должно получиться следующее:
1 mHinstDLL = LoadLibrary( "C:\\Windows\\SysWOW64\\gdi32.dll" );
3. В том же файле gdi32.cpp замените обёртку функции TextOutA с именем TextOutA_wrapper на код из листинга 5-10.
TextOutA 1 extern "C" BOOL __stdcall TextOutA_wrapper(
2 _In_ HDC hdc,
3 _In_ int nXStart,
4 _In_ int nYStart,
5 _In_ LPCSTR lpString,
6 _In_ int cchString
7 )
8 {
9 if (cchString < 10)
10 {
11 INPUT Input = { 0 };
12 Input.type = INPUT_KEYBOARD;
13 Input.ki.wVk = '1';
14 SendInput(1, &Input, sizeof(INPUT));
15 }
16
17 typedef BOOL(__stdcall *pS)(HDC, int, int, LPCTSTR, int);
18 pS pps = (pS)mProcs[696];
19 return pps(hdc, nXStart, nYStart, lpString, cchString);
20 }
Полная версия файла gdi32.cpp доступна в архиве с примерами к этой книге.
Вспомним код вызова функции TextOutA из нашего тестового приложения:
1 std::string str(gLife, '#');
2 TextOutA(GetDC(NULL), 0, 0, str.c_str(), str.size());
Здесь мы используем объект string библиотеки STL. Его конструктор принимает два входных параметра: длину строки и символ, которым её надо заполнить. В качестве длины мы передаём переменную gLife. Дальше объект string используется в вызове TextOutA. Параметры этой WinAPI-функции приведены в таблице 5-5.
| Номер параметра | Переданное значение | Описание |
|---|---|---|
| 1 | GetDC(NULL) | Контекст устройства в котором будет напечатана строка. |
| 2 | 0 | Координата X начала строки. |
| 3 | 0 | Координата Y начала строки. |
| 4 | str.c_str() | Указатель на строку с нуль-символом на конце. |
| 5 | str.size() | Длина строки в байтах. |
Алгоритм бота выглядит следующим образом:
- Последний параметр с именем
cchStringобёрткиTextOutA_wrapperхранит длину выводимой строки. Эта длина равна переменнойgLifeтестового приложения. Сравниваем её со значением 10. - Если длина строки меньше десяти, симулируем нажатие клавиши “1” с помощью WinAPI-функции
SendInput. В противном случае ничего не делаем. - Вызываем функцию
TextOutAиз системной библиотекиgdi32. Для этого используем указатель на неё, хранящийся в глобальном массивеmProcs. Он содержит указатели на все функции, экспортируемые библиотекойgdi32.dll. Его инициализация происходит в функцииDllMainв момент загрузки proxy DLL в память процесса (см листинг 5-11).
mProcs с указателями на функции gdi32.dll 1 HINSTANCE mHinst = 0, mHinstDLL = 0;
2 UINT_PTR mProcs[727] = {0};
3 LPCSTR mImportNames[] = {...}
4
5 BOOL WINAPI DllMain( HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved ) {
6 mHinst = hinstDLL;
7 if ( fdwReason == DLL_PROCESS_ATTACH ) {
8 mHinstDLL = LoadLibrary( "C:\\Windows\\SysWOW64\\gdi32.dll" );
9 if ( !mHinstDLL )
10 return ( FALSE );
11 for (int i = 0; i < 727; i++)
12 {
13 mProcs[i] = (UINT_PTR)GetProcAddress(mHinstDLL, mImportNames[i]);
14 }
15 } else if ( fdwReason == DLL_PROCESS_DETACH ) {
16 FreeLibrary( mHinstDLL );
17 }
18 return ( TRUE );
19 }
Алгоритм инициализации массива mProcs крайне прост. Скрипт-генератор составил список имён экспортируемых библиотекой функций и поместил его в массив mImportNames. В функции DllMain мы загружаем gdi32.dll библиотеку с помощью WinAPI-вызова LoadLibrary. Затем циклом for проходим по массиву mImportNames и для каждого имени функции читаем её адрес с помощью GetProcAddress. Результат сохраняем массив mProcs.
Как в листинге 5-10 мы узнали, что порядковый номер TextOutA в массиве mProcs равен 696? Этот номер указан в обёртке, которую сгенерировал скрипт DLL Wrapper Generator:
1 extern "C" __declspec(naked) void TextOutA_wrapper(){__asm{jmp mProcs[696*4]}}
Единственный неясный момент: почему в сгенерированной обёртке индекс 696 умножается на 4? Дело в том, что в языке ассемблера любой массив представляется как байтовый. Однако, каждый элемент массива mProcs имеет тип UINT_PTR. Это указатель на беззнаковое целое. Размер всех указателей на 32-разрядной платформе равен четырём байтам (или 32 битам). Таким образом, если мы хотим из ассемблера получить доступ к элементу массива mProcs с индексом 696, мы должны умножить это число на размер элемента (т.е. на четыре). Язык C++ учитывает размер типа UINT_PTR и смещается на нужный элемент без дополнительного умножения.
Наша библиотека proxy DLL почти готова. Последние несколько шагов нужны, чтобы подготовить окружение для её использования:
- Скомпилируйте проект gdi32 в Visual Studio под 32-разрядную архитектуру.
- Скопируйте собранную proxy DLL с именем
gdi32.dllв каталог с тестовым приложениемTestApplication.exe. - Добавьте библиотеку
gdi32.dllв ключ системного реестраExcludeFromKnownDLL. Для этого через меню Start (Пуск) запустите стандартное Windows-приложениеregedit. Путь до нужного ключа следующий:
1 HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\
2 Session Manager\ExcludeFromKnownDlls
4. Перезагрузите компьютер, чтобы изменения реестра вступили в силу.
Чего мы добились этой правкой реестра? В Windows есть механизм, защищающий системные библиотеки от подмены вредоносным ПО. Наиболее важные из них указываются в реестре. Таким образом PE-загрузчик загружает эти библиотеки только из предопределённых путей. Однако, есть специальный ключ реестра ExcludeFromKnownDLL, который отменяет эту защиту для указанных в нём библиотек. В него надо добавить gdi32.dll. После этого PE-загрузчик станет придерживаться стандартной последовательности поиска DLL библиотеки, начиная с текущего каталога запускаемого приложения. Таким образом, будет загружена proxy DLL.
Теперь вы можете запустить наше тестовое приложение. В окне консоли вы увидите, что параметр gLife не опускается ниже 10, благодаря действиям бота.
Модификация WinAPI
Вторая техника перехвата вызовов приложения, которую мы рассмотрим, заключается в модификации системных функций. Предположим, что PE-загрузчик прочитал gdi32.dll библиотеку в память процесса. Теперь, получив доступ к этой памяти, мы можем модифицировать функции gdi32.dll модуля, которые следует перехватить. Достаточно изменить только первую ассемблерную инструкцию, заменив её на переход в наш код.
Есть несколько способов передачи управления из WinAPI-функции. Самое распространённое решение заключается в использовании ассемблерных инструкций JMP или CALL. Таким образом код бота получит управление. После выполнения его алгоритма, мы должны вернуться в оригинальную WinAPI-функцию. Но после модификации, этого сделать нельзя. Мы получим рекурсию, поскольку бот будет циклично вызывать WinAPI-функцию, а она — его. Это приведёт к переполнению стека, поскольку в нём сохраняется адрес возврата. В результате приложение завершит свою работу с ошибкой. Чтобы предотвратить этот сценарий, нам следует восстановить первую инструкцию WinAPI-функции и только потом её вызывать. Когда она закончит свою работу, надо снова установить переход (JMP или CALL) на код бота. Так мы будем готовы к следующему вызову.
Каким образом можно модифицировать WinAPI-функции в памяти процесса? В третьей главе при разработке бота для Diablo 2 мы рассмотрели способы записи в память. Но тогда речь шла о сегменте данных, который доступен для чтения и записи. Теперь же нам надо модифицировать сегмент кода с доступом на чтение и исполнение. Нам на помощь опять приходит WinAPI, который предоставляет функции VirtualQuery и VirtualProtect. С их помощью можно поменять флаги доступа к сегменту. Примеры использования этих функций приведены на форуме.
Мы разобрались, как модифицировать WinAPI-функции и передавать управление в код бота. Но остаётся ещё один вопрос. Чтобы код бота получил управление, он должен находиться в памяти процесса. Кто будет его загружать в нашем случае? PE-загрузчик и игровое приложение исключаются. Значит, это должен сделать сам бот с помощью WinAPI-функции RemoteLoadLibrary. Подробнее её использование описано в статье.
Иллюстрация 5-3 демонстрирует порядок вызова функции TextOutA после модификации WinAPI. В рассмотренном случае алгоритмы бота реализованы в библиотеке handler.dll.
TextOutA после модификации WinAPIАлгоритм вызова выглядит следующим образом:
- С помощью WinAPI-функции
RemoteLoadLibraryбиблиотекаhandler.dllзагружается в память целевого процесса. Сразу после этого её функцияDllMainполучает управление и модифицирует функциюTextOutAв загруженном ранее модулеgdi32.dll. - Исполнение кода EXE-модуля достигает инструкции
CALL DWORD PTR DS:[0x0]. В ней читается линейный адрес функции из Import Address Table. Затем управление передаётся в функциюTextOutAмодуляgdi32.dll. - Первая инструкция функции
TextOutAзаменена наJMPинструкцию. Она выполняет безусловный переход в обработчикTextOutA_handlerмодуляhandler.dll. - Код бота отрабатывает в обработчике
TextOutA_handler. - Обработчик
TextOutA_handlerвосстанавливает в исходное значение первую инструкцию функцииTextOutAмодуляgdi32.dll. Затем она вызывается с помощью инструкцииCALL. - После выполнения функция
TextOutAвозвращает управление обратно в обработчикTextOutA_handlerс помощью инструкцииRETN. - Первая инструкция
TextOutAснова замещается наJMP, которая передаёт управление в модульhandler.dll. - Обработчик
TextOutA_handlerвозвращает управление в EXE-модуль с помощьюRETNна инструкцию следующую за вызовомTextOutA.
Техника модификации WinAPI имеет следующие достоинства:
- Она позволяет перехватывать вызовы функций любой системной библиотеки (в том числе
kernel32.dll). - Существует несколько фреймворков для модификации WinAPI. Они предоставляют в готовом виде большую часть кода для внедрения модуля DLL с обработчиком и модификации первой инструкции перехватываемой функции.
К недостаткам техники можно отнести:
- Она не позволяет перехватывать вызовы функций, размер кода которых меньше пяти байтов. Это ограничение продиктовано размером инструкции
JMP. Если функция короче этой инструкции, то её модификация может привести к завершению работы процесса с ошибкой. - Достаточно сложно реализовать эту технику вручную без использования фреймворков.
- Техника работает ненадёжно с многопоточными приложениями. Причина заключается в том, что вызовы модифицированной WinAPI-функции никак не синхронизированы. Если она вызывается из первого потока (при этом первая инструкция функции восстанавливается), то её вызовы из других потоков не будут перехвачены.
Пример модификации WinAPI
Разработаем бота, который использует технику модификации WinAPI. Он будет работать по хорошо знакомому нам алгоритму: симулировать нажатие кнопки “1”, если значение gLife опустится ниже 10. Для разработки мы воспользуемся фреймворком Deviare.
Сначала познакомимся с фреймворком и его основными возможностями. В архиве с ним распространяется несколько демонстрационных примеров. Один из них под названием CTest перехватывает WinAPI-вызовы и записывает информацию о них в текстовый файл. В этом примере реализованы основные шаги техники перехвата: загрузка DLL библиотеки с обработчиками вызовов в память целевого процесса и алгоритм модификации WinAPI-функций.
Попробуем перехватить вызовы нашего тестового приложение с помощью примера CTest. Для этого выполните следующие действия:
- Скачайте архив с уже собранными исполняемыми файлами и библиотеками фреймворка. Распакуйте его в каталог с именем
deviare-bin. - Скопируйте исполняемый файл тестового приложения
TestApplication.exeв каталогdeviare-bin. - Откройте для редактирования конфигурационный файл
ctest.hooks.xmlиз каталогаdeviare-bin. В нём указаны WinAPI-вызовы, которые будут перехвачены. Добавьте в этот список функциюTextOutA:
1 <hook name="TextOutA">gdi32.dll!TextOutA</hook>
4. В командной строке запустите пример CTest со следующими параметрами:
1 CTest.exe exec TestApplication.exe -log=out.txt
Рассмотрим параметры командной строки примера CTest.exe. Первый из них exec TestApplication.exe указывает целевое приложение, которое следует запустить. После запуска в память процесса TestApplication будет загружена DLL библиотека с обработчиками вызовов. Второй параметр -log=out.txt указывает текстовый файл для вывода информации о перехваченных вызовах.
После запуска откроются два окна: CTest и TestApplication. Когда значение переменной gLife достигнет нуля в окне TestApplication, остановите выполнение приложения CTest нажатием Ctrl+C в его окне.
Откройте лог-файл out.txt и найдите в нём следующие строчки:
1 CNktDvEngine::CreateHook (gdi32.dll!TextOutA) => 00000000
2 ...
3 21442072: Hook state change [2500]: gdi32.dll!TextOutA -> Activating
4 ...
5 21442306: LoadLibrary [2500]: C:\Windows\System32\gdi32.dll / Mod=00000003
6 ...
7 21442852: Hook state change [2500]: gdi32.dll!TextOutA -> Active
Они означают, что CTest успешно модифицировал WinAPI-функцию TextOutA модуля gdi32.dll в памяти тестового приложения. Прокрутите лог-файл дальше. Вы найдёте информацию о каждом перехваченном вызове TextOutA в следующем виде:
1 21442852: Hook called [2500/2816 - 1]: gdi32.dll!TextOutA (PreCall)
2 [KT:15.600100ms / UT:0.000000ms / CC:42258224]
3 21442852: Parameters:
4 HDC hdc [0x002DFA60] "1795229328" (unsigned dword)
5 long x [0x002DFA64] "0" (signed dword)
6 long y [0x002DFA68] "0" (signed dword)
7 LPCSTR lpString [0x002DFA6C] "#" (ansi-string)
8 long c [0x002DFA70] "19" (signed dword)
9 21442852: Custom parameters:
10 21442852: Stack trace:
11 21442852: 1) TestApplication.exe + 0x00014A91
12 21442852: 2) TestApplication.exe + 0x0001537E
13 21442852: 3) TestApplication.exe + 0x000151E0
14 21442852: 4) TestApplication.exe + 0x0001507D
Как вы видите, CTest извлекает полную информацию о типах и значениях параметров перехваченных функций. Также мы получили точное время перехвата и трассировку стека. Благодаря трассировке можно определить, из какого места тестового приложения был сделан каждый вызов. Эта информация окажется полезной, если вам нужно перехватывать только некоторые вызовы.
Для реализации нашего бота будет достаточно функциональности, которую предоставляет пример CTest. Возьмём его код за основу и добавим в него алгоритм бота. Для этого необходимо выполнить следующие действия:
- Откройте в Visual Studio файл проекта примера CTest. Его можно найти в архиве с исходным кодом Deviare по пути
Samples\C\Test\CTest.sln - Откройте файл
MySpyMgr.cpp, который содержит код обработки перехваченных функций. - В открытом файле найдите метод обработчика
CMySpyMgr::OnFunctionCalled. Он вызывается перед тем, как управление будет передано в WinAPI-функцию. Метод достаточно длинный, но всё что в нём происходит — это вывод в лог-файл трассировки стека, параметров и возвращаемого значения перехваченной функции. - Перед методом
CMySpyMgr::OnFunctionCalledдобавьте функциюProcessParamиз листинга 5-12, реализующую алгоритм бота.
ProcessParam 1 VOID ProcessParam(__in Deviare2::INktParam *lpParam)
2 {
3 CComBSTR cBstrName;
4 lpParam->get_Name(&cBstrName);
5
6 unsigned long val = 0;
7 HRESULT hRes = lpParam->get_ULongVal(&val);
8 if (FAILED(hRes))
9 return;
10
11 wprintf(L"ProcessParam() - name = %s value = %u\n",
12 (BSTR)cBstrName, (unsigned int)(val));
13
14 if (val < 10)
15 {
16 INPUT Input = { 0 };
17 Input.type = INPUT_KEYBOARD;
18 Input.ki.wVk = '1';
19 SendInput( 1, &Input, sizeof( INPUT ) );
20 }
21 }
5. В метод CMySpyMgr::OnFunctionCalled добавьте вызов функции ProcessParam. Найдите следующую строчку:
1 if (sCmdLineParams.bAsyncCallbacks == FALSE &&
2 SUCCEEDED(callInfo->Params(&cParameters)))
3 {
4 LogPrint(L" Parameters:\n");
Замените её на это:
1 if (sCmdLineParams.bAsyncCallbacks == FALSE &&
2 SUCCEEDED(callInfo->Params(&cParameters)))
3 {
4 if (SUCCEEDED(cParameters->GetAt(4, &cParam)))
5 ProcessParam(cParam);
6 LogPrint(L" Parameters:\n");
Разберём подробнее код вызова функции ProcessParam. В первом операторе if проверяются два условия:
- Был ли указан ключ командной строки
-asyncпри запуске CTest. Если был, то параметры перехваченного вызова будут обрабатываться асинхронно. - Из объекта
callInfoуспешно удалось извлечь параметры перехваченной функции и записать их в массив объектовcParameters.
Если одна из этих проверок не прошла, алгоритм бота не будет вызван.
Во втором операторе if проверяется, что пятый параметр перехваченной функции удалось прочитать без ошибки. Он соответствует длине печатаемой строки. Этот параметр передаётся в следующий далее вызов ProcessParam.
Рассмотрим алгоритм функции ProcessParam из листинга 5-12:
- В переменную
cBstrNameпрочитать имя пятого параметра функцииTextOutA. Для этого используется методget_NameобъектаlpParam, в котором хранится вся информация о параметре. - В переменную
valпрочитать значение параметра с помощью методаget_ULongValобъектаlpParam. Если это не удалось, функцияProcessParamзавершит свою работу. - Вывести в консоль имя
cBstrNameи значениеvalпараметра с помощью функцииwprintf. Этот диагностический вывод позволит проверить входные данные для следующего далее алгоритма бота. - Проверить, что текущее значение
valпараметра меньше десяти. Если это так, симулировать нажатие клавиши “1”.
Чтобы запустить CTest и тестовое приложение, выполните следующие шаги:
- Скомпилируйте проект CTest под 32-разрядную платформу.
- Получившийся бинарный файл
CTest.exeскопируйте с заменой в каталогdeviare-bin. - Скопируйте исполняемый файл тестового приложения
TestApplication.exeв каталогdeviare-bin. - Запустите приложение CTest следующей командой:
1 CTest.exe exec TestApplication.exe -log=out.txt
Иллюстрация 5-5 демонстрирует окна запущенных приложений CTest и TestApplication.
В окне TestApplication выводится текущее значение переменной gLife. Его же мы видим в окне CTest, но полученное из перехваченного вызова TextOutA. Если gLife опустится ниже десяти, бот будет симулировать нажатие клавиши “1”.
Выводы
Мы рассмотрели две техники перехвата данных на уровне ОС. Они позволяют получить точную информацию о состоянии игровых объектов. В то же время эти техники имеют несколько преимуществ над чтением данных из памяти процесса игры:
- Большинство антиотладочных приёмов не защищают от перехвата WinAPI-вызовов.
- Намного проще реализовать обработчик перехваченной функции, чем анализировать память игрового приложения.
- Системам защиты крайне сложно обнаружить факт перехвата вызовов.
Вы можете использовать техники перехвата WinAPI-вызовов не только в алгоритме бота, но и для исследования памяти процесса игрового приложения. Они помогут вам проверить предположения об алгоритмах игры и организации её данных.
В статье рассмотрены техники перехвата WinAPI-вызовов, не упомянутые в этой книге.