В данной статье рассматривается перехват функций графического API на примере DirectX 9 под x64 применительно к игре Dota 2.
Будет подробно рассказано, как внедриться в процесс игры, как изменить поток выполнения, приведено краткое описание внедряемой логики. В конце поговорим о других возможностях для отрисовки, которые предоставляет движок.
Disclaimer: Автор не несет ответственности за применение вами знаний полученных в данной статье или ущерб в результате их использования. Вся информация здесь изложена только в познавательных целях. Особенно для компаний разрабатывающих MOBA, чтобы помочь им бороться с читерами. И, естественно, автор статьи ботовод, читер и всегда им был.
В последнем предложении стоит объясниться — я за честное соревнование. Читы использую только в качестве спортивного интереса, улучшения навыков реверса, изучения работы античитов и только вне рейтинговых состязаний.
1. Вступление
Данная статья планируется как первая из цикла и дает представление о том, как можно использовать графическое API в своих целях, описывает функционал, требуемый для понимания следующей части. Вторую статью я планирую посвятить поиску указателя на список сущностей в Source 2 (также на примере Dota 2) и использовании его в связке с Source2Gen для написания «дополнительной» логики (что-нибудь по типу этого, скорее всего покажу «map hack» (обратите внимание на кавычки, о чем идет речь можно посмотреть на видео), либо автоматизации первой статьи). Третья статья планируется в виде написания драйвера, общение с ним (IOCTL), использование его для обхода защиты VAC (что-то похожее на это).
2. Для чего мне это понадобилось
Использование графического API мне понадобилось для визуальной отладки моего бота, которого я писал для Dota 2 (визуализированная информация в реальном времени очень удобна). Я являюсь аспирантом и занимаюсь реконструированием 3D головы и морфинга при помощи снимков и камеры глубины — тема довольно интересная, но для меня не самая любимая. Так как я занимаюсь этим уже пятый год (начиная с магистратуры), я понял одно — да, я неплохо изучил данную сферу, легко изучаю статьи с методами и подходами, реализую их. Но это все, сам я могу только оптимизировать очередной изученный алгоритм, сравнить его с уже изученными и реализованными и принять решение, стоит ли его использовать в определенной задаче. На оптимизации дело заканчивается, самому придумать что-то новое не получается, что для аспирантуры очень важно (новизна исследования). Начал думать — пока есть время, можно найти новую тему. В теме уже нужно хорошо разбираться (на уровне текущей) или ее можно быстро подтянуть.
Параллельно я работал в геймдеве и это, наверное, самое интересное из того, чем можно заняться программисту (личное мнение) и очень интересовался темой AI, ботов. На тот момент было две темы, в которых я неплохо разбирался — тогда я занимался построением динамического навигационного меша (клиент-сервер) и изучением сетевой части динамического шутера. Тема с динамическим нав мешем не подходила сразу — этим я занимался в рабочее время, нужно было спрашивать разрешение на ее использование в дипломе у руководства, к тому же, тема новизны была открыта — я так же неплохо изучал и реализовывал имеющиеся подходы по статьям, но в этом не было новизны. Тема с сетевой частью динамического шутера (я планировал применять ее для взаимодействия в виртуальной реальности) снова разбивалась как о то, что я занимался этим в рабочее время, так и о новизну, можно почитать цикл статей от Pixonic, где сам автор говорит, что тема эта интересная, вот только подходы изобретены 30 лет назад и особо не поменялись.
Примерно в это время OpenAI выпустили своего бота. Это конечно не 5 на 5, но это было потрясающе! Я не мог выкинуть мысли попробовать сделать бота и начал первым делом думать о том, как это использовать в качестве диссертации, о новизне, как это преподнести руководителю. С новизной в этом плане все было куда лучше — наверняка можно было придумать что-то и для двух предыдущих тем, но видимо бот заставил меня думать, цепляться, развивать и искать идеи куда сильнее. Итак, я решил сделать бота 1 на 1 (сражение на миду, как у OpenAI), презентовать его руководителю, рассказать как это круто, как тут много разных подходов, математики, а самое главное — нового.
Самое необходимое, что нужно боту на первом этапе, это знание среды, в которой он находится — состояние мира я намеревался брать из памяти игры и первый этап провел за поиском указателя на список сущностей (Entity List) и интеграции с детищем praydog`а Source2Gen — эта штука генерирует структуру движка Source2, которую берет из схем. Основной идеей и предпосылкой возникновения схем является репликация состояния между клиентом и сервером, но видимо идея разработчикам очень понравилась и они распространили ее намного шире, советую почитать тут.
У меня имелся опыт reverse engineering: делал читы для Silent Storm, делал генераторы ключей (самый интересный был для Black&White) – что такое кейген можно почитать у DrMefistO тут, выполнение комбо в Cabal Online (тут все усложнялось тем, что эту игру охранял Game Guard, охранял его из ring0 (под драйвером в режиме ядра), пряча процесс (что как минимум не дает легко внедриться в него) — подробней можно почитать тут).
Соответственно, у меня были наработки в этой сфере, бот получил доступ к окружению за планируемое время. Удивительно, как много информации сервер доты реплицирует через дельту клиенту, например, клиент имеет информацию о любых телепортах, здоровье и его изменении у энжентов (кроме Рошана, он не реплицируется) — все это в тумане войны. Хотя я и столкнулся с некоторыми трудностями — это то, о чем я собираюсь рассказать в следующей статье.
Если у вас возник вопрос, почему я не использовал Dota Bot Scripting, отвечу выдержкой из документации:
The API is restricted such that scripts can't cheat — units in FoW can't be queried, commands can't be issued to units the script doesn't control, etc.
Данный цикл статей ориентирован на новичков, которым интересна тема обратной разработки.
3. Зачем я об этом пишу
По итогу я столкнулся с множеством проблем в реализации бота со стороны ml, над которыми просидел достаточно времени, чтобы понять, что за два года до конца обучения не смогу переплюнуть мои знания и опыт в текущей теме. В Dota 2 я не играю с выхода кастомки Dota Auto Chess, свободное время теперь трачу на диплом и реверc Apex Legend (структура которой довольно схожа с Dota 2, как мне кажется). Соответственно, единственная польза от проделанной работы — публикация технической статьи на эту тему.
4. Dota 2
Приведенные принципы я планируют показывать на реальной игре — Dota 2. Игра использует античит Valve Anti Cheat. Мне очень нравится Valve как компания: очень классные продукты, директор, отношение к игрокам, Steam, Source Engine 2, … VAC. VAC работает из user-mode (ring3), он не сканирует все подряд и сравнительно с остальными античитами безобиден (от того, что делает esea (конкретно их античит) пропадает все желание пользоваться этой платформой). Я уверен, что VAC делает свою работу таким щадящим образом — не мониторит из режима ядра, не банит по железу (только аккаунт), не вставляет водяные знаки в скрины — благодаря отношению Valve к игрокам, они не устанавливают вам полноценный антивирус, как это делают Game Guard, BattlEye, Warden и прочие, потому что все это итак взламывается и в придачу тратит ресурсы процессора, которые могла бы занять игра (даже если это делается периодически), бывают ложные срабатывания (особенно у игроков на ноутбуках). Разве в PUBG, Apex, Fortnite нет wall hack, aimbot, speed hack, ESP?
Собственно о Dota 2. Игра работает с частотой 40Hz (25 ms), клиент интерполирует игровое состояние, предсказание ввода не используется — если у вас случается лаг, игра — важно даже не игра, подконтрольные юниты — полностью фризится. Сервер игровой механики обменивается с клиентом сообщениями через RUDP (надежное UDP) шифрованными сообщениями, клиент в основном отправляет ввод (если вы хостите лобби, могут отправляться команды), сервер шлет реплику игрового мира и команды. Навигация осуществляется по 3D сетке, каждая ячейка имеет свой тип проходимости. Передвижение осуществляется при помощи навигации и физики (невозможность прохождения через фиссуру шейкера, коги клокверка и тд).
Состояние мира со всеми сущностями находится в памяти в чистом виде без шифрования — можно изучать память игры при помощи Cheat Engine. Обфускация к строкам и коду не применяется.
5. Постановка задачи
В игре Dota 2 есть нейтральный «древний», убийство которого дает хорошее вознаграждение: опыт, золото, возможность откатить кулдауны скилов и предметов, Аегис (вторая жизнь), зовут его Рошан. Получение Аегиса может в корне перевернуть игру или дать еще большее преимущество более сильной стороне, соответственно игроки стараются запомнить/записать время его смерти, чтобы запланировать, когда нужно собраться вместе и напасть на него, либо быть поблизости для его охраны. О смерти Рошана оповещаются все десять игроков вне зависимости от того, скрыт ли он в тумане войны. Время возрождения имеет обязательные восемь минут, после которых Рошан может появиться случайным образом в интервале трех минут.
Задача следующая: предоставить игроку информацию по текущему состоянию Рошана (alive-жив, ressurect_base-возрождается базовое время, ressurect_extra-возрождается дополнительное время).
Рисунок 1 — Условия переходов между состояниями и действия при переходе
Для состояний, в которых Рошан мертв, выводить время окончания пребывания в данном состоянии. Переход из состояния alive в ressurect_base должно производиться игроком в ручном режиме по кнопке. В случае обнаружения/смерти Рошана в состоянии ressurect_extra (например вражеская команда тайком пробралась в логово и убила его), переход в состояние alive/ressurect_base также осуществляется в ручном режиме по кнопке. Статус Рошана (и время окончания пребывания в состоянии возрождения) показывать в текстовом виде, необходимый ввод (убийство и прерывание состояния ressurect_extra) обеспечить кнопкой.
Рисунок 2 — Элементы интерфейса — лейбл, кнопка и холст
Эта единственная задача, которую я смог придумать, чтобы не требовалась работа с памятью игры и имелась хоть какая-то ценность для игрока — даже для вывода каких-либо элементарных характеристик, таких как здоровья, мана, позиции сущностей, нужно их либо предварительно найти при помощи Cheat Engine в памяти игры, что необходимо дополнительно и довольно долго объяснять, либо при помощи Source2Gen, о чем и будет следующая статья. Постановка задачи заставляет игрока следить за Рошаном, перекладывая на него много действий, что довольно неудобно — зато будет на что опереться во второй части.
Мы напишем свою injected.dll, в которой будет содержаться бизнес логика на основе MVC и внедрим ее в процесс Dota 2. Dll будет использовать нашу библиотеку silk_way.lib, которая будет содержать логику ловушек для изменения потока выполнения, логгер, сканер памяти и структуры данных.
6. Injector
Создадим пустой проект на C++, назовем NativeInjector. Основной код находится в функции Inject.
void Inject(string & dllPath, string & processName) {
DWORD processId = GetProcessIdentificator(processName);
if (processId == NULL)
throw invalid_argument("Process dont existed");
HANDLE hProcess =
OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE,
FALSE, processId);
HMODULE hModule = GetModuleHandle("kernel32.dll");
FARPROC address = GetProcAddress(hModule, "LoadLibraryA");
int payloadSize = sizeof(char) * dllPath.length() + 1;
LPVOID allocAddress = VirtualAllocEx(
hProcess, NULL, payloadSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
SIZE_T written;
bool writeResult = WriteProcessMemory(hProcess, allocAddress,
dllPath.c_str(), payloadSize, & written);
DWORD treadId;
CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE) address,
allocAddress, 0, & treadId);
CloseHandle(hProcess);
}
Функция получает путь и название процесса, ищет по названию процесса его Id при помощи GetProcessIdentificator.
DWORD GetProcessIdentificator(string & processName) {
PROCESSENTRY32 processEntry;
processEntry.dwSize = sizeof(PROCESSENTRY32);
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);
DWORD processId = NULL;
if (Process32First(snapshot, & processEntry)) {
while (Process32Next(snapshot, & processEntry)) {
if (!_stricmp(processEntry.szExeFile, processName.c_str())) {
processId = processEntry.th32ProcessID;
break;
}
}
}
CloseHandle(snapshot);
return processId;
}
Вкратце, GetProcessIdentificator пробегает по всем запущенным процессам и ищет процесс с соответствующим названием.
Рисунок 3 — Начальное состояние процесса
Далее непосредственное внедрение библиотеки при помощи создания удаленного потока.
Рисунок 4 — Создание удаленного потока
Обязательно в конце закрываем обработчик процесса функцией CloseHandle, можно также освободить выделенную память. Наш инжектор готов и ждет, когда мы напишем бизнес логику в injected.dll с библиотекой silk_way.lib.
Рисунок 5 — Завершение внедрения библиотеки
Для лучшего понимания принципа можно посмотреть видео. В завершении скажу, что безопасней является подход с прямым внедрением кода в главный поток процесса.
7. Silk Way
Приступим к реализации silk_way.lib — статической библиотеки, которая содержит структуры данных, логгер, сканер памяти и ловушки. По сути я взял маленькую часть своих наработок, то, что можно объяснить проще всего, что не слишком завязано на остальное, но в тоже время решает поставленную задачу.
7.1. Структуры данных.
Вкратце про структуры данных: Vector – классический список, время вставки и удаления O(N), поиск O(N), память O(N); Queue – циклическая очередь, время вставки и удаления O(1), поиск отсутствует, память O(N); RBTree — красно-черное дерево, время вставки и удаления O(logN), поиск O(logN), память O(N). Я предпочитаю хешу, который используется для реализации словарей в C# и Python, красно-черные деревья, которые использует стандартная библиотека C++. Причина — хеш сложнее реализовать правильнее по сравнению с деревом (примерно каждые пол года нахожу и пробую разновидности хешей), и обычно хеш занимает больше памяти (хотя работает быстрее). Данные структуры используются для создания коллекций как в бизнес логике, так и в ловушках.
Я стараюсь не использовать структуры из стандартной библиотеки и реализую их сам, конкретно в нашем случае это не имеет значения, но это важно, если ваша dll будет подвергнута дебагу или сборка находится в открытом виде (это скорее касается коммерческих читов, что мы с вами осуждаем). Все структуры я советую писать самим, это дает вам бОльшие возможности.
Как пример, если вы делаете игру и не хотите, чтобы «школьники» сканировали ее при помощи Cheat Engine, можно сделать обертки для примитивных типов и хранить в памяти зашифрованное значение. На самом деле это не спасение, но может отсеять некоторую часть тех, кто пытается прочитать и изменить память игры.
7.2. Логгер
Реализован вывод в консоль и запись в файл. Интерфейс:
class ILogger {
protected:
ILogger(const char * _path) {
path = path;
}
public:
virtual ~ILogger() {}
virtual void Log(const char * format, ...) = 0;
protected:
const char * path;
};
Реализация для вывода в файл:
class MemoryLogger: public ILogger {
public:
MemoryLogger(const char * _path): ILogger(_path) {
fopen_s( & fptr, _path, "w+");
}
~MemoryLogger() {
fclose(fptr);
}
void Log(const char * format, ...) {
char log[MAX_LOG_SIZE];
log[MAX_LOG_SIZE - 1] = 0;
va_list args;
va_start(args, format);
vsprintf_s(log, MAX_LOG_SIZE, format, args);
va_end(args);
fprintf(fptr, log);
}
protected:
FILE * fptr;
};
Реализация для вывода в консоль однотипна. Если мы хотим использовать логирование, необходимо определить интерфейс ILogger*, объявить нужный логгер, вызвать функцию Log c требуемым форматом, например:
ILogger* logger = new MemoryLogger(filename);
logger->Log("(%llu)%s: %dn", GetCurrentThreadId(), "EnumerateThread result",
result);
7.3. Сканер
Сканер занимается тем, что выводит значение памяти, на который указывает переданный указатель и производит сравнение с образцом в памяти. Функционал сравнения с паттерном будет рассмотрен позже.
Интерфейс:
class IScanner {
protected:
IScanner() {}
public:
virtual ~IScanner() {}
virtual void PrintMemory(const char * title, unsigned char * memPointer,
int size) = 0;
};
Реализация заголовочного файла:
class FileScanner : public IScanner {
public:
FileScanner(const char* _path) : IScanner() {
fopen_s(&fptr, _path, "w+");
}
~FileScanner() {
fclose(fptr);
}
void PrintMemory(const char* title, unsigned char* memPointer, int size);
protected:
FILE* fptr;
};
Реализация файла источника:
void FileScanner::PrintMemory(const char* title, unsigned char* memPointer,
int size) {
fprintf(fptr, "%s:n", title);
for (int i = 0; i < size; i++)
fprintf(fptr, "%x ", (int)(*(memPointer + i)));
fprintf(fptr, "n", title);
}
Для использования необходимо определить интерфейс IScanner*, объявить нужный сканер и вызвать функцию PrintMemory, где задать титул, указатель и длину, например:
IScanner* scan = new ConsoleScanner();
scan->PrintMemory("source orig", (unsigned char*)source, 30);
7.4. Ловушки
Самая интересная часть библиотеки silk_way.lib. Ловушки (hook) служат для того, чтобы изменять поток выполнения программы. Создадим исполняемый проект с названием Sandbox.
class Unknown {
protected:
Unknown() {}
public:
~Unknown() {}
virtual HRESULT QueryInterface() = 0;
virtual ULONG AddRef(void) = 0;
virtual ULONG Release(void) = 0;
};
class Device : public Unknown {
public:
Device() : Unknown() {}
~Device() {}
virtual HRESULT QueryInterface() {
return 0;
}
virtual ULONG AddRef(void) {
return 0;
}
virtual ULONG Release(void) {
return 0;
}
virtual int Present() {
cout << "Present()" << " " << i << endl;
return i;
}
virtual void EndScene(int j) {
cout << "EndScene()" << " " << i << " " << j << endl;
}
void Dispose() {
cout << "Dispose()" << " " << i << endl;
}
public:
int i;
};
Класс Device наследуется от интерфейса IUnknown, наша задача перехватить вызов функций Present и EndScene любого экземпляра Device, в приемнике вызвать оригинальные функции. Мы не знаем места в коде, где и зачем вызываются эти функции, в каком потоке.
Смотря на функции Present и EndScene видно, что они виртуальные. Виртуальные функции нужны для того, чтобы переопределять поведение родительского класса. Виртуальные функций, как и не виртуальные, представляют из себя указатель на память, в которой записаны операционные коды (opcode) и значения аргументов. Так как виртуальные функции отличаются у наследников и родителей, они имеют разные указатели (это совершенно разные функции) и хранятся в Таблице виртуальных методов (VMT). Эта таблица хранится в памяти и представляет собой указатель на указатель класса, найдем ее для Device:
Device* device = new Device();
unsigned long long vmt = **(unsigned long long**)&device;
VMT хранит указатели на виртуальные функции, если мы захотим наследоваться от Device, наследник будет содержать свою VMT. VMT хранит указатели на функции последовательно с шагом, равным размеру указателя (для x86 это 4 байта, для x64 – 8), соответствуя порядку определения функции в классе. Найдем указатели на функции Present и EndScene, которые располагаются на третьем и четвертом месте:
typedef int (*pPresent)(Device*);
typedef void (*pEndScene)(Device*, int j);
pPresent ptrPresent = nullptr;
pEndScene ptrEndScene = nullptr;
int main() {
//declare Device and find pointer vmt
ptrPresent = (pPresent)(*(unsigned long long*)(vmt + 8 * 3));
ptrEndScene = (pEndScene)(*(unsigned long long*)(vmt + 8 * 4));
}
Важно также то, что указатель на метод класса должен первым аргументом содержать ссылку на экземпляр класса. В C++, С# это прячется от нас, а компилятор об этом знает — в Python явно указывается self первым параметром в методе класса. Подробней о соглашение о вызове (calling convention) тут, искать нужно thiscall.
Рассмотрим инструкцию e9 ff 3a fd ff — здесь e9 является опкодом (с мнемоникой JMP), который говорит процессору изменить указатель на инструкцию (EIP для x86, RIP для x64), прыгнуть от текущего адреса на FFFD3AFF (4294785791). Стоит также отметить, что в памяти числа хранятся «наоборот». Функции имеют пролог и эпилог и хранятся в секции .code. Давайте посмотрим, что хранится у нас по указателю на функцию Present при помощи сканера:
IScanner* scan = new ConsoleScanner();
scan->PrintMemory("Present", (unsigned char*)ptrPresent, 30);
В консоли видим:
Present:
48 89 4c 24 8 48 83 ec 28 48 8d 15 40 4a 0 0 48 8b d 71 47 0 0 e8 64 10 0 0 48 8d
Чтобы разобраться в наборе этих кодов можно посмотреть таблицу, либо использовать имеющиеся дизассемблеры. Мы возьмем готовый дизассемблер — hde(hacker disassembler engine). Также для сравнения можно глянуть на distorm и capstone. Передайте указатель на функцию любому дизассемблеру и он скажет, что за опкоды в ней используются, значения аргументов и прочее.
7.4.1 Opcode Hook
Теперь мы готовы перейти непосредственно к ловушкам. Мы рассмотрим Opcode Hook и Hardware Breakpoint. Самые распространенные ловушки, которые я советую реализовать и поизучать.
Наверное самой часто используемой и простой ловушкой является Opcode Hook (в статье с перечислением ловушек она называется Byte patching) — заметьте, она легко распознается античитом при неумелом использовании (без понимания, как работает античит, без знания, какую область и секцию памяти он сканирует в текущий момент и прочего бан не замедлит себя ждать). При умелом использовании это прекрасная ловушка, быстрая и простая для понимания.
Если при чтении статьи вы параллельно воспроизводите код и находитесь в режиме Debug, переключитесь в Release — это важно.
Итак, напомню, нам необходимо перехватить выполнение функций Present и EndScene.
Реализуем перехватчики — функции, куда мы хотим передать управление:
int PresentHook(Device* device) {
cout << "PresentHook" << endl;
return 1;
}
void EndSceneHook(Device* device, int j) {
cout << "EndSceneHook" << " " << j << endl;
}
Подумаем над абстракциями, которые нам нужны. Нам требуется интерфейс, который даст возможность поставить ловушку, убрать ее и предоставить информацию о ней. Информация о ловушке должна содержать указатель на перехватываемую функцию, функцию приемник и трамплин (то, что мы перехватили функцию не значит, что она больше не нужна, мы также хотим иметь возможность использовать ее — трамплин поможет вызвать исходную перехваченную функцию).
#pragma pack(push, 1)
struct HookRecord {
HookRecord() {
reservationLen = 0;
sourceReservation = new void*[RESERV_SIZE]();
}
~HookRecord() {
reservationLen = 0;
delete[] sourceReservation;
}
void* source;
void* destination;
void* pTrampoline;
int reservationLen;
void* sourceReservation;
};
#pragma pack(pop)
class IHook {
protected:
IHook() {}
public:
virtual ~IHook() {}
virtual void SetExceptionHandler(
PVECTORED_EXCEPTION_HANDLER pVecExcHandler) = 0;
virtual int SetHook(void* source, void* destination) = 0;
virtual int UnsetHook(void* source) = 0;
virtual silk_data::Vector<HookRecord*>* GetInfo() = 0;
virtual HookRecord* GetRecordBySource(void* source) = 0;
};
Интерфейс IHook предоставляет нам такие возможности. Мы хотим, чтобы когда любой экземпляр класса Device вызывал функции Present и EndScene (то есть указатель RIP переходил на эти адреса), выполнялись соответственно наши функции PresentHook и EndSceneHook.
Представим визуально, как расположены в памяти (секция .code) перехватываемая функция, приемник и трамплин в момент, когда управление заходит в перехватываемую функцию:
Рисунок 6 — Начальное состояние памяти, исполнение заходит в перехватываемую функцию
Теперь мы хотим, чтобы RIP (красная стрелка) перешла с source на начало destination. Как это сделать? Как уже написано выше, участок памяти source содержит опкод, который процессор выполнит, когда до source дойдет выполнение. По сути, нам нужно перепрыгнуть из одной части в другую, перенаправить указатель RIP. Как вы уже могли догадаться, есть опкод, который позволяет переводить управление из текущего адреса в желаемый, называется эта мнемоника JMP.
Прыгать можно как напрямую на нужный адрес, так и относительно текущего адреса, эти прыжки можно отыскать в табличке – ff и e9 соответственно. Создадим структуры для этих инструкций:
#pragma pack(push, 1)
// 32-bit relative jump.
typedef struct {
unsigned char opcode;
unsigned int delta;
} JMP_REL;
// 64-bit absolute jump.
typedef struct {
unsigned char opcode1;
unsigned char opcode2;
unsigned int dummy;
unsigned long long address;
} JMP_ABS;
#pragma pack(pop)
Инструкция относительного прыжка короче, но есть ограничение — unsigned int говорит о том, что прыгнуть можно в пределах 4,294,967,295, что для x64 процесса не достаточно.
Соответственно, адрес функции приемника destination может легко перевалить это значение и находиться за пределами unsigned int, что вполне возможно для x64 процесса (для x86 все намного проще и можно ограничиться как раз этим самым относительным прыжком для реализации Opcode Hook). Прямой прыжок занимает 14 байт, для сравнения относительный — лишь 5 (мы упаковали структуры, обратите внимание на #pragma pack(push, 1)).
Нам нужно переписать значение по адресу source на одну из этих прыжковых инструкций.
Перед тем, как ловить функцию, следует изучить ее — проще всего это сделать при помощи дебагера (дальше я покажу, как это делать при помощи x64dbg), или дизассемблера. Для Present мы уже вывели 30 байт от ее начала, инструкция 48 89 4c 24 8 занимает 5 байт.
Давайте реализуем относительный прыжок. Мне больше нравится этот вариант во многом из-за длины инструкции. Идея в следующем: мы заменяем первые 5 байт исходной функции, пресохранив измененные байты, заменяем их относительным прыжком на адрес инструкции, который лежит в пределах unsigned int.
Рисунок 7 — Исходные 5 байт функции source заменяются относительным прыжком
Что нам дает прыжок на выделенную память (фиолетовая область), как этим действием мы приблизили себя к передачи управления на destination? В выделенной нами памяти располагается прямой прыжок, который и будет перемещать RIP на destination.
Рисунок 8 — Переключение RIP на функцию приемник
Осталось придумать, как вызвать пойманную функцию. Нам нужно выполнить затертые инструкции и начать выполнение с нетронутой части source. Поступим следующим образом — сохраним поврежденные инструкции в начало trampoline, запомним, сколько байт было повреждено и прыгнем прямым прыжком на source + corruptLen, к «здоровым» инструкциям.
Выполнение сохраненных инструкций, затертых относительным прыжком:
Рисунок 9 — Использование трамплина для вызова перехваченной функции
Дальнейшее выполнение инструкций, которых не коснулось затирание:
Рисунок 10 — Продолжение исполнения инструкций перехваченной функции
int OpcodeHook::SetHook(void* source, void* destination) {
auto record = new HookRecord();
record->source = source;
record->destination = destination;
info->PushBack(record);
JMP_ABS pattern = {0xFF, 0x25, 0x00000000, // JMP[RIP + 6] empty
0x0000000000000000
}; // absolute address
pattern.address = (ULONG_PTR)source;
int currentLen = 0;
int redLine = sizeof(JMP_REL);
while (currentLen < redLine) {
hde64s context;
const void* pSource = (void*)((unsigned char*)source + currentLen);
hde64_disasm(pSource, &context);
memcpy((unsigned char*)record->sourceReservation + currentLen, pSource, context.len);
record->reservationLen += context.len;
currentLen += context.len;
}
int trampolineMemorySize = 2 * sizeof(JMP_ABS) + record->reservationLen;
record->pTrampoline = AllocateMemory(source, trampolineMemorySize);
pattern.address = (unsigned long long)(unsigned char*)source + record->reservationLen;
memcpy((unsigned char*)record->pTrampoline, record->sourceReservation, record->reservationLen);
int offset = record->reservationLen;
memcpy((unsigned char*)record->pTrampoline + offset, &pattern, sizeof(JMP_ABS));
pattern.address = (ULONG_PTR)destination;
ULONG_PTR relay = (ULONG_PTR)record->pTrampoline + sizeof(pattern) + record->reservationLen;
memcpy((void*)relay, &pattern, sizeof(pattern));
DWORD oldProtect = 0;
VirtualProtect(source, sizeof(JMP_REL), PAGE_EXECUTE_READWRITE, &oldProtect);
JMP_REL* pJmpRelPattern = (JMP_REL*)source;
pJmpRelPattern->opcode = 0xE9;
pJmpRelPattern->delta = (unsigned int)((LPBYTE)relay - ((LPBYTE)source + sizeof(JMP_REL)));
VirtualProtect(source, sizeof(JMP_REL), oldProtect, &oldProtect);
return SUCCESS_CODE;
}
Очень важный момент заключается в том, что нам нужно выделить память под трамплин и relay, в которых мы будет хранить инструкции для перенаправления потока от source к destination и адрес на эту память должен быть в пределах, на которые может себе позволить прыгнуть относительный прыжок (unsigned int).
Данный функционал реализует функция AllocateMemory.
void* OpcodeHook::AllocateMemory(void* origin, int size) {
const unsigned int MEMORY_RANGE = 0x40000000;
SYSTEM_INFO sysInfo;
GetSystemInfo(&sysInfo);
ULONG_PTR minAddr = (ULONG_PTR)sysInfo.lpMinimumApplicationAddress;
ULONG_PTR maxAddr = (ULONG_PTR)sysInfo.lpMaximumApplicationAddress;
ULONG_PTR castedOrigin = (ULONG_PTR)origin;
ULONG_PTR minDesired = castedOrigin - MEMORY_RANGE;
if (minDesired > minAddr && minDesired < castedOrigin)
minAddr = minDesired;
int test = sizeof(ULONG_PTR);
ULONG_PTR maxDesired = castedOrigin + MEMORY_RANGE - size;
if (maxDesired < maxAddr && maxDesired > castedOrigin)
maxAddr = maxDesired;
DWORD granularity = sysInfo.dwAllocationGranularity;
ULONG_PTR freeMemory = 0;
ULONG_PTR ptr = castedOrigin;
while (ptr >= minAddr) {
ptr = FindPrev(ptr, minAddr, granularity, size);
if (ptr == 0)
break;
LPVOID pAlloc = VirtualAlloc((LPVOID)ptr, size, MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (pAlloc != 0)
return pAlloc;
}
while (ptr < maxAddr) {
ptr = FindNext(ptr, maxAddr, granularity, size);
if (ptr == 0)
break;
LPVOID pAlloc = VirtualAlloc((LPVOID)ptr, size, MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (pAlloc != 0)
return pAlloc;
}
return NULL;
}
Идея проста — будем идти по памяти, начиная от определенного адреса (в нашем случае указателя на source) вверх и вниз, пока не найдем подходящий по размеру свободный кусок.
Вернемся к функции SetHook. В выделенную память копируем потертые байты из source и после сразу вставляем прямой прыжок на source + corrupt, чтобы продолжить выполнение с неповрежденных инструкций.
Далее идет установка указателя relay, который отвечает за перенаправление потока выполнения на destination путем прямого прыжка на адрес приемника. В конце изменяем source – выставляем права на запись в то место памяти, где находится функция и заменяем первые 5 байт на относительный прыжок, ведущий на адрес relay.
Ловушку мы установили, но ее также нужно уметь убирать. Ломать — не строить, идея простая — вернем потертые байты source, удалим запись о ловушке из коллекции, а выделенную память освободим:
int OpcodeHook::UnsetHook(void* source) {
auto record = GetRecordBySource(source);
DWORD oldProtect = 0;
VirtualProtect(source, sizeof(JMP_REL), PAGE_EXECUTE_READWRITE, &oldProtect);
memcpy(source, record->sourceReservation, record->reservationLen);
VirtualProtect(source, sizeof(JMP_REL), oldProtect, &oldProtect);
info->Erase(record);
FreeMemory(record);
return SUCCESS_CODE;
}
Тестируем работу. Сразу изменим наши приемники так, чтобы они могли вызывать перехваченные функции при помощи трамплина:
int PresentHook(Device* device) {
auto record = hook->GetRecordBySource(ptrPresent);
pPresent pTrampoline = (pPresent)record->pTrampoline;
auto result = pTrampoline(device);
cout << "PresentHook" << endl;
return result;
}
void EndSceneHook(Device* device, int j) {
auto record = hook->GetRecordBySource(ptrEndScene);
pEndScene pTrampoline = (pEndScene)record->pTrampoline;
pTrampoline(device, 2);
cout << "EndSceneHook" << " " << j << endl;
}
int main() {
while (true) {
Device* device = new Device();
device->i = 3;
unsigned long long vmt = **(unsigned long long**)&device;
ptrPresent = (pPresent)(*(unsigned long long*)(vmt + 8 * 3));
ptrEndScene = (pEndScene)(*(unsigned long long*)(vmt + 8 * 4));
IScanner* scan = new ConsoleScanner();
scan->PrintMemory("Present", (unsigned char*)ptrPresent, 30);
hook = new OpcodeHook();
hook->SetHook(ptrPresent, &PresentHook);
hook->SetHook(ptrEndScene, &EndSceneHook);
device->Present();
device->EndScene(7);
device->Present();
device->EndScene(7);
device->i = 5;
ptrPresent(device);
ptrEndScene(device, 9);
hook->UnsetHook(ptrPresent);
hook->UnsetHook(ptrEndScene);
ptrPresent(device);
ptrEndScene(device, 7);
delete hook;
delete device;
}
}
Работает. Дополнительно проверить можно в x64dgb.
Помните, вначале я попросил вас работать в Release сборке? Теперь перейдем в Debug и запустим программу. Программа падает… Ловушка срабатывает, но попытка вызвать трамплин вызывает исключение, которое говорит, что адрес, по которому мы вызываем трамплин, совсем не на выполнение. Что мы упустили? В чем проблема Debug сборки? Запускаем и смотрим на опкод функции Present:
Present:
e9 f4 36 0 0 e9 df 8d 0 0 e9 aa b0 0 0 e9 75 3e 0 0 e9 80 38 0 0 e9 da 81 0 0
При запуске в x64dbg можно увидеть следующее.
Рисунок 11 — Инструкции Debug сборки
В Debug опкод изменился, теперь компилятор добавляет относительный прыжок e9 f4 36 0. В прыжок оборачиваются все функции, включая main и точка входа в программу mainCRTStartup. Другой опкод, ну и ладно, он должен был скопироваться в трамплин, при вызове трамплина должен быть вызван этот относительный прыжок, далее прямой прыжок на неповрежденную часть source.
Тут становится понятно, что все делается как мы и реализовали, только вот относительный прыжок на то и относительный, что его выполнение от разных адресов, source и trampoline, выставляют RIP на совершенно разные значения.
По моему скромному опыту, реализация случая с относительным прыжком покрывает 99% использования. Есть еще несколько опкодов, которые следует обрабатывать отдельно. Помните, что перед тем, как ставить ловушку на функцию, следует не полениться и изучить ее. Я не буду забивать вам голову и дописывать функционал до 100 процентного варианта (опять же, по моему скромному опыту), если вам это нужно или интересно, вы можете посмотреть, как устроены такие библиотеки и конкретно какие еще случаи они проверяют — это будет легко сделать, если вы разобрались с тем, о чем тут идет речь.
Относительный прыжок действительно встречается довольно часто, поэтому я предлагаю реализовать его. Относительный прыжок состоит из опкода e9 и значения, на которое относительно текущего адреса нужно прыгнуть. Соответственно, можно просто узнать, куда нужно прыгать, и прыгнуть туда сразу из трамплина прямым прыжком. Даже если мы встретим там новый относительный прыжок — он уже будет от правильного адреса.
int OpcodeHook::SetHook(void* source, void* destination) {
auto record = new HookRecord();
record->source = source;
record->destination = destination;
info->PushBack(record);
JMP_ABS pattern = {0xFF, 0x25, 0x00000000, // JMP[RIP + 6] empty
0x0000000000000000
}; // address
pattern.address = (ULONG_PTR)source;
int currentLen = 0;
bool isJmpOpcode = false;
int redLine = sizeof(JMP_REL);
while (currentLen < redLine && !isJmpOpcode) {
hde64s context;
const void* pSource = (void*)((unsigned char*)source + currentLen);
hde64_disasm(pSource, &context);
if (context.opcode == 0xE9) {
ULONG_PTR ripPtr = (ULONG_PTR)pSource + context.len + (INT32)context.imm.imm32;
pattern.address = ripPtr;
isJmpOpcode = true;
}
memcpy((unsigned char*)record->sourceReservation + currentLen, pSource, context.len);
record->reservationLen += context.len;
currentLen += context.len;
}
int trampolineMemorySize = isJmpOpcode ? 2 * sizeof(JMP_ABS) : 2 * sizeof(JMP_ABS) + record->reservationLen;
record->pTrampoline = AllocateMemory(source, trampolineMemorySize);
if (!isJmpOpcode) {
pattern.address = (unsigned long long)(unsigned char*)source + record->reservationLen;
memcpy((unsigned char*)record->pTrampoline, record->sourceReservation, record->reservationLen);
}
int offset = isJmpOpcode ? 0 : record->reservationLen;
memcpy((unsigned char*)record->pTrampoline + offset, &pattern, sizeof(JMP_ABS));
pattern.address = (ULONG_PTR)destination;
ULONG_PTR relay = (ULONG_PTR)record->pTrampoline + sizeof(pattern) + record->reservationLen;
memcpy((void*)relay, &pattern, sizeof(pattern));
DWORD oldProtect = 0;
VirtualProtect(source, sizeof(JMP_REL), PAGE_EXECUTE_READWRITE, &oldProtect);
JMP_REL* pJmpRelPattern = (JMP_REL*)source;
pJmpRelPattern->opcode = 0xE9;
pJmpRelPattern->delta = (unsigned int)((LPBYTE)relay - ((LPBYTE)source + sizeof(JMP_REL)));
VirtualProtect(source, sizeof(JMP_REL), oldProtect, &oldProtect);
return SUCCESS_CODE;
}
Если дизассемблер возвращает информацию о том, что опкод данной команды равен e9, мы вычисляем адрес, на который нужно прыгнуть (ULONG_PTR ripPtr = (ULONG_PTR)pSource + context.len + (INT32)context.imm.imm32), и записываем адрес в трамплин как значение аргумента прямого прыжка.
Также замечу, что в многопоточной среде может возникнуть ситуация, когда в момент установки/снятия хука один из потоков может начать выполнять функцию, которую мы ловим — в итоге процесс упадет. Частично как с этим бороться будет рассказано в Hardware Breakpoint.
Если вам нужно проверенное средство, вам хочется быть уверенным, что ваша ловушка сработает, у вас нет своих наработок и вы не хотите изучать пролог функции — используйте готовые решения, например Microsoft предлагает свою библиотеку Detour. Я не использую такие библиотеки и пользуюсь самописным решением по ряду причин, поэтому не могу что-то посоветовать, могу лишь назвать те библиотеки, изучением которых занимался в целях открыть что-то новое и взять себе на вооружение: PolyHook, MinHook, EasyHook (особенно если нужны хуки на C#).
7.4.2. Hardware Breakpoint
Opcode Hook является простой и быстрой ловушкой, однако не самой эффективной. Античит может легко отследить изменение куска памяти, но Opcode Hook можно использовать применительно к самому античиту или перехвату системных вызывов (например NtSetInformationThread), которыми он пользуется. Hardware Breakpoint является ловушкой, которая не изменяет память процесса. Я видел треды на форумах, где спрашивалось, отслеживает ли VAC эту ловушку — ответы обычно неоднозначны. Лично меня VAC не банил за их использование и не сбрасывал регистры (это было чуть меньше полугода назад, возможно что-то изменилось).
Сам я не понимаю, в чем проблема, почему VAC не затирает DR регистры и никак это не отслеживает/отслеживал, если кто-то может дать ответ на этот вопрос, я буду очень рад. Даже если за HWBP сейчас банят, если кто-то может объяснить, почему не банили столько времени, я буду рад, так как не понимаю сложности очистки регистров DR0-DR7 для каждого контекста потока.
HWBP использует специальные регистры процессора для прерывания выполнения потока. Если контекст потока содержит установленные определенным образом регистры DR0-DR7 и RIP переходит на один из четырех адресов, хранящихся в DR0-DR3, вылетает исключение, которое можно поймать, по типу исключения и состоянию контекста определить, на каком адресе управление кинуло исключение и сделать вывод — ловушка это или нет. Существенное ограничения этого подхода в том, что использовать можно только четыре функции единовременно и выставлять их необходимо для каждого потока отдельно, что приводит к неудобствам в случае, если ловушка установлена и создается новый / пересоздается старый поток, который вызывает ловушку. Это не является особым препятствием и правится перехватом функции BaseThreadInitThunk, ограничение в использование 4 ловушек лично мне не особо мешали. Если вам критично количество хуков, обратите внимание на подход PageGuard.
Итак, задача стоит та же самая — мы находимся в песочнице (проект Sandbox), необходимо перехватить методы класса Device Present и EndScene, в которых вызвать исходные методы. Мы уже имеем готовый интерфейс для ловушек — IHook, давайте разберемся с работой «железных» точек останова.
Принцип такой: есть четыре «рабочих» регистра DR0-DR3, в которые можно записать адрес, в зависимости от настройки управляющего регистра DR7 при попытке записи, чтения или выполнения по заданному адресу произойдет исключение с типом EXCEPTION_SINGLE_STEP, которое должно быть обработано в предварительно зарегистрированном обработчике. Можно использовать как SEH обработчик, так и VEH – будем использовать последний, так как он имеет больший приоритет.
Реализуем эту идею:
int HardwareBPHook::SetHook(void* source, void* destination, HANDLE* hThread,
int* reg) {
CONTEXT context;
ZeroMemory(&context, sizeof(context));
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (!GetThreadContext(*hThread, &context))
return ERROR_GET_CONTEXT;
*(&context.Dr0 + *reg) = (unsigned long long)source;
context.Dr7 |= 1ULL << (2 * (*reg));
context.Dr7 |= HW_EXECUTE << ((*reg) * 4 + 16);
context.Dr7 |= HW_LENGTH << ((*reg) * 4 + 18);
if (!SetThreadContext(*hThread, &context))
return ERROR_SET_CONTEXT;
return SUCCESS_CODE;
}
Подробней о том, что представляет из себя DR6 и DR7, как и о подходе PageGuard могу посоветовать книгу Gray Hat Python: Python Programming for Hackers and Reverse Engineers. Вкратце, DR7 включает/отключает использование «рабочего» регистра — даже если какой-либо из регистров DR0-DR3 будет содержать адрес, но в DR7 флаг соответствующего регистра будет отключен, работать точка останова не будет. Также DR7 задает тип работы с адресом, при котором нужно кидать исключение — прочитался ли адрес, была ли произведена запись или адрес используется для выполнение инструкции (нас интересует последний вариант).
Снятие ловушки тоже довольно простое и производится через управляющий регистр DR7.
int HardwareBPHook::UnsetHook(void* source, HANDLE* hThread) {
CONTEXT context;
ZeroMemory(&context, sizeof(context));
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (!GetThreadContext(*hThread, &context))
return ERROR_GET_CONTEXT;
for (int i = 0; i < DEBUG_REG_COUNT; i++) {
if ((unsigned long long)source == *(&context.Dr0 + i)) {
info->GetItem(i)->source = 0;
*(&context.Dr0 + i) = 0;
context.Dr7 &= ~(1ULL << (2 * i));
context.Dr7 &= ~(3 << (i * 4 + 16));
context.Dr7 &= ~(3 << (i * 4 + 18));
break;
}
}
if (!SetThreadContext(*hThread, &context))
return ERROR_SET_CONTEXT;
return SUCCESS_CODE;
}
Осталось разобраться с потоками — ловушка должна быть расставлена для тех потоков, которые вызывают перехватываемую функцию. Мы не будет заморачиваться на этот счет.
int HardwareBPHook::SetHook(void* source, void* destination) {
THREADENTRY32 te32;
HANDLE hThread = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hThread == INVALID_HANDLE_VALUE)
return ERROR_ENUM_THREAD_START;
te32.dwSize = sizeof(THREADENTRY32);
if (!Thread32First(hThread, &te32)) {
CloseHandle(hThread);
return ERROR_ENUM_THREAD_START;
}
DWORD dwOwnerPID = GetCurrentProcessId();
bool isRegDefined = false;
int freeReg = -1;
Freeze();
do {
if (te32.th32OwnerProcessID == dwOwnerPID) {
HANDLE openThread = OpenThread(THREAD_ALL_ACCESS, FALSE, te32.th32ThreadID);
if (!isRegDefined) {
CONTEXT context;
ZeroMemory(&context, sizeof(context));
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (!GetThreadContext(openThread, &context))
return ERROR_GET_CONTEXT;
freeReg = GetFreeReg(&context.Dr7);
if (freeReg == -1)
return ERROR_GET_FREE_REG;
isRegDefined = true;
}
SetHook(source, destination, &openThread, &freeReg);
CloseHandle(openThread);
}
} while (Thread32Next(hThread, &te32));
CloseHandle(hThread);
Unfreeze();
auto record = info->GetItem(freeReg);
record->source = source;
record->destination = destination;
record->pTrampoline = source;
return SUCCESS_CODE;
}
Приведенный код обходит все видимые процессы и ищет текущий процесс. В найденном процессе для очередного потока получаем обработчик потока, находим один из четырех свободных регистров и устанавливаем ловушку. Стоит обратить внимание на функции Freeze и Unfreeze – это то, о чем говорилось в Opcode Hook по поводу многопоточности — они полностью останавливают выполнение потоков данного процесса (кроме текущего), чтобы не произошло ситуации, когда один из потоков заходит в перехватываемую функцию.
int IHook::Freeze() {
THREADENTRY32 te32;
HANDLE hThread = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hThread == INVALID_HANDLE_VALUE)
return ERROR_ENUM_THREAD_START;
te32.dwSize = sizeof(THREADENTRY32);
if (!Thread32First(hThread, &te32)) {
CloseHandle(hThread);
return ERROR_ENUM_THREAD_START;
}
DWORD dwOwnerPID = GetCurrentProcessId();
do {
if (te32.th32OwnerProcessID == dwOwnerPID &&
te32.th32ThreadID != GetCurrentThreadId()) {
HANDLE openThread = OpenThread(THREAD_ALL_ACCESS, FALSE, te32.th32ThreadID);
if (openThread != NULL) {
SuspendThread(openThread);
CloseHandle(openThread);
}
}
} while (Thread32Next(hThread, &te32));
CloseHandle(hThread);
return SUCCESS_CODE;
}
int IHook::Unfreeze() {
// equal
{
HANDLE openThread = OpenThread(THREAD_ALL_ACCESS, FALSE, te32.th32ThreadID);
if (openThread != NULL) {
ResumeThread(openThread);
CloseHandle(openThread);
}
}
// equal
return 0;
}
Аналогичное нужно реализовать в функции снятия ловушки.
Осталось добавить обработчик исключений VEH. Добавление и удаление производится функциями AddVectoredExceptionHandler и RemoveVectoredExceptionHandler из любого потока.
void HardwareBPHook::SetExceptionHandler(PVECTORED_EXCEPTION_HANDLER
pVecExcHandler) {
pException = AddVectoredExceptionHandler(1, pVecExcHandler);
}
~HardwareBPHook() {
info->Clear();
delete info;
RemoveVectoredExceptionHandler(pException);
}
Обработчик должен проверять тип исключения (нужен EXCEPTION_SINGLE_STEP), проверять соответствие адреса, на котором возникло исключение с тем, что есть в регистрах и в случае обнаружения такого адреса переставляет указатель RIP на адрес приемника. Состояние стека сохраняется, так что при дальнейшем выполнении приемника все параметры на стеке будут целы.
Реализуем описанный обработчик в песочнице:
LONG OnExceptionHandler(
EXCEPTION_POINTERS* exceptionPointers) {
if (exceptionPointers->ExceptionRecord->ExceptionCode !=
EXCEPTION_SINGLE_STEP)
return EXCEPTION_CONTINUE_EXECUTION;
for (int i = 0; i < DEBUG_REG_COUNT; i++) {
if (exceptionPointers->ContextRecord->Rip ==
(unsigned long long)hook->GetInfo()->GetItem(i)->source) {
exceptionPointers->ContextRecord->Rip =
(unsigned long long)hook->GetInfo()->GetItem(i)->destination;
break;
}
}
return EXCEPTION_CONTINUE_EXECUTION;
}
По идее все готово, запускаем программу, ожидая точно такой же работы, как у OpcodeHook.
Этого не происходит, наша программа зависает — точнее она постоянно заходит в PresentHook и в тот момент, когда должен вызываться трамплин, функция вызывается снова. Дело в том, что «железная» точка останова никуда не делась, так как при вызове трамплина (который в случае «железных» точек останова указывает на исходную функцию) мы снова тревожим тот же адрес и вызываем исключение. Решение следующее — уберем точку останова при ее обнаружении в обработчике для конкретного потока, а в нужный момент снова выставим. Местом обновления выберем момент завершения функции приемника.
Реализовано это следующим образом — в обработчике вместе со снятием точки останова добавляется отложенная команда, смыслом которой и является обновление точки останова в указанном потоке. Команда запускается в конце функции приемника.
IDeferredCommands* hookCommands;
int PresentHook(Device* device) {
auto record = hook->GetRecordBySource(ptrPresent);
pPresent pTrampoline = (pPresent)record->pTrampoline;
auto result = pTrampoline(device);
cout << "PresentHook" << endl;
hookCommands->Run();
return result;
}
void EndSceneHook(Device* device, int j) {
auto record = hook->GetRecordBySource(ptrEndScene);
pEndScene pTrampoline = (pEndScene)record->pTrampoline;
pTrampoline(device, 2);
cout << "EndSceneHook" << " " << j << endl;
hookCommands->Run();
}
LONG OnExceptionHandler(EXCEPTION_POINTERS* exceptionPointers) {
if (exceptionPointers->ExceptionRecord->ExceptionCode !=
EXCEPTION_SINGLE_STEP)
return EXCEPTION_CONTINUE_EXECUTION;
for (int i = 0; i < DEBUG_REG_COUNT; i++) {
if (exceptionPointers->ContextRecord->Rip ==
(unsigned long long)hook->GetInfo()->GetItem(i)->source) {
exceptionPointers->ContextRecord->Dr7 &= ~(1ULL << (2 * i));
exceptionPointers->ContextRecord->Rip =
(unsigned long long)hook->GetInfo()->GetItem(i)->destination;
IDeferredCommand* cmd = new SetD7Command(hook,
GetCurrentThreadId(), i);
hookCommands->Enqueue(cmd);
break;
}
}
return EXCEPTION_CONTINUE_EXECUTION;
}
namespace silk_way {
class IDeferredCommand {
protected:
IDeferredCommand(silk_way::IHook* _hook) {
hook = _hook;
}
public:
virtual ~IDeferredCommand() {
hook = nullptr;
}
virtual void Run() = 0;
protected:
silk_way::IHook* hook;
};
class SetD7Command : public IDeferredCommand {
public:
SetD7Command(silk_way::IHook* _hook, unsigned long long _threadId,
int _reg) : IDeferredCommand(_hook) {
threadId = _threadId;
reg = _reg;
}
void Run() {
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, threadId);
if (hThread != NULL) {
bool res = SetD7(&hThread);
CloseHandle(hThread);
}
}
private:
bool SetD7(HANDLE* hThread) {
CONTEXT context;
ZeroMemory(&context, sizeof(context));
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (!GetThreadContext(*hThread, &context))
return false;
*(&context.Dr0 + reg) =
(unsigned long long)hook->GetInfo()->GetItem(reg)->source;
context.Dr7 |= 1ULL << (2 * reg);
if (!SetThreadContext(*hThread, &context))
return false;
return true;
}
private:
unsigned long long threadId;
int reg;
};
class IDeferredCommands : public silk_data::Queue<IDeferredCommand*>,
public IDeferredCommand {
protected:
IDeferredCommands() : Queue(), IDeferredCommand(nullptr) {}
public:
virtual ~IDeferredCommands() {}
};
}
Представим визуально работу «железных» точек останова.
Рисунок 12 — Начальное состояние
Ставим ловушку, добавляем VEH обработчик, ждем, когда управление дойдет до функции-источника:
Рисунок 13 — Стадия подготовки перехвата
Возбуждается исключение, вызывается обработчик, который перенаправляет RIP на приемник и сбрасывает точку останова:
Рисунок 14 — Перенаправление потока выполнения на функцию приемник
На этом тему ловушек можно закончить, статическая библиотека silk_way.lib готова. Из своего опыта могу сказать, что довольно часто пользуюсь OpcodeHook, VMT Hook, Forced Exception Hook (наверное, самая «геморойная» ловушка), HardwareBreakpoint и PageGuard (когда время выполнения не критично, разовые перехваты).
8. Архитектура логики
Основа логики представлена в виде MVC (model-view-controller). Все основные сущности наследуются от интерфейса ISilkObject.
8.1. Модель
В библиотеке при разработке бота сначала я реализовал ECS (про данный подход можно почитать тут и тут). Когда я понял, что запуск бота с реальными игроками довольно долгое занятие, я написал симуляцию, где тестировались ml библиотеки (с трехмерной сеткой для навигации (Dota 2 как раз использует 3D сетку для навигации) и упрощенной 2D физикой для бодиблока). Когда надобность в симуляции пропала и я придумал, как и что логировать, какую информацию собирать в ходе сражения, в ECS отпала всякая необходимость и модели стали просто содержать словарь компонент (для представления примерно так, как сделано у ребят из SkyForge, раздел «Аватары и мобы»), которые содержали, по сути, обертки над структурами из Source2Gen. Для данной статьи я не стал переносить эту реализацию в целях упрощения материала. Модель содержит Schema, в которой хранится ее описание (этот момент упрощен и в данной реализации модель не создается по схеме, схема лишь описывает ее (хранит в себе предустановленные значения, которые можно захардкодить) — это можно сравнить с хранением наполнения игры в xml/json).
Схематично устройство модели можно представить так:
Рисунок 15 — Схематичное представление Модели
Реализация в коде:
template <class S>
SILK_OBJ(IModel) {
ACCESSOR(IIdentity, Id)
ACCESSOR(S, Schema)
public:
IModel(IIdentity * id, ISchema * schema) {
Id = id;
Schema = dynamic_cast<S*>(schema);
components = new silk_data::RBTree<SILK_STRING*, IComponent>(
new StringCompareStrategy());
}
~IModel() {
delete Id;
Schema = nullptr;
components->Clear();
delete components;
}
template <class T>
T* Get(SILK_STRING * key) {
return (T*)components->Find(key);
}
private:
silk_data::RBTree<SILK_STRING*, IComponent>* components;
};
Схема включает в себя описание конкретной модели и содержит контекст, которым может пользоваться модель.
class IModelSchema : public BaseSchema {
ACCESSOR(ModelContext, Context)
public:
IModelSchema(const char* type, const char* name, IContext* context) :
BaseSchema(type, name) {
Context = dynamic_cast<ModelContext*>(context);
}
~IModelSchema() {
Context = nullptr;
}
};
class ModelContext : public SilkContext {
ACCESSOR(ILogger, Logger)
ACCESSOR(IChrono, Clock)
ACCESSOR(GigaFactory, Factory)
ACCESSOR(IGameModel*, Model)
public:
ModelContext(SILK_GUID* guid, ILogger* logger, IChrono* clock,
GigaFactory* factory, IGameModel** model) : SilkContext(guid) {
Logger = logger;
Clock = clock;
Factory = factory;
Model = model;
}
~ModelContext() {
Logger = nullptr;
Clock = nullptr;
Factory = nullptr;
Model = nullptr;
}
};
template <class T, class S>
class IModelCollection : public silk_data::Vector<T*>, public IModel<S> {
protected:
IModelCollection(IIdentity* id, ISchema* schema) : Vector(),
IModel(id, schema) {
auto factory = Schema->GetContext()->GetFactory();
auto guid = Schema->GetContext()->GetGuid();
foreach (Schema->Length()) {
auto itemSchema = Schema->GetItem(i);
auto item = factory->Build<T>(itemSchema->GetType()->GetValue(),
guid->Get(), itemSchema);
PushBack(item);
}
}
public:
~IModelCollection() {
Clear();
}
T* GetByName(const char* name) {
foreach (Length())
if (GetItem(i)->GetSchema()->CheckName(name))
return GetItem(i);
return nullptr;
}
};
DEFINE_IMODEL(IRoshanStatusModel, IRoshanStatusSchema) {
VIRTUAL_COMPONENT(IStatesModel, States)
public:
virtual void Resolve() = 0;
protected:
IRoshanStatusModel(IIdentity * id, ISchema * schema) : IModel(id, schema) {}
};
DEFINE_MODEL(RoshanStatusModel, IRoshanStatusModel) {
COMPONENT(IStatesModel, States)
public :
RoshanStatusModel(IIdentity * id, ISchema* schema) : IRoshanStatusModel(
id, schema) {
auto factory = Schema->GetContext()->GetFactory();
auto guid = Schema -> GetContext() -> GetGuid();
auto statesSchema = Schema -> GetStates();
States = factory->Build<IStatesModel>(
statesSchema->GetType()->GetValue(), guid->Get(), statesSchema);
}
~RoshanStatusModel() {
delete States;
}
void Resolve() {
auto currentStateSchema = States->GetCurrent()->GetSchema();
Schema->GetContext()->GetLogger()->Log("RESOLVEn");
foreach (currentStateSchema->GetTransitions()->Length()) {
auto transition = currentStateSchema->GetTransitions()->GetItem(i);
if (transition->GetRequirement()->Check()) {
transition->GetAction()->Make();
States->SetCurrent(States->GetByName(
transition->GetTo()->GetValue()));
break;
}
}
}
};
8.2. Представление, Состояние Представления и Контроллер
Про Представление, Состояние Представления и Контроллер особо сказать нечего, реализация схожа с Моделями. Они также состоят из схемы и контекста. Для решения задачи для Представления реализованы Canvas, ViewCollection, Label и Button, для последних двух также реализованы состояния, соответствующие состояниям, в которых находится Рошан.
8.3. Фабрика
Объекты создаются с использованием фабрики. Фабрики используют в качестве ключа тип интерфейса, переводя его в строку посредством typeid(T).raw_name(). Вообще, так делать плохо, почему и как правильно можно почитать у Andrei Alexandrescu, Modern C++ Design: Generic Programming. Реализация фабрики:
class SilkFactory {
public:
SilkFactory() {
items = new silk_data::RBTree<SILK_STRING*, IImplementator>(
new StringCompareStrategy());
}
~SilkFactory() {
items->Clear();
delete items;
}
template <class... Args>
ISILK_WAY_OBJECT* Build(const char* type, Args... args) {
auto key = new SILK_STRING(type);
auto impl = items->Find(key)->payload;
return impl->Build(args...);
}
void Register(const char* type, IImplementator* impl) {
auto key = new SILK_STRING(type);
items->Insert(*items->MakeNode(key, impl));
}
protected:
silk_data::RBTree<SILK_STRING*, IImplementator>* items;
};
class GigaFactory {
public:
GigaFactory() {
items = new silk_data::RBTree<SILK_STRING*, SilkFactory>(
new StringCompareStrategy());
}
~GigaFactory() {
items->Clear();
delete items;
}
template <class T, class... Args>
T* Build(const char* concreteType, Args... args) {
auto key = new SILK_STRING(typeid(T).raw_name());
auto factory = items->Find(key)->payload;
return (T*)factory->Build(concreteType, args...);
}
template <class T>
void Register(SilkFactory* factory) {
auto key = new SILK_STRING(typeid(T).raw_name());
items->Insert(*items->MakeNode(key, factory));
}
protected:
silk_data::RBTree<SILK_STRING*, SilkFactory>* items;
};
Перед использованием фабрики для построения объектов нужно произвести регистрацию.
void ModelRegistrator::Register(
GigaFactory* factory) {
auto requirement = new SilkFactory();
requirement->Register("true", new SchemaImplementator<TrueRequirement>);
requirement->Register("false", new SchemaImplementator<FalseRequirement>);
requirement->Register("roshan_killed",
new SchemaImplementator<RoshanKilledRequirement>);
requirement->Register("roshan_alive_manual",
new SchemaImplementator<RoshanAliveManualRequirement>);
requirement->Register("time", new SchemaImplementator<TimeRequirement>);
requirement->Register("roshan_state",
new SchemaImplementator<RoshanStateRequirement>);
factory->Register<IRequirement>(requirement);
auto action = new SilkFactory();
action->Register("action", new SchemaImplementator<EmptyAction>);
action->Register("set_current_time",
new SchemaImplementator<SetCurrentTimeAction>);
factory->Register<IAction>(action);
auto transition = new SilkFactory();
transition->Register("transition", new SchemaImplementator<TransitionSchema>);
factory->Register<ITransitionSchema>(transition);
auto transitions = new SilkFactory();
transitions->Register("transitions",
new SchemaImplementator<TransitionsSchema>);
factory->Register<ITransitionsSchema>(transitions);
auto stateSchema = new SilkFactory();
stateSchema->Register("state", new SchemaImplementator<StateSchema>);
factory->Register<IStateSchema>(stateSchema);
auto statesSchema = new SilkFactory();
statesSchema->Register("states", new SchemaImplementator<StatesSchema>);
factory->Register<IStatesSchema>(statesSchema);
auto roshanStatusSchema = new SilkFactory();
roshanStatusSchema->Register("roshan_status",
new SchemaImplementator<RoshanStatusSchema>);
factory->Register<IRoshanStatusSchema>(roshanStatusSchema);
auto triggerSchema = new SilkFactory();
triggerSchema->Register("trigger", new SchemaImplementator<TriggerSchema>);
factory->Register<ITriggerSchema>(triggerSchema);
auto triggersSchema = new SilkFactory();
triggersSchema->Register("triggers", new SchemaImplementator<TriggersSchema>);
factory->Register<ITriggersSchema>(triggersSchema);
auto resourceSchema = new SilkFactory();
resourceSchema->Register("resource", new SchemaImplementator<ResourceSchema>);
factory->Register<IResourceSchema>(resourceSchema);
auto resourcesSchema = new SilkFactory();
resourcesSchema->Register("resources",
new SchemaImplementator<ResourcesSchema>);
factory->Register<IResourcesSchema>(resourcesSchema);
auto gameSchema = new SilkFactory();
gameSchema->Register("game", new SchemaImplementator<GameSchema>);
factory->Register<IGameSchema>(gameSchema);
auto gameModel = new SilkFactory();
gameModel->Register("game", new ConcreteImplementator<GameModel>);
factory->Register<IGameModel>(gameModel);
auto resources = new SilkFactory();
resources->Register("resources",
new ConcreteImplementator<ResourceCollection>);
factory->Register<IResourceCollection>(resources);
auto resource = new SilkFactory();
resource->Register("resource", new ConcreteImplementator<Resource>);
factory->Register<IResource>(resource);
auto triggers = new SilkFactory();
triggers->Register("triggers", new ConcreteImplementator<TriggerCollection>);
factory->Register<ITriggerCollection>(triggers);
auto trigger = new SilkFactory();
trigger->Register("trigger", new ConcreteImplementator<Trigger>);
factory->Register<ITrigger>(trigger);
auto roshanStatus = new SilkFactory();
roshanStatus->Register("roshan_status",
new ConcreteImplementator<RoshanStatusModel>);
factory->Register<IRoshanStatusModel>(roshanStatus);
auto states = new SilkFactory();
states->Register("states", new ConcreteImplementator<StatesModel>);
factory->Register<IStatesModel>(states);
auto state = new SilkFactory();
state->Register("state", new ConcreteImplementator<StateModel>);
factory->Register<IStateModel>(state);
}
Заполнение схемы можно производить любым способом — можно использовать json, можно прямо в коде.
{
"game": {
"roshan_status": {
"states": [
{
"name": "alive",
"transitions": [
{
"from": "alive",
"to": "ressurect_base",
"requirement": {
"typename": "roshan_killed",
"action": {
"typename": "set_current_time",
"resource": "roshan_killed_ts"
}
}
}
]
},
{
"name": "ressurect_base",
"transitions": [
{
"from": "ressurect_base",
"to": "ressurect_extra",
"requirement": {
"typename": "time",
"resource": "roshan_killed_ts",
"offset": 480
},
"action": {
"typename": "action"
}
}
]
},
{
"name": "ressurect_extra",
"transitions": [
{
"from": "ressurect_extra",
"to": "alive",
"requirement": {
"typename": "time",
"resource": "roshan_killed_ts",
"offset": 660
},
"action": {
"typename": "action"
}
},
{
"from": "ressurect_extra",
"to": "alive",
"requirement": {
"typename": "roshan_alive_manual"
},
"action": {
"typename": "action"
}
}
]
}
]
},
"triggers": {
"roshan_killed": {},
"roshan_alive_manual": {}
},
"resources": {
"roshan_killed_ts": {}
}
}
}
void GameController::InitViewSchema(ICanvasSchema** schema) {
*schema = factory->Build<ICanvasSchema>("canvas_d9", "canvas_d9", "canvas_d9",
viewContext);
IViewCollectionSchema* elements = factory->Build<IViewCollectionSchema>(
"elements", "elements", "elements", viewContext);
(*schema)->SetElements(elements);
ILabelSchema* labelSchema = factory->Build<ILabelSchema>(
"label_d9", "label_d9", "roshan_status_label", viewContext);
labelSchema->SetRecLeft(new SILK_INT(30));
labelSchema->SetRecTop(new SILK_INT(100));
labelSchema->SetRecRight(new SILK_INT(230));
labelSchema->SetRecDown(new SILK_INT(250));
labelSchema->SetColorR(new SILK_FLOAT(1.0f));
labelSchema->SetColorG(new SILK_FLOAT(1.0f));
labelSchema->SetColorB(new SILK_FLOAT(1.0f));
labelSchema->SetColorA(new SILK_FLOAT(1.0f));
labelSchema->SetText(new SILK_STRING("Roshan status: alive"));
elements->PushBack((IViewSchema*&)labelSchema);
IButtonSchema* buttonSchema = factory->Build<IButtonSchema>(
"button_d9", "button_d9", "roshan_kill_button", viewContext);
ILabelSchema* buttonLabelSchema = factory->Build<ILabelSchema>(
"label_d9", "label_d9", "button_text", viewContext);
buttonLabelSchema->SetRecLeft(new SILK_INT(30));
buttonLabelSchema->SetRecTop(new SILK_INT(115));
buttonLabelSchema->SetRecRight(new SILK_INT(110));
buttonLabelSchema->SetRecDown(new SILK_INT(130));
buttonLabelSchema->SetColorR(new SILK_FLOAT(1.0f));
buttonLabelSchema->SetColorG(new SILK_FLOAT(0.0f));
buttonLabelSchema->SetColorB(new SILK_FLOAT(0.0f));
buttonLabelSchema->SetColorA(new SILK_FLOAT(1.0f));
buttonLabelSchema->SetText(new SILK_STRING("Kill Roshan"));
buttonSchema->SetLabel(buttonLabelSchema);
buttonSchema->SetBorderColorR(new SILK_INT(0));
buttonSchema->SetBorderColorG(new SILK_INT(0));
buttonSchema->SetBorderColorB(new SILK_INT(0));
buttonSchema->SetBorderColorA(new SILK_INT(70));
buttonSchema->SetFillColorR(new SILK_INT(255));
buttonSchema->SetFillColorG(new SILK_INT(119));
buttonSchema->SetFillColorB(new SILK_INT(0));
buttonSchema->SetFillColorA(new SILK_INT(150));
buttonSchema->SetPushColorR(new SILK_INT(0));
buttonSchema->SetPushColorG(new SILK_INT(0));
buttonSchema->SetPushColorB(new SILK_INT(0));
buttonSchema->SetPushColorA(new SILK_INT(70));
buttonSchema->SetBorder(new SILK_FLOAT(5));
elements->PushBack((IViewSchema*&)buttonSchema);
}
8.4. События
Представление узнает об изменении Модели посредством событий. Получать обратную связь можно в методы класса и обычные функции.
#define VIRTUAL_EVENT(e) public: virtual IEvent* Get##e() = 0;
#define EVENT(e) private: IEvent* e; public: IEvent* Get##e() { return e; }
const int MAX_EVENT_CALLBACKS = 1024;
class IEventArgs {};
class ICallback {
public:
virtual void Invoke(IEventArgs* args) = 0;
};
template <class A>
class Callback : public ICallback {
typedef void (*f)(A*);
public:
Callback(f _pFunc) {
ptr = _pFunc;
}
~Callback() {
delete ptr;
}
void Invoke(IEventArgs* args) {
ptr((A*)args);
}
private:
f ptr = nullptr;
};
template <typename T, class A>
class MemberCallback : public ICallback {
typedef void (T::*f)(A*);
public:
MemberCallback(f _pFunc, T* _obj) {
ptr = _pFunc;
obj = _obj;
}
~MemberCallback() {
delete ptr;
obj = nullptr;
}
void Invoke(IEventArgs* args) {
(obj->*(ptr))((A*)args);
}
private:
f ptr = nullptr;
T* obj;
};
class IEvent {
public:
virtual void Invoke(IEventArgs* args) = 0;
virtual void Add(ICallback* callback) = 0;
virtual bool Remove(ICallback* callback) = 0;
virtual ~IEvent() {}
};
Если объект хочет сообщать о происходящих внутри него событиях, необходимо добавить для каждого события IEvent*. Другой объект, которому интересны события, происходящие внутри этого объекта, должен создать ICallback* и передать его внутрь IEvent* (подписаться на событие).
void Attach() {
statesChangedCallback = new MemberCallback<GameController, IEventArgs>(
&GameController::OnStatesChanged, this);
Model->GetRoshanStatus()->GetStates()->GetCurrentChanged()->Add(
statesChangedCallback);
buttonClickedCallback = new MemberCallback<GameController, IEventArgs>(
&GameController::OnKillRoshanClicked, this);
killButton->GetClickedEvent()->Add(buttonClickedCallback);
}
class IChrono {
VIRTUAL_EVENT(Struck)
public:
virtual void Tick() = 0;
virtual long long GetStamp() = 0;
virtual long long GetDiffS(long long ts) = 0;
};
class Chrono : public IChrono {
EVENT(Struck)
public:
Chrono() {
start = time(0);
Struck = new Event();
}
~Chrono() {
delete Struck;
}
void Tick() {
auto cur = clock();
worked += cur - savepoint;
bool isStriking = savepoint < cur;
savepoint = cur;
if (isStriking)
Struck->Invoke(nullptr);
}
long long GetStamp() {
return start * CLOCKS_PER_SEC + worked;
}
long long GetDiffS(long long ts) {
return (GetStamp() - ts) / CLOCKS_PER_SEC;
}
private:
long long worked = 0;
time_t start;
time_t savepoint;
};
Основные примитивные типы (SILK_INT, SILT_FLOAT, SILK_STRING, …) реализованы в Core.h.
9. DirectX 9
DirectX 9 является одним из графических API, поддерживаемых Dota 2. Device представляет из себя класс, унаследованный от IUnknown и содержит виртуальные функции. Соответственно, получив указатель на виртуальную таблицу методов, мы можем получить указатели к нужным нам функциям. Не виртуальные функции класса не включаются в таблицу и находятся в сегменте .code, так как они одни единственные, их нельзя переопределить. К слову, в OpenGL и Vulkan перехват функций устройства заметно проще, так как они не виртуальные и получить указатель можно используя GetProcAddress(). Архитектура DirectX 11 сложнее 9, но не сильно.
Чтобы перехватить виртуальный метод класса (как и не виртуальный) нам нужен экземпляр этого класса, любой экземпляр. При помощи экземпляра мы достанем Таблицу виртуальных методов и получим нужные указатели на функции. Самый простой способ найти экземпляр класса — создать его самим.
Для этого нам потребуется создать объект с интерфейсом IDirect3D9 при помощи функции Direct3DCreate9, а само устройство создадим при помощи этого объекта вызовом метода CreateDevice. Мы можем вызвать эти функции напрямую из библиотеки DirectX, но ради закрепления материала вызовем их через указатели. Как видно из d3d9.h, Direct3DCreate9 является обычной функцией и указатель на нее может быть получен через GetProcAddress (точно так же, как мы делали в NativeInjector для получения указателя на LoadLibrary).
Рисунок 18 — описание CreateDevice в d3d9.h
Создадим экземпляр IDirect3D9:
typedef IDirect3D9* (WINAPI *SILK_Direct3DCreate9)
(UINT SDKVersion);
//IDirect3D9* pD3D = Direct3DCreate9(D3D_SDK_VERSION);
SILK_Direct3DCreate9 Silk_Direct3DCreate9 = (SILK_Direct3DCreate9)GetProcAddress(GetModuleHandle("d3d9.dll"),
"Direct3DCreate9");
IDirect3D9* pD3D = Silk_Direct3DCreate9(D3D_SDK_VERSION);
При помощи IDirect3D9 мы можем создать девайс вызовом pD3D->CreateDevice(...). Для получения указателя на нужные функции из VMT, нам нужно узнать порядок определения данных методов.
Рисунок 19 — Поиск индекса метода CreateDevice интерфейса IDirect3D9
Получаем 16-й индекс. Помимо CreateDevice нам также нужны методы Release и GetAdapterDisplayMode.
typedef HRESULT(WINAPI *SILK_GetAdapterDisplayMode)(IDirect3D9* direct3D9,
UINT Adapter, D3DDISPLAYMODE* pMode);
typedef HRESULT(WINAPI *SILK_CreateDevice)(IDirect3D9* direct3D9,
UINT Adapter, D3DDEVTYPE DeviceType, HWND hFocusWindow,
DWORD BehaviorFlags, D3DPRESENT_PARAMETERS* pPresentationParameters,
IDirect3DDevice9** ppReturnedDeviceInterface);
typedef ULONG(WINAPI *SILK_Release)(IDirect3D9* direct3D9);
const int RELEASE_INDEX = 2;
const int GET_ADAPTER_DISPLAY_MODE_INDEX = 8;
const int CREATE_DEVICE_INDEX = 16;
BOOL CreateSearchDevice(IDirect3D9** d3d, IDirect3DDevice9** device) {
if (!d3d || !device)
return FALSE;
*d3d = NULL;
*device = NULL;
//IDirect3D9* pD3D = Direct3DCreate9(D3D_SDK_VERSION);
SILK_Direct3DCreate9 Silk_Direct3DCreate9 = (SILK_Direct3DCreate9)GetProcAddress(GetModuleHandle("d3d9.dll"),
"Direct3DCreate9");
IDirect3D9* pD3D = Silk_Direct3DCreate9(D3D_SDK_VERSION);
if (!pD3D)
return FALSE;
D3DDISPLAYMODE displayMode;
int pointerSize = sizeof(unsigned long long);
unsigned long long vmt = **(unsigned long long **)&pD3D;
SILK_GetAdapterDisplayMode pGetAdapderDisplayMode =
(SILK_GetAdapterDisplayMode)((*(unsigned long long *)
(vmt + pointerSize * GET_ADAPTER_DISPLAY_MODE_INDEX)));
pGetAdapderDisplayMode(pD3D, D3DADAPTER_DEFAULT, &displayMode);
//pD3D->GetAdapterDisplayMode(D3DADAPTER_DEFAULT, &displayMode);
HWND hWindow = GetDesktopWindow();
D3DPRESENT_PARAMETERS pp;
ZeroMemory(&pp, sizeof(pp));
pp.Windowed = TRUE;
pp.hDeviceWindow = hWindow;
pp.BackBufferCount = 0;
pp.BackBufferWidth = 0;
pp.BackBufferHeight = 0;
pp.BackBufferFormat = displayMode.Format;
pp.SwapEffect = D3DSWAPEFFECT_DISCARD;
IDirect3DDevice9* pDevice = NULL;
SILK_CreateDevice pCreateDevice = (SILK_CreateDevice)
((*(unsigned long long *)(vmt + pointerSize * CREATE_DEVICE_INDEX)));
if(SUCCEEDED(pCreateDevice(pD3D, D3DADAPTER_DEFAULT,
D3DDEVTYPE_HAL, hWindow, D3DCREATE_SOFTWARE_VERTEXPROCESSING | D3DCREATE_DISABLE_DRIVER_MANAGEMENT, &pp, &pDevice))) {
//if (SUCCEEDED(pD3D->CreateDevice(D3DADAPTER_DEFAULT,
D3DDEVTYPE_HAL, hWindow, D3DCREATE_SOFTWARE_VERTEXPROCESSING | D3DCREATE_DISABLE_DRIVER_MANAGEMENT, &pp, &pDevice))) {
if (pDevice != NULL) {
*d3d = pD3D;
*device = pDevice;
}
}
BOOL result = (*d3d != NULL);
if (result == FALSE)
if (pD3D) {
SILK_Release pRelease=
(SILK_Release)((*(unsigned long long *)(vmt + pointerSize * RELEASE_INDEX)));
pRelease(pD3D);
//pD3D->Release();
}
return result;
}
Отлично, мы создали устройство DirectX 9, теперь нужно понять, какие функции используются для отрисовки сцены, что нам нужно перехватывать. Нам нужно ответить на вопрос: «Как DirectX 9 показывает нам сцену?». Для показа сцены используется функция Present. Стоит также ввести такие понятия, как front buffer (буфер, который хранит то, что отображается (продолжительное действие) на экране), back buffer – содержит то, что готово для отображения и готовится стать front buffer, swap chain – собственно набор буферов, которые поочередно сменяются (flipping) с front на back (DirectX 9 имеет только 1 swap chain). Перед тем, как вызвать Present, вызывается пара функций BeginScene и EndScene, где можно изменять back buffer.
Давайте перехватим две функции (на самом деле для выполнения бизнес логики нам хватит и одной): EndScene и Present. Для этого посмотрим на расположение этих функций в классе IDirect3DDevice9
Рисунок 20 — Объявление интерфейса IDirect3DDevice9
Объявим указатели со следующими сигнатурами функций:
typedef HRESULT(*VirtualOverloadPresent)(IDirect3DDevice9* pd3dDevice,
CONST RECT* pSourceRect, CONST RECT* pDestRect, HWND hDestWindowOverride,
CONST RGNDATA* pDirtyRegion);
VirtualOverloadPresent oOverload = NULL;
typedef HRESULT(*VirtualOverloadEndScene)(IDirect3DDevice9* pd3dDevice);
VirtualOverloadEndScene oOverloadEndScene = NULL;
const int PRESENT_INDEX = 17;
const int END_SCENE_INDEX = 42;
Объявим ловушку сразу с обработчиком ошибок, так как HardwareBreakpoint на самом деле наш единственный реализованный безопасный вариант перехвата, который не отслеживает VAC (потестить можно и с Opcode Hook, но аккаунт скорее всего улетит в бан):
silk_way::IDeferredCommands* deferredCommands;
silk_way::IHook* hook;
LONG OnExceptionHandler(EXCEPTION_POINTERS* exceptionPointers) {
if (exceptionPointers->ExceptionRecord->ExceptionCode !=
EXCEPTION_SINGLE_STEP)
return EXCEPTION_EXIT_UNWIND;
for (int i = 0; i < silk_way::DEBUG_REG_COUNT; i++) {
if (exceptionPointers->ContextRecord->Rip ==
(unsigned long long) hook->GetInfo()->GetItem(i)->source) {
exceptionPointers->ContextRecord->Dr7 &= ~(1ULL << (2 * i));
exceptionPointers->ContextRecord->Rip =
(unsigned long long) hook->GetInfo()->GetItem(i)->destination;
silk_way::IDeferredCommand* cmd =
new silk_way::SetD7Command(hook, GetCurrentThreadId(), i);
deferredCommands->Enqueue(cmd);
break;
}
}
return EXCEPTION_CONTINUE_EXECUTION;
}
Захукаем обозначенные функции любой из наших двух ловушек:
BOOL HookDevice(IDirect3DDevice9* pDevice) {
unsigned long long vmt = **(unsigned long long **)&pDevice;
int pointerSize = sizeof(unsigned long long);
VirtualOverloadPresent pointerPresent= (VirtualOverloadPresent)
((*(unsigned long long *)(vmt + pointerSize * PRESENT_INDEX)));
VirtualOverloadEndScene pointerEndScene = (VirtualOverloadEndScene)
((*(unsigned long long *)(vmt + pointerSize * END_SCENE_INDEX)));
oOverload = pointerPresent;
oOverloadEndScene = pointerEndScene;
deferredCommands = new silk_way::DeferredCommands();
//hook = new silk_way::HardwareBPHook();
hook = new silk_way::OpcodeHook();
hook->SetExceptionHandler(OnExceptionHandler);
hook->SetHook(pointerPresent, &PresentHook);
hook->SetHook(pointerEndScene, &EndSceneHook);
return TRUE;
}
Функции приемники:
HRESULT WINAPI PresentHook(IDirect3DDevice9* pd3dDevice,
CONST RECT* pSourceRect, CONST RECT* pDestRect,
HWND hDestWindowOverride, CONST RGNDATA* pDirtyRegion) {
Capture(pd3dDevice);
auto record = hook->GetRecordBySource(oOverload);
VirtualOverloadPresent pTrampoline = (VirtualOverloadPresent)
record->pTrampoline;
auto result = pTrampoline(pd3dDevice, pSourceRect, pDestRect,
hDestWindowOverride, pDirtyRegion);
deferredCommands->Run();
return result;
}
HRESULT WINAPI EndSceneHook(IDirect3DDevice9* pd3dDevice) {
if (controller == nullptr) {
controller = new GameController();
controller->SetDevice(pd3dDevice);
}
controller->Update();
auto record = hook->GetRecordBySource(oOverloadEndScene);
VirtualOverloadEndScene pTrampoline = (VirtualOverloadEndScene)
record->pTrampoline;
auto result = pTrampoline(pd3dDevice);
deferredCommands->Run();
return result;
}
VOID WINAPI Capture(IDirect3DDevice9* pd3dDevice) {
IDirect3DSurface9 *renderTarget = NULL;
IDirect3DSurface9 *destTarget = NULL;
HRESULT res1 = pd3dDevice->GetRenderTarget(0, &renderTarget);
D3DSURFACE_DESC descr;
HRESULT res2 = renderTarget->GetDesc(&descr);
HRESULT res3 = pd3dDevice->CreateOffscreenPlainSurface(
descr.Width, descr.Height, /*D3DFMT_A8R8G8B8*/descr.Format,
D3DPOOL_SYSTEMMEM, &destTarget, NULL);
HRESULT res4 = pd3dDevice->GetRenderTargetData(renderTarget, destTarget);
D3DLOCKED_RECT lockedRect;
ZeroMemory(&lockedRect, sizeof(lockedRect));
if (destTarget == NULL)
return;
HRESULT res5 = destTarget->LockRect(&lockedRect, NULL,
D3DLOCK_READONLY);
HRESULT res7 = destTarget->UnlockRect();
HRESULT res6 = D3DXSaveSurfaceToFile(screenshootPath, D3DXIFF_BMP,
destTarget, NULL, NULL);
renderTarget->Release();
destTarget->Release();
}
В EndScene создается контроллер бизнес логики. После создания вызывается обновление контроллера, где обновляется вся логика.
Замечу, что сейчас мы реализовали работу с DirectX 9. Если мы хотим делать какой нибудь мод, чит и тд — необходимо поддерживать все четыре API. Это оправдано, если на вооружении уже есть любимые библиотеки, заготовки для UI, в ином случае можно использовать другой способ — функционал, который использует движок для отрисовки игры.
Также стоит сказать, что вызов обновления логики из EndScene() не лучший вариант — можно найти периодические вызовы функций движка или вызывать логику в своем потоке. Если все же вызов из EndScene устраивает, лучше делать это при помощи lockstep.
Теперь мы реализовали все, что планировали.
Рисунок 21 — Внедрения в пример из DirectX SDK под названием StateManager.exe
Теперь можно создать фейковый аккаунт в стиме и заинжектить injected.dll в процесс Dota 2. Скажу сразу, я не знаю, как на текущий момент обстоит дело с «железными» точками останова — за использование Opcode Hook (так, как делаем это мы в текущем виде) вы точно получите бан. Я занимался этим около полугода назад — бана за Hardware Breakpoint не было, какова ситуация на текущий момент сказать не могу. Перед подготовкой статьи я взял два аккаунта и попробовал Opcode Hook и HWBP на них, первый улетел в бан (прошло около 2-х недель), второй нет (прошло 3 недели). Но все равно нет никаких гарантий, что бана не будет в будущем. Потом не обижайтесь, если случайно произведете внедрение со своего основного аккаунта или забудете перелогиниться на фейковый — тут уже следите за собой сами и будьте внимательны.
Рисунок 22 — Без внедрения
Рисунок 23 — Внедрение в меню игры
Внедрение в режиме 1x1.
Рисунок 24 — Внедрение в матч
Стоит также сказать, что существует другой способ отрисовки — поверхностная отрисовка путем создания второго окна с соответствующим размером. К сожалению, у меня не получилось реализовать возможность использования поверхностного подхода для случая полноэкранного режима, описанный же в статье подход позволяет внедрять отрисовку как в полноэкранный режим, так и в оконный без каких-либо проблем.
Наше внедренное UI содержит только текстовый лейб и кнопку, реализованные на чистом DirectX 9 — это все, что требуется для решения поставленной задачи. Вы же можете реализовать как на чистом API, так и с использованием готовых библиотек сложные таблицы, красивые меню и диаграммы — в общем, UI любой сложности. Разумеется, не только 2D.
10. Использование функций движка
Реализовывать один и тот же функционал для каждого API довольно муторно, разработчики делают удобные обертки, предоставляя функции для рисования, UI и прочее, что использует непосредственно игра. Также Valve предоставляет для Dota 2 API на Javascript и Lua. Делается это для того, чтобы облегчить жизнь модерам и гейм дизайнерам, для которых C++ сложен (даже не сам C++, а правильное использование в контексте движка). Тут есть и функции по отрисовке, и по логике игры — можно прописывать поведение юнита, например, подбор предметов, использование скилов и прочее. Собственно, при помощи этого и пишутся кастомки.
Нас будет интересовать функция DoIncludeScript, которая позволяет запускать свои скрипты на Lua и использовать там Scripting API. Я не использовал ее в своем проекте, так как не видел в ней ценности, пользуясь функциями напрямую из C++, идею ее использования я увидел у or_75 и решил включить в статью. Это познакомит вас с тем, что будет во второй части и сэкономит в ней место, не придется объяснять определенные моменты работы отладчика.
Приступим. Задача стоит следующая: необходимо найти указатель на функцию DoIncludeScript, которая принимает название скрипта и хендлер, изучить ее. Искать функцию мы будем при помощи сканера из нашей библиотеки silk_way.lib. Функции, как мы уже выяснили, кодируются в памяти при помощи таблицы опкодов — давайте изучим эту функцию и попробуем выявить ее шаблон хранения в памяти. Сейчас сканер не обладает нужным функционалом, нам нужна возможность поиска шаблона в памяти процесса.
Для ускорения поиска мы будем искать паттерн не по всей памяти процесса, а в конкретном модуле (наша функция лежит в client.dll, это будет видно в отладчике и рассмотрено ниже). Модуль будем искать при помощи tlHelp32 по названию путем перебора всех модулей процесса, для чего создадим функцию нахождения модуля в текущим процессе GetModuleInfo.
int IScanner::GetModuleInfo(const char* name,
MODULEENTRY32* entry) {
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE32 | TH32CS_SNAPMODULE, GetCurrentProcessId());
if (snapshot == INVALID_HANDLE_VALUE)
return 1;
entry->dwSize = sizeof(MODULEENTRY32);
if (!Module32First(snapshot, entry)) {
CloseHandle(snapshot);
return 1;
}
do {
if (!_stricmp(entry->szModule, name))
break;
} while (Module32Next(snapshot, entry));
CloseHandle(snapshot);
return 0;
}
Паттерн представляет из себя строку со значением байт, пропуск байта обозначается символом «??» — например “j9 ?? ?? ?? ?? 48 03 08 ?? f1 ff”.
Парсим строку, для удобства переведем паттерн из строкового представления в список unsigned char значений, установим флаги пропускаемых байт.
unsigned char* IScanner::Parse(int& len,
const char* strPattern, unsigned char* skipByteMask) {
int strPatternLen = strlen(strPattern);
unsigned char* pattern = new unsigned char[strPatternLen];
for (int i = 0; i < strPatternLen; i++)
pattern[i] = 0;
len = 0;
for (int i = 0; i < strPatternLen; i += 2) {
unsigned char code = 0;
if (strPattern[i] == SKIP_SYMBOL)
skipByteMask[len] = 1;
else
code = Parse(strPattern[i]) * 16 + Parse(strPattern[i + 1]);
i++;
pattern[len++] = code;
}
return pattern;
}
unsigned char IScanner::Parse(char byte) { // some magic values
if (byte >= '0' && byte <= '9')
return byte - '0';
else if (byte >= 'a' && byte <= 'f')
return byte - 'a' + 10;
else if (byte >= 'A' && byte <= 'F')
return byte - 'A' + 10;
return 0;
}
Ядро поиска реализовано в функции FindPattern, где по полученной информации о модуле устанавливается начальный и конечный адрес поиска. Информация о памяти, по которой будет произведен поиск, запрашивается функцией VirtualQuery, к памяти есть ряд требований — она должна быть занята (будет ошибкой искать в свободной памяти), память должна быть читаемая, выполняемая и не содержать флага PageGuard:
void* pStart = moduleEntry.modBaseAddr;
void* pFinish = moduleEntry.modBaseAddr +
moduleEntry.modBaseSize;
unsigned char* current = (unsigned char*)pStart;
for (; current < pFinish && j < patternLen; current++) {
if (!VirtualQuery((LPCVOID)current, &info, sizeof(info)))
continue;
unsigned long long protectMask =
PAGE_READONLY |
PAGE_READWRITE | PAGE_EXECUTE_READWRITE |
PAGE_EXECUTE | PAGE_EXECUTE_READ;
if (info.State == MEM_COMMIT && info.Protect & protectMask &&
!(info.Protect & PAGE_GUARD)) {
unsigned long long finish = (unsigned long long)pFinish <
(unsigned long long)info.BaseAddress + info.RegionSize ?
(unsigned long long)pFinish :
(unsigned long long) info.BaseAddress + info.RegionSize;
current = (unsigned char*)info.BaseAddress;
unsigned char* rip = 0;
for (unsigned long long k = (unsigned long long)info.BaseAddress;
k < finish && j < patternLen; k++, current++) {
if (skipByteMask[j] || pattern[j] == *current) {
if (j == 0)
rip = current;
j++;
}
else {
j = 0;
if (pattern[0] == *current) {
rip = current;
j = 1;
}
}
}
if (j == patternLen) {
current = rip;
break;
}
}
else
current += sysInfo.dwPageSize;
}
Теперь мы умеем искать нужный шаблон в памяти процесса, но пока не знаем, что нужно искать. Запустим Steam под ФЕЙКОВЫМ аккаунтом и откроем любимый отладчик (условимся, что на время прочтения статьи x64dbg является таковым и для вас — платной лицензией на IDA Pro я не обладаю), запустим в нем dota2.exe из каталога ...Steamsteamappscommondota 2 betagamebinwin64. В принципе не замечал, чтобы VAC был неравнодушен к Cheat Engine и x64dbg, не помню, чтобы при использовании данных инструментов аккаунт банился. К слову, отладчик имеет плагин ScyllaHide, который перехватывает системные функции по типу NtCreateThreadEx, NtSetInformationThread и т. д., скрывая факт своей работы, можете поставить этот плагин.
При каждом останове (их будет 10-15) продолжаем выполнения при помощи Run (F9). Когда игра запустится, мы увидим меню и можем приступить к исследованию. После запуска игры выполним поиск по строкам (Search for->All Modules->String References), установим фильтр “DoIncludeScript”.
Рисунок 25 — Поиск по строкам в памяти процесса игры
Перейдем в дизассемблер (вкладка CPU) двойным нажатием на первый результат. Это и будет наш отправной адрес, так как он находится в client.dll, остальные результаты ведут в server.dll и animationsystem.dll.
Построим граф вызовов с полученного адреса.
После декомпиляции находим точку входа, где используется DoIncludeScript — четвертая нода графа. Собственно, сама функция.
Рисунок 27 — функция DoIncludeScript
Граф.
Рисунок 28 — Граф вызовов из DoIncludeScript
Декомпиляция использования функции показывает следующий код и место его вызова (декомпилирование производим из графа, не из дизассемблера).
Рисунок 29 — Декомпиляция вызова функции DoIncludeScript
Составим шаблон из инструкций на Рисунке 27 вызова функции DoIncludeScript. Аргументы могут меняться, соответственно аргументы в шаблоне мы хотим пропускать при поиске, обозначим их «??». У меня получилось следующее: 40 57 48 81 EC ?? ?? ?? ?? 48 83 3D ?? ?? ?? ?? ?? 48 8B F9 0F 84. Для составления шаблона использовался первый узел графа из Рисунка 28, инструкции которого можно посмотреть на Рисунке 27.
Создадим скрипт на Lua silk_way.lua, положим его в «...Steamsteamappscommondota 2 betagamedotascriptsvscripts».
print("SILK_WAY START")
local first = Entities:First()
while (first ~= nil) do
local position = first:GetAbsOrigin()
local strInfo = "[" .. "pos:" .. tostring(position.x) .. "," .. tostring(position.y) .. "," .. tostring(position.z) .. "]"
DebugDrawText(position, strInfo, true, 300.0)
first = Entities:Next(first)
end
print("SILK_WAY FINISH")
--[[ListenToGameEvent("dota_roshan_kill",roshan_kill,nil)]]
Данный скрипт обходит все сущности и выводит в соответствии с ее позицией координаты.
Объявим функцию, используя приведенную выше документацию и декомпилированный код из Рисунка 29.
typedef bool(*fDoIncludeScript)(const char*, unsigned long long);
Вызов функции.
HRESULT WINAPI EndSceneHook(IDirect3DDevice9* pd3dDevice) {
if (controller == nullptr) {
controller = new GameController();
controller->SetDevice(pd3dDevice);
fDoIncludeScript DoIncludeScript = (fDoIncludeScript)
scanner->FindPattern("client.dll",
"40 57 48 81 EC ?? ?? ?? ?? 48 83 3D ?? ?? ?? ?? ?? 48 8B F9 0F 84");
DoIncludeScript("silk_way", 0);
}
//...
}
После внедрения увидим информацию о позиции сущностей игры.
Рисунок 30 — Результат внедрения
Теперь мы умеет запускать свои скрипты. Но они исполняются на Lua, а допустим, событие о том, что Рошан умер, нам нужно в C++ коде (так как на нем у нас написана основная логика), как быть? Придется аналогичным образом найти указатели на нужные функции (как мы это сделали для DoIncludeScript), функции движка и другой интересующий нас функционал при помощи Source SDK и Source2Gen. Но об этом в следующей части, где мы найдем указатель на список сущностей и напишем более приближенную к механике игры логику. Если же вам хочется все и сразу, можете попробовать, прилагаю для вас в качестве помощи эту, эту, эту и
эту ссылки.
11. Заключение
В заключении я бы хотел сказать спасибо всем, кто делится своими наработками и знаниями в области реверса, передавая свой опыт другим. Говоря только о Dota 2 без praydog я бы убил массу времени, чтобы при помощи Cheat Engine получить структуру данных игры, причем сделанные наработки могли сломаться при любом залитом Valve обновлении. Обновления ломают как найденные статические указатели, так и изредка изменяют структуру сущностей. У or75 я увидел использование функции DoIncludeScript и с ее помощью показал пример вывода текста средствами игрового движка.
В погоне за простотой изложения я мог что-то пропустить, опустить различные случаи, которые посчитал недостойными внимания, либо наоборот, раздуть объяснение — если внимательный читатель найдет такие ошибки, буду рад их исправить и послушать замечания. Исходный код можно найти по ссылке.
Спасибо всем, кто потратил время на прочтение статьи.
Автор: Александр Стешенко