IAT Hook

本节为轩辕的编程宇宙公众号《从零开始学逆向》的“IAT Hook”小节的学习笔记。详细课程内容请自行 跳转 加入查看。

在之前的 Maldev 开发课程中,我们已经深入学习过对 PE 文件的结构解析以及导入表(Import Table)的相关机制。这里的 IAT Hook 本质上是一种基于“间接地址寻址”原理的执行流劫持技术。

在 PE 文件被加载至内存并完成重定位后,程序对所有外部 API 的调用(如 MessageBoxW)都会通过 IAT(Import Address Table)进行间接跳转。

IAT Hook 的核心逻辑在于:通过同步遍历 INT(Import Name Table) 与 IAT(Import Address Table),精准定位目标函数在内存中的地址槽位(Slot);随后利用 VirtualProtect 暂时突破内存页的只读保护,将槽位中原始的 API 入口地址篡改为自定义的劫持函数地址。

为了文章的易读性,我把之前写过的”PE文件头解析“文章的 INT Table 及 IAT Table 部分搬了过来。如果还是不理解的话,建议跳转详细学习”PE文件头解析“。

本文代码在 VS 2026 - x86 Debug 模式下编译测试完成。

1、导入表描述符

在解析 PE 文件的逻辑流程中,每一个被引入的 DLL(如 kernel32.dlluser32.dll)都必须拥有自己独立的 INTIAT

  • INT (Import Name Table):记录了该 DLL 中所有需要导入的函数名

  • IAT (Import Address Table):记录了该 DLL 中所有导入函数对应的内存地址

如果引入了 5 个 DLL,那么在逻辑解析时,就会看到 5 个不同的 INT 表和 5 个不同的 IAT 表。

image-20260121183623510

IMAGE_IMPORT_DESCRIPTOR 结构在 Winnt.h 头文件 中定义如下,但微软并未正式记录该结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;
        DWORD   OriginalFirstThunk;   // 桥1 => 指向 INT 表
    } DUMMYUNIONNAME;
    DWORD   TimeDateStamp;
    DWORD   ForwarderChain;
    DWORD   Name;                     // Dll 名称
    DWORD   FirstThunk;               // 桥2 => 指向 IAT 表
} IMAGE_IMPORT_DESCRIPTOR;

IMAGE_IMPORT_DESCRIPTOR 结构体是 PE 文件导入表的核心入口。每一个被程序引用的 DLL 都会对应一个 IMAGE_IMPORT_DESCRIPTOR 结构。

  • OriginalFirstThunk (桥1):指向 INT (Import Name Table) 的 RVA(相对虚拟地址)。它是查找函数名的依据。
  • Name:指向一个以 NULL 结尾的字符串,包含该 DLL 的名称(如 kernel32.dll)。
  • FirstThunk (桥2):指向 IAT (Import Address Table) 的 RVA。它是程序运行时实际跳转执行的地址所在地。

2、INT Table(Import Name Table)

INT 是一个结构体数组,其对应的底层定义是 IMAGE_THUNK_DATA。它是一份只读的“索引清单”。

1
2
3
4
5
6
7
8
typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // 转发字符串的 RVA
        DWORD Function;             // 在 INT 中,此字段指向名字结构 (AddressOfData)
        DWORD Ordinal;              // 被导入函数的序号
        DWORD AddressOfData;        // 被导入函数的名称RVA,指向 IMAGE_IMPORT_BY_NAME 结构
    } u1;
} IMAGE_THUNK_DATA32;
  • 桥1的终点:OriginalFirstThunk 指向的就是这个数组的开头。

  • 在磁盘文件里,它的 u1.AddressOfData 存储着一个 RVA,指向真正的函数名结构体 IMAGE_IMPORT_BY_NAME

1
2
3
4
typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;          // 被导入函数的序号
    CHAR    Name[1];       // 被导入函数的函数名称字符串(如 "ExitProcess")
} IMAGE_IMPORT_BY_NAME;

3、IAT Table(Import Address Table)

IAT 在代码层面的定义与 INT 完全一样(也是 IMAGE_THUNK_DATA 数组),但其字段在不同阶段代表不同的含义。

1
2
3
4
5
6
7
8
9
// 结构体与 INT 相同,但在内存中加载后,Function 字段意义改变
typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // 转发字符串的 RVA
        DWORD Function;             // 重点:加载后,这里存储的是函数的【真实内存地址】
        DWORD Ordinal;              // 被导入函数的序号
        DWORD AddressOfData;        // 被导入函数的名称 RVA,指向 IMAGE_IMPORT_BY_NAME 结构
    } u1;
} IMAGE_THUNK_DATA32;
  • 桥2的终点:FirstThunk 指向这个数组的开头。

  • 文件状态(静止):其内容通常和 INT 一样,也指向 IMAGE_IMPORT_BY_NAME

  • 内存状态(运行):Windows 加载器会根据 INT 找到函数名,查询地址后,直接覆写这个 u1.Function 字段。此时它不再是 RVA 偏移,而是一个硬编码的 VA(虚拟地址),如 0x77E11234

4、整体联动

静态阶段(磁盘上的 PE 文件)。程序还没运行、存储在硬盘中时。

  • IMAGE_IMPORT_DESCRIPTOR 里的 OriginalFirstThunk(桥1)指向 INT
  • IMAGE_IMPORT_DESCRIPTOR 里的 FirstThunk(桥2)指向 IAT
  • 此时,INT 和 IAT 里的内容是完全一样的:它们都存储着一系列 RVA(相对虚拟地址),这些地址都指向同一个目的地——IMAGE_IMPORT_BY_NAME 结构体(即函数名字符串,如 “ExitProcess”)。

动态阶段(Windows 加载器的工作)。当双击运行程序时,Windows 加载器(Loader)会将 PE 文件映射到内存,并开始“整体联动”。

  • 定位 DLL:加载器读取 IMAGE_IMPORT_DESCRIPTORName 字段(如 “kernel32.dll”),并在系统中找到该 DLL,将其加载到内存中。
  • 寻找函数(通过桥1):加载器查看 OriginalFirstThunk 指向的 INT。它读取里面的 IMAGE_THUNK_DATA,找到函数名称字符串,如 “ExitProcess”。
  • 获取真实地址:加载器在已加载的 kernel32.dll 导出表中搜索 “ExitProcess”,计算出它在当前内存中的绝对地址(例如 0x77E11234)。
  • 填充结果(通过桥2):加载器将这个绝对地址写回 FirstThunk 指向的 IAT 对应位置。

最终状态。

  • 桥1 (OriginalFirstThunk -> INT):依然指向函数名 “ExitProcess”。它就像备份清单,永远记录着最初想要什么。
  • 桥2 (FirstThunk -> IAT):现在指向的是真实的函数地址

5、Windows 加载器全量初始化

Windows 加载器通过以下逻辑确保所有依赖环境就绪。

外层循环:遍历 DLL 列表

  • 动作:加载器从 DataDirectory[1] 指向的地址开始,挨个读取 IMAGE_IMPORT_DESCRIPTOR
  • 联动:读取 Name 字段,加载目标 DLL(如 user32.dll)到进程空间。
  • 终止:直到遇到一个全 0 的描述符结构体,外层循环结束。

内层循环:遍历函数名与填充地址

  • 初始化双指针:

    • 指针 A (INT):指向 OriginalFirstThunk 指向的 IMAGE_THUNK_DATA 数组。

    • 指针 B (IAT):指向 FirstThunk 指向的 IMAGE_THUNK_DATA 数组。

  • 查名(INT 侧):加载器读取指针 A 处的 RVA,定位到 IMAGE_IMPORT_BY_NAME 结构,获取函数名字字符串(如 “MessageBoxW”)。

  • 解析地址:在 DLL 的导出表中查找该名字对应的真实内存虚拟地址 (VA)

  • 填位(IAT 侧):加载器将得到的 VA 地址直接覆盖掉指针 B 所在的槽位。

  • 同步步进:指针 A 和 指针 B 同时向后移动 4 字节(32位)或 8 字节(64位)。

  • 全量填充:即便程序的代码只用了一个函数,加载器也会把该 DLL 在导入表中列出的所有函数全部填完,以备不时之需。直到遇到 0x00000000 结束该 DLL 的内层循环。

获取每个 DLL 对应的 INT Table 和 IAT Table:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Maldev 50 小节中,使用的是 ReadFile 将文件读入 pBuff。这属于物理文件读取,而不是内存映射加载。
// 需通过如下两行获取当前加载进内存后的 PE 文件地址,才能获取每个 DLL 对应的 INT Table 和 IAT Table
HMODULE hModule = GetModuleHandleA(NULL);  // NULL 时表示当前 PE 文件 
PBYTE pBase = (PBYTE)hModule;              // 也可直接使用 hModule
	
// 获取 DOS Header
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase;
// 获取 Nt Header
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew);
// 获取 Nt Header 下的 Optional Header
IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader;

// 第一步:获取导入表描述符数组的首地址
PIMAGE_IMPORT_DESCRIPTOR pImgImpDesc = (PIMAGE_IMPORT_DESCRIPTOR)(pBase + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);

// 第二步:遍历所有的 DLL (因为每个 DLL 都有自己的 INT 和 IAT)
while (pImgImpDesc->Name != NULL) {

    // 获取当前 DLL 的名称
    PCHAR pDllName = (PCHAR)(pBase + pImgImpDesc->Name);
    printf("[+] DLL Name: %s \n", pDllName);

    // 第三步:获取该 DLL 对应的 INT 表 (桥1)
    // 物理路径:pBase + OriginalFirstThunk
    PIMAGE_THUNK_DATA pINT = (PIMAGE_THUNK_DATA)(pBase + pImgImpDesc->OriginalFirstThunk);

    // 第四步:获取该 DLL 对应的 IAT 表 (桥2)
    // 物理路径:pBase + FirstThunk
    PIMAGE_THUNK_DATA pIAT = (PIMAGE_THUNK_DATA)(pBase + pImgImpDesc->FirstThunk);

    printf("\t[#] INT RVA: 0x%0.8X (Address: 0x%p)\n", pImgImpDesc->OriginalFirstThunk, pINT);
    printf("\t[#] IAT RVA: 0x%0.8X (Address: 0x%p)\n", pImgImpDesc->FirstThunk, pIAT);

    // 指向下一个 DLL 描述符
    pImgImpDesc++;
}

运行效果:

[+] DLL Name: KERNEL32.dll
        [#] INT RVA: 0x0001B250 (Address: 0x008CB250)
        [#] IAT RVA: 0x0001B000 (Address: 0x008CB000)
[+] DLL Name: USER32.dll
        [#] INT RVA: 0x0001B2E0 (Address: 0x008CB2E0)
        [#] IAT RVA: 0x0001B090 (Address: 0x008CB090)
[+] DLL Name: VCRUNTIME140D.dll
        [#] INT RVA: 0x0001B310 (Address: 0x008CB310)
        [#] IAT RVA: 0x0001B0C0 (Address: 0x008CB0C0)
[+] DLL Name: ucrtbased.dll
        [#] INT RVA: 0x0001B360 (Address: 0x008CB360)
        [#] IAT RVA: 0x0001B110 (Address: 0x008CB110)

6、IAT Hook 实现

下面的代码为我本人对轩辕大佬实现代码的理解。加入了自己理解的一些注释,重命名了一些变量名、变量类型等操作,来方便自己的理解。下面代码仅供参考,如有标注错误或代码实现错误,均以参考的轩辕大佬实现的代码为正确实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// IATHook.cpp
#include <Windows.h>
#include <stdio.h>

int WINAPI HookMessageBoxW(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType) {

	MessageBoxA(hWnd, "IAT Hook Success!\n", "IAT Hook", MB_OK);

	return 0;
}


BOOL InstallIATHook(const char *DllName, const char *Hooked_Func_Name, void *HandlerFunction) {

	DWORD dwOldProtection = NULL;

	// 获取当前加载进内存后的 PE 文件地址 pBase。
	HMODULE hModule = GetModuleHandleA(NULL); 
	PBYTE pBase = (PBYTE)hModule;  // 也可直接使用 hModule
	// HMODULE 类型实际上是一个指向加载的模块(通常是 DLL 或 EXE)的基地址,GetModuleHandleA 参数 NULL 时,表示当前 PE 文件。
	// PBYTE 类型则是一个指向字节的指针,它用于按字节访问内存。


	// 获取 DOS Header
	PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase;
	// 获取 Nt Header
	PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew);
	// 获取 Nt Header 下的 Optional Header
	IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader;
	// 获取 导入表描述符 数组的首地址
	PIMAGE_IMPORT_DESCRIPTOR pImgImpDesc = (PIMAGE_IMPORT_DESCRIPTOR)(pBase + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);


	// 外层循环:遍历所有的 DLL (因为每个 DLL 都有自己的 INT 和 IAT)
	while (pImgImpDesc->Name != NULL) {
		
		// 获取当前 DLL 的名称(pImgImpDesc->Name 是 RVA)
		PCHAR pDllName = (PCHAR)(pBase + pImgImpDesc->Name);  
		printf("[+] DLL Name: %s \n", pDllName);

		// 判断是否与传入的 DLL 名称是否一致(不区分大小写)
		if (_stricmp(DllName, pDllName) == 0) {

			// 如果一致,获取该 DLL 对应的 INT 表 (桥1)、 IAT 表 (桥2)
			PIMAGE_THUNK_DATA pINT = (PIMAGE_THUNK_DATA)(pBase + pImgImpDesc->OriginalFirstThunk);
			PIMAGE_THUNK_DATA pIAT = (PIMAGE_THUNK_DATA)(pBase + pImgImpDesc->FirstThunk);
			printf("\t[#] INT RVA: 0x%0.8X (Address: 0x%p)\n", pImgImpDesc->OriginalFirstThunk, pINT);
			printf("\t[#] IAT RVA: 0x%0.8X (Address: 0x%p)\n", pImgImpDesc->FirstThunk, pIAT);

			// 内层循环:遍历该 DLL 的 INT 表中的所有函数名。
			while (pINT->u1.Function != NULL) {

				// 获取当前 INT 节点指向的函数名结构体 IMAGE_IMPORT_BY_NAME
				PIMAGE_IMPORT_BY_NAME pImportByName = (PIMAGE_IMPORT_BY_NAME)(pBase + pINT->u1.Function);

				// 判断是否与传入的目标函数名一致(如 "MessageBoxW")(区分大小写)
				if (strcmp(Hooked_Func_Name, pImportByName->Name) == 0) {

					// 由于 INT (pInt) 和 IAT (pIat) 是并行排列的数组。
					// 那么,当我们在 pInt 中找到匹配的名字时,当前的 pIat 指针正好指向存储该函数地址的槽位。
					DWORD *TargetFunAddrPtr = (DWORD*)pIAT;

					// 修改内存页访问保护属性
					VirtualProtect(TargetFunAddrPtr, 4, PAGE_EXECUTE_READWRITE, &dwOldProtection);
					// 将 IAT 槽位中原始的 API 函数入口地址覆盖为自定义的劫持函数地址 (HandlerFunction)。
					*TargetFunAddrPtr = (DWORD)HandlerFunction;
					// 还原内存页访问保护属性
					VirtualProtect(TargetFunAddrPtr, 4, dwOldProtection, &dwOldProtection);

					// Hook 完成
					return TRUE; 

				}

				// 同步步进 INT 和 IAT,保持索引对应关系
				pINT++;
				pIAT++;
			}
		}
		// 移动到下一个 DLL 描述符
		pImgImpDesc++;
	}
	
	// 未找到指定的 DLL 或函数
	return FALSE; 
}



int main(int argc, char *argv[]) {

	InstallIATHook("User32.dll", "MessageBoxW", HookMessageBoxW);
	MessageBoxW(NULL, L"Test Information.", L"Test", MB_OK);

	return 0;

}

运行效果:

image-20260128020104137

updatedupdated2026-01-282026-01-28