恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从内存加载DLL:PE手动映射与重定位实现原理详解
首页
资讯中心
/
从内存加载DLL:PE手动映射与重定位实现原理详解
从内存加载DLL:PE手动映射与重定位实现原理详解
发布时间:2026/10/14 17:04:12
简介从内存加载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是加载到内存后的 RVAPointerToRawData是文件里的偏移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 出HIGHLOW64 位 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 恰好加载到了它期望的ImageBasedelta 为 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 存调用前必须加上模块基址。很多人会有一个错觉导出表的地址「已经算好了」。没有导出表在你手动加载时不会自动修正必须自己加基址。解决写个统一的GetProcAddressLLFARPROC 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里的值取出来后加基址这是整个导出表处理唯一正确的方式。加了基址之后拿到的函数指针才能和正常LoadLibraryGetProcAddress的结果互换使用。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 跑通再说。我自己的习惯是新写的加载器永远先在纯字节加载下做冒烟测试通过后再考虑加密、压缩这些叠加逻辑。谁还没为对齐和重定位熬过夜呢。希望帮到你。本文还有配套的精品资源点击获取