简介:面向需要掌握DLL内存加载与手动映射技术的C/C++开发者,内容围绕从内存(资源形式)直接加载并调用DLL导出函数的完整示例展开。压缩包共22个文件,约87KB,包含h头文件、cpp源码、dsp/dsw工程配置、dll/lib二进制文件及txt说明等,区分xDll测试DLL与testLoadDll调用端两个工程:前者提供导出函数,后者演示使用MemoryLoadLibrary加载、MemoryGetProcAddress取址、MemoryFreeLibrary释放的流程。已有1511人学习,适合研究PE加载、免落盘调用DLL或排查模块加载问题的读者;源码与工程文件可直接编译调试,便于对照理解内存模块管理的接口设计、资源释放时机及测试DLL与调用端的组织方式。
1. 内存加载 DLL 的真实门槛:卡住人的从来不是那两百行代码
内存加载 DLL 这件事,我第一次写的时候真以为两百行搞定,结果一个下午全耗在 DllMain 里那次访问冲突上。后来才想明白,难点从来不在代码量,而在于你得先搞清楚系统加载器背着你做了多少事:重定位、导入表、TLS 回调、异常展开表登记,漏掉哪一个都会当场崩,而且崩的位置离真正原因十万八千里,玄学得很。
这篇把我自己趟过的完整流程摊开讲。前半段讲 PE 结构和每个阶段的判断依据,为什么这一步必须做、不做会怎样;后半段给你能直接编译跑通的代码、编译参数和排查清单。做安全研究的、写插件加载器的、单纯想搞懂"为什么非得自己映射一遍"的,都能照着往下走。看完你至少能做到两件事:代码能跑,且出问题时知道去哪一层找。
2. 手写映射和系统加载差在哪:四个阶段拆开看
2.1 先看清加载器替你做了哪些事
一个最容易犯的错,是把"加载 DLL"理解成"把文件读进内存然后跳过去执行"。中间被省略掉的每一步,都是后面崩掉的伏笔。判断依据很简单:打开一个 DLL 的可选头,看 DataDirectory 里哪些项非零,非零的项就是加载器必须处理的额外工作,你自己写加载器时一个都不能跳。
系统加载器实际做的事,按顺序大致是:把文件映射进地址空间、按 SectionAlignment 对齐铺开各节区、检查首选基址是否被占用并做重定位、遍历导入表把依赖模块拉进来并填充 IAT、注册异常处理表、执行 TLS 回调、最后才调 DllMain。而"没走系统加载器"的代价是:这个模块不进 PEB 的模块链表,系统不认识它,所以后面任何一步都得你自己来。
我一般会这么判断一个 DLL 好不好手工加载:可选头里 ImageBase 是多少、有没有 .reloc 节、导入表里依赖了几个模块、有没有 TLS 目录。这四项决定了你要写多少代码,也决定了第一次能不能跑通。
2.2 四个阶段的输入输出对照表
把整个流程拆成四个阶段,每一阶段的输入、输出和失败表现列清楚,比读十遍文档都管用。下面这张表是我自己调试时贴在屏幕边上的,后面写代码基本是照着它填:
| 阶段 | 输入 | 输出 | 失败时的典型表现 |
|---|---|---|---|
| 头部解析 | 磁盘或内存中的 DLL 字节流 | SizeOfImage、SizeOfHeaders、节区数量 | 立刻返回空,或映射后越界写 |
| 内存申请与节区映射 | 上一步的参数 + 节区表 | 一块可读写的连续镜像 | 调用导出函数读到的字符串是乱码 |
| 重定位修复 | 基址差 delta + .reloc 内容 | 所有绝对地址改成新基址 | 32/64 位切换时访问冲突,静态变量必崩 |
| 导入表修复 | 导入描述符 + 系统 API | 填满的 IAT | 走到某个 API 调用就跳进空地址 |
前两个阶段是"体力活",参数抄对了基本不会错;后两个阶段是"理解活",参数抄对了也可能错,因为依赖你对 PE 结构的理解深度。真正的分界线在第三个阶段:只要 delta 不为零,重定位就必须做,没有"可能不需要"这一说。
2.3 为什么重定位和导入表必须自己修
重定位的本质是:编译期假设模块会落在 ImageBase 上,所以所有绝对地址都按这个基址算死了。系统加载器拿到模块后如果发现基址被占,会整体平移,然后把所有需要修正的位置按 .reloc 表里记录的偏移改一遍。你自己 VirtualAlloc 拿到的地址几乎不可能是首选基址,所以 delta 一定非零——除非你运气好到用 lpAddress 参数强行申请到了同一个地址。
常见做法是先尝试按 ImageBase 申请,失败再随便给个地址并走重定位。这个顺序值得保留:能跳过重定位就跳过,调试时少一层变量。
导入表的逻辑更直白:IAT 里每一项原本存的是"函数名在内存中的相对偏移",加载器要把它替换成函数真实地址。系统加载器通过模块链表找到依赖模块,你没有这个链表,就只能自己调 LoadLibrary 把依赖模块拉起来,再 GetProcAddress 填进去。这就是为什么手写加载器最终还是要用到系统 API——你不是在替代整个加载体系,只是在替代"登记"这一步。
提示:先把 delta 打印出来看一眼。很多"重定位写错了"的问题,其实是 delta 算错,而不是遍历逻辑错。
3. 代码逐段落地:把字节流变成能调用的模块
3.1 头部校验与内存申请的关键参数
第一段只做两件事:确认这块字节流真的是个 PE,拿到后面要用的三个尺寸。别小看校验,调试阶段把一份被截断的文件喂进去,没有校验的话崩在几万行之外的 memcpy 上,找起来非常难受。
// 传入完整 DLL 字节流,返回映射后的镜像基址;失败返回 NULL BYTE* MapImage(const BYTE* buf, SIZE_T size) { if (size < sizeof(IMAGE_DOS_HEADER)) return NULL; PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)buf; if (dos->e_magic != IMAGE_DOS_SIGNATURE) return NULL; // "MZ" if (dos->e_lfanew <= 0 || (SIZE_T)dos->e_lfanew + sizeof(IMAGE_NT_HEADERS) > size) return NULL; // e_lfanew 越界是常见脏数据 PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(buf + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) return NULL; // "PE\0\0" DWORD sizeOfImage = nt->OptionalHeader.SizeOfImage; // 镜像铺开后的总大小 DWORD sizeOfHeaders = nt->OptionalHeader.SizeOfHeaders; // 头部对齐后的大小 if (sizeOfImage == 0 || sizeOfHeaders > sizeOfImage) return NULL; // 先试首选基址,能拿到就省掉重定位 BYTE* base = (BYTE*)VirtualAlloc((LPVOID)nt->OptionalHeader.ImageBase, sizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); if (!base) { base = (BYTE*)VirtualAlloc(NULL, sizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); if (!base) return NULL; } // 整块清零,.bss 段(VirtualSize > SizeOfRawData 的部分)靠这一步兜底 memset(base, 0, sizeOfImage); memcpy(base, buf, sizeOfHeaders); // 头部原样拷过去,节区表后面还要用 return base; }三个尺寸里最容易用错的是 sizeOfHeaders 和 sizeOfImage。前者是"头部本身占多少字节",用来拷贝 DOS 头、NT 头和节区表;后者是"整个镜像铺开占多少字节",用来申请内存。这两个值反着用,表现是能跑但某天加载一个稍大的 DLL 就崩——典型的血泪经验。
权限这里我用了 PAGE_READWRITE 而不是一步到位的可执行权限。原因在最后一章会讲,简单说就是:能不给执行权限的阶段就别给,等重定位和导入都修完了再按节区改权限,既不给自己埋坑,也让行为看起来正常一点。
3.2 节区映射:别只盯着 SizeOfRawData
节区映射看起来就是一次循环拷贝,但有两个细节决定成败:一是 SizeOfRawData 为 0 的节区要跳过(比如只有未初始化数据的节),二是 VirtualSize 大于 SizeOfRawData 时,多出来的部分必须是零。上一段已经整块 memset 过了,所以这里只需要拷贝有文件内容的那些节。
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt); for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) { if (sec->SizeOfRawData == 0) continue; // 无文件内容的节,跳过 if (sec->PointerToRawData + sec->SizeOfRawData > size) return NULL; // 数据越界保护 memcpy(base + sec->VirtualAddress, buf + sec->PointerToRawData, sec->SizeOfRawData); // 目标按 VirtualAddress 对齐 }这段代码里唯一需要解释的参数是 VirtualAddress 和 PointerToRawData 的区别:前者是"在内存镜像里的偏移",后者是"在磁盘文件里的偏移",两者通常不同,因为内存里按 SectionAlignment(一般 0x1000)对齐,文件里按 FileAlignment(一般 0x200)对齐。很多新手直接把文件偏移当内存偏移用,结果是数据整体错位,导出的函数能调,但读到的全局变量全是垃圾值。
顺带说一句,如果某个节区的 Characteristics 里带了 IMAGE_SCN_MEM_DISCARDABLE,理论上可以不映射,但实践中建议照常拷——少拷一节省不了多少内存,出问题排查成本却翻倍。
3.3 重定位表遍历与 delta 计算
这段是整份代码里最容易写错的地方,没有之一。重定位表是一串"块",每块描述一个 4KB 页面内需要修正的若干位置,每个位置用一个 WORD 表示:高 4 位是类型,低 12 位是页内偏移。类型不匹配就直接跳过,这是规范要求,不是容错。
typedef struct { WORD offset : 12; WORD type : 4; } RELOC_ENTRY; void DoRelocate(BYTE* base, PIMAGE_NT_HEADERS nt) { // delta 必须用有符号的指针宽度整型,32 位下算成 32 位会翻转 LONGLONG delta = (LONGLONG)(base - (BYTE*)nt->OptionalHeader.ImageBase); if (delta == 0) return; // 正好落在首选基址,什么都不用做 IMAGE_DATA_DIRECTORY dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (dir.VirtualAddress == 0 || dir.Size == 0) return; DWORD consumed = 0; while (consumed < dir.Size) { PIMAGE_BASE_RELOCATION block = (PIMAGE_BASE_RELOCATION)(base + dir.VirtualAddress + consumed); if (block->SizeOfBlock < sizeof(IMAGE_BASE_RELOCATION)) break; DWORD count = (block->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); RELOC_ENTRY* items = (RELOC_ENTRY*)(block + 1); for (DWORD i = 0; i < count; i++) { if (items[i].type == 0) continue; // IMAGE_REL_BASED_ABSOLUTE,填充项 BYTE* target = base + block->VirtualAddress + items[i].offset; if (items[i].type == IMAGE_REL_BASED_HIGHLOW) // 32 位 *(DWORD*)target += (DWORD)delta; else if (items[i].type == IMAGE_REL_BASED_DIR64) // 64 位 *(ULONGLONG*)target += (ULONGLONG)delta; } consumed += block->SizeOfBlock; } }参数说明:delta 是"实际基址减首选基址",可正可负,所以必须用有符号类型;block->VirtualAddress 是页起始的 RVA,加上页内 offset 才是最终位置。判定条件里我特意保留了type == 0就跳过,因为编译器经常插入这种填充项,把它当真实项处理会写坏相邻数据。
这里还有一个隐藏判断点:如果你在 32 位进程里加载 64 位 DLL,类型是 DIR64 但你按 DWORD 写,结果就是只改了低 32 位,表面上不崩,实际调到某个函数才炸。所以位宽必须匹配,后面的差异清单会再提一次。
3.4 导入表修复:自己把 IAT 填满
导入表的遍历逻辑比重定位简单,但有两个坑:OriginalFirstThunk 可能为 0,以及按序号导入时高位掩码。这两点后面排查章还会遇到,这里先把正确写法摆出来。
void FixImports(BYTE* base, PIMAGE_NT_HEADERS nt) { IMAGE_DATA_DIRECTORY dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (dir.VirtualAddress == 0) return; PIMAGE_IMPORT_DESCRIPTOR desc = (PIMAGE_IMPORT_DESCRIPTOR)(base + dir.VirtualAddress); for (; desc->Name; desc++) { const char* modName = (const char*)(base + desc->Name); HMODULE hMod = LoadLibraryA(modName); // 依赖模块交给系统加载 if (!hMod) continue; // OFT 为 0 时退回用 FirstThunk 当名字表(此时两者指向同一块) PIMAGE_THUNK_DATA oft = (PIMAGE_THUNK_DATA)(base + (desc->OriginalFirstThunk ? desc->OriginalFirstThunk : desc->FirstThunk)); PIMAGE_THUNK_DATA iat = (PIMAGE_THUNK_DATA)(base + desc->FirstThunk); for (; oft->u1.AddressOfData; oft++, iat++) { FARPROC fn = NULL; if (oft->u1.Ordinal & IMAGE_ORDINAL_FLAG) { // 按序号导入 WORD ord = (WORD)(oft->u1.Ordinal & 0xFFFF); // 必须屏蔽高位标志 fn = GetProcAddress(hMod, (LPCSTR)(ULONG_PTR)ord); } else { // 按名字导入 PIMAGE_IMPORT_BY_NAME nb = (PIMAGE_IMPORT_BY_NAME)(base + oft->u1.AddressOfData); fn = GetProcAddress(hMod, nb->Name); } iat->u1.Function = (ULONG_PTR)fn; // 写进 IAT } } }关键参数:IMAGE_ORDINAL_FLAG 在 32 位下是 0x80000000,64 位下是 0x8000000000000000,所以判断时直接用它,取序号时用 0xFFFF 掩码。这里没有做"函数找不到就报错"的处理,是因为研究场景下我更需要它继续跑完、把缺失的函数名全部打印出来,一次性看全,而不是卡在第一个失败项上。
还有一点值得留意:LoadLibraryA 传的是模块名(比如某个常见的运行时库),系统会按它自己的搜索顺序去找。如果你的依赖模块根本不在搜索路径里,这一段会静默失败,表现是后面调到某个 API 直接跳空。所以调试版里把hMod == NULL的分支加上日志,能省很多时间。
3.5 TLS 回调与入口点调用顺序
顺序这件事没有商量余地:TLS 回调必须在 DllMain 之前跑,因为很多编译器把静态对象的构造放进 TLS 回调里。漏了这一步,DllMain 里访问任何全局对象都可能是未初始化状态,崩溃点看起来莫名其妙。
typedef BOOL (WINAPI *PDLLMAIN)(HMODULE, DWORD, LPVOID); typedef void (NTAPI *PTLS_CALLBACK)(PVOID, DWORD, PVOID); // 先跑 TLS 回调,DLL_PROCESS_ATTACH 阶段 IMAGE_DATA_DIRECTORY tlsDir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; if (tlsDir.VirtualAddress) { PIMAGE_TLS_DIRECTORY tls = (PIMAGE_TLS_DIRECTORY)(base + tlsDir.VirtualAddress); PTLS_CALLBACK* cb = (PTLS_CALLBACK*)tls->AddressOfCallBacks; // 注意:这是 VA,不是 RVA if (cb) { for (; *cb; cb++) (*cb)(base, DLL_PROCESS_ATTACH, NULL); } } // 再调入口点,等价于 DllMain(hModule, DLL_PROCESS_ATTACH, NULL) if (nt->OptionalHeader.AddressOfEntryPoint) { PDLLMAIN entry = (PDLLMAIN)(base + nt->OptionalHeader.AddressOfEntryPoint); entry((HMODULE)base, DLL_PROCESS_ATTACH, NULL); }这里有个特别容易踩的点:TLS 目录里的 AddressOfCallBacks 存的是绝对地址(VA),不是 RVA,因为它是在链接期由重定位修好的。如果你像其他目录那样加上 base 偏移,就会跳到野地址。判断依据是 PE 规范里 TLS 目录字段的类型定义,其余目录基本都是 RVA,唯独这里不是——这种"例外"最坑人。
入口点调用完之后,导出函数就能正常用了。如果目标 DLL 里还有 C++ 静态对象、需要注册异常处理表,那还得补一步,这个留到排查章里说。
4. 一次完整复现:从编译参数到调用成功
4.1 调用方与目标 DLL 的编译约定
复现环境我建议这样搭:一个极简的目标 DLL,只导出一个加法函数和一个返回字符串的函数;一个控制台程序做加载器。目标 DLL 先别用 C++、别加静态对象、别开异常,把变量降到最低,跑通之后再逐项加回来——这个顺序能让你明确定位是哪一项特性让加载器崩掉。
// 目标 DLL:只用 C,导出两个函数,不引入任何 C++ 特性 __declspec(dllexport) int Add(int a, int b) { return a + b; } __declspec(dllexport) const char* Greet(void) { return "loaded-from-memory"; }加载器侧读取文件、调映射、调导出:
// 加载器:读文件 -> 映射 -> 修表 -> 调入口 -> 取导出地址 FILE* fp = fopen("demo_target.dll", "rb"); fseek(fp, 0, SEEK_END); long fsize = ftell(fp); fseek(fp, 0, SEEK_SET); BYTE* raw = (BYTE*)malloc(fsize); fread(raw, 1, fsize, fp); fclose(fp); BYTE* image = MapImage(raw, (SIZE_T)fsize); // 头部校验 + 申请 + 拷贝头部 MapSections(image, raw, fsize); // 节区铺开 PIMAGE_NT_HEADERS nt = GetNt(image); DoRelocate(image, nt); // 基址不一致时修正 FixImports(image, nt); // 填 IAT RunTlsAndEntry(image, nt); // TLS 回调 + 入口点编译参数上有两个约定值得写死:目标 DLL 用/MT静态链接运行时,避免依赖额外的运行时 DLL;加载器和目标 DLL 的位宽必须一致,32 位加载器只能加载 32 位 DLL。这两条不是最优解,但能让第一次复现的成功率接近百分之百。
4.2 三步验证加载是否真的成功
很多人验证的方式是"没崩就算成功",这个标准太低,因为大部分错误是延迟触发的。我一般会用三个检查点,从弱到强:
第一,检查镜像是否真的铺开。打印 base、delta、SizeOfImage,再用调试器看 base + 某个节区的 VirtualAddress 处的内容,跟磁盘文件对应偏移的内容比对,一致才算映射正确。
第二,用自己实现的取地址函数拿导出,而不是靠计算偏移。这一步同时验证了导出表解析和节区映射两件事。
// 自己实现 GetProcAddress:遍历导出表 FARPROC MemoryGetProcAddress(BYTE* base, const char* name) { PIMAGE_NT_HEADERS nt = GetNt(base); IMAGE_DATA_DIRECTORY dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; if (dir.VirtualAddress == 0) return NULL; PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)(base + dir.VirtualAddress); DWORD* names = (DWORD*)(base + exp->AddressOfNames); // 函数名 RVA 数组 WORD* ords = (WORD*)(base + exp->AddressOfNameOrdinals); // 名字索引到函数索引的映射 DWORD* funcs = (DWORD*)(base + exp->AddressOfFunctions); // 函数 RVA 数组 for (DWORD i = 0; i < exp->NumberOfNames; i++) { if (strcmp((const char*)(base + names[i]), name) == 0) { DWORD rva = funcs[ords[i]]; // 注意二次索引 // 落在导出目录范围内的 RVA 是转发导出,这里直接放弃 if (rva >= dir.VirtualAddress && rva < dir.VirtualAddress + dir.Size) return NULL; return (FARPROC)(base + rva); } } return NULL; }这段里最容易错的是funcs[ords[i]]:names 数组和 funcs 数组的下标不是一一对应的,中间隔了一层序号映射。直接用funcs[i]取到的是别的函数,症状是"能取到地址但调用结果不对",比取不到还难查。
第三,真正调用一次,并且调两次。第一次看返回值是否符合预期,第二次看是否有状态被写坏——很多加载器的问题是第一次调用正常、第二次崩,原因是全局变量或静态缓冲区地址没被正确重定位。
4.3 32 位与 64 位的差异清单
位宽切换是这套代码里最容易翻车的一环,我把它单独列出来。核心差异集中在三处:
| 差异项 | 32 位 | 64 位 |
|---|---|---|
| 重定位类型 | IMAGE_REL_BASED_HIGHLOW,写 DWORD | IMAGE_REL_BASED_DIR64,写 ULONGLONG |
| 序号导入标志 | IMAGE_ORDINAL_FLAG 为 0x80000000 | 同名为 0x8000000000000000 |
| 指针宽度 | 4 字节,thunk 用 u1.Function | 8 字节,同字段不同宽度 |
除此之外还有一个不在表里的差异:64 位下异常展开依赖 .pdata 段里登记的函数表,模块没被系统加载器登记,异常就没法正常展开。表现是一旦目标 DLL 里出现 try/catch 或任何需要栈展开的路径,进程直接终止。解决办法是调系统的函数表注册接口把 .pdata 范围登记进去,或者干脆在目标 DLL 编译期关掉异常——研究阶段我更倾向于后者,先把主流程跑通。
5. 常见问题排查:五次真实的翻车记录
5.1 加载阶段就崩掉的三种情况
现象一:入口点一进去就崩在全局对象构造里,调用栈只有几层。原因:没有执行 TLS 回调,或者执行顺序放到了入口点之后。C++ 的静态对象构造、部分运行时的初始化都挂在 TLS 回调上,跳过它等于让代码在未初始化环境里跑。 解决:把 TLS 回调的遍历放到入口点调用之前,并且严格使用 DLL_PROCESS_ATTACH。调试时把回调地址打印出来,确认至少有一个非空项。
现象二:映射完成后读到的字符串是乱码,函数能调但数据全错。原因:节区映射只拷了 SizeOfRawData,而 VirtualSize 更大的部分没有被清零,或者漏拷了头部导致某些依赖头部数据的逻辑读到脏值。 解决:申请内存后立刻整块 memset 清零,再拷头部、再按节区拷。养成"先清零再填充"的习惯,这类问题基本绝迹。
现象三:访问冲突发生在 VirtualAlloc 返回的地址附近,偏移看着眼熟。原因:节区拷贝时把文件偏移 PointerToRawData 当成内存偏移 VirtualAddress 用了,数据整体错位,越界写穿到相邻页。 解决:把两个字段的含义写在注释里,调试版里对每个节区打印VirtualAddress / PointerToRawData / SizeOfRawData三元组,肉眼比对一次就能发现异常。
5.2 加载成功但一调用就出问题的两种
现象四:GetProcAddress 返回空,导入表里明明有这个函数名。原因:OriginalFirstThunk 为 0 时错误地继续用它做名字表,读到的是已经被填过的 IAT 内容;或者按序号导入时没屏蔽 IMAGE_ORDINAL_FLAG,把 0x80000005 当成序号 2147483653 去查。 解决:OFT 为 0 时回退用 FirstThunk,但要注意此时不能边读边写同一块内存,稳妥做法是先完整解析出地址再统一回填。序号分支一定加 0xFFFF 掩码。
现象五:本地跑得好好的,一放到有安全产品的环境就被拦,或者行为直接消失。原因:整个镜像是一块没有对应模块记录的私有内存,权限还是可读写可执行,这个组合在检测规则里非常显眼;再加上申请内存、改权限、跳转执行这条调用序列本身就很有特征。 解决:研究阶段在隔离环境里做,别在生产机上试;实现层面把权限收紧,映射时只给读写,等所有修正完成再按节区分别改成可执行或只读,别一上来就给全权限。这不是为了"绕过",而是让实现本身更接近正常加载器的行为,减少误报也减少自己踩坑。
注意:以上排查建议仅用于自有程序的功能调试与安全产品的能力验证,任何针对他人系统的使用都不在讨论范围内。
6. 收尾验证:三个检查点确认加载器没有埋雷
写到这里,代码能跑已经不算难事,难的是确认它"跑对了"。我自己收尾时会强制走三个检查点,缺一个都不算完。
第一个检查点看头部一致性。镜像铺开之后,把 base 处的前 0x1000 字节和磁盘文件的前 0x1000 字节做一次逐字节比对,理论上只有节区表之后的部分可能不同。这一步能一次性抓出"头部没拷全"和"拷贝长度算错"两类问题,比在导出函数里调试快得多。对比的时候顺手把 SizeOfImage 和实际分配长度也打出来,两个值不相等就说明申请阶段就有问题。
第二个检查点看每个导入函数的落点。填完 IAT 之后,遍历一遍所有非空项,判断每个函数地址是否落在某个已加载模块的地址范围内——取模块基址和大小做区间比较就行,不需要多精确。这一步的价值在于抓"静默失败":某个依赖模块没加载成功,对应的 IAT 项是 0 或者是个野指针,不检查的话要等到真正调用那一行才崩,中间可能隔了几百行代码。
第三个检查点看两次调用的差异。同一个导出函数连着调两次,中间穿插一次读全局状态的调用,两次结果必须一致。不一致说明有状态在第一次调用时被写坏,通常是重定位漏改或者 .bss 段没清零。这一步是我被坑过之后才加上的,之前一直以为"调通一次就是通了"。
最后一个技巧,关于重定位到底有没有跑对。加一行调试输出,把实际分配基址和首选 ImageBase 的差值打出来:差值为 0 说明你运气好跳过了重定位,这时候"能跑"完全不能证明重定位代码正确;差值为 0 的情况下,我会人为在申请时加一个 0x10000 的偏移,强制让 delta 非零,再跑一遍全部检查点。这个动作多花不了五分钟,但能提前发现绝大多数位宽和遍历边界的问题。
回过头看,这套东西真正的门槛不是代码,而是你愿不愿意为"看不见的步骤"付出耐心。从那以后我每次写这类加载逻辑,都会先把 delta 打印出来看它是不是非零,再决定要不要相信后面的测试结果——玄学归玄学,能验证的东西就别靠感觉。希望帮到你。
本文还有配套的精品资源,点击获取