模块 53 - IAT 隐藏与混淆 - 自定义 GetProcAddress
恶意软件开发课程 - IAT 隐藏与混淆 - 自定义 GetProcAddress
模块 53 - IAT 隐藏与混淆 - 自定义 GetProcAddress#
[!IMPORTANT] 本知识库声明
本知识库由本人整理自互联网 MalDev Academy 泄露资源,并由本人手动翻译为中文,过程中增加了大量关键技术提示与实践心得。
- 内容完整性:并未修改任何核心代码与技术逻辑,仅做汉化与注释加强。
- 版权归属:原始知识产权归原作者/官方所有。
- 支持正版:本仓库仅供内部学习交流,如果您有经济能力,请务必支持正版课程。
- 权利申诉:如相关内容侵犯了您的权益,请联系我,我将立即核实并删除。
IAT 隐藏与混淆 - 自定义 GetProcAddress#
简介#
Windows 原生的 GetProcAddress API 用于从指定的模块句柄中检索导出函数的地址。如果找不到对应的函数名,它将返回 NULL。
在本模块中,我们将实现一个名为 GetProcAddressReplacement 的函数来替代官方 API。其函数原型如下:
FARPROC GetProcAddressReplacement(IN HMODULE hModule, IN LPCSTR lpApiName) {}cGetProcAddress 的工作原理#
要重写这个 API,首先要理解它是如何找到函数地址的。
hModule 参数本质上是该 DLL 在内存中加载的 基地址 (Base Address)。检索函数地址的过程就是:通过基地址找到 DLL 的 导出表 (Export Table),遍历其中导出的所有函数名,将其与我们要搜索的目标名称进行对比。如果匹配成功,则提取对应的代码地址。
知识回顾:导出表结构#
在之前的 解析 PE 头 (PE Headers) 模块中,我们了解到导出表是一个名为 IMAGE_EXPORT_DIRECTORY 的结构。
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // 导出函数地址数组的 RVA
DWORD AddressOfNames; // 导出函数名称数组的 RVA
DWORD AddressOfNameOrdinals; // 导出函数序号数组的 RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;c本模块重点关注最后三个成员,它们是以 RVA (相对虚拟地址) 形式存储的数组数组。
访问导出表#
下面的代码段应该让您感到熟悉。我们通过 DOS 头找到 NT 头,再通过可选头中的数据目录找到导出表的基址。
FARPROC GetProcAddressReplacement(IN HMODULE hModule, IN LPCSTR lpApiName) {
// 将 HMODULE 转换为 PBYTE 方便后续加减运算
PBYTE pBase = (PBYTE) hModule;
// 获取 DOS 头并执行签名检查
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase;
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE)
return NULL;
// 获取 NT 头并执行签名检查
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew);
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE)
return NULL;
// 获取可选头
IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader;
// 获取导出目录 (Export Directory)
PIMAGE_EXPORT_DIRECTORY pImgExportDir = (PIMAGE_EXPORT_DIRECTORY)(pBase + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
// ... 后续逻辑
}c定位导出函数数组#
得到 IMAGE_EXPORT_DIRECTORY 后,我们需要初始化三个关键数组。由于结构体中存储的是 RVA,我们必须加上 pBase 才能在内存中直接访问它们。
// 函数名称数组的指针 (指向一组 RVA,每个 RVA 指向一个字符串)
PDWORD FunctionNameArray = (PDWORD)(pBase + pImgExportDir->AddressOfNames);
// 函数地址数组的指针 (指向一组 RVA,每个 RVA 是函数的实际代码入口)
PDWORD FunctionAddressArray = (PDWORD)(pBase + pImgExportDir->AddressOfFunctions);
// 函数序号数组的指针 (用于将名称与地址对应起来)
PWORD FunctionOrdinalArray = (PWORD)(pBase + pImgExportDir->AddressOfNameOrdinals);c理解函数序号 (Ordinals)#
序号 (Ordinal) 是表示函数在 DLL 导出表中位置的整数。导出表不仅仅是一个名称到地址的映射,它本质上是按序号组织的。
💡 初学者提示:图书馆的借书卡
想象导出表是一个图书馆。
AddressOfNames数组就像是 目录卡片(按字母顺序排列的名字)。AddressOfFunctions数组就像是 书架上的书。FunctionOrdinalArray就像是 索引号。当你通过名字找一本书时,你先在目录卡片(Names 数组)中找到名字,它会告诉你这本书的索引号(Ordinal)。然后,由于书在书架(Functions 数组)上的位置并非按字母排列,你需要根据这个索引号去那排书架里取书。
为什么这么麻烦?因为在 Windows 中,有些函数是可以只有序号而没有名字的,或者查询序号比查询字符串要快得多。
实现查找逻辑#
我们要遍历 NumberOfNames 个名称,将每个导出的名字与 lpApiName 对比。
for (DWORD i = 0; i < pImgExportDir->NumberOfNames; i++){
// 获取第 i 个函数的名称
CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]);
// 如果名称匹配
if (strcmp(lpApiName, pFunctionName) == 0){
// 通过序号数组找到该函数在地址数组中的索引
WORD wOrdinal = FunctionOrdinalArray[i];
// 最终拿到函数地址
PVOID pFunctionAddress = (PVOID)(pBase + FunctionAddressArray[wOrdinal]);
return (FARPROC)pFunctionAddress;
}
}c完整代码实现#
FARPROC GetProcAddressReplacement(IN HMODULE hModule, IN LPCSTR lpApiName) {
PBYTE pBase = (PBYTE)hModule;
// 获取头部并验证 (省略部分错误检查,见上文) ...
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase;
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew);
PIMAGE_EXPORT_DIRECTORY pImgExportDir = (PIMAGE_EXPORT_DIRECTORY)(pBase + pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
// 初始化数组
PDWORD FunctionNameArray = (PDWORD)(pBase + pImgExportDir->AddressOfNames);
PDWORD FunctionAddressArray = (PDWORD)(pBase + pImgExportDir->AddressOfFunctions);
PWORD FunctionOrdinalArray = (PWORD)(pBase + pImgExportDir->AddressOfNameOrdinals);
// 遍历导出名称
for (DWORD i = 0; i < pImgExportDir->NumberOfNames; i++){
CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]);
// 比较名称
if (strcmp(lpApiName, pFunctionName) == 0){
// 返回对应地址
return (FARPROC)(pBase + FunctionAddressArray[FunctionOrdinalArray[i]]);
}
}
return NULL;
}c演示#
对比原生的 GetProcAddress 和我们的 GetProcAddressReplacement 搜索 NtAllocateVirtualMemory 的结果。两者返回的地址完全一致。

🎯 总结#
在本模块中,我们学习了:
- 导出表深挖:深入理解了
AddressOfNames,AddressOfFunctions和AddressOfNameOrdinals三位一体的逻辑。 - 序号机制:理解了序号是连接函数名和代码地址的桥梁。
- 隐匿实战:通过手写解析逻辑,我们不再需要调用系统 API 就能在内存中找到任何函数的入口,这极大地增强了程序的反分析能力。
💡 关键要点
- RVA 转换:始终记得操作 PE 结构时,
基址 + 偏移 = 实际地址。- 独立性:这个自定义函数没有任何外部依赖(除了
strcmp,如果需要也可以手写一个),让我们的代码变得非常“干净”。
📚 知识扩展
您可能注意到了,我们在代码中依然使用了明文的函数名字符串(如
"NtAllocateVirtualMemory")。为了达到极致的隐匿,下一步我们将把这些名字替换为 哈希值。
📚 下一步学习
下一个模块我们将介绍 IAT Hiding & Obfuscation - Custom GetModuleHandle。学会了找函数后,我们还得学会如何脱离 API 去独立寻找 DLL 模块的基地址!