Хотел продолжить первый гайд, но понял что нужно объяснить что такое хуки
- Создание ASI-плагина с нуля
- Хуки – что это такое и как с ними работать
- Безопасная инициализация и работа с SAMP
- Работа с рендером и Directx9
- Обработка событий окна + ImGui
В этом гайде я расскажу что такое хуки, как они работают, и как их использовать.
При использовании на других ресурсах необходимо указание авторства и ссылки на оригинальную темы!
Перед тем как начать:
Так как этот гайд сделан в целях обучения, здесь не будет показано использование готовых библиотек, а только сырой код.
В процессе написания гайда я понял что без знаний ассемблера и низкоуровневых вы поймете лишь малую часть от написанного здесь. Но если вы поймете что здесь написано - будет очень хорошо.
Все действия производились на Visual Studio 2019 с параметром
, в других версиях интерфейс может отличаться.
Все адреса указаны для SAMP R3-1, на других версиях будете ловить краши
И так, начнем:
Хук(от англ. Hook) - перехват. В нашем случае это перехват внутриигровых функций; Когда игра захочет их вызвать - будут выполняться наши действия (наш код), а затем уже можно продолжить выполнение функции, либо сразу сделать возврат, чтобы функция ничего не сделала.
Перед тем как я расскажу про сами хуки, нужно немного углубится в устройство вызова функций.
У каждой функции есть свое соглашение о вызове. Соглашение о вызове - "Правило" которое регулирует каким образом аргументы функции будут переданы самой функции и как именно будет произведен возврат значения, а также кто будет очищать стек после вызова функции(это не полный список, но самое основное что стоит знать). Если например вызвать
функцию указав соглашение о вызове
, то вы получите UB(Undefined behaviour - неопределенное поведение)
В архитектуре 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:
C++:
Код:
.
text
:
0053E972
00
C E8
89
AE
1
D
00
call _ZN5CFont12InitPerFrameEv
;
CFont
::
InitPerFrame
(
void
)
Вызов происходит по адресу 0x53E972, вызываемая функция находится по адресу 0x719800
Но asm код выше - дизассемблированный код. В самой программе он хранится вот так:
Как вы уже могли догадаться, размер инструкции вызова - 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
(
HookAddress
+
1
)
+
HookAddress
+
5
;
DWORD oldProt
;
VirtualProtect
(
reinterpret_cast
(
HookAddress
+
1
)
,
sizeof
(
uintptr_t
)
,
PAGE_READWRITE
,
&
oldProt
)
;
*
reinterpret_cast
(
HookAddress
+
1
)
=
reinterpret_cast
(
DetourFunction
)
-
HookAddress
-
5
;
VirtualProtect
(
reinterpret_cast
(
HookAddress
+
1
)
,
sizeof
(
uintptr_t
)
,
oldProt
,
&
oldProt
)
;
return
reinterpret_cast
(
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
Соглашение о вызове у нас
(перед вызовом в ecx кладется pChat)
MSVC не дает использовать
вне классов, поэтому мы будем эмулировать его через
. Единственное отличие - дополнительный параметр EDX. В самой функции вы увидите его как void* EDX
Сначала напишем тело функции хука:
C++:
[CODE]
void
__fastcall
HOOK_AddChatMessage
(
void
*
pChat
,
void
*
EDX
,
int
nType
,
const
char
*
szText
,
const
char
*
szPrefix
,
unsigned
long
textColor
,
unsigned
long
prefixColor
)
{
std
::
cout
: "
Ну а теперь установим сам хук:
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
(
GetModuleHandleA
(
"samp.dll"
)
)
;
dwSAMP
!=
0
)
{
pOriginalFunction
=
reinterpret_cast
(
SetCallHook
(
dwSAMP
+
0x67A3C
,
&
HOOK_AddChatMessage
)
)
;
}
Компилируем код, кидаем асишник в папку игры, устанавливаем
DebugConsole, заходим в игру и видим в консоли сообщения из чата.
Сообщение от
Спойлер
1623618152617.pngkin4stat · 14 Июн 2021 в 00:43' data-fancybox="lb-post-766481" data-lb-caption-extra-html="" data-lb-sidebar-href="" data-single-image="1" data-src="https://www.blast.hk/attachments/101028/" style="cursor: pointer;" title="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
(
Trampoline
)
+
HookSize
;
memcpy
(
Trampoline
,
reinterpret_cast
(
HookAddress
)
,
HookSize
)
;
// Копируем оригинальные байты в трамплин
DWORD oldProt
;
VirtualProtect
(
reinterpret_cast
(
HookAddress
)
,
HookSize
,
PAGE_READWRITE
,
&
oldProt
)
;
memset
(
reinterpret_cast
(
HookAddress
)
,
0x90
,
HookSize
)
;
// Заполняем место хука нопами(чтобы не ломать листинг ассемблера)
*
reinterpret_cast
(
HookAddress
)
=
0xE9
;
// Ставим опкод прыжка
*
reinterpret_cast
(
HookAddress
+
1
)
=
reinterpret_cast
(
DetourFunction
)
-
HookAddress
-
5
;
// Ставим релативный адрес для прыжка в функцию обработчик хука
VirtualProtect
(
reinterpret_cast
(
HookAddress
)
,
HookSize
,
oldProt
,
&
oldProt
)
;
*
reinterpret_cast
(
TrampolineJmpBack
)
=
0xE9
;
// Ставим в конец трамплина прыжок обратно
// Ставим релативный адрес для прыжка обратно в функцию для продолжения выполнения
*
reinterpret_cast
(
TrampolineJmpBack
+
1
)
=
(
HookAddress
+
HookSize
)
-
TrampolineJmpBack
-
5
;
return
Trampoline
;
}
return
nullptr
;
}
В конец трамплина мы добавляем прыжок обратно, чтобы продолжить выполнение и ничего не сломать
Теперь пишем обработчик хука:
К сожалению в голых хуках не обойтись без функции-обработчика с ассемблерным кодом.
C++:
[CODE]
void
HOOK_AddChatMessage
(
void
*
pChat
,
int
nType
,
const
char
*
szText
,
const
char
*
szPrefix
,
unsigned
long
textColor
,
unsigned
long
prefixColor
)
{
std
::
cout
: "
Вероятно посмотрев на код вы ничего не поняли. Сейчас объясню.
Т.к. теперь телом хука выступает функция HOOK_Raw_AddChatMessage и из нее мы вызываем наш обработчик, т.к. вызов делаем мы сами, то
и прочие заморочки в обработчике не нужны
указывает компилятору на то что не нужно генерировать код на входе и выходе из функции - мы сделаем это сами
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
(
GetModuleHandleA
(
"samp.dll"
)
)
;
dwSAMP
!=
0
)
{
SetJmpHook
(
dwSAMP
+
0x67460
,
5
,
&
HOOK_Raw_AddChatMessage
)
;
}
Снова компилируем, кидаем в папку игры и видим в консоли все сообщения из чата. На этот раз все, а не только сообщений от сервера, ведь мы перехватили саму функцию AddChatMessage, а не одно из ее использований