inline hook 原理 教程
inline hook 原理&教程
2021年5月24日
- <1> inline hook 是什么
- <2> inline hook 基本原理
- <3> inline hook 跳板函数
- <4> inline hook 线程安全
- <5> inline hook 推荐库
- <6> thiscall hook的方法
- <7> hook现有进程的其他事项
<1> inline hook 是什么
当我们想要拦截现有运行中的进程内某个现有的汇编函数体,最常用的办法就是 inline hook
。
它可以在权限允许内,通过修改程序运行中的内存代码段汇编,以达到拦截任何函数的目的,包括系统api(只限非内核态的函数体,要hook内核函数需要进内核态),以及程序内部现有的任何函数体。。
比如想拦截系统APICreateFileW
的调用,修改原调用参数并继续执行CreateFileW
原函数逻辑,获得返回值,或者直接拦截返回NULL
失败,或者拦截程序本身代码汇编的函数体,用 inline hook
都可以做到。
<2>inline hook 基本原理
在windows下,程序执行的时候会把dll和exe的代码段 text
以及其他数据整理后加载进内存,以顺序排列在指定的虚拟内存空间内。
xx.exe, 0xc80000
abseil_dll.dll, 0x6ca0000
AcLayers.dll, 0x7b9d0000
AddrSearch.dll, 0x46a0000
advapi32.dll, 0x75b80000
advapi32.dll.mui, 0x201b0000
Advertisement.dll, 0x1e560000
AdvVideoDev.dll, 0x7c930000
AFBase.dll, 0x3a80000
AFCtrl.dll, 0x7c9b0000
AFUtil.dll, 0x7c2b0000
AppCenter.dll, 0x7b9b0000
AppFramework.dll, 0x796f0000
...
其中,dll或者exe的内存空间首地址,被称为基地址,此时xx.exe的基地址就是0xc80000。
既然在内存内,那就意味着一个exe或者dll的代码段
text
或者其他段的运行时数据是可能被修改的?
是的,windows下可以使用VirtualProtect
函数,修改虚拟内存地址块的保护属性,标记为可读写
。
请看下面的代码
<此代码只适用32位程序>
#include <Windows.h>
#include <iostream>
//构造了一个 参数 与原CreateFileA一样的的函数
HANDLE __stdcall MY_CreateFileA(
LPCSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile) {
printf("MY_CreateFileA: %s \n", lpFileName);
return NULL;
}
int main()
{
HANDLE hFile;
printf("第一次调用CreateFileA \n");
hFile = CreateFileA(
"abc.txt",
GENERIC_WRITE,
0,
NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == NULL) {
printf("hFile==NULL\n");
}
else if (hFile != INVALID_HANDLE_VALUE) {
printf("CreateFileA success\n");
CloseHandle(hFile);
}
//原CreateFileA的函数内存地址 或者直接用&CreateFileA
char* target = (char*)GetProcAddress(GetModuleHandleA("Kernel32.dll"), "CreateFileA");
//MY_CreateFileA的内存地址
char* detour = (char*)&MY_CreateFileA;
DWORD oldProtect;
//修改CreateFileA的内存地址块 5个大小为可读写
if (!VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect)) {
printf("VirtualProtect false\n");
return 0;
}
//0xe9为汇编代码jmp的二进制值
unsigned char jmp_0xe9 = 0xE9;
//这是一个jmp用的偏移地址,从CreateFileA位置跳转到MY_CreateFileA
unsigned int jmp_addr = detour - (target + 5);
//将target 原CreateFileA 的内存前五个 改写,覆盖为一个jmp指令,和一个jmp需要的偏移地址,刚好五个字节大小
memcpy(target, &jmp_0xe9, 1);
memcpy(target + 1, &jmp_addr, 4);
//恢复原内存保护属性
VirtualProtect(target, 5, oldProtect, &oldProtect);
printf("第二次调用CreateFileA \n");
hFile = CreateFileA(
"abc.txt",
GENERIC_WRITE,
0,
NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == NULL) {
printf("hFile==NULL\n");
}
else if (hFile != INVALID_HANDLE_VALUE) {
CloseHandle(hFile);
}
std::cout << "Hello World!\n";
}
执行结果输出为
第一次调用CreateFileA
CreateFileA success
第二次调用CreateFileA
MY_CreateFileA: abc.txt
hFile==NULL
Hello World!
可以很明显的看到,第二次调用CreateFileA
被拦截到MY_CreateFileA
,并成功获取到了原调用参数!
这其中的原理就是,修改了CreateFileA
前五个字节的汇编,覆盖为jmp 0x 0x 0x 0x
, 四字节的0x 0x 0x 0x
为MY_CreateFileA
的函数地址!
[---原函数---]
[[ jmp 0x 0x 0x 0x ]-被修改的函数---](头五个字节汇编被覆盖)
当执行CreateFileA
,就会执行jmp 指令
,跳转到给定的函数地址,所以就跳转到了MY_CreateFileA
。
0xE9
jmp的汇编之后的四个字节,是偏移的地址,通用公式为:要跳转的地址-(jmp下一行汇编的地址)
,即detour - (target + 5)
。
上面的代码完成了32位
下对某一个函数的简单拦截。
思考几个问题?
1:为什么要一个参数和调用方式与原函数一样参数一样调用方式
的MY_CreateFileA
函数?
答:因为退栈的原则,CreateFileA
是stdcall
调用,参数全是push压栈,且,清理栈还原esp
值责任也全在目标函数CreateFileA
!所以,有一个参数与CreateFileA
一样的函数,MY_CreateFileA
正常退栈esp
值,才能保证正常esp值,否则函数执行完,esp
值并没有还原到 call CreateFileA
之前的状态,造成程序错乱异常甚至崩溃!
2:为什么直接jmp
到MY_CreateFileA
能获取到参数?
答:因为call之后跳转到CreateFileA
中途只有一个jmp
,栈是传参后的原样没有改变,MY_CreateFileA
和CreateFileA
的调用方式都是__stdcall
,所以MY_CreateFileA
去获取参数的时候,就能获取到原本的传参。
3:MY_CreateFileA
执行完成为什么会成功跳转到原来的调用代码?
答:原本执行的过程是 call CreateFileA
-> 在CreateFileA
函数结束ret
。call
与 ret
是成对工作的,call
会压栈一个ip地址,而ret
会退栈一个值,并跳转到这个值地址,从而回到call
的下一行汇编。当覆盖原CreateFileA
前五个字节为jmp
,并没有改变栈的状态,所以跳转到MY_CreateFileA
,不仅能够获取到原本的参数,而且ret
同样能跳转到原来call CreateFileA
的代码ip位置。
思考这些汇编实现问题很重要,如果不能想通,就需要去补习函数调用增栈退栈和传参的实现原理。
现在已经完成了对某一个函数的拦截,那么如何成功的调用原函数呢?
<3>inline hook 跳板函数
原CreateFileA
已被破坏了,因为前5个字节的汇编代码都被覆盖了。无法正常调用!
如何才能正常的调用原函数?
可以尝试这样
<此代码只适用于32位>
#include <Windows.h>
#include <iostream>
char backups_asm[5];
int __stdcall MY_MessageBoxA(
HWND hWnd,
LPCSTR lpText,
LPCSTR lpCaption,
UINT uType);
//拦截,并备份
void Intercept()
{
char* target = (char*)&MessageBoxA;
char* detour = (char*)&MY_MessageBoxA;
DWORD oldProtect;
VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
//----------------备份原来的5个字节------------------------------
memcpy(backups_asm, target, 5);
//----------------备份原来的5个字节------------------------------
unsigned char jmp_0xe9 = 0xE9;//jmp
unsigned int jmp_addr = detour - (target + 5);//jmp 地址
//覆盖原函数5个字节为 jmp
memcpy(target, &jmp_0xe9, 1);
memcpy(target + 1, &jmp_addr, 4);
VirtualProtect(target, 5, oldProtect, &oldProtect);
}
int __stdcall MY_MessageBoxA(
HWND hWnd,
LPCSTR lpText,
LPCSTR lpCaption,
UINT uType)
{
printf("MY_MessageBoxA: %s \n", lpText);
//------在这里通过备份恢复原函数------
char* target = (char*)&MessageBoxA;
DWORD oldProtect;
VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
//----------------恢复原来的5个字节------------------------------
memcpy(target, backups_asm, 5);
//----------------恢复原来的5个字节------------------------------
VirtualProtect(target, 5, oldProtect, &oldProtect);
//继续调用原函数
return MessageBoxA(hWnd, lpText, lpCaption, uType);
}
int main()
{
Intercept();
MessageBoxA(NULL, "this my text!", "title", MB_OK);
std::cout << "end \n";
}
输出:
MY_MessageBoxA: this my text!
end
可以看到,MY_MessageBoxA
已拦截到,同时也正确执行了MessageBoxA
原函数。
但这种方式很别扭,每次都需要进行memcpy
,不停改变代码段汇编,并不是线程安全的,不太实用。
有一种更好的办法,那就是创建一个跳板函数。
步骤如下,在虚拟内存内开辟一块可被执行的内存,将原函数的前5个字节复制到这里,然后在尾部再加上一个往原函数地址jmp
,接着逻辑继续执行,如果执行到这个地址,那么先会执行备份的5个字节,然后jmp到原来函数的逻辑,就能成功调用原函数了。
开辟的新可执行内存空间 跳板函数()
{
备份复制原函数的5字节汇编
jmp 到原函数5字节之后位置
}
但这里有一个问题,汇编指令集不是一直为5个字节大小,有各种长度的,如果贸然只备份5个字节,可能会切断原本有的汇编指令,从而无法完整执行正常的代码段,造成程序崩溃。
所以,这里这里在备份原函数的时候,需要读取汇编指令,从而备份的一个大于5字节的完整汇编段。
还有一个问题,当备份汇编字节的汇编有各种跳转指令的时候,copy到跳板函数内存区域,这些偏移地址也要进行修改。 比如原函数 (0xe9)jmp 0x 0x 0x 0x
相对跳转指令,复制到跳板函数的时候就需要重新计算跳转偏移。
需要进行修改的跳转指令大概有这些,call
jmp
jcc
等,当然如果是0xFF 0x25 jmp
这样的绝对跳转,地址是不用变的。
如何获得完整的汇编指令大小和值,可以通过开源代码实现,hde32_disasm
或 hde64_disasm
。
创建跳板Trampoline函数示例如下
<此代码只适用于32位>
#include <Windows.h>
#include <iostream>
#include "./hde/hde32.h"
#include <cassert>
typedef HANDLE(__stdcall* FN_PTR_CreateFileA)(
LPCSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile);
FN_PTR_CreateFileA CreateFileATrampoline = NULL;
HANDLE __stdcall MY_CreateFileA(
LPCSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile) {
printf("MY_CreateFileA: %s \n", lpFileName);
//执行原函数 跳板函数
return CreateFileATrampoline(lpFileName,
dwDesiredAccess,
dwShareMode,
lpSecurityAttributes,
dwCreationDisposition,
dwFlagsAndAttributes,
hTemplateFile);
}
//将target前面第一条汇编改成 jmp跳转到detour,所需5个字节
void Hook(char* target, char* detour)
{
DWORD oldProtect;
VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
unsigned char jmp_0xe9 = 0xE9;//jmp
unsigned int jmp_addr = detour - (target + 5);//jmp 地址
memcpy(target, &jmp_0xe9, 1);
memcpy(target + 1, &jmp_addr, 4);
VirtualProtect(target, 5, oldProtect, &oldProtect);
}
//创建跳板函数,备份至少5字节汇编指令的完整汇编,并在末尾补充跳转到原函数
void* CreateTrampoline(char* target)
{
DWORD oldProtect;
VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
UINT asm_size;
hde32s hs;
UINT trampoline_size = 0;
char* hde_target = target;
//开辟一个足够大的空间 +5,是因为末尾还需要jmp指令,jmp到原函数
char* TrampolineMem = (char*)VirtualAlloc(NULL, trampoline_size + 10 + 5,
MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (TrampolineMem == NULL) {
printf("VirtualAlloc Error \n");
exit(0);
}
do {
//通过hde32_disasm 读取下一条完整汇编
asm_size = hde32_disasm(hde_target, &hs);
//备份一条汇编
memcpy(TrampolineMem + trampoline_size, hde_target, asm_size);
//当汇编指令是以下的相对偏移指令,需要重新修改偏移跳转值,此代码!未实现完整!!!
if (hs.opcode == 0xE8) {
printf("CALL \n");
assert(false);
}
else if ((hs.opcode & 0xFD) == 0xE9) {
printf("JMP (EB or E9) \n");
if (hs.opcode == 0xe9) {
uint32_t* jmp_addr = (uint32_t*)(TrampolineMem + trampoline_size + asm_size - 4);
*jmp_addr = *jmp_addr + (hde_target + asm_size) - (TrampolineMem + trampoline_size + asm_size);
printf("0xe9 地址修复 \n");
}
else
assert(false);
}
else if ((hs.opcode & 0xF0) == 0x70
|| (hs.opcode & 0xFC) == 0xE0
|| (hs.opcode2 & 0xF0) == 0x80) {
printf(" Jcc \n");
assert(false);
}
trampoline_size += asm_size;
hde_target += asm_size;
} while (trampoline_size < 5);//至少备份5个字节的汇编
//已将大于5字节的汇编备份到TrampolineMem,然后在TrampolineMem末尾加jmp
unsigned char jmp_0xe9 = 0xE9;//jmp
unsigned int jmp_addr = (target + trampoline_size) - (TrampolineMem + trampoline_size + 5);//jmp 到原函数
memcpy(TrampolineMem + trampoline_size, &jmp_0xe9, 1);
memcpy(TrampolineMem + trampoline_size + 1, &jmp_addr, 4);
VirtualProtect(target, 5, oldProtect, &oldProtect);
return TrampolineMem;
};
typedef int(*FN_add)(int a, int b);
FN_add old_add = NULL;
int add(int a, int b)
{
return a + b + 100;
}
int my_add(int a, int b)
{
printf("add %d %d \n", a, b);
return old_add(a + 1, b);
}
void main()
{
//先在hook破坏原函数前创建跳板函数
old_add = (FN_add)CreateTrampoline((char*)&add);
Hook((char*)&add, (char*)&my_add);
int x = add(1, 2);
printf("add(1, 2) x : %d \n", x);
//先在hook破坏原函数前创建跳板函数
CreateFileATrampoline = (FN_PTR_CreateFileA)CreateTrampoline((char*)&CreateFileA);
Hook((char*)&CreateFileA, (char*)&MY_CreateFileA);
HANDLE hFile;
hFile = CreateFileA("abc.txt",
GENERIC_WRITE,
0,
NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile != INVALID_HANDLE_VALUE) {
printf("CreateFileA success\n");
CloseHandle(hFile);
}
printf("end \n");
}
输出:
JMP (EB or E9)
0xe9 地址修复
add 1 2
add(1, 2) x : 104
MY_CreateFileA: abc.txt
CreateFileA success
end
<4>inline hook 线程安全
知道了hook原理和创建跳板函数的大概流程,但现在还有一个问题,那就是线程安全
。
当hook时候,hook流程正在覆盖目标函数起始的几个汇编字节的时候,如果有其他线程正在执行这个函数,也恰好正在执行起始位置,贸然覆盖汇编代码,可能会造成程序崩溃!
解决方法:在附加hook的时候,暂停当前进程内 除当前线程外的其他所有线程,再继续执行附加hook的逻辑,附加hook完成之后,判断其他所有线程的eip,就是执行的代码地址,是否为目标函数的前几个覆盖的字节,如果是,需要把eip重新设置到跳板函数对应的位置。最后重新启动其他所有线程。
多线程下安全的 attach hook 步骤
1> 遍历当前进程的所有线程,暂停当前线程以外的所有线程。
(遍历所有线程可以用TH32CS_SNAPTHREAD,暂停线程函数为SuspendThread。)
2>执行attach hook流程。
(覆盖目标函数头jmp到detour函数。创建跳板函数。)
3>判断其他所有线程的执行的代码ip地址,如果正在执行目标函数,且恰好正在执行起始几个覆盖的汇编,则将此线程的ip地址重新设置到trampoline跳板函数对应的地址。
(获取线程ip地址的函数为GetThreadContext,32eip 64rip,重新设置为SetThreadContext)
4>恢复其他所有线程。
(ResumeThread)
卸载hook也需要线程安全,同样需要在detach hook前后暂停和恢复线程。但缺无法准确判断一个线程ip是不是正在执行hook代码段,因为有可能detour正在执行其他另外的函数。所以这是一个问题。只能尽可能的保证线程安全,稍晚一点释放跳板函数。
<5>inline hook 推荐库
要实现整个hook的流程,保证通用性和稳定性,这是一项不小的工作量,有两个推荐的开源库。
Detours
微软开源的库,支持x86/x64,arm。针对windows api适配得很好,同样也可以hook普通函数。有比较好的稳定性。
https://github.com/microsoft/Detours
使用流程&问题
DetourRestoreAfterWith();
DetourTransactionBegin();
DetourSetIgnoreTooSmall(TRUE);
DetourUpdateThread() 将某个线程加入休眠队列
-----------开始执行-----------
DetourAttachEx 创建hook
DetourDetach 移除分离hook
----------------------
DetourTransactionCommit() 提交hook 并且会恢复由DetourUpdateThread休眠的线程
这里有个问题需要注意,
DetourUpdateThread函数内部代码
// Silently (and safely) drop any attempt to suspend our own thread.
if (hThread == GetCurrentThread()) {
return NO_ERROR;
}
GetCurrentThread是一个伪句柄
,由TH32CS_SNAPTHREAD得到的线程id再OpenThread,和这个伪句柄是对不上的。
所以你需要自己在外部过滤掉当前线程,不能把由OpenThread的当前线程HANDLE传进去!!
也许这是一个bug吧。
CreateToolhelp32Snapshot TH32CS_SNAPTHREAD
这里得到的线程是当前操作系统所有的线程!!无论传不传进程id都是。
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
DWORD currentthreadid = GetCurrentThreadId();
DWORD processid = GetCurrentProcessId();
if (hSnapshot != INVALID_HANDLE_VALUE)
{
THREADENTRY32 te;
te.dwSize = sizeof(THREADENTRY32);
if (Thread32First(hSnapshot, &te))
{
do
{
if (processid == thread_entry32.th32OwnerProcessID
&& currentthreadid != thread_entry32.th32ThreadID) {
既是当前进程,又不是当前线程
调用DetourUpdateThread(OpenThread);
}
} while (Thread32Next(hSnapshot, &te));
}
CloseHandle(hSnapshot);
}
再推荐另外一个库,使用更便捷,api也简单易懂。不需要解决这个当前线程伪句柄的问题。
minhook 同样支持线程安全。支持x86/x64。个人推荐。
https://github.com/TsudaKageyu/minhook
MH_Initialize()
MH_CreateHook()
//启用hook,这里内部会暂停其它线程和恢复线程
MH_EnableHook()
MH_DisableHook()
MH_RemoveHook();
MH_Uninitialize()
当hook数量比较多的时候,最好用MH_ALL_HOOKS
MH_EnableHook(MH_ALL_HOOKS);
MH_DisableHook(MH_ALL_HOOKS);
这样不用每次单独一个hook执行MH_EnableHook都会暂停和恢复线程。
例子
#include "MinHook.h"
#if defined _M_X64
#pragma comment(lib, "libMinHook.x64.lib")
#elif defined _M_IX86
#pragma comment(lib, "libMinHook.x86.lib")
#endif
#include <iostream>
int Function(int x)
{
x++;
std::cout << "real function" << std::endl;
return x;
}
typedef int (*FUNCTION)(int x);
FUNCTION fpFunction = NULL;
int DetourFunction(int x)
{
x--;
std::cout << "fake function" << std::endl;
return x;
}
int main()
{
int value = 0;
if (MH_Initialize() != MH_OK) {
return 1;
}
if (MH_CreateHook(&Function, &DetourFunction,
reinterpret_cast<LPVOID*>(&fpFunction)) != MH_OK) {
return 1;
}
if (MH_EnableHook(&Function) != MH_OK) {
return 1;
}
value = Function(123);
if (MH_DisableHook(&Function) != MH_OK) {
return 1;
}
value = Function(123);
if (MH_Uninitialize() != MH_OK) {
return 1;
}
return 0;
}
<6> thiscall hook的方法
关于thiscall
,就是c++的成员函数
的调用方式,thiscall
和__cdecl
, __stdcall
,很大不同,原因在于vc
的thiscall
会固定this
参数在ecx
寄存器。
如果构建一个thiscall or cdecl fake(void* this,...args)
行不行呢?那肯定不行,因为this
是ecx寄存器
传参,这里的this
却是thiscall
用栈传参(64位用寄存器
),所以不能用c++语法的方式去写代码,要用汇编的角度去思考。
而且thiscall
不能标注在普通非成员函数的方法上,所以最好去创建一个类成员函数的指针
,当调用类成员函数的指针
,就会自己处理this
ecx
函数参数
的相关流程。
class TestA
{
public:
TestA() {}
~TestA() {}
public:
// 这是需要hook的函数
void ClassMemberFunction(void* arg)
{
printf("%s this = %p arg = %p\n", __FUNCTION__, this, arg);
}
};
class FakerClass;
typedef void(__thiscall* mfunc)(FakerClass*, void*);
mfunc org_mfunc = nullptr;
struct FakerClass
{
// 这是拦截的伪函数
void Mfunc(void* arg)
{
printf("%s this = %p arg = %p\n", __FUNCTION__, this, arg);
//调用原函数
org_mfunc(this, arg);
}
};
int main(int argc, char** argv)
{
MH_Initialize();
//asMETHOD .ptr.f.func 的作用是获得成员函数的函数地址,当然你可以用其他方法去做
auto f = asMETHOD(TestA, ClassMemberFunction);
auto ff = asMETHOD(FakerClass, Mfunc);
auto s = MH_CreateHook(f.ptr.f.func, ff.ptr.f.func,
(void**)&org_mfunc);
if (s == MH_OK){
MH_EnableHook(MH_ALL_HOOKS);
}
TestA t;
void* arg = (void*)0x88888;
printf("t = %p\n", &t);
t.ClassMemberFunction(arg);
return getchar();
}
t = 00F3FCEB
FakerClass::Mfunc this = 00F3FCEB arg = 00088888
TestA::ClassMemberFunction this = 00F3FCEB arg = 00088888
<7> hook现有进程的其他事项
当你需要对运行中的目标进程进行hook,你可能先需要知道目标函数的地址,这个地址应该是rva地址,就是相对于当前模块的偏移地址,rva地址+模块基地址=最终的函数在内存中的地址
,因为模块在内存中的位置有可能每次启动都不一样,所以偏移地址+当前基地址才是正确的做法。
你还需要注入逻辑,把自己的代码dll,注入到目标程序,否则无法方便操作,当前篇幅不涉及。
<完> 2021年5月26日 qq: base64(MTcxMjgzNjQ0) 【转载请注明出处】。