简介:从内存加载DLL的完整实现与调用示例,面向需要在运行时绕过磁盘落盘、以资源方式直接加载动态库的Windows开发者。示例工程包含一个测试用DLL项目与一个实际加载测试项目,演示了如何将DLL作为资源编译进程序,并通过MemoryLoadLibrary从内存加载、MemoryGetProcAddress定位导出函数、MemoryFreeLibrary释放模块的完整流程。压缩包内共22个文件,以C/C++源文件和头文件为主体,附带工程文件、编译生成的DLL/EXE及说明文档,整体仅87KB,结构紧凑,适合直接参考改造。已有1506人学习下载,代码含作者编写的MemoryModule实现,可帮助快速理解内存加载的关键机制与调用约定,实际项目中遇到DLL释放、函数地址解析等问题时具备较高参考价值。
1. 从内存加载 DLL:为什么绕开 LoadLibrary 反而更常见
你写过一个插件系统的话,大概率遇到过这种场景:插件 DLL 不是磁盘上的固定文件,而是程序启动时根据配置动态拼出来的字节流。Loader 只认路径,于是你得先把它 WriteFile 到临时目录,再调 LoadLibraryA 读回来。单机调试没问题,放到装了一堆监控组件的机器上就开始出幺蛾子——扫描引擎盯上「进程写 PE 到临时目录再加载」这种动作,日志刷屏,甚至直接被拦。更难受的是插件升级一次就要清一次临时文件,路径一被占用整个加载就断。
反直觉的结论是:Windows 公开 API 里根本没有「从字节缓冲区直接加载 DLL」的接口,LoadLibrary 的第一步永远是拿路径去打开文件。所谓从内存加载 DLL,本质是绕过文件系统,把系统加载器内部那套流程自己实现一遍:解析 PE、按节分配内存、复制镜像、修导入表、做重定位,最后调 DllMain。这套方案解决的是「不落盘加载」「字节流即来即用」「临时产物不留痕」这三类需求,适合插件热插拔、动态生成逻辑,以及做二进制分析时不想让临时文件留在磁盘上的场景。先把话说在前头:这是标准 PE 结构知识,使用边界要把握好,别拿去做突破正常安全机制的事。
2. 先拆 PE 再手动映射:加载器背后的五个固定步骤
2.1 从 DOS 头到节表:先读懂 DLL 的骨架
一个标准 DLL 在文件里长这样:DOS 头、NT 头、节表、若干个节数据。DOS 头里真正有用的是e_magic(校验MZ)和e_lfanew(指向 NT 头的文件偏移)。NT 头才是核心,Signature必须是PE\0\0,里面有文件头和控制头,控制头里包含了ImageBase、AddressOfEntryPoint、SectionAlignment、FileAlignment、SizeOfImage这些关键字段。
节表紧跟在 NT 头后面,每一项描述一个节:VirtualAddress是加载到内存后的 RVA,PointerToRawData是文件里的偏移,SizeOfRawData是文件里的字节数,Characteristics决定这块内存的属性。手动加载 DLL 的第一步就是把这一套结构摸清楚,否则后面全是盲写。
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)rawData; if (dos->e_magic != IMAGE_DOS_SIGNATURE) return NULL; PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(rawData + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) return NULL; printf("ImageBase: 0x%llX\n", nt->OptionalHeader.ImageBase); printf("EntryPoint: 0x%X\n", nt->OptionalHeader.AddressOfEntryPoint); printf("Sections: %d\n", nt->FileHeader.NumberOfSections);dos->e_lfanew是一个文件偏移,不是 RVA,直接加在rawData上就能拿到 NT 头,不需要做任何换算。nt->OptionalHeader.ImageBase是链接器期望的加载基址,64 位值,打印时用%llX。SizeOfImage是按SectionAlignment对齐后的总大小,内存分配要用它,不能拿GetFileSize的值去分配,否则后面复制的节会溢出。
2.2 系统加载器做了四件事:分配、复制、导入、重定位
LoadLibrary拿到路径后,进程内的系统加载器会按这么个顺序干活:先读文件头,按SizeOfImage在进程地址空间里VirtualAlloc一片内存;再把 DOS 头、NT 头、节表这个头部区域整个复制过去,接着按节表把每个节的原始数据复制到对应VirtualAddress;然后扫导入表,对每个依赖的 DLL 调LoadLibrary,再把GetProcAddress拿到的函数地址填进 IAT;最后处理重定位表,因为实际分配的基址几乎不可能等于ImageBase。
这四步完成后,系统加载器才去调用入口点(DllMain)和 TLS 回调。内存加载 DLL 就是把前三步自己写一遍,区别只在于:系统加载器从路径读文件,我们从自己手里的字节缓冲区读;系统加载器有进程内缓存和引用计数,我们自己写的加载器要自己管理模块生命周期。
整个流程对调用方是黑匣子,只有出了问题才需要打开看。手动映射最大的价值就是把这个黑匣子拆开,每一段都变成自己能控制的数据操作,这也是为什么很多框架选择自己实现加载逻辑。
2.3 基址与 delta:重定位是内存加载最容易翻车的部分
DLL 编译时默认带/DYNAMICBASE,也就是 ASLR。链接器生成的重定位表记录了所有绝对地址的位置——不管这个 DLL 最后落在哪个基址,加载器都得把这些位置的值修正一遍。
修正的计算方式很简单:
delta = 实际加载基址 - ImageBase 内存地址处的原值 += delta重定位表由一个个IMAGE_BASE_RELOCATION块组成,每个块描述一个 4KB 页里需要修正的位置。块里有VirtualAddress(页起始 RVA)和SizeOfBlock(整个块大小),后面的数据是若干 16 位项,高 4 位是类型,低 12 位是页内偏移。
DWORD_PTR delta = (DWORD_PTR)imageBase - (DWORD_PTR)nt->OptionalHeader.ImageBase; PIMAGE_BASE_RELOCATION reloc = (PIMAGE_BASE_RELOCATION)(imageBase + relocRVA); while (reloc->VirtualAddress) { DWORD count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); PWORD items = (PWORD)((BYTE*)reloc + sizeof(IMAGE_BASE_RELOCATION)); for (DWORD i = 0; i < count; i++) { int type = items[i] >> 12; int offset = items[i] & 0xFFF; BYTE* addr = (BYTE*)imageBase + reloc->VirtualAddress + offset; if (type == IMAGE_REL_BASED_HIGHLOW) *(DWORD*)addr += (DWORD)delta; else if (type == IMAGE_REL_BASED_DIR64) *(ULONGLONG*)addr += (ULONGLONG)delta; } reloc = (PIMAGE_BASE_RELOCATION)((BYTE*)reloc + reloc->SizeOfBlock); }注意类型判断:32 位 DLL 出HIGHLOW,64 位 DLL 出DIR64。程序集目标平台要对上,否则看成DWORD加等于把 8 字节里高 4 字节丢了一半,程序跑起来就是一个随机崩。块遍历结束后,VirtualAddress为 0 表示重定位表到了末尾,这段代码里靠这个条件跳出循环。这里就是加载器玄学最集中的地方,十次翻车有八次在重定位。
3. 一个可运行的 Memory-Loader:完整代码与关键参数
3.1 把字节流读进来,并校验 MZ 与 PE 签名
写加载器的第一步不是分配内存,是校验输入。一个文件后缀叫.dll、内容根本不是 PE 的情况太常见了:下载断了一半、代码里少读了几百字节、或者直接把文本文件传了进来。提前校验能省下后面好几个小时的调试时间。
BYTE* ReadFileToMemory(const char* path, DWORD* outSize) { HANDLE hFile = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile == INVALID_HANDLE_VALUE) return NULL; DWORD size = GetFileSize(hFile, NULL); BYTE* buffer = (BYTE*)malloc(size); DWORD read = 0; BOOL ok = ReadFile(hFile, buffer, size, &read, NULL); CloseHandle(hFile); if (!ok || read != size) { free(buffer); return NULL; } // 最低限度检查:MZ + PE 签名 PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)buffer; if (dos->e_magic != IMAGE_DOS_SIGNATURE || read < 64) { free(buffer); return NULL; } PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(buffer + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) { free(buffer); return NULL; } *outSize = read; return buffer; }GENERIC_READ | FILE_SHARE_READ是文件读取的常规组合,第二个参数放开共享读,避免文件被其他进程占用时打不开。read != size这个判断必须留着,网络映射盘或某些虚拟文件系统里 ReadFile 可能一次读不完。校验放在文件读取函数里做,加载器本体就不再重复校验,让职责单一。
3.2 按节表复制镜像,修正文件对齐与内存对齐
PE 在磁盘上的对齐和内存里的对齐是两套。文件里一个节可能因为对齐占了 0x200 字节,但复制到内存后它旁边还有一片填充区域要清零,这片区域叫「展开区」。SizeOfRawData 是文件里实打实的字节数,Misc.VirtualSize 才是代码真正声明的长度,后者通常小于前者对应的内存范围。
BYTE* LoadMemoryModule(BYTE* rawData) { PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)rawData; PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(rawData + dos->e_lfanew); // 分配内存,必须按 SizeOfImage,不能按文件大小 BYTE* imageBase = (BYTE*)VirtualAlloc(NULL, nt->OptionalHeader.SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); if (!imageBase) return NULL; // 头部区域一次性复制 memcpy(imageBase, rawData, nt->OptionalHeader.SizeOfHeaders); // 逐个节复制 PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt); for (int i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) { memcpy(imageBase + sec->VirtualAddress, rawData + sec->PointerToRawData, sec->SizeOfRawData); } return imageBase; }VirtualAlloc用MEM_RESERVE | MEM_COMMIT一次性提交,参数PAGE_READWRITE只是初始属性,后面还要根据节的Characteristics精调。IMAGE_FIRST_SECTION(nt)是一个宏,展开后是「NT 头地址 + NT 头大小」,正好落在节表首项。节复制用的是PointerToRawData和SizeOfRawData,读文件偏移;VirtualAddress是内存 RVA,两个概念不要搞反。SizeOfHeaders通常比 DOS 头 + NT 头 + 节表的总尺寸大,多出来的部分是对齐填充,全复制过去没有任何副作用。
3.3 重定位处理:delta 算不对一切都白搭
复制完镜像,第一件要做的事是重定位。如果目标 DLL 恰好加载到了它期望的ImageBase,delta 为 0,重定位可以跳过,但这种情况只有在关闭 ASLR 且地址冲突时才可能出现。生产环境的 DLL 基本都开了 ASLR,重定位这一步不能省。
BOOL ProcessRelocations(BYTE* imageBase, PIMAGE_NT_HEADERS nt) { // 找重定位表在镜像里的位置 DWORD relocRVA = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress; if (!relocRVA) return TRUE; // 没有重定位表,直接返回 DWORD_PTR delta = (DWORD_PTR)imageBase - (DWORD_PTR)nt->OptionalHeader.ImageBase; PIMAGE_BASE_RELOCATION reloc = (PIMAGE_BASE_RELOCATION)(imageBase + relocRVA); while (reloc->VirtualAddress) { DWORD count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); PWORD items = (PWORD)((BYTE*)reloc + sizeof(IMAGE_BASE_RELOCATION)); for (DWORD i = 0; i < count; i++) { int type = items[i] >> 12; int offset = items[i] & 0xFFF; BYTE* target = imageBase + reloc->VirtualAddress + offset; if (type == IMAGE_REL_BASED_HIGHLOW) *(DWORD*)target += (DWORD)delta; else if (type == IMAGE_REL_BASED_DIR64) *(ULONGLONG*)target += (ULONGLONG)delta; else if (type == IMAGE_REL_BASED_ABSOLUTE) continue; // 对齐项,跳过 else return FALSE; // 未知类型,停止处理 } reloc = (PIMAGE_BASE_RELOCATION)((BYTE*)reloc + reloc->SizeOfBlock); } return TRUE; }IMAGE_DIRECTORY_ENTRY_BASERELOC在数据目录里的索引是 5,.reloc节就挂在这里。delta 的类型必须用DWORD_PTR,它能跟着目标平台位数自动变宽;在 64 位下用DWORD算 delta,高 32 位全丢,加出来的地址直接飞掉。遇到ABSOLUTE类型直接跳过,那是链接器为了对齐页边界塞的占位项,不参与计算。
3.4 遍历导入表:用 LoadLibraryA/GetProcAddress 补地址
DLL 里所有对外部函数的调用都经过 IAT(导入地址表)。文件里的 IAT 存的是函数名或序号,系统加载器负责把它们替换成真正的函数地址。我们手动加载时也得干这件活,而且必须一个依赖一个依赖地解析。
BOOL ProcessImports(BYTE* imageBase, PIMAGE_NT_HEADERS nt) { DWORD impRVA = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress; if (!impRVA) return TRUE; PIMAGE_IMPORT_DESCRIPTOR imp = (PIMAGE_IMPORT_DESCRIPTOR)(imageBase + impRVA); while (imp->Name) { LPCSTR dllName = (LPCSTR)(imageBase + imp->Name); HMODULE hDll = LoadLibraryA(dllName); if (!hDll) return FALSE; PIMAGE_THUNK_DATA origThunk = (PIMAGE_THUNK_DATA)(imageBase + imp->OriginalFirstThunk); PIMAGE_THUNK_DATA firstThunk = (PIMAGE_THUNK_DATA)(imageBase + imp->FirstThunk); while (origThunk->u1.AddressOfData) { if (origThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG) { // 按序号导入 LPCSTR ordinal = (LPCSTR)(origThunk->u1.Ordinal & 0xFFFF); firstThunk->u1.Function = (ULONG_PTR)GetProcAddress(hDll, ordinal); } else { // 按名称导入,Name 字段是 Hint/Name 结构里的函数名 PIMAGE_IMPORT_BY_NAME ibn = (PIMAGE_IMPORT_BY_NAME)(imageBase + origThunk->u1.AddressOfData); firstThunk->u1.Function = (ULONG_PTR)GetProcAddress(hDll, (LPCSTR)ibn->Name); } origThunk++; firstThunk++; } imp++; } return TRUE; }注意OriginalFirstThunk和FirstThunk的区别:前者是原原本本的导入描述,后者是要被改写的 IAT。文件里两者都指向同一片数据,加载后FirstThunk被覆盖成函数地址,OriginalFirstThunk保留原样。按序号导入时GetProcAddress的第二个参数直接传序号值即可,但要先检查最高位标志(IMAGE_ORDINAL_FLAG),否则会把序号当成地址解引用直接崩。
3.5 调 DllMain 和拿导出函数:让加载器具备入口管理
镜像完整落地、IAT 补齐、重定位做完,接下来要做的和系统加载器一样:调用入口点。DLL 的入口点不是有人想的那样叫DllMain,它是AddressOfEntryPoint指向的那个函数,签名固定,链接器包装后名字才有DllMain的语义。
typedef BOOL(WINAPI* DLLMAIN)(HMODULE, DWORD, LPVOID); BOOL StartModule(BYTE* imageBase, PIMAGE_NT_HEADERS nt) { DLLMAIN entry = (DLLMAIN)(imageBase + nt->OptionalHeader.AddressOfEntryPoint); // 调用前把代码节设为可执行,避免 DEP 拦截 PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt); for (int i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) { DWORD protect = PAGE_READWRITE; if (sec->Characteristics & IMAGE_SCN_MEM_EXECUTE) { protect = (sec->Characteristics & IMAGE_SCN_MEM_WRITE) ? PAGE_EXECUTE_READWRITE : PAGE_EXECUTE_READ; } VirtualProtect(imageBase + sec->VirtualAddress, sec->Misc.VirtualSize ? sec->Misc.VirtualSize : sec->SizeOfRawData, protect, &oldProtect); } return entry((HMODULE)imageBase, DLL_PROCESS_ATTACH, NULL); }设置节属性的逻辑:带执行位的节给PAGE_EXECUTE_READ,如果这节还要写就放宽到PAGE_EXECUTE_READWRITE,数据节保持PAGE_READWRITE。先改保护再调入口点,这一步顺序不能反——大多数 DLL 的全局构造函数、静态初始化都在这时候执行,权限不够直接触发访问冲突或 DEP 拦截。entry调完返回后不要释放内存,模块处于「已加载」状态,和系统 DLL 一样,要等卸载时才释放。
获取导出函数也可以做一个小工具函数:遍历导出表,按名字匹配AddressOfNames,找到AddressOfFunctions里对应的 RVA,加基址就是函数地址。这里必须先加基址再返回,否则拿到的是文件里的偏移,调用必崩。
4. 避坑:内存加载最常见的 4 个翻车现场
4.1 DllMain 没有触发,加载却返回了成功
现象:加载流程走完了,函数返回了非空模块基址,但 DLL 里的初始化日志一条都没打出来,依赖 DLL 的全局变量全是零。
原因:入口点的调用被写在了所有保护设置之前,或者干脆忘记调用。典型错误是把AddressOfEntryPoint当成了一个「可选步骤」。另一个隐蔽情况是AddressOfEntryPoint为 0——因为某些 DLL 是纯资源模块,没有代码入口,编译时入口点被设成了 0。
解决:复制完镜像、处理完导入和重定位后,无条件调一次入口点;入口点地址非零才调,零就当作成功跳过。调之前先按节的Characteristics设置VirtualProtect,否则入口代码大概率被 DEP 拦。可以在入口点前后各打一条调试输出,立刻能定位是没调还是调了崩。
4.2 导出函数一调用就访问违例:地址加基址加错了
现象:GetProcAddressLL返回了一个小数值,比如0x1A30,你把它转成函数指针直接调用,进程当场崩溃。
原因:导出表里AddressOfFunctions存的既有 RVA 也有文件偏移,具体是哪种取决于这个函数是否在某个节里经过了特殊处理。绝大多数情况下它按 RVA 存,调用前必须加上模块基址。很多人会有一个错觉:导出表的地址「已经算好了」。没有,导出表在你手动加载时不会自动修正,必须自己加基址。
解决:写个统一的GetProcAddressLL:
FARPROC GetProcAddressLL(HMODULE hModule, LPCSTR funcName) { PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hModule; PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hModule + dos->e_lfanew); PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY) ((BYTE*)hModule + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress); DWORD* names = (DWORD*)((BYTE*)hModule + exp->AddressOfNames); WORD* ordinals = (WORD*)((BYTE*)hModule + exp->AddressOfNameOrdinals); DWORD* funcs = (DWORD*)((BYTE*)hModule + exp->AddressOfFunctions); for (DWORD i = 0; i < exp->NumberOfNames; i++) { LPCSTR name = (LPCSTR)((BYTE*)hModule + names[i]); if (strcmp(name, funcName) == 0) { return (FARPROC)((BYTE*)hModule + funcs[ordinals[i]]); } } return NULL; }AddressOfFunctions里的值取出来后加基址,这是整个导出表处理唯一正确的方式。加了基址之后拿到的函数指针,才能和正常LoadLibrary+GetProcAddress的结果互换使用。
4.3 重定位块解析正常,运行几分钟后随机崩
现象:加载成功,DLL 自带的功能调用也正常,但运行一段时间后某几个全局变量或函数指针开始指向错误地址,崩溃点每次都不一样。
原因:delta 计算混用了位数。在 64 位进程里把delta声明成DWORD,或者把IMAGE_REL_BASED_HIGHLOW的修正目标想当然当成 4 字节。如果 DLL 是 64 位的,重定位表里大量出现DIR64类型,代码里只处理HIGHLOW,那些 8 字节地址就永远没修,运行时地址爆炸是必然的。
解决:delta 统一用DWORD_PTR,修地址时的指针宽度跟着目标平台走。写代码时在重定位循环里同时处理三种类型:HIGHLOW按 4 字节、DIR64按 8 字节、ABSOLUTE跳过。不要只写一种类型就上线,项目切换 32/64 位编译目标时这是固定埋雷点。
还有一个附加建议:在重定位循环里加一个计数器,处理完的条目数和重定位表声明的总条目数对比,不一致就认为镜像损坏,直接失败返回。我现在写任何加载器都会加这个断言,它比任何日志都能更快暴露问题。
4.4 主机安全产品提示可疑内存镜像:节区属性全被铺平了
现象:某主机安全产品在进程内存扫描里报告「进程内存中存在可疑可执行镜像」,加载虽然成功,但日志里全是告警,审查起来非常被动。
原因:整块镜像在VirtualAlloc时用了单一PAGE_READWRITE,加载完又没按节修正保护属性。扫描引擎看到一块大内存整体可写且包含 PE 头,特征和它要盯的行为重叠,自然拉高告警级别。这不是玄学,是节属性铺平导致的特征太明显。
解决:加载完成后,严格按照节表的Characteristics逐节重设保护属性:代码节PAGE_EXECUTE_READ,可写数据节PAGE_READWRITE,资源节PAGE_READONLY。关键点是要用VirtualProtect而不是修改VirtualAlloc的初始参数——初始参数只影响刚分配时的状态,节表铺完后再整体改一次才能真实还原可执行文件的内存布局。
5. 进阶:加载前先压缩再还原,让内存镜像与磁盘文件不一致
如果你的应用场景允许在字节流上做一层变换,我建议加一个「打包还原」步骤,这样磁盘上的产物和内存里的镜像就不保持逐字节一致。做法很简单:把原始 DLL 字节流做一次异或混淆,或者用RtlDecompressBuffer压缩,加载时先还原成原始 PE 字节,再走前面那套LoadMemoryModule流程。
BYTE* XorDecode(const BYTE* input, DWORD size, BYTE key) { BYTE* output = (BYTE*)malloc(size); for (DWORD i = 0; i < size; i++) { output[i] = input[i] ^ key; // 还原时用同一个 key 再异或一次 } return output; }异或是最低成本的方案,密钥设计可以做成多字节滚动,固定一个字节的密钥太容易被统计分析识破。压缩相对复杂一点,但收益是加载前的传输体积变小、内存占用峰值降低。注意RtlDecompressBuffer需要调用者输入UncompressedBufferSize,这个大小必须在压缩前记录在头部,最好是自定义一个 8 字节包头:前 4 字节存原始长度,后 4 字节存压缩标志,还原时先读包头再解压。
这块我最早是在一个插件框架里试的,当时是为了让插件编译产物带一份版本水印,顺带把不影响加载的填充字节做掉。后来发现这个设计还附带两个好处:磁盘文件被拷贝走也拿不到直接可用的 PE,安全审计时内存镜像和磁盘文件对不上号,能减少误判。加了这层之后,加载流程的调试复杂度会变高——如果你之前一次都没跑通过裸加载,别急着上这层,先把原味 PE 跑通再说。
我自己的习惯是:新写的加载器永远先在纯字节加载下做冒烟测试,通过后再考虑加密、压缩这些叠加逻辑。谁还没为对齐和重定位熬过夜呢。希望帮到你。
本文还有配套的精品资源,点击获取