Windows下的hook技术学习
hook分类
这张图非常经典,很多平台都能看到一模一样的分类。
根据《逆向工程核心原理》中的一个分类图:
这篇用来记录应用层hook,内核还没学😅
1、消息hook类型
原理
消息机制算得上Windows中的最常见的常用的机制之一,比如当键盘敲击事件发生,这个事件就被添加到系统的消息队列中,然后操作系统判断哪个程序发生了事件,然后把这个事件转移到对应程序的私有队列中,程序检测到自己的队列中有新增消息,就检查消息进行响应,就完了。
这种hook技术又称Windows hook,这种类型的hook情况,Windows给出了一个官方的API:SetWindowsHookEx
。这个常常和dll注入配合使用。
Hook分为全局和局部,全局一般用来实现dll注入(因为每一个进程的地址都是独立的,如果不将hook函数作为一个独立的地址,那么执行起来将非常麻烦),局部hook就是针对某一个线程进行hook。
HHOOK WINAPI SetWindowsHookEx(
__in int idHook, \\钩子类型
__in HOOKPROC lpfn, \\回调函数地址,也就是自定义的消息处理函数
__in HINSTANCE hMod, \\实例句柄
__in DWORD dwThreadId); \\线程ID,设置为0则为全局钩子,会影响所有的进程
使用这个api之后,当任意进程中生成指定的消息的时候,os就会把这个dll载进去,然后调用hook,这都是os自动进行的。
键盘 Hook 代码
代码来自《逆向工程核心原理》,下面是keylogger.dll的代码,通过另一个载入程序执行这个dll,然后就可以敲击键盘记录了。
#include<stdio.h>
#include<windows.h>
HINSTANCE g_hInstance = NULL;
HHOOK g_hHook = NULL;
HWND g_hWnd = NULL;
#define DEF_PROCESS_NAME "notepad.exe"
// 这个回调函数用来处理键盘输入
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
{
char szPath[MAX_PATH] = {0};
char* p = NULL;
if(nCode >= 0)
{
if(!(lParam & 0x80000000))
{
GetModuleFileNameA(NULL, szPath, MAX_PATH);
// 取\\之后的字符,用来判断是不是目标进程notepad。
p = strrchr(szPath, '\\');
}
}
if( !_stricmp(p + 1, DEF_PROCESS_NAME) )
return 1;
// 不是就换下一个函数
return CallNextHookEx(g_hHook, nCode, wParam, lParam);
}
// 利用api启动挂钩
__declspec(dllexport) void StartHook()
{
g_hHook = SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, g_hInstance, 0);
}
// 取消挂钩
__declspec(dllexport) void StopHook()
{
// 先检查一下第一步挂钩是否成功,是被就不用了
if(g_hHook)
{
UnhookWindowsHookEx(g_hHook);
g_hHook = NULL;
}
}
// 这里的dllmain实际上用不到, 写不写都无所谓了
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD dwReason, LPVOID lpvReserved)
{
switch (dwReason)
{
case DLL_PROCESS_ATTACH:
g_hInstance = hinstDLL;
break;
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
2、IAT Hook
这种方式主要是针对程序中的函数进行hook,而且是针对需要从外部调用的函数的进行HOOK。我认为与之对应的是inline hook。
IAT是导入地址表,对应的EAT导出地址表也可以被hook
原理
1、修改被hook函数的在IAT表中的地址,让他指向自定义的函数。偷梁换柱。
2、根据这个原理,思路就是:找到目标函数iat中的位置--->修改iat中的函数的原来的地址---->改成自定义函数的地址---->正常调用原来的函数。
3、可以使用外部dll进行hook,也可以使用程序自身内部代码实现iathook。
Walkthrough
- 得到 PE baseaddr
- 利用 e_lfanew 地址获得NT头
- 得到NT头找到可选头
- 可选头的IDD数组
- 数组第二项为导入表
- 根据偏移+基地址得到导入表
- 利用导入表里的name字段的值+基地址得到每个导入表的名称
导入表中的双桥结构
- 参考学习:导入表中的双桥结构
这个结构的主要的两个指针就是OriginalFirstThunk
还有FirstThunk
IID的结构如下:
struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; //指向INT 桥1
} DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name; //dll名称
DWORD FirstThunk; //指向IAT 桥2
} IMAGE_IMPORT_DESCRIPTOR;
OriginalFirstThunk 和 FirstThunk 他们都是两个类型为IMAGE_THUNK_DATA 的数组
OriginalFirstThunk:
双字最高位为0,导入符号是一个数值,该数值是一个RVA。
双字最高位为1,导入符号是一个名称
PE 装载器首先搜索 OriginalFirstThunk ,找到之后加载程序迭代搜索数组中的每个指针,找到每个 IMAGE_IMPORT_BY_NAME 结构所指向的输入函数的地址,然后加载器用函数真正入口地址来替代由 FirstThunk 数组中的一个入口,因此我们称为输入地址表(IAT)。
静态结构如下:
其实就可以看作一个指向的是INT,另一个指向的是IAT。
实现代码
其实代码的关键就是确定要hook的函数,然后遍历导入表,修改地址,调用原函数。
核心:利用OriginalFirstThunk
和FirstThunk
进行导入表的遍历。
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>
#include <winternl.h>
#include <winnt.h>
// 定义MessageBoxA函数原型
typedef int (WINAPI* PrototypeMessageBox)(HWND, LPCSTR, LPCSTR, UINT);
// 原始MessageBoxA函数的地址
PrototypeMessageBox originalMsgBox;
// Hook函数
int hookedMessageBox(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType)
{
MessageBoxW(NULL, TEXT("Hooked sucess"), TEXT("Title"), 0);
return originalMsgBox(hWnd, lpText, lpCaption, uType);
}
int main()
{
// 获取模块基地址
HMODULE module = GetModuleHandle(NULL);
// 获取DOS头
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)module;
// 获取NT头
PIMAGE_NT_HEADERS ntHeader = (PIMAGE_NT_HEADERS)((BYTE*)module + dosHeader->e_lfanew);
// 获取导入表目录
PIMAGE_DATA_DIRECTORY importDir = &ntHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
// 获取导入表入口
PIMAGE_IMPORT_DESCRIPTOR importDesc = (PIMAGE_IMPORT_DESCRIPTOR)(importDir->VirtualAddress + (DWORD)module);
// 遍历导入表
while (importDesc->Name)
{
// 获取DLL名称
LPCSTR dllName = (LPCSTR)importDesc->Name + (DWORD)module;
// 程序在函数没用调用的时候就没用把对应的dll载入内存,这样强制载入,初始化iat表结束之后,才能进行后续的修改
HMODULE dllHandle = LoadLibraryA(dllName);
if (dllHandle)
{
// 获取原始和修正后的IAT
PIMAGE_THUNK_DATA originalIAT = (PIMAGE_THUNK_DATA)((BYTE*)module + importDesc->OriginalFirstThunk);
PIMAGE_THUNK_DATA firstIAT = (PIMAGE_THUNK_DATA)((BYTE*)module + importDesc->FirstThunk);
// 遍历IAT查找MessageBoxA
while (originalIAT->u1.AddressOfData)
{
PIMAGE_IMPORT_BY_NAME funcInfo = (PIMAGE_IMPORT_BY_NAME)((BYTE*)module + originalIAT->u1.AddressOfData);
if (strcmp(funcInfo->Name, "MessageBoxA") == 0)
{
// 保存原始地址
originalMsgBox = (PrototypeMessageBox)firstIAT->u1.Function;
// 修改内存权限,方便后续写入
DWORD oldProtect;
VirtualProtect(firstIAT, sizeof(PIMAGE_THUNK_DATA), PAGE_READWRITE, &oldProtect);
// 写入Hook地址
firstIAT->u1.Function = (DWORD)hookedMessageBox;
// 完成任务就直接退出循环
break;
}
originalIAT++;
firstIAT++;
}
}
importDesc++;
}
// 调用MessageBoxA
originalMsgBox(NULL, "Hello", "Original", 0);
return 0;
}
/*代码参考:https://www.ired.team/offensive-security/code-injection-process-injection/import-adress-table-iat-hooking*/
ps: 这里发现不少的定位偏移都是可以直接使用宏来实现,之前的代码中我还在苦哈哈的手动算偏移,程序里全是++--,通过上面的代码学到了多去找找对应的宏,微软肯定不会适配这么不好的。
2.1、EAT hook
这个和上面的思路基本一模一样,区别有两个:
- 代码区别:更改使用
IMAGE_DIRECTORY_ENTRY_EXPORT
宏来定位导出表。 - 使用区别:导出表该一个程序的,作用一个程序;导出表hook,直接改dll的,影响使用该dll的所有程序。
3、inline hook
顾名思义,就是直接改程序里的指令。类似于直接patch程序,但是是动态的。通过直接修改内存中任意函数的代码,将其劫持至Hook API。同时,它比IAT Hook的适用范围更广,因为只要是内存中有的函数它都能Hook,而后者只能Hook IAT表里存在的函数(有些程序会动态加载函数)。
被hook的位置
在进行hook的时候,最重要的就是找对hook的准确的地址,这样可以保证hook之后程序还可以正常执行,不会因为堆栈问题退出。注意的是hook过程中修改代码的位置不在调用函数中,而是在被调用函数的前几行。
能产生控制流分支的指令常见的无非call、jmp家族、ret这些,call和ret都会对栈产生影响,所以就用jmp进行hook最方便了。
思路
思路很简单,inline hook是通过直接修改进程代码来实现的,所以如果不考了稳定性,可以在一个程序的任意一个地方做hook方式,跳转到指定的代码段。
问题
有一个难点,hook的地址怎么找,虽然她可以在任意被执行的地方进行hook,但是如果确定地址呢?一般来说两种情况。
- 自己hook自己
- hook目标进程
hook自己的话,得到一些自定义的函数的地址是很方便的,其他位置的地址基本要靠偏移推测出来,所以实际上还是只能hook函数调用,无法任意地址修改。
hook目标进程,怎么找到对面的要修改jmp的地方的地址是大问题,一般操作都是针对win api进行的hook,对于对方自定义的函数调用hook实现比较困难,实在要hook只能人工介入手动分析算出偏移利用baseaddr进行计算从而进行hook。
应用场景
除了用在CTF出题中有些用,好像也没啥用了,想执行shellcode不如直接注入,而且操作其他进程也需要很多计算,自动化程度不高。
实现代码
使用jmp + 偏移地址
的方式进行hook,去hook别的比较困难,这里就自己hook自己的方式,hook一个写好了但是没调用的函数,简单主要是。就32位的程序进hook,地址比较短。
这里需要注意的是,虽然jmp在汇编来看是jmp+dst_addr
的格式,但是在实际字节码中,是jmp+offset-len
的方式;第二个就是需要注意,0xeb和0xe9这两个的区别,虽然都是jmp指令,但是后面跟的地址不同。
0xE9 JMP 后面的四个字节是偏移
0xEB JMP 后面的二个字节是偏移
0xFF25 JMP 后面的四个字节是存放地址的地址
0xFF15 CALL 后面的四个字节是存放地址的地址
0xE8 CALL 后面的四个字节是地址
0x68 PUSH 后面的四个字节入栈
0x69 PUSH 后面的1个字节入栈
eb和e9这灵活搭配就行,可以先用eb这种目标地址短的方式跳到位置不那么紧张的地方,再用e9来实现跳转hook。
还有需要注意的是,如果使用vs2022,第二次调用函数会被内联进去,所以hook的成功之后根本执行不到那里,导致看起来失败。
注意修改权限,可执行代码一般没有写的权限。先添加写权限,修改完事再恢复。这里不一定要用WriteProcessMemory
函数,直接用memcpy也行。
DWORD oldProtect;
VirtualProtect(origin_func, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
WriteProcessMemory(GetCurrentProcess(), (LPVOID)(addr_origfunc), newcode, 2, NULL);
VirtualProtect(origin_func, 5, oldProtect, &oldProtect);
4、热补丁 - HotFix HOOK
之前的方式需要修改程序然后再将程序修改回来,当进行全局Hook的时候,系统运行效率会受影响。而且,当一个线程尝试运行某段代码时,若另一个线程正在对该段代码进行“写”操作,这时就会程序冲突,最终引发一些错误。可以使用HotFix Hook(“热补丁”)方法弥补上述问题。
为啥叫热补丁,当目标进程不能停止的时候进行补丁操作的方式。这种方式并没有找到太多的解释和定义,他不是一种特定的技术,而是一类技术的概况,精细化到操作上可以按照如下理解:
差异 和inline hook
这是正常的调用一个api时候的汇编
看到在push上面还有很多空白的地方可以操作,inline hook的方式就是直接e9+目标地址,hot fix使用的方法是 eb+5先跳到最上面那个ret下面,然后再进行e9的inline hook操作。
原因
这种方式更加安全,因为他并没有更改任何有意义的程序代码。inline hook会占用5字节的空间,这就遮挡了push和mov指令;hotfix方式只占用了mov edi,edi
这个无意义的指令。即使是hook失败了,程序还是可以正常执行,不受影响。