Гайд Хуки – что это такое и как с ними работать [2]

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

  1. Создание ASI-плагина с нуля
  2. Хуки – что это такое и как с ними работать
  3. Безопасная инициализация и работа с SAMP
  4. Работа с рендером и Directx9
  5. Обработка событий окна + ImGui

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

При использовании на других ресурсах необходимо указание авторства и ссылки на оригинальную темы!

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

Все действия производились на Visual Studio 2019 с параметром /std:c++17, в других версиях интерфейс может отличаться.
Все адреса указаны для SAMP R3-1, на других версиях будете ловить краши

И так, начнем:
Хук(от англ. Hook) - перехват. В нашем случае это перехват внутриигровых функций; Когда игра захочет их вызвать - будут выполняться наши действия (наш код), а затем уже можно продолжить выполнение функции, либо сразу сделать возврат, чтобы функция ничего не сделала.

Перед тем как я расскажу про сами хуки, нужно немного углубится в устройство вызова функций.
У каждой функции есть свое соглашение о вызове. Соглашение о вызове - "Правило" которое регулирует каким образом аргументы функции будут переданы самой функции и как именно будет произведен возврат значения, а также кто будет очищать стек после вызова функции(это не полный список, но самое основное что стоит знать). Если например вызвать cdecl функцию указав соглашение о вызове stdcall, то вы получите UB(Undefined behaviour - неопределенное поведение)
В архитектуре x86 исторически сложилось, что разным людям не нравилось что-то в других соглашениях о вызове, и они создавали свои. На x64 такой бардак тоже есть, но уже между разными OC.
Существует много соглашений о вызовах, но описывать все я не буду, ибо они вам вряд ли пригодятся(pascal к примеру)
Мы же рассмотрим 4 соглашения о вызовах: cdecl, stdcall, fastcall, thiscall
У всех соглашений аргументы передаются справа налево через стек.

cdecl является основным соглашением о вызове и используется почти везде. Возврат осуществляется через регистр eax, регистр st0 для x87, и пару регистров eax:edx для значений размером в 5-8 байт. Стек очищает тот кто вызывает функцию, поэтому cdecl поддерживает переменное число аргументов. Установлено по умолчанию в MSVC.

stdcall является основным соглашением о вызовах в Windows, а также во многих библиотеках(например basslib). Возврат осуществляется через регистр eax, очистка стека производится самой функцией. Переменное число аргументов не поддерживает.

thiscall используется для вызова методов класса. В регистр ecx кладется скрытый аргумент this, очистка стека производится самой функцией, возврат значения через регистр eax. Переменное число аргументов не поддерживает.

fastcall используется редко. В хуках зачастую используется для обхода thiscall в msvc(чуть позже расскажу что это). Первые два аргумента кладутся в регистры ecx и edx, остальные в том же порядке через стек. Очистка стека производится самой функцией. Переменное число аргументов не поддерживает. Из-за использования регистров для передачи аргументов его назвали fastcall, т.к. операции с регистрами на старых компьютерах были заметно быстрее операциями со стеком.

Теперь можно перейти к теории о хуках.
Для перехвата используются две техники - подмена вызова(call hook) и уже после вызова(в прологе) прыжок в хук.
Начнем с первого: в основном вызовы происходят по релативному адресу, но бывают и вызовы по абсолютному адресу который находится в регистре.
Релативный адрес(от англ. Relative address) - это адрес, относительно места откуда происходит вызов.
Абсолютный адрес - это адрес, указываемый относительно всего адресного пространства программы.
Наперед скажу что мы будем работать только с релативными адресами.
Покажу на примере вызова функции в GTA:SA:
C++:
.text:0053E972 00C E8 89 AE 1D 00                    call    _ZN5CFont12InitPerFrameEv ; CFont::InitPerFrame(void)
Вызов происходит по адресу 0x53E972, вызываемая функция находится по адресу 0x719800
Но asm код выше - дизассемблированный код. В самой программе он хранится вот так:
E8 89 AE 1D 00
Как вы уже могли догадаться, размер инструкции вызова - 5 байт
E8 - опкод вызова. В asm мнемонике записывается как call
89 AE 1D 00 - Релативный адрес вызова, записанный в порядке байт little endian. Что такое порядок байт - лучше почитать на википедии
Если перевести релативный адрес в нормальное число, то получим 0x1DAE89. Откуда же вышло это число?
Оно было посчитано как разница между адреса вызова и адреса вызываемой функции. Считается по формуле: (Адрес назначения вызова/прыжка) - (Адрес вызова/прыжка) - 5
В процессоре есть специальный регистр EIP(RIP на x64). Расшифровывается как Instruction Pointer. После считывания процессором инструкции по адресу 0x53E972, Instrustion pointer смещается на 5 байт вперед(инструкция вызова имеет размер 5). А конечный адрес вызова вычисляется относительно EIP, поэтому нужно добавлять 5 байт смещения.
Очевидно что для перехвата нужно заменить релативный адрес на свой. Но если лишь заменить адрес вызова функции, то вы затрете оригинальную функцию, и те действия что должны были произойти - не перезайдут. Поэтому помимо этого нам нужно сохранить оригинальный релативный адрес и пересчитать его.
Займемся этим.
Сама функция установки хука будет предельна проста и будет лишь возвращать адрес, по которому нужно сделать прыжок обратно.
Перед подменой релативного адреса нужно снять защиту с секции кода приложения(защита там стоит воизбежание случайной записи в код и дальшейнего UB), а после подмены вернуть все обратно
Установка call хука:
void* SetCallHook(uintptr_t HookAddress, void* DetourFunction) {
    uintptr_t OriginalFunction = *reinterpret_cast<uintptr_t*>(HookAddress + 1) + HookAddress + 5;
    DWORD oldProt;
    VirtualProtect(reinterpret_cast<void*>(HookAddress + 1), sizeof(uintptr_t), PAGE_READWRITE, &oldProt);
    *reinterpret_cast<uintptr_t*>(HookAddress + 1) = reinterpret_cast<uintptr_t>(DetourFunction) - HookAddress - 5;
    VirtualProtect(reinterpret_cast<void*>(HookAddress + 1), sizeof(uintptr_t), oldProt, &oldProt);
    return reinterpret_cast<void*>(OriginalFunction);
}
Показывать буду на примере хука вывода сообщения в чат.
Хук будем ставить вот сюда:
Код:
.text:10067A2A 008 6A 00                                   push    0               ; a6
.text:10067A2C 00C C1 E8 08                                shr     eax, 8
.text:10067A2F 00C 0D 00 00 00 FF                          or      eax, 0FF000000h
.text:10067A34 00C 50                                      push    eax             ; a5
.text:10067A35 010 6A 00                                   push    0               ; a4
.text:10067A37 014 56                                      push    esi             ; a3
.text:10067A38 018 6A 04                                   push    4               ; a2
.text:10067A3A 01C 8B CF                                   mov     ecx, edi        ; this

.text:10067A3C 01C E8 1F FA FF FF                          call    CChat__AddEntry  ; this call will be hooked
Соглашение о вызове у нас thiscall (перед вызовом в ecx кладется pChat)
MSVC не дает использовать thiscall вне классов, поэтому мы будем эмулировать его через fastcall. Единственное отличие - дополнительный параметр EDX. В самой функции вы увидите его как void* EDX
Сначала напишем тело функции хука:
C++:
void __fastcall HOOK_AddChatMessage(void* pChat, void* EDX, int nType, const char* szText, const char* szPrefix, unsigned long textColor, unsigned long prefixColor) {
    std::cout << "<" << std::string_view{ ((szPrefix) ? szPrefix : "") } << ">: " << std::string_view{ szText } << std::endl;
    // Вызов оригинальной функции, можно просто return, но тогда сообщение в чат не будет добавлено, т.к. мы не вызываем оригинальную функцию
    return pOriginalFunction(pChat, EDX, nType, szText, szPrefix, textColor, prefixColor);
}
Ну а теперь установим сам хук:

C++:
// Где нибудь за пределами функции
using CChat__AddChatMessage = void(__fastcall*)(void*, void*, int, const char*, const char*, unsigned long, unsigned long); // прототип функции, взят из IDA PRO
CChat__AddChatMessage pOriginalFunction = nullptr;

// Сама установка хука
if (uintptr_t dwSAMP = reinterpret_cast<uintptr_t>(GetModuleHandleA("samp.dll")); dwSAMP != 0) {
    pOriginalFunction = reinterpret_cast<CChat__AddChatMessage>(SetCallHook(dwSAMP + 0x67A3C, &HOOK_AddChatMessage));
}

Компилируем код, кидаем асишник в папку игры, устанавливаем DebugConsole, заходим в игру и видим в консоли сообщения из чата.
1623618152617.png

Перейдем к jmp хукам.
jmp хуки ставятся где угодно(на самом деле call хуки тоже, но более запарно)
Для установки jmp хука требуется немного больше действий. Так как ставить мы его будем в случайном месте, не факт что там где мы будем ставить его, будет ровно 5 байт. Поэтому перед установкой хука нужно смотреть сколько байт занимают инструкции на месте установки хука. Я буду смотреть опять же на функции добавления сообщения в чат. Переходим в пролог(начало) функции и смотрим на ассемблерный код:
Код:
.text:10067460 000 55                                      push    ebp
.text:10067461 004 56                                      push    esi
.text:10067462 008 8B E9                                   mov     ebp, ecx
.text:10067464 008 57                                      push    edi
И видим что у нас тут 4 опкода занимающих ровно 5 байт. Но если вам не повезет как тут, то нужно брать в большую сторону.
Например тут, нужно будет взять 6 байт.
Код:
.text:10067478 00C 8B 74 24 18                             mov     esi, [esp+0Ch+a4]
.text:1006747C 00C 85 F6                                   test    esi, esi
Идем дальше. Если мы просто затрем код по адресу, то у нас все сломается. Поэтому перед тем как ставить сам хук, нужно скопировать оригинальный код.
Пишем функцию для установки jmp хука(опкод у JMP - E9):
C++:
void* SetJmpHook(uintptr_t HookAddress, size_t HookSize, void* DetourFunction) {
    void* Trampoline = VirtualAlloc(0, HookSize + 5, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); // Аллоцируем память для трамплина
    if (Trampoline) {
        uintptr_t TrampolineJmpBack = reinterpret_cast<uintptr_t>(Trampoline) + HookSize;
        memcpy(Trampoline, reinterpret_cast<void*>(HookAddress), HookSize); // Копируем оригинальные байты в трамплин

        DWORD oldProt;
        VirtualProtect(reinterpret_cast<void*>(HookAddress), HookSize, PAGE_READWRITE, &oldProt);
        memset(reinterpret_cast<void*>(HookAddress), 0x90, HookSize); // Заполняем место хука нопами(чтобы не ломать листинг ассемблера)
        *reinterpret_cast<unsigned char*>(HookAddress) = 0xE9; // Ставим опкод прыжка
        *reinterpret_cast<uintptr_t*>(HookAddress + 1) = reinterpret_cast<uintptr_t>(DetourFunction) - HookAddress - 5; // Ставим релативный адрес для прыжка в функцию обработчик хука
        VirtualProtect(reinterpret_cast<void*>(HookAddress), HookSize, oldProt, &oldProt);

        *reinterpret_cast<unsigned char*>(TrampolineJmpBack) = 0xE9; // Ставим в конец трамплина прыжок обратно
        // Ставим релативный адрес для прыжка обратно в функцию для продолжения выполнения
        *reinterpret_cast<uintptr_t*>(TrampolineJmpBack + 1) = (HookAddress + HookSize) - TrampolineJmpBack - 5;
        return Trampoline;
    }
    return nullptr;
}
В конец трамплина мы добавляем прыжок обратно, чтобы продолжить выполнение и ничего не сломать
Теперь пишем обработчик хука:
К сожалению в голых хуках не обойтись без функции-обработчика с ассемблерным кодом.
C++:
void HOOK_AddChatMessage(void* pChat, int nType, const char* szText, const char* szPrefix, unsigned long textColor, unsigned long prefixColor) {
    std::cout << "<" << std::string_view{ ((szPrefix) ? szPrefix : "") } << ">: " << std::string_view{ szText } << std::endl;
}

void __declspec(naked) HOOK_Raw_AddChatMessage(void) {
    static void* pChat;
    static int nType;
    static const char* szText;
    static const char* szPrefix;
    static unsigned long textColor, prefixColor;
    __asm {
        // Вытаскиваем все аргументы со стека
        mov eax, [esp + 0x04]
        mov nType, eax
        mov eax, [esp + 0x08]
        mov szText, eax
        mov eax, [esp + 0x0C]
        mov szPrefix, eax
        mov eax, [esp + 0x10]
        mov textColor, eax
        mov eax, [esp + 0x14]
        mov prefixColor, eax
        pushad // Сохраняем все регистры
    }

    HOOK_AddChatMessage(pChat, nType, szText, szPrefix, textColor, prefixColor);

    __asm {
        popad // вытаскиваем сохраненные регистры
        // Прыгаем в трамплин для продолжения исполнения. Если не нужно продолжать исполнение - нужно поставить опкод ret
        jmp pOriginalFunction
    }
}
Вероятно посмотрев на код вы ничего не поняли. Сейчас объясню.
Т.к. теперь телом хука выступает функция HOOK_Raw_AddChatMessage и из нее мы вызываем наш обработчик, т.к. вызов делаем мы сами, то __fastcall и прочие заморочки в обработчике не нужны
__declspec(naked) указывает компилятору на то что не нужно генерировать код на входе и выходе из функции - мы сделаем это сами
pushad и popad нужны для сохранения и возврата в исходное состояние регистров. Т.к. мы указали компилятору не генерировать код на входе и выходе, то сохранять регистры некому, поэтому делаем это вручную.
Аргументы со стека теперь придется тащить самим, т.к. мы перехватываем уже после вызова. В этот момент на стеке появится еще одно значение, и еще один вызов сместит все аргументы в функции на 1, и произойдет не то, чего мы ожидали.
Теперь устанавливаем сам хук. Ставить будем тут:
Код:
.text:10067460 000 55                                      push    ebp
.text:10067461 004 56                                      push    esi
.text:10067462 008 8B E9                                   mov     ebp, ecx
.text:10067464 008 57                                      push    edi
C++:
// Где-то вне функций
void* pOriginalFunction = nullptr;

// Сама установка хука:
if (uintptr_t dwSAMP = reinterpret_cast<uintptr_t>(GetModuleHandleA("samp.dll")); dwSAMP != 0) {
    SetJmpHook(dwSAMP + 0x67460, 5, &HOOK_Raw_AddChatMessage);
}
Снова компилируем, кидаем в папку игры и видим в консоли все сообщения из чата. На этот раз все, а не только сообщений от сервера, ведь мы перехватили саму функцию AddChatMessage, а не одно из ее использований
1623619709314.png
 
Последнее редактирование:

kin4stat

mq-team
Автор темы
Всефорумный модератор
2,730
4,710
наконец то ты запилил гайд по хукам
Хотел сначала сделать вторую часть гайда, но потом понял что про хуки надо рассказать перед этим
У меня мозг закипел...
У меня у самого закипел пока гайд писал
 

Fott

Простреленный
3,423
2,249
Дайте ссылку на 1 тему, я не могу найти
 
  • Нравится
Реакции: Akat

Yuriy Code

Известный
754
928
Код:
.text:10067460 000 55 push ebp
.text:10067461 004 56 push esi
.text:10067462 008 8B E9 mov ebp, ecx
.text:10067464 008 57 push edi
И видим что у нас тут 4 опкода занимающих ровно 5 байт. Но если вам не повезет как тут, то нужно брать в большую сторону.
Например тут, нужно будет взять 6 байт.
Как ты узнал, что опкоды по 5 байт?
Расскажи, как ты посчитал это. Ибо, вообще нихуя не понятно.