ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

深入理解内存加载DLL:从原理到代码实现

深入理解内存加载DLL:从原理到代码实现 简介一份从内存直接加载并调用DLL导出函数的完整示例主要面向需要实现免落地加载、提升程序隐蔽性或希望在插件系统中动态装载模块的Windows开发者。资源采用Visual C 6.0工程组织核心是MemoryModule实现封装了MemoryLoadLibrary、MemoryGetProcAddress、MemoryFreeLibrary三个关键接口配套两个工程xDll是仅用于测试的DLL导出若干函数供调用验证testLoadDll则演示如何将DLL作为资源嵌入自身在运行时从内存解析并调用导出函数。包内共22个文件文件类型覆盖C/C头文件与源码、编译生成的DLL与LIB、VC工程文件以及文本说明整体仅87KB结构紧凑便于对照学习。已有1500余人学习下载。仔细阅读代码可以梳理出内存PE映像的映射步骤、导入表处理思路、函数地址查找机制和资源释放顺序适合具备一定PE结构基础、想深入理解DLL加载原理的读者作为参考实现。1. 从内存加载DLL是什么解决什么问题做 Windows 开发的兄弟多少都遇到过 DLL 加载失败的问题。“failed to load python dll”“找不到指定的模块”“dll冲突”这些报错本质都是系统加载器在文件系统里找不到依赖或者版本不匹配。而今天要聊的从内存加载 DLL走的是和系统加载器完全不同的一条路——全程不把 DLL 写到磁盘直接在进程内存里把 PE 结构解析出来、映射好、修复好导入表和重定位然后像正常 LoadLibrary 一样拿到 HMODULE 和函数地址。这种技术最常见的落地场景是插件系统、软件热更新和工具加载器。比如你写了一个核心程序插件不落地释放而是从加密包或网络流里读出来直接加载又或者是想在同一进程里加载两个同名但不同版本的 DLL系统 LoadLibrary 会因为文件名冲突直接翻车内存加载则没有这个限制。适合谁适合已经理解了 PE 格式基础想自己掌控 DLL 加载过程的开发者。它不挑语言但核心代码必然要用 C/C 写因为要操作的是 Windows 的进程地址空间。2. 内存加载 DLL 的原理PE 结构与手动映射2.1 为什么系统 LoadLibrary 能加载内存加载不行系统加载 DLL 时Windows 内核的加载器会做一系列操作读取文件头、分配内存、把各个节区从磁盘偏移拷贝到虚拟地址、处理重定位、解析导入表、执行 TLS 回调最后调用 DllMain。这个过程对开发者是黑匣子LoadLibrary 一句就完事。但内存加载没有这个黑匣子可用因为加载器的输入是文件路径它只认文件系统。如果你手里只有一个内存缓冲区想让系统加载器去读是不可能的。所以我们要自己按照 PE 规范把整个流程重写一遍从缓冲区里解析出 DOS 头、NT 头、节区表然后分配一块内存把 DLL 的镜像按节区摆放好再手动修复重定位和导入表。听起来不难但细节非常多。PE 格式在磁盘上的布局和加载到内存后的布局不一样。磁盘上节区是按文件偏移对齐的内存里是按页对齐的。比如 .text 节在磁盘上的偏移可能是 0x200但在内存里的虚拟地址是 0x1000。手动映射的时候不能整个文件 memcpy必须一个节一个节按 VirtualAddress 拷贝。2.2 手动映射的 6 个关键步骤内存加载 DLL 的流程可以拆成六步每一部都不能跳步骤动作说明1校验 DOS 头与 NT 头确认缓冲区里真的是一个合法的 PE 文件2分配镜像内存用 VirtualAlloc 分配 SizeOfImage 大小的空间权限先给读写3按节区拷贝数据把每个节从磁盘偏移拷贝到 image VirtualAddress4修复重定位表加载地址和首选基址不一致时把所有绝对地址加上 Delta5修复导入表遍历导入描述符用 LoadLibrary 加载依赖 DLL用 GetProcAddress 填 IAT6执行入口函数先执行 TLS 回调再调用 DllMain 的 DLL_PROCESS_ATTACH这六步里最容易做错的是第 4 步和第 5 步的边界处理。后面我会用完整代码说明先牢记一个原则每个步骤的输入输出都要基于“内存镜像地址”而不是原始文件地址。2.3 内存分配为什么不直接用 malloc很多人第一次写内存加载器会直接用 malloc 分配一块内存来装 DLL。结果调用导出函数时直接 Access Violation。原因很简单malloc 分配的内存默认是不可执行的而且没有按页对齐到适合 PE 加载的边界。正确做法是使用 VirtualAlloc并指定 MEM_COMMIT | MEM_RESERVE。这样分配的地址空间由你自己控制页权限。开始先给 PAGE_READWRITE因为后面修复导入表和重定位要写内存等全部修复完之后再通过 VirtualProtect 把 .text 节改成 PAGE_EXECUTE_READ把 .data 节改成 PAGE_READWRITE。这一步常被省略省略的后果就是 DLL 里的静态变量一旦写入就报错。这里还有一个选型细节分配镜像内存时常见做法是用 VirtualAlloc(NULL, SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE)。不要用 MAPV11 这类 API也不要自己指定固定地址因为进程地址空间碎片化后固定地址可能分配失败。3. 完整代码实现从内存加载 DLL 的 Loader3.1 数据结构与辅助函数先定义一个上下文结构用来跟踪加载状态比如是否已经调用过 DllMain这样卸载时能避免重复通知。#include windows.h #include winnt.h #include stdio.h typedef struct _MEMORY_DLL { BYTE* pImageBase; // 内存镜像基址 BOOL bDllMainCalled; // 是否已调用过 DllMain } MEMORY_DLL; // 计算两个值的较小者 #ifndef MIN #define MIN(a, b) (((a) (b)) ? (a) : (b)) #endif辅助函数IsValidPeFile用来校验缓冲区里的 PE 文件是否合法。注意计算e_lfanew后要检查是否超出缓冲区长度防止越界读取。BOOL IsValidPeFile(const BYTE* pData, SIZE_T nSize) { if (pData NULL || nSize sizeof(IMAGE_DOS_HEADER)) return FALSE; IMAGE_DOS_HEADER* pDos (IMAGE_DOS_HEADER*)pData; if (pDos-e_magic ! IMAGE_DOS_SIGNATURE) return FALSE; if (pDos-e_lfanew 0 || pDos-e_lfanew sizeof(IMAGE_NT_HEADERS) nSize) return FALSE; IMAGE_NT_HEADERS* pNt (IMAGE_NT_HEADERS*)(pData pDos-e_lfanew); if (pNt-Signature ! IMAGE_NT_SIGNATURE) return FALSE; return TRUE; }这里的参数说明pData是 DLL 文件的字节数组nSize是数组长度。很多内存加载失败都是因为只传了指针没传长度导致后面访问节区表时越界。所以函数签名一定要带 size。3.2 核心函数 MemoryLoadLibrary下面是完整的内存加载函数。这个版本以 32 位 DLL 为例64 位 DLL 需要把IMAGE_NT_HEADERS换成IMAGE_NT_HEADERS64重定位项类型也要改成ULONGLONG。HMODULE MemoryLoadLibrary(const BYTE* pData, SIZE_T nSize) { if (!IsValidPeFile(pData, nSize)) return NULL; IMAGE_DOS_HEADER* pDos (IMAGE_DOS_HEADER*)pData; IMAGE_NT_HEADERS* pNt (IMAGE_NT_HEADERS*)(pData pDos-e_lfanew); // 1. 分配镜像内存先用读写权限后面修复完再改 BYTE* pImageBase (BYTE*)VirtualAlloc(NULL, pNt-OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (pImageBase NULL) return NULL; // 2. 拷贝文件头包括DOS头、NT头、节区表 memcpy(pImageBase, pData, pNt-OptionalHeader.SizeOfHeaders); // 3. 按节区表逐个拷贝节数据 IMAGE_SECTION_HEADER* pSec (IMAGE_SECTION_HEADER*)( (BYTE*)pNt sizeof(IMAGE_NT_HEADERS)); for (DWORD i 0; i pNt-FileHeader.NumberOfSections; i, pSec) { if (pSec-SizeOfRawData 0) { memcpy(pImageBase pSec-VirtualAddress, pData pSec-PointerToRawData, MIN(pSec-SizeOfRawData, pSec-Misc.VirtualSize)); } } // 4. 计算重定位Delta ULONG_PTR delta (ULONG_PTR)pImageBase - pNt-OptionalHeader.ImageBase; // 5. 修复导入表 if (pNt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress ! 0) { PIMAGE_IMPORT_DESCRIPTOR pImp (PIMAGE_IMPORT_DESCRIPTOR)( pImageBase pNt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress); for (; pImp-Name ! 0; pImp) { char* pDllName (char*)(pImageBase pImp-Name); HMODULE hDep LoadLibraryA(pDllName); if (hDep NULL) { // 依赖DLL加载失败这里可以记录日志但不中断 continue; } PIMAGE_THUNK_DATA pOrigThunk (PIMAGE_THUNK_DATA)( pImageBase pImp-OriginalFirstThunk); PIMAGE_THUNK_DATA pThunk (PIMAGE_THUNK_DATA)( pImageBase pImp-FirstThunk); // 某些链接器不生成OriginalFirstThunk此时直接用FirstThunk if (pOrigThunk NULL) pOrigThunk pThunk; for (; pOrigThunk-u1.AddressOfData ! 0; pOrigThunk, pThunk) { if (IMAGE_SNAP_BY_ORDINAL(pOrigThunk-u1.Ordinal)) { pThunk-u1.Function (ULONG_PTR)GetProcAddress(hDep, (LPCSTR)IMAGE_ORDINAL(pOrigThunk-u1.Ordinal)); } else { PIMAGE_IMPORT_BY_NAME pName (PIMAGE_IMPORT_BY_NAME)( pImageBase pOrigThunk-u1.AddressOfData); pThunk-u1.Function (ULONG_PTR)GetProcAddress(hDep, pName-Name); } } } } // 6. 修复重定位表仅当delta非0 if (delta ! 0) { if (pNt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress ! 0) { PIMAGE_BASE_RELOCATION pReloc (PIMAGE_BASE_RELOCATION)( pImageBase pNt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress); while (pReloc-VirtualAddress ! 0) { DWORD count (pReloc-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* pItems (WORD*)((BYTE*)pReloc sizeof(IMAGE_BASE_RELOCATION)); for (DWORD j 0; j count; j) { if ((pItems[j] 0x3000) 0x3000) { // IMAGE_REL_BASED_HIGHLOW ULONG_PTR* pAddr (ULONG_PTR*)( pImageBase pReloc-VirtualAddress (pItems[j] 0x0FFF)); *pAddr delta; } } pReloc (PIMAGE_BASE_RELOCATION)((BYTE*)pReloc pReloc-SizeOfBlock); } } } // 7. 设置节区内存权限 pSec (IMAGE_SECTION_HEADER*)((BYTE*)pNt sizeof(IMAGE_NT_HEADERS)); for (DWORD i 0; i pNt-FileHeader.NumberOfSections; i, pSec) { DWORD protect PAGE_READONLY; if (pSec-Characteristics IMAGE_SCN_MEM_WRITE) protect PAGE_READWRITE; if (pSec-Characteristics IMAGE_SCN_MEM_EXECUTE) protect PAGE_EXECUTE_READ; DWORD oldProtect 0; VirtualProtect(pImageBase pSec-VirtualAddress, pSec-Misc.VirtualSize, protect, oldProtect); } // 8. 调用DllMain(DLL_PROCESS_ATTACH) MEMORY_DLL* pCtx (MEMORY_DLL*)malloc(sizeof(MEMORY_DLL)); pCtx-pImageBase pImageBase; pCtx-bDllMainCalled FALSE; if (pNt-OptionalHeader.AddressOfEntryPoint ! 0) { typedef BOOL (WINAPI *DllMainProc)(HMODULE, DWORD, LPVOID); DllMainProc pDllMain (DllMainProc)( pImageBase pNt-OptionalHeader.AddressOfEntryPoint); BOOL ret pDllMain((HMODULE)pImageBase, DLL_PROCESS_ATTACH, NULL); if (!ret) { VirtualFree(pImageBase, 0, MEM_RELEASE); free(pCtx); return NULL; } pCtx-bDllMainCalled TRUE; } // 这里返回HMODULE但实际上要把pCtx保存下来才能正确释放 // 常见的做法是用静态表保存指针 return (HMODULE)pImageBase; }代码逻辑说明第 1 步分配的内存是整块连续的大小等于SizeOfImage。这个值在 PE 文件里已经算好了包含了所有节的虚拟大小对齐后的总和。第 3 步拷贝节区时VirtualAddress是这个节在内存中的偏移PointerToRawData是文件中的偏移。注意拷贝长度用MIN(SizeOfRawData, VirtualSize)因为最后一个节的文件数据可能比虚拟大小小多出的部分应该零填充这里简单的 memcpy 没有清零如果你需要严谨应该对不足部分用memset补零。导入表修复时OriginalFirstThunk是导入名称表INTFirstThunk是导入地址表IAT。有些链接器会把两个字段设为相同所以必须处理pOrigThunk NULL的情况。这里的逻辑和系统 LoadLibrary 加载时的做法一致只是我们把 LoadLibrary 换成自己调用。重定位修复只处理了 32 位的 HIGHLOW 类型。64 位 DLL 还需要处理IMAGE_REL_BASED_DIR64类型判断条件变成pItems[j] 0xF000 0xA000。如果你要支持 64 位把重定位分支补充一下。3.3 调用示例与释放函数从磁盘读文件到内存然后调用MemoryLoadLibrary的示例BOOL LoadDllFromFile(const char* path) { HANDLE hFile CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile INVALID_HANDLE_VALUE) return FALSE; DWORD fileSize GetFileSize(hFile, NULL); BYTE* pBuffer (BYTE*)malloc(fileSize); DWORD bytesRead 0; ReadFile(hFile, pBuffer, fileSize, bytesRead, NULL); CloseHandle(hFile); HMODULE hMem MemoryLoadLibrary(pBuffer, fileSize); free(pBuffer); return hMem ! NULL; }对应释放函数MemoryFreeLibraryvoid MemoryFreeLibrary(HMODULE hModule, MEMORY_DLL* pCtx) { if (hModule NULL) return; if (pCtx pCtx-bDllMainCalled) { typedef BOOL (WINAPI *DllMainProc)(HMODULE, DWORD, LPVOID); DllMainProc pDllMain (DllMainProc)( (BYTE*)hModule pCtx-pImageBase); // 这里要有入口偏移实际应从PE头读取 pDllMain(hModule, DLL_PROCESS_DETACH, NULL); pCtx-bDllMainCalled FALSE; } VirtualFree(hModule, 0, MEM_RELEASE); free(pCtx); }需要注意的是上面MemoryLoadLibrary返回的只是基址释放时需要拿到入口点地址。常见做法是把MEMORY_DLL上下文放到一个全局链表中用基址做 key。为了简洁示例里直接用静态变量保存入口偏移。真正工程化时建议写一个AddToGlobalList和FindFromGlobalList管理多个内存 DLL。4. 依赖处理导入表、重定位与 TLS 回调4.1 导入表修复LoadLibrary 加 GetProcAddress 的配合导入表是内存加载 DLL 的重灾区。很多朋友在执行导出函数时崩溃用调试器一看停在call dword ptr [xxx]上这个地址指向的是一块无效内存。原因就是导入表没有被正确填充。修复流程说简单也简单对于每个导入描述符先用LoadLibraryA把依赖的 DLL 加载到进程里然后遍历该描述符对应的函数名称列表用GetProcAddress找到函数地址写回到 IAT 中。但有几个坑第一依赖 DLL 自身的依赖也要被递归加载不过LoadLibraryA会自己处理不用你操心第二如果依赖 DLL 找不到别急着返回 NULL先记录错误然后尝试继续修复其他依赖最后通过一个总开关判断是否成功第三有些 DLL 使用延迟加载也就是函数第一次调用时才加载这种在导入表里没有记录需要额外处理延迟加载描述符。// 修复延迟导入表的简化逻辑 // 需要遍历 DirectoryEntryDelayImport延迟导入的修复方式和普通导入类似但函数地址要写成ImgpDelayLoad的 thunk。做法是在每个延迟导入项的函数地址上放一个跳板。因为逻辑比较复杂一般工具库都选择不支持延迟加载遇到就报错。我的建议是如果你的业务 DLL 用了/DELAYLOAD在打包阶段把延迟加载关掉改用普通静态导入。4.2 重定位表修复的原理与边界重定位的作用是修正代码里的绝对地址。DLL 编译时默认的ImageBase通常是0x10000000但加载到进程后没人保证这个地址空闲。系统加载器选择占用其他地址时就必须把代码里所有写死的绝对地址都加上一个差值 delta。重定位表的结构是以IMAGE_BASE_RELOCATION为头每个块描述一个 4KB 页内的偏移项。VirtualAddress是页起始地址SizeOfBlock是整个块的大小。项数据是 16 位的高 4 位是类型低 12 位是页内偏移。32 位 DLL 最常见的类型是IMAGE_REL_BASED_HIGHLOW0x3需要把偏移处的 32 位值加上 delta。边界坑有两个。第一个是重定位块的长度可能覆盖到下一页所以遍历时必须用SizeOfBlock移动指针不能用自增。第二个是有些 DLL 编译时不生成重定位表VirtualAddress为 0此时如果 delta 不为 0直接跳过即可不能因此认定加载失败。实际上不生成重定位表的 DLL 只能在首选基址上运行这种 DLL 不适合内存加载。// 处理64位重定位 if ((pItems[j] 0xF000) IMAGE_REL_BASED_DIR64) { ULONGLONG* pAddr (ULONGLONG*)(pImageBase pReloc-VirtualAddress (pItems[j] 0x0FFF)); *pAddr (LONGLONG)delta; }4.3 TLS 回调和 DLL_PROCESS_ATTACH 的执行顺序TLS线程局部存储回调在 DllMain 之前执行用于初始化每个线程的 TLS 槽。内存加载时必须手动找到 TLS 目录然后依次调用回调函数。顺序不对会导致在线程里访问 TLS 变量时拿到垃圾值。PIMAGE_TLS_DIRECTORY pTls (PIMAGE_TLS_DIRECTORY)( pImageBase pNt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS].VirtualAddress); if (pTls ! NULL pTls-AddressOfCallBacks ! 0) { PIMAGE_TLS_CALLBACK* pCallback (PIMAGE_TLS_CALLBACK*)( pImageBase (ULONG_PTR)pTls-AddressOfCallBacks); while (*pCallback ! NULL) { (*pCallback)((HMODULE)pImageBase, DLL_PROCESS_ATTACH, NULL); pCallback; } }注意TLS 目录中的AddressOfCallBacks是一个 RVA但由于 TLS 回调指针本身可能是绝对地址这里需要根据指针大小做转换。上面的代码在 32 位下通常没问题但严谨做法是用(ULONG_PTR)pTls-AddressOfCallBacks作为 RVA再转换成 VA。这个细节很多开源实现都会踩。DLL_PROCESS_ATTACH 的调用时机和系统 LoadLibrary 不同系统加载器先设置好所有节区权限再执行回调我们也应该先做 VirtualProtect再执行 TLS 回调最后调用 DllMain。反过来会出现什么如果 DllMain 里写了全局变量而 .data 节还没拿到写权限直接崩溃。5. 踩坑指南常见崩溃场景与排查方法5.1 一调导出函数就崩溃原因是导入表没修现象加载成功后动态获取导出函数地址调用时进程直接报错“访问冲突”。原因最常见的是内存加载时跳过了导入表修复的OriginalFirstThunk为 0 的情况。某些编译选项下PE 文件里没有 INT 字段只有 IAT。网上很多示例代码只处理OriginalFirstThunk遇到这种情况直接忽略了整个导入表导致 IAT 一直是 0。调用导出函数时指令跳到一个空指针上。解决参照前面代码当pOrigThunk NULL时把pThunk当作名称列表使用。同时建议你在加载后、调用导出函数前写一个自检函数遍历导出表把每个导出函数的 IAT 项都打印出来看哪些是 0。// 自检遍历导出表并测试调用 void DebugDumpExports(HMODULE hMod) { PIMAGE_DOS_HEADER pDos (PIMAGE_DOS_HEADER)hMod; PIMAGE_NT_HEADERS pNt (PIMAGE_NT_HEADERS)((BYTE*)hMod pDos-e_lfanew); PIMAGE_EXPORT_DIRECTORY pExp (PIMAGE_EXPORT_DIRECTORY)( (BYTE*)hMod pNt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress); DWORD* pFuncs (DWORD*)((BYTE*)hMod pExp-AddressOfFunctions); for (DWORD i 0; i pExp-NumberOfFunctions; i) { if (pFuncs[i] 0) continue; char* pName NULL; // 按序号或名称取函数名 printf(Export %d at %p\n, i, (BYTE*)hMod pFuncs[i]); } }5.2 全局变量值不对是重定位没修现象DLL 里的静态计数器每次调用都复位或者某个全局配置读出来是乱码。原因DLL 编译时所有的全局变量地址都基于ImageBase。你没做重定位修复代码里引用全局变量的指令还在用老地址访问的只是进程地址空间里的垃圾数据。这种情况在 Visual Studio 调试配置下偶尔看不出来因为调试器会把 DLL 加载到首选基址附近但 Release 下随机地址一分配必现。解决在调用 DllMain 前先验证 delta 和重定位表的存在。最直接的办法是写一个调试输出打印delta的值如果非 0 且重定位表中没有对应条目就要怀疑 DLL 编译时关闭了重定位生成选项。// 检查重定位表是否为空 BOOL HasBaseReloc(IMAGE_NT_HEADERS* pNt) { DWORD va pNt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress; return va ! 0; }5.3 节区权限没设置导致写入全局变量时崩溃现象导出的函数第一次调用能过第二次执行到某个静态变量写入就“未处理的异常”。原因内存加载初期整个镜像被设置为 PAGE_READWRITE。如果你在修复完导入和重定位后忘了调用 VirtualProtect 修改 .text 节为可执行那么代码读取没问题一旦代码尝试写 .data 节而 .data 节依然是只读权限就会触发异常。反之如果整块内存一直保持 PAGE_READWRITE那么符合“可写可读不可执行”的安全策略但很多 CPU 下 Page 被标记为 NX代码一运行就崩。解决严格按每个节区的Characteristics设置权限。项目发布前做一遍全函数回归尤其是带字符串字面量、全局开关的函数。有一个玄学现象有些机器上 PAGE_READWRITE 也能执行是因为开了兼容模式。别依赖这个该设就设。5.4 依赖 DLL 搜索路径不一致提示找不到模块现象内存加载时调用LoadLibraryA(dep.dll)失败然后在系统里看到了“failed to load python dll”这样的报错。原因系统 LoadLibrary 搜索路径包括应用程序目录、系统目录、PATH 环境变量等但你的程序可能设置了 CWD当前工作目录或者 DLL 自带 QIP 资源里指定了某个路径。内存加载器把解析依赖 DLL 的任务交给了 LoadLibrary而 LoadLibrary 自己也有搜索顺序两者如果不一致就会找不到。解决在加载前调用SetDllDirectory或AddDllDirectory把依赖 DLL 所在的目录手动加进去。更稳妥的做法是自己在内存加载器里维护一张依赖映射表先尝试从内存缓存加载已经读进来过的 DLL再 fallback 到 LoadLibrary。这种方式能解决同名不同版本的 dll 冲突问题也是内存加载相对系统加载器的一大优势。// 常见做法加载前把依赖目录加入搜索链 SetDllDirectoryA(C:\\libs\\my_deps);5.5 卸载时反复崩溃是 DllMain 通知次数没管理好现象调用了VirtualFree释放镜像内存但后续其他模块再申请内存时地址冲突或者程序退出时崩溃在某个 DllMain 回调里。原因我们手动调用了 DLL_PROCESS_DETACH但 DllMain 内部可能注册了线程钩子或者锁DllMain 执行后有些线程还在用这个 DLL 的代码。另外如果重复调用 MemoryFreeLibrary 两次DllMain 被执行两次第二次参考到的内存已经被释放直接崩溃。解决用MEMORY_DLL上下文里的bDllMainCalled标志位保证每次加载只调用一次 ATTACH 和一次 DETACH。释放后把基址指针置空避免悬空引用。还有一个血泪经验不要在 DllMain 里直接调用FreeLibrary自己否则死锁同理内存加载器在释放 DLL 时也尽量不要持有它的线程锁等待。6. 验证技巧确认内存 DLL 加载成功最终交付前我习惯做一个自带验证样本。写一个很简单的 DLL导出两个函数一个做整数加法一个返回版本号。然后内存加载它分别调用这两个函数再执行一个稍复杂的场景——在 DLL 内部申请堆内存、写入数据、读取校验。// 验证用DLL的部分代码 extern C __declspec(dllexport) int AddTwo(int a, int b) { static int calls 0; // 故意用静态变量验证重定位 calls; return a b calls; } extern C __declspec(dllexport) const char* Version() { return memdll-v1.0; }加载验证代码HMODULE hMod MemoryLoadLibrary(pBuffer, fileSize); if (hMod NULL) { printf(load failed\n); return; } typedef int (*AddTwo_t)(int, int); typedef const char* (*Version_t)(); AddTwo_t addFn (AddTwo_t)GetProcAddress(hMod, AddTwo); Version_t verFn (Version_t)GetProcAddress(hMod, Version); if (addFn verFn) { printf(version: %s\n, verFn()); int sum addFn(3, 4); if (sum ! 10) // 34calls(1)calls初始值? 注意静态变量 printf(unexpected result: %d\n, sum); else printf(basic call ok\n); }注意上面的加法结果因为静态变量calls每次调用都会加 1所以第一次返回可能不是 7。如果它返回一个负数或者巨大值说明重定位确实坏了。这种小技巧比任何调试手段都直观。我通常还会做一个边界测试把 DLL 文件内容随机改一个字节再加载确认加载器能在校验段直接拒绝。这能防止线上加载到损坏数据时静默出错。把这一套写进自动化脚本每次改 PE 解析代码就回归一遍。最后要说的是内存加载 DLL 虽然绕过了一些系统机制但也扛下了更多责任尤其是内存权限、导入依赖和生命周期的管理。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表