[Из песочницы] Визуализация времени возрождения Рошана

В данной статье рассматривается перехват функций графического API на примере DirectX 9 под x64 применительно к игре Dota 2.

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

b0rpknk7iobhh3darfcrte8ncmo.jpeg

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. Обфускация к строкам и коду не применяется.

Из графического API доступны DirectX9, DirectX11, Vulkan, OpenGL.
xcxqkl2qpwy3gved7_jblzsqumo.png


5. Постановка задачи


В игре Dota 2 есть нейтральный «древний», убийство которого дает хорошее вознаграждение: опыт, золото, возможность откатить кулдауны скилов и предметов, Аегис (вторая жизнь), зовут его Рошан. Получение Аегиса может в корне перевернуть игру или дать еще большее преимущество более сильной стороне, соответственно игроки стараются запомнить/записать время его смерти, чтобы запланировать, когда нужно собраться вместе и напасть на него, либо быть поблизости для его охраны. О смерти Рошана оповещаются все десять игроков вне зависимости от того, скрыт ли он в тумане войны. Время возрождения имеет обязательные восемь минут, после которых Рошан может появиться случайным образом в интервале трех минут.

Задача следующая: предоставить игроку информацию по текущему состоянию Рошана (alive-жив, ressurect_base-возрождается базовое время, ressurect_extra-возрождается дополнительное время).

qfef3s2h4a6l_pfageiblxwqhd8.png
Рисунок 1 — Условия переходов между состояниями и действия при переходе

Для состояний, в которых Рошан мертв, выводить время окончания пребывания в данном состоянии. Переход из состояния alive в ressurect_base должно производиться игроком в ручном режиме по кнопке. В случае обнаружения/смерти Рошана в состоянии ressurect_extra (например вражеская команда тайком пробралась в логово и убила его), переход в состояние alive/ressurect_base также осуществляется в ручном режиме по кнопке. Статус Рошана (и время окончания пребывания в состоянии возрождения) показывать в текстовом виде, необходимый ввод (убийство и прерывание состояния ressurect_extra) обеспечить кнопкой.

cbxwneudadwlvbx4ch2gwumlona.png
Рисунок 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.

функция 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 пробегает по всем запущенным процессам и ищет процесс с соответствующим названием.

duzap1ptnhys_gyk_e1zrjm436e.png
Рисунок 3 — Начальное состояние процесса

Далее непосредственное внедрение библиотеки при помощи создания удаленного потока.

Подробное объяснение работы функции Inject

По найденному Id открывается процесс с помощью функции OpenProcess с правами на создание потока, получение информации о процессе, возможности записи и чтения. Функция GetModuleHandle извлекает модуль библиотеки kernel32, делается это для получения адреса содержащейся в ней функции LoadLibraryA функцией GetProcAddress. Назначение LoadLibrary — загрузка нашей injected.dll в указанный процесс. То есть, нам нужно вызвать LoadLibrary из интересующего нас процесса («Dota2.exe»), для этого мы удаленно создаем новый поток при помощи CreateRemoteThread. В качестве указателя на функцию, с которой запустится новый поток, передаем адрес функции LoadLibraryA. Если посмотреть сигнатуру функции LoadLibraryA, то на вход она требует в качестве параметра путь к загружаемой библиотеке — HMODULE LoadLibraryA (LPCSTR lpLibFileName). Доставляем этот аргумент мы следующим образом: CreateRemoteThread в параметрах после адреса стартовой функции принимает указатель на ее параметры, указатель на lpLibFileName мы формируем записью значения в память процесса функцией WriteProcessMemory (предварительно выделив память при помощи VirtualAllocEx).


wx8r5viycrlhjarivk-02dgh124.png
Рисунок 4 — Создание удаленного потока

Обязательно в конце закрываем обработчик процесса функцией CloseHandle, можно также освободить выделенную память. Наш инжектор готов и ждет, когда мы напишем бизнес логику в injected.dll с библиотекой silk_way.lib.

no8ctrxtn_vz31nad0uj25tb8zs.png
Рисунок 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: %d\n", 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.

Класс Device будет нашим манекеном для исследований работы ловушек.
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* GetInfo() = 0;
    virtual HookRecord* GetRecordBySource(void* source) = 0;
};


Интерфейс IHook предоставляет нам такие возможности. Мы хотим, чтобы когда любой экземпляр класса Device вызывал функции Present и EndScene (то есть указатель RIP переходил на эти адреса), выполнялись соответственно наши функции PresentHook и EndSceneHook.

Представим визуально, как расположены в памяти (секция .code) перехватываемая функция, приемник и трамплин в момент, когда управление заходит в перехватываемую функцию:

rcszdw_mh77t0jlxithbmseq5i8.png
Рисунок 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.

spvmgsf5iymwkeyncackpgaldty.png
Рисунок 7 — Исходные 5 байт функции source заменяются относительным прыжком

Что нам дает прыжок на выделенную память (фиолетовая область), как этим действием мы приблизили себя к передачи управления на destination? В выделенной нами памяти располагается прямой прыжок, который и будет перемещать RIP на destination.

nl-cu2sfhlsg-7nvfdmkzmlz8fq.png
Рисунок 8 — Переключение RIP на функцию приемник

Осталось придумать, как вызвать пойманную функцию. Нам нужно выполнить затертые инструкции и начать выполнение с нетронутой части source. Поступим следующим образом — сохраним поврежденные инструкции в начало trampoline, запомним, сколько байт было повреждено и прыгнем прямым прыжком на source + corruptLen, к «здоровым» инструкциям.

Выполнение сохраненных инструкций, затертых относительным прыжком:

eaytyckn1czk5ud3iid3fagbey0.png
Рисунок 9 — Использование трамплина для вызова перехваченной функции

Дальнейшее выполнение инструкций, которых не коснулось затирание:

mh7f-wck4tzh20cj7jefie3yl3q.png
Рисунок 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;
}


Объяснение работы функции SetHook
Создается запись, которая хранит информацию о ловушке, после запись добавляется в коллекцию. Производится обход инструкций с начала адреса source до тех пор, пока инструкция относительного прыжка не сможет быть полностью вписана (5 байт), затертые инструкции копируются в резервацию, запоминается их длина.

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

sqqlealqgipytxtvqo65agm9ky8.png
Рисунок 11 — Инструкции Debug сборки

В Debug опкод изменился, теперь компилятор добавляет относительный прыжок e9 f4 36 0. В прыжок оборачиваются все функции, включая main и точка входа в программу mainCRTStartup. Другой опкод, ну и ладно, он должен был скопироваться в трамплин, при вызове трамплина должен быть вызван этот относительный прыжок, далее прямой прыжок на неповрежденную часть source.

Тут становится понятно, что все делается как мы и реализовали, только вот относительный прыжок на то и относительный, что его выполнение от разных адресов, source и trampoline, выставляют RIP на совершенно разные значения.

По моему скромному опыту, реализация случая с относительным прыжком покрывает 99% использования. Есть еще несколько опкодов, которые следует обрабатывать отдельно. Помните, что перед тем, как ставить ловушку на функцию, следует не полениться и изучить ее. Я не буду забивать вам голову и дописывать функционал до 100 процентного варианта (опять же, по моему скромному опыту), если вам это нужно или интересно, вы можете посмотреть, как устроены такие библиотеки и конкретно какие еще случаи они проверяют — это будет лег

© Habrahabr.ru