Гайд Хуки – что это такое и как с ними работать [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
 
Последнее редактирование:

Yuriy Code

Известный
755
926
uintptr_t OriginalFunction = *reinterpret_cast<uintptr_t*>(HookAddress + 1) + HookAddress + 5;
Что за дичь...
Зачем ты сложил адрес хука???
HookAddress + HookAddress... ЗАЧЕМ??? Если нужно вычесть. Но, Киня же сделал зачем-то наоборот...

Как мне быть, если я ничего не понимаю... 😭
 
Последнее редактирование:

sc6ut

неизвестный
Модератор
382
1,074
Что за дичь...
Зачем ты сложил адрес хука???
HookAddress + HookAddress... ЗАЧЕМ??? Если нужно вычесть. Но, Киня же сделал зачем-то наоборот...

Как мне быть, если я ничего не понимаю... 😭
так, для начала хватит давать эти уебищные прозвища, заебал и не только меня. итак, к делу:

опкод jmp, размер которой 5 (x86), прыгает на определенное место в коде и она является релативной, то есть на сколько прыгнуть ПОСЛЕ себя.
например ты на адрес 10 и тебе надо прыгнуть на адрес 20. чтобы это сделать воспользуемся jmp. подводя итог прошлому абзацу, после инструкции jmp адрес будет 15 (т.к её размер 5) соотвественно нам нужно прыгнуть еще на 5. как это вычислить математически? jmp {куда надо}20-{откуда}10-{размер инструкции}5=5, то есть jmp 5.
так же работает и call, но она делает ещё несколько действий кроме прыжка.
с этим разобрались.

теперь, то что ты процитировал, kin4stat берет адрес оригинальной функции (для дальнейшего её вызова чтобы не прерывать flow программы) из существующей инструкции call/jmp (не читал контекст).
пользуясь знаниями из прошлого абзаца, зная как устроена инструкция jmp (JMP ADDR), то на 0 месте находится опкод, а на 1-4 сам РЕЛАТИВНЫЙ адресс.
возьмем пример из прошлого абзаца: jmp 5 на адресе 10, соотвественно после этой инструкции программа будет на адресе 20, его нам и нужно достать.
addr = {размер прыжка}5+{текущий адрес}10+{размер инструкции jmp}5=20.
мы добавляем 5 т.к jmp берет за основу адрес после самой себя, поэтому при её построение мы отнимаем (т.к эти байты нам прыгать не надо), а при возвращение оригинального адреса мы их добавляем.

вроде все понятно, не перечитывал да и похуй
 
  • Нравится
Реакции: rvng, ARMOR и Yuriy Code

sc6ut

неизвестный
Модератор
382
1,074
😱, Скаут, но тогда jmp будет на адрес 5...
она является релативной, то есть на сколько прыгнуть ПОСЛЕ себя.
ебаный рот читать научись я просто вахуе нет слов
15(адрес после jmp)+5(на сколько прыгнуть)=20
 

ARMOR

kjor32 is legend
Модератор
4,827
6,011
У меня одного jmp хук вызывает краш при запуске игры? В консоль выводится только первое сообщение сампа, и дальше моментальный краш.