Хотел продолжить первый гайд, но понял что нужно объяснить что такое хуки
В этом гайде я расскажу что такое хуки, как они работают, и как их использовать.
При использовании на других ресурсах необходимо указание авторства и ссылки на оригинальную темы!
Перед тем как начать:
Так как этот гайд сделан в целях обучения, здесь не будет показано использование готовых библиотек, а только сырой код.
В процессе написания гайда я понял что без знаний ассемблера и низкоуровневых вы поймете лишь малую часть от написанного здесь. Но если вы поймете что здесь написано - будет очень хорошо.
Все действия производились на Visual Studio 2019 с параметром
Все адреса указаны для SAMP R3-1, на других версиях будете ловить краши
И так, начнем:
Хук(от англ. Hook) - перехват. В нашем случае это перехват внутриигровых функций; Когда игра захочет их вызвать - будут выполняться наши действия (наш код), а затем уже можно продолжить выполнение функции, либо сразу сделать возврат, чтобы функция ничего не сделала.
Перед тем как я расскажу про сами хуки, нужно немного углубится в устройство вызова функций.
У каждой функции есть свое соглашение о вызове. Соглашение о вызове - "Правило" которое регулирует каким образом аргументы функции будут переданы самой функции и как именно будет произведен возврат значения, а также кто будет очищать стек после вызова функции(это не полный список, но самое основное что стоит знать). Если например вызвать
В архитектуре x86 исторически сложилось, что разным людям не нравилось что-то в других соглашениях о вызове, и они создавали свои. На x64 такой бардак тоже есть, но уже между разными OC.
Существует много соглашений о вызовах, но описывать все я не буду, ибо они вам вряд ли пригодятся(
Мы же рассмотрим 4 соглашения о вызовах:
У всех соглашений аргументы передаются справа налево через стек.
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:
Вызов происходит по адресу 0x53E972, вызываемая функция находится по адресу 0x719800
Но asm код выше - дизассемблированный код. В самой программе он хранится вот так:
Как вы уже могли догадаться, размер инструкции вызова - 5 байт
E8 - опкод вызова. В asm мнемонике записывается как call
89 AE 1D 00 - Релативный адрес вызова, записанный в порядке байт little endian. Что такое порядок байт - лучше почитать на википедии
Если перевести релативный адрес в нормальное число, то получим 0x1DAE89. Откуда же вышло это число?
Оно было посчитано как разница между адреса вызова и адреса вызываемой функции. Считается по формуле: (Адрес назначения вызова/прыжка) - (Адрес вызова/прыжка) - 5
Очевидно что для перехвата нужно заменить релативный адрес на свой. Но если лишь заменить адрес вызова функции, то вы затрете оригинальную функцию, и те действия что должны были произойти - не перезайдут. Поэтому помимо этого нам нужно сохранить оригинальный релативный адрес и пересчитать его.
Займемся этим.
Сама функция установки хука будет предельна проста и будет лишь возвращать адрес, по которому нужно сделать прыжок обратно.
Перед подменой релативного адреса нужно снять защиту с секции кода приложения(защита там стоит воизбежание случайной записи в код и дальшейнего UB), а после подмены вернуть все обратно
Показывать буду на примере хука вывода сообщения в чат.
Хук будем ставить вот сюда:
Соглашение о вызове у нас
MSVC не дает использовать
Сначала напишем тело функции хука:
Ну а теперь установим сам хук:
Компилируем код, кидаем асишник в папку игры, устанавливаем DebugConsole, заходим в игру и видим в консоли сообщения из чата.
Перейдем к jmp хукам.
jmp хуки ставятся где угодно(на самом деле call хуки тоже, но более запарно)
Для установки jmp хука требуется немного больше действий. Так как ставить мы его будем в случайном месте, не факт что там где мы будем ставить его, будет ровно 5 байт. Поэтому перед установкой хука нужно смотреть сколько байт занимают инструкции на месте установки хука. Я буду смотреть опять же на функции добавления сообщения в чат. Переходим в пролог(начало) функции и смотрим на ассемблерный код:
И видим что у нас тут 4 опкода занимающих ровно 5 байт. Но если вам не повезет как тут, то нужно брать в большую сторону.
Например тут, нужно будет взять 6 байт.
Идем дальше. Если мы просто затрем код по адресу, то у нас все сломается. Поэтому перед тем как ставить сам хук, нужно скопировать оригинальный код.
Пишем функцию для установки jmp хука(опкод у JMP - E9):
В конец трамплина мы добавляем прыжок обратно, чтобы продолжить выполнение и ничего не сломать
Теперь пишем обработчик хука:
К сожалению в голых хуках не обойтись без функции-обработчика с ассемблерным кодом.
Вероятно посмотрев на код вы ничего не поняли. Сейчас объясню.
Т.к. теперь телом хука выступает функция HOOK_Raw_AddChatMessage и из нее мы вызываем наш обработчик, т.к. вызов делаем мы сами, то
pushad и popad нужны для сохранения и возврата в исходное состояние регистров. Т.к. мы указали компилятору не генерировать код на входе и выходе, то сохранять регистры некому, поэтому делаем это вручную.
Аргументы со стека теперь придется тащить самим, т.к. мы перехватываем уже после вызова. В этот момент на стеке появится еще одно значение, и еще один вызов сместит все аргументы в функции на 1, и произойдет не то, чего мы ожидали.
Теперь устанавливаем сам хук. Ставить будем тут:
Снова компилируем, кидаем в папку игры и видим в консоли все сообщения из чата. На этот раз все, а не только сообщений от сервера, ведь мы перехватили саму функцию AddChatMessage, а не одно из ее использований
- Создание ASI-плагина с нуля
- Хуки – что это такое и как с ними работать
- Безопасная инициализация и работа с SAMP
- Работа с рендером и Directx9
- Обработка событий окна + 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)
Но 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, заходим в игру и видим в консоли сообщения из чата.
Перейдем к 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
Например тут, нужно будет взять 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);
}
Последнее редактирование: