从内存加载DLL:手写PE装载器原理与避坑实现

发布时间:2026/10/14 20:10:16
从内存加载DLL:手写PE装载器原理与避坑实现
简介针对从内存加载DLL并调用导出函数的需求一套完整可运行的示例代码包含两个关联工程xDll负责编译生成测试用DLLtestLoadDll演示将DLL作为资源读入内存再通过MemoryLoadLibrary加载、MemoryGetProcAddress查找函数、MemoryFreeLibrary释放。适合需要规避磁盘释放、实现动态插件注入或加固分发的Windows开发者参考也适合学习PE加载原理的进阶者。压缩包共22个文件以h/cpp源码、dsp/dsw工程配置为主同时包含编译好的DLL、lib、exe及说明文档整体仅87KB结构紧凑源码与工程分离Release产物可直接运行验证。核心MemoryModule.c/h实现封装清晰可在自己的项目中直接复用或按需修改加载逻辑。目前已有1506人学习浏览。读者参照代码即可理解内存模块的加载原理、导出表解析流程与释放机制改造为资源型插件或隐蔽加载方案时能少走弯路还可进一步研究PE镜像映射、重定位与导入表修复等底层细节。1. 从内存加载 DLL为什么必须自己写一个装载器“从内存加载DLL”这件事很多人的第一反应是把DLL读成BYTE数组然后调用LoadLibrary就行。实际上LoadLibrary只接收文件路径底层会走 CreateFile → 创建 Section → NtMapViewOfSection → LdrpLoadDll 的链路根本没有“从字节流加载”这个选项。所以只要你手里只有一段DLL数据网络接收、解密得到、资源释放前就必须自己写一个 PE Loader手动完成系统装载器做的事分配镜像内存、复制节区、修复重定位、解析导入表、调用入口点。这个方案在插件热升级、DLL保护与运行时模块分发场景里非常实用也是很多内存模块机制的底层。下面的章节给出一套可复现的完整C实现并把踩过的坑按现象、原因、解决的方式写清楚新手可以照着抄熟手可以拿来做边界校验。2. 加载前的功课PE 数据目录决定内存加载的成败2.1 为什么不能直接把字节流丢给 LoadLibrary系统加载DLL的标准链是调用 LoadLibrary → 内部调用 LdrLoadDll → 校验文件路径 → 创建 Section 对象 → NtMapViewOfSection 到进程地址空间 → 链接导入表 → 执行初始化。整个链路最初的输入是一个文件路径或一个已映射的文件句柄手里只有一块裸内存字节流时系统根本没有对象可以映射所以必然要自己重造中间那一层。有人会想先把字节 WriteFile 写进临时目录再 LoadLibrary这不就“加载”了吗那仍然不是内存加载而且会留下磁盘痕迹临时文件还要考虑清理防病毒实时扫描经常拦截新释放的DLL。做插件热升级时也希望新版本直接进内存替换旧版本避免文件被占用、避免闪断磁盘IO。这才是“从内存加载DLL”真正的价值所在。手工装载器的本质是把磁盘上的 PE 镜像File 布局转换成内存里的 PE 镜像Memory 布局。两者最大的区别在于三个地方节区对齐由 FileAlignment 变成 SectionAlignment所有 RVA 字段必须被解释为“基址 RVA”可写的导入表、重定位地址需要被真正写入内存。2.2 需要处理的数据目录清单PE 的 OptionalHeader 里有 16 个数据目录DataDirectory但并不是每个都能在内存加载时忽略。我按“内存加载器处理优先级”排了一张表照着这张表检查自己的装载器基本不会漏。目录索引名称内存加载是否必须处理不处理的后果0ExportTable不需要主动处理GetProcAddress 直接解析该目录无需特殊处理1ImportTable必须所有导入函数地址无效调用即崩溃2ResourceTable建议处理FindResource / 图标 / 对话框模板加载异常5RelocationTable必须加载基址与 ImageBase 不一致时整体不可用9TLS建议处理__declspec(thread) 变量行为不可预知13DelayImportTable可选处理延迟加载函数首次调用时崩溃其他调试 / 安全目录可忽略通常不影响运行为什么重定位不能省每种PE都有一个默认基址 ImageBase64位下一般是 0x000000014000000032位一般是 0x00400000。当用 VirtualAlloc 分配镜像时系统几乎不可能恰好给你这个地址。如果没有把代码里的绝对地址按“实际基址 - ImageBase”修正所有全局变量与函数指针都指向旧地址区跑起来第一句就可能段错误。2.3 32 位与 64 位装载的差异写装载器最容易翻车的地方就是位数。建议先确认“编译平台位数”和“目标 DLL 位数”一致再读结构体32位用 IMAGE_NT_HEADERS3264位用 IMAGE_NT_HEADERS64OptionalHeader.Magic 是 0x10b 还是 0x20b。差异点集中在三处重定位类型32位是 IMAGE_REL_BASED_HIGHLOW值364位是 IMAGE_REL_BASED_DIR64值10代码里两种都要判断IAT Thunk 宽度32位是4字节64位是8字节遍历导入表时按位数决定步长地址计算DWORD 只能存32位地址64位下所有指针变量都用 ULONGLONG否则重定位增量写一半截断。如果你希望一个执行文件同时兼容32/64位的DLL也就是内部解析两套结构结构体要用联合体加位宽判断代码复杂度会明显上升。我的建议是第一版先做“编译平台与目标一致”跑通之后再抽壳成通用解析器否则很容易在结构体 offset 上翻车。3. 手工映射 DLL 的完整代码从字节流到可调用的 HMODULE这一章是主干。以“64位进程加载64位DLL”为最小示例代码可直接保存为 MemoryModule.cpp编译时不需要额外第三方库只要 Windows.h。3.1 函数签名与基础结构先定义入口函数// MemoryModule.h #pragma once #include windows.h HMODULE LoadLibraryFromMemory(const unsigned char* dllData, size_t dataSize); BOOL FreeLibraryFromMemory(HMODULE module);这段只是接口声明。返回值用 HMODULE 是因为调用方后续要用 GetProcAddress、GetModuleHandle 等习惯 API本质上 HMODULE 就是装载器返回的镜像基址。LoadLibraryFromMemory 负责完成完整装载FreeLibraryFromMemory 负责调用 DllMain(DLL_PROCESS_DETACH) 并释放镜像。我一般会额外要求 dllData 在整个装载期间保持有效至少在复制完所有节区数据之前不能释放源缓冲区因为导入表、重定位表都需要按源镜像偏移读取。复制完成后源缓冲区就可以抹掉或释放。3.2 申请镜像内存与复制节区这是装载器的第一步也是最容易写错的一步。磁盘镜像里各节是紧挨着按 FileAlignment 对齐存放的而内存镜像要求每个节按 SectionAlignment 对齐到自身 VirtualAddress 位置所以不能整块整体复制必须逐节操作。// MemoryModule.cpp #include MemoryModule.h #include winnt.h static IMAGE_NT_HEADERS* GetNtHeaders(const unsigned char* data) { const IMAGE_DOS_HEADER* dos (const IMAGE_DOS_HEADER*)data; if (dos-e_magic ! IMAGE_DOS_SIGNATURE) return NULL; IMAGE_NT_HEADERS* nt (IMAGE_NT_HEADERS*)(data dos-e_lfanew); if (nt-Signature ! IMAGE_NT_SIGNATURE) return NULL; return nt; } HMODULE LoadLibraryFromMemory(const unsigned char* dllData, size_t dataSize) { IMAGE_NT_HEADERS* nt GetNtHeaders(dllData); if (!nt) return NULL; SIZE_T imageSize nt-OptionalHeader.SizeOfImage; BYTE* imageBase (BYTE*)VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!imageBase) return NULL; // 先复制 PE 头到内存镜像 memcpy(imageBase, dllData, nt-OptionalHeader.SizeOfHeaders); // 逐个节区复制按磁盘布局搬到内存布局 IMAGE_SECTION_HEADER* sec IMAGE_FIRST_SECTION(nt); for (DWORD i 0; i nt-FileHeader.NumberOfSections; i) { if (sec[i].SizeOfRawData 0 sec[i].PointerToRawData 0) { memcpy(imageBase sec[i].VirtualAddress, dllData sec[i].PointerToRawData, sec[i].SizeOfRawData); } } // ... 后续修复 }逻辑说明GetNtHeaders 先验证 MZ 和 PE 两个签名避免把任意字节当成PE解析。SizeOfImage 是装载器最终占用的虚拟内存大小必须由 VirtualAlloc 提供整块连续的提交区域。我用 PAGE_EXECUTE_READWRITE 是为了避免段属性切分太细开发阶段最省事生产环境建议按节区的 Characteristics 分配将可读、可写、可执行分别设保护属性。参数说明dllData 是指向原始PE字节的指针dataSize 目前仅在调试时用真正对齐复制时依赖的是节区里的 PointerToRawData 与 SizeOfRawData不依赖 dataSize。因此如果调用方给的 dataSize 小于 SizeOfRawData 的目标区域越界读会发生在你头上——这也是为什么我建议调用方保证缓冲区完整。3.3 重定位与导入表修复镜像搬完接下来是最核心的两步重定位与导入表修复。这两步做完DLL 才具备可执行条件。// 在 LoadLibraryFromMemory 内继续 // 1) 重定位修复 IMAGE_DATA_DIRECTORY* relocDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (relocDir-Size 0) { ULONGLONG delta (ULONGLONG)imageBase - nt-OptionalHeader.ImageBase; if (delta ! 0) { BYTE* p imageBase relocDir-VirtualAddress; DWORD remaining relocDir-Size; while (remaining sizeof(IMAGE_BASE_RELOCATION)) { IMAGE_BASE_RELOCATION* block (IMAGE_BASE_RELOCATION*)p; if (block-VirtualAddress 0) break; DWORD count (block-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* entries (WORD*)(p sizeof(IMAGE_BASE_RELOCATION)); for (DWORD i 0; i count; i) { WORD type entries[i] 12; WORD offset entries[i] 0xFFF; BYTE* patchAddr imageBase block-VirtualAddress offset; if (type IMAGE_REL_BASED_DIR64) { // 10 *(ULONGLONG*)patchAddr delta; } else if (type IMAGE_REL_BASED_HIGHLOW) { // 3 *(DWORD*)patchAddr (DWORD)(*(DWORD*)patchAddr (DWORD)delta); } // IMAGE_REL_BASED_ABSOLUTE(0) 不需要处理 } p block-SizeOfBlock; remaining - block-SizeOfBlock; } } } // 2) 导入表修复 IMAGE_DATA_DIRECTORY* importDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (importDir-Size 0) { IMAGE_IMPORT_DESCRIPTOR* desc (IMAGE_IMPORT_DESCRIPTOR*)(imageBase importDir-VirtualAddress); for (; desc-Name ! 0; desc) { const char* dllName (const char*)(imageBase desc-Name); HMODULE dep LoadLibraryA(dllName); // 依赖库可以正常落盘加载 if (!dep) return NULL; BYTE* iat imageBase desc-FirstThunk; BYTE* oft desc-OriginalFirstThunk ? imageBase desc-OriginalFirstThunk : iat; for (ULONGLONG* t (ULONGLONG*)oft; *t ! 0; t, iat sizeof(ULONGLONG)) { FARPROC proc NULL; if (*t IMAGE_ORDINAL_FLAG64) { WORD ordinal (WORD)(*t 0xFFFF); proc GetProcAddress(dep, (LPCSTR)(ULONG_PTR)ordinal); } else { IMAGE_IMPORT_BY_NAME* ibn (IMAGE_IMPORT_BY_NAME*)(imageBase (DWORD)*t); proc GetProcAddress(dep, (LPCSTR)ibn-Name); } *(ULONGLONG*)iat (ULONGLONG)proc; } } }逻辑说明重定位表是一组连续的重定位块每个块包含一个 VirtualAddress页基址和一组16位项。每项高4位是类型、低12位是页内偏移。遍历时把每个 DIR64/HIGHLOW 类型的地址按 delta 做一次原地修正。导入表部分每个导入描述符对应一个依赖DLL名字在 desc-NameOriginalFirstThunk 是导入名称表INTFirstThunk 是导入地址表IAT加载器拿到依赖库句柄后将 IAT 的每一项从“符号描述”改写为“函数真实地址”。参数说明如果 DLL 明确标记为可被重定位但重定位目录是空的装载器需要在 VirtualAlloc 时尽量靠近原 ImageBase否则只能失败退出不能静默继续。依赖库这里直接用了 LoadLibraryA会让依赖DLL正常落盘如果希望整套依赖都不落盘需要递归地对依赖库也走内存加载复杂度会成倍上升一般场景没必要。3.4 TLS 回调与 DllMain 的调用顺序导入表完成后DLL 还不能直接开始用因为初始化顺序是固定的先 TLS 回调再 DllMain(DLL_PROCESS_ATTACH)。这里有个细节容易忽略——TLS 目录里的 AddressOfCallBacks 在磁盘上是“按默认基址计算的绝对地址”实际装载时必须重定位。// 3) TLS 回调 IMAGE_DATA_DIRECTORY* tlsDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; if (tlsDir-Size 0) { IMAGE_TLS_DIRECTORY* tls (IMAGE_TLS_DIRECTORY*)(imageBase tlsDir-VirtualAddress); // 修正 TLS 目录中保存的绝对 VA ULONGLONG delta (ULONGLONG)imageBase - nt-OptionalHeader.ImageBase; tls-AddressOfIndex delta; tls-AddressOfCallBacks delta; PIMAGE_TLS_CALLBACK* cb (PIMAGE_TLS_CALLBACK*)tls-AddressOfCallBacks; if (cb) { while (*cb) { (*cb)(imageBase, DLL_PROCESS_ATTACH, NULL); cb; } } } // 4) 调用 DllMain typedef BOOL(WINAPI *fnDllMain)(HINSTANCE, DWORD, LPVOID); fnDllMain entry (fnDllMain)GetProcAddress((HMODULE)imageBase, DllMain); if (entry) { entry((HINSTANCE)imageBase, DLL_PROCESS_ATTACH, NULL); } return (HMODULE)imageBase;逻辑说明TLS 目录里的 AddressOfIndex、AddressOfCallBacks 在链接器生成时是“默认基址下的绝对地址”实际装载到别的基址后必须用 delta 修正否则回调地址全部无效。很多简单实现会漏掉这一步症状是 DLL 里的 __declspec(thread) 变量错乱或线程退出时崩掉。DllMain 第一参数是模块基址第二个是 DLL_PROCESS_ATTACH第三个保留参数必须为 NULL。如果 DLL 没有导出 DllMain说明是纯资源DLL直接跳过入口点调用即可。把 3.2 到 3.4 的三段代码拼在一起就是完整的 LoadLibraryFromMemory。调用方只要拿到字节流就能得到 HMODULE然后用 GetProcAddress 找导出函数用法和 LoadLibrary 几乎一致。4. 让内存模块更像“正常模块”资源、句柄与模块链表4.1 GetProcAddress 对内存模块的有效性边界上一章已经用 GetProcAddress 找了 DllMain这一步有效。但熟手会问GetProcAddress 内部不校验句柄是不是登记的模块吗严格说系统 API 实现上没有强制校验它直接按 HMODULE 指向的 PE 头去走导出目录。所以在纯净环境里对内存模块调用 GetProcAddress 是完全可行的。不过我在某些带安全软件的机器上遇到过返回 NULL 的情况原因通常是安全软件在应用层钩挂了 GetProcAddress会先做一次模块链表查找发现句柄不在链表里就直接返回失败。这个现象不是必现但在做端侧方案时要有心理准备。对应手段是本章 4.3 讲的“登记模块链表”把这个句柄放进系统模块链表中才能让这一层校验也放行。4.2 DLL 内部加载资源的根因如果内存DLL里用到了自己的资源比如图标、字符串表、对话框模板最直接的问题不是 GetProcAddress而是 FindResource / LoadResource 这一族 API。它们在解析时通常会走加载器数据区检查模块合法性一部分实现还会根据 hModule 查模块链查不到就返回 ERROR_RESOURCE_DATA_NOT_FOUND。“资源明明在DLL里装载器返回的HMODULE也没问题但加载资源就是失败”——这是内存加载方案里出镜率最高的玄学问题。一般情况下我会把资源加载坑放到开发中期再处理先确认业务里有没有用到 LoadString、LoadIcon、DialogBox 这类函数。如果只是纯逻辑插件不读资源这一章可以跳过一旦要读就得在装载器里补一个“资源重定向层”或按 4.3 登记模块链表。4.3 手动登记 LDR_DATA_TABLE_ENTRY让模块进入系统模块链表最常见的成熟做法是把这块内存模块插到进程的模块链表中让它拥有一个“合法身份”。系统模块链表的入口在 PEB-Ldr-InLoadOrderModuleList里面每个节点是 LDR_DATA_TABLE_ENTRYDllBase 指向模块基址。构造新节点填好 DllBase、EntryPoint、SizeOfImage、FullDllName然后挂到链表尾部即可。这里只给核心思路代码具体字段兼容建议以目标系统的 ntdll 头文件为准// 简化结构实际按目标系统的 ntdll 展开 typedef struct _LDR_DATA_TABLE_ENTRY { LIST_ENTRY InLoadOrderLinks; LIST_ENTRY InMemoryOrderLinks; LIST_ENTRY InInitializationOrderLinks; PVOID DllBase; PVOID EntryPoint; ULONG SizeOfImage; UNICODE_STRING FullDllName; UNICODE_STRING BaseDllName; ULONG Flags; USHORT LoadCount; // ... 不同版本后面字段有差异 } LDR_DATA_TABLE_ENTRY; // 挂接时 PPEB peb NtCurrentTeb()-ProcessEnvironmentBlock; PLIST_ENTRY head peb-Ldr-InLoadOrderModuleList; PLIST_ENTRY tail head-Blink; InsertTailList(tail, entry-InLoadOrderLinks);逻辑说明PEB 是进程环境块通过 NtCurrentTeb() 可以拿到。链表是双向链表插入尾部不影响其它模块遍历。完成这一步后GetModuleHandleA、FindResource 以及部分安全校验都会认为模块已正常加载。参数说明FullDllName 和 BaseDllName 在登记时必须给一个合理的“虚拟路径”因为后续可能有代码要读它我会给一个形如 Lmemory:\module123.dll 的名称既不落盘又能识别来源。这个登记动作属于对系统加载器数据结构的手工修改写坏链表会导致进程直接崩溃生产环境一定要做好异常保护并兼容多个 Windows 版本的结构差异。4.4 不登记模块链表时的务实取舍如果你暂时不想碰 PEB 这种高危结构也可以把登记这步省掉但代价是三件事不能做DLL 内部调 GetModuleFileName 拿真实路径会拿不到FindResource 大概率失败GetModuleHandle 用名字找不到。反过来只要业务里这三样都没依赖纯导出函数调用模式不登记也能正常运行。我通常的取舍是先跑通不登记的最小版本验证业务功能需要资源或模块名时再补登记。这样出了问题能立刻分清楚是“装载器错误”还是“模块身份问题”排查效率高很多。5. 避坑记录内存加载 DLL 最容易翻车的 5 个位置5.1 现象加载成功但调用导出函数立即崩溃加载函数返回了非 NULL但一调用导出函数就崩溃。原因几乎都是重定位没生效或者节区复制错位。最容易发生的是编译DLL时开了 /DYNAMICBASE默认开重定位表存在但装载器遍历重定位时把 delta 当成了 DWORD高32位被截断于是所有修正都错位。也可能是复制节区时漏了 SectionAlignment 对齐导致函数里的代码还在但全局变量地址镜像不对。解决先在 VirtualAlloc 的返回值上校验确认镜像是完整 SizeOfImage再在重定位循环里对每个 patchAddr 打日志看写入前后的值。把 DLL 的 ImageBase 强制改为与 VirtualAlloc 结果相同可以临时遮蔽问题但正式代码一定要把重定位修好。5.2 现象32位DLL在64位进程里加载失败本质不是代码问题是结构体位宽问题。64位进程用 IMAGE_NT_HEADERS 解析一个32位PE时OptionalHeader 读取长度不匹配后面所有数据目录都错位。就算硬解析完成了32位代码依赖32位系统调用约定64位进程也跑不起来。解决确认进程位数与目标 DLL 位数一致。对加载器来说编译时位数就决定了它能加载的PE位数。要做一个同时支持两种位数的加载器建议拆成两个模块用子进程承载而不是在同一个进程里同时跑两套。5.3 现象DLL 内部的 GetModuleFileName 返回空或错误路径这个现象在登记模块链表之前必然出现。原因是 GetModuleFileName 内部会根据 HMODULE 反查模块链表拿 FullDllName链表里没有这个条目自然拿不到。结果是 DLL 内部写日志、定位配置目录、读同目录资源全部失败但又不会直接崩溃排查起来很耗时间。解决按 4.3 登记模块链表或者在模块进入前把需要的外部路径作为参数传入不依赖 GetModuleFileName。我在封装时倾向于后者少依赖系统状态逻辑更可控。5.4 现象DllMain 里加载其它内存模块导致死锁这条是血泪经验内存加载的 DLL 在 DllMain 中又去调用 LoadLibraryFromMemory 加载它的子插件进程 LoaderLock 已被持有子插件装载器再申请锁就会挂起。表现出来就是程序启动后卡死无响应没有任何异常输出。解决把嵌套加载行为移出 DllMain在插件主函数里做或者做依赖预加载把所有依赖在调用入口点之前批量装载完成。5.5 现象重复释放或重复加载导致内存泄漏内存模块没有系统引用计数FreeLibraryFromMemory 不能用系统的释放路径。不少人在收尾时顺手调用 FreeLibrary(hMem)系统会尝试在模块链表里找这个句柄然后走正常的卸载流程——如果之前登记过链表这会造成节点被系统误删如果没登记FreeLibrary 直接返回失败。两种结果都可能导致内存泄漏或后续崩溃。解决只使用装载器配套的释放函数先调 DllMain(DLL_PROCESS_DETACH)再释放 TLS 资源最后 VirtualFree 镜像。重复加载同一个 DLL 字节流时建议维护模块实例缓存同一个基址二次加载直接返回旧句柄。6. 验证技巧与进阶方向把内存加载包装成可靠的插件机制要验证“从内存加载 DLL”真的没落盘光靠代码里不调 WriteFile 不够建议用工具从外部观测。我常用的验证组合是 Process Monitor 加调试器模块列表。Process Monitor 里过滤进程路径观察 CreateFile 事件中是否出现该 DLL 对应的文件名如果全程没有 name 等于目标 DLL 的 CreateFile说明没有落盘动作。调试器里用lm或!dlls查看模块列表已登记的内存模块会显示路径为 memory 伪路径或自定义路径不登记则在列表里看不到。进阶方向是把装载器往“模块管理器”方向收敛几步。核心接口可以做成struct ModuleSpec { const unsigned char* data; size_t size; const wchar_t* virtualName; // 供登记模块链表使用 bool registerToLoader; // 是否需要登记模块链表 };然后把 LoadLibraryFromMemory、FreeLibraryFromMemory、GetProcAddress、GetModuleHandle 包装成统一的插件加载接口同时维护引用计数和依赖表。这样插件热更新时可以直接加载新版本字节流用新句柄替换旧句柄旧模块先走 DllMain(DLL_PROCESS_DETACH) 再释放实现无感替换。最后给个个人习惯每次加载成功、进入正式使用前我会把源字节流 memset 掉。这个动作虽然不是为了防复现但能让内存里不藏多余的PE副本排查内存占用时更容易确认镜像只有一份。内存加载方案本身不算复杂但重定位、导入表、TLS 回调、模块链表这些点一个都不能省。希望这份完整代码和踩坑记录能帮到你。本文还有配套的精品资源点击获取