尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

从内存加载DLL:绕过LoadLibrary的PE手动映射与重定位实战

发布时间:2026/9/29 16:12:00

资讯中心
01
ARTICLE

从内存加载DLL:绕过LoadLibrary的PE手动映射与重定位实战

从内存加载DLL:绕过LoadLibrary的PE手动映射与重定位实战
简介这是一份面向Windows逆向与安全开发学习者的内存加载DLL完整实现代码解决的是如何绕过常规磁盘加载流程、直接从内存或资源中加载DLL并调用其导出函数的问题适合具备一定C/C与PE结构基础的开发者研究。资源包共22个文件约87KB包含h头文件、cpp源码、dll动态库、lib导入库、dsp/dsw工程文件及rc资源脚本等其中MemoryModule.c与MemoryModule.h是核心加载模块xDll为测试用DLLtestLoadDll为实际调用示例编译产物与工程配置一应俱全。核心接口清晰MemoryLoadLibrary负责加载MemoryGetProcAddress负责查找导出函数MemoryFreeLibrary负责释放便于读者快速理解内存加载的完整链路。目前已有1506人学习下载可作为研究PE解析、手动映射与免杀技术的实践参考帮助读者掌握从资源到函数调用的完整实现思路。1. 从内存加载 DLL为什么杀软盯着 LoadLibrary而这条链路能绕开落地文件做过红队工具、外挂防护或者壳开发的人迟早会撞上一个需求手上有 DLL 的二进制但不想让它以文件形式落在磁盘上。原因很直接——LoadLibrary一调用Windows 加载器就会把模块路径写进 PEB 的Ldr链表杀软和 EDR 只要枚举已加载模块就能看到你。而「从内存加载 DLL」这套做法本质是自己实现一个迷你加载器把 PE 文件读进内存手动完成重定位、导入表解析、节区映射最后拿到导出函数地址直接调用。它解决的是「无文件落地」这个核心诉求适合做安全研究、加壳保护、插件热更新的工程师。热搜里那些dll 修复工具、dll 冲突、dll 木马的搜索背后其实是同一件事的两面DLL 的加载机制既可以被滥用也可以被用来做防御检测。这篇不讲玄学只讲一条能跑通的完整链路从 PE 结构到代码落地再到你一定会踩的坑。2. 内存加载 DLL 的底层账本PE 结构、重定位与导入表2.1 为什么不能直接把 DLL 字节丢进 VirtualAlloc 就调用很多人第一次尝试时思路是「分配一块可执行内存把 DLL 文件读进去然后强转函数指针调用」。这个做法在极简单的情况下偶尔能跑但稍微复杂一点的 DLL 立刻翻车。原因是 PE 文件在磁盘上的布局和加载到内存后的布局不是一回事。磁盘上的节区按FileAlignment对齐内存中按SectionAlignment对齐两者通常不同。更关键的是磁盘上的代码里存的地址是「假设自己加载到某个基址」的相对值一旦实际加载基址和ImageBase不一致所有硬编码的绝对地址全部失效。所以内存加载 DLL 的核心工作是模拟 Windows 加载器做三件事按节区把数据搬到正确偏移、根据实际基址修正重定位表、解析导入表把依赖的 DLL 函数地址填进去。少做任何一步要么崩溃要么调用到错误地址。2.2 PE 头部里必须读懂的四个字段不用把整个 PE 规范背下来但下面这几个字段必须能准确取到否则代码写不下去。字段位置作用取错后果e_lfanewDOS 头偏移 0x3C指向 NT 头后续全部解析错位SizeOfImage可选头内存中总大小分配内存不足写入越界SizeOfHeaders可选头头部映射大小头部拷贝不完整DataDirectory[5]可选头重定位表 RVA/大小重定位失败地址全错DataDirectory[1]可选头导入表 RVA/大小导入函数地址为 0这里有个容易忽略的点SizeOfImage是内存对齐后的大小分配时要用它而不是文件大小。头部拷贝时SizeOfHeaders通常小于第一个节区的VirtualAddress中间的空隙要清零否则残留数据可能触发校验异常。2.3 重定位把「假设基址」修正成「实际基址」重定位表是一串块每块对应一个 4KB 页面块内每个条目记录一个需要修正的偏移。修正逻辑是实际值 原值 (实际基址 - ImageBase)。如果实际基址恰好等于ImageBase理论上可以跳过但工程上不建议跳过——因为你不一定控制得了分配地址而且跳过会让代码路径不一致调试时容易误判。// 重定位处理核心逻辑 typedef struct _BASE_RELOCATION_BLOCK { DWORD PageRVA; DWORD BlockSize; } BASE_RELOCATION_BLOCK, *PBASE_RELOCATION_BLOCK; typedef struct _BASE_RELOCATION_ENTRY { WORD Offset : 12; WORD Type : 4; } BASE_RELOCATION_ENTRY, *PBASE_RELOCATION_ENTRY; void ProcessRelocations(BYTE* pImageBase, BYTE* pPreferredBase, DWORD relocRva, DWORD relocSize) { if (relocRva 0 || relocSize 0) return; // 无重定位表直接返回 DWORD delta (DWORD)(pImageBase - pPreferredBase); // 实际基址与首选基址差值 if (delta 0) return; // 基址相同无需修正 PBASE_RELOCATION_BLOCK pBlock (PBASE_RELOCATION_BLOCK)(pImageBase relocRva); DWORD processed 0; while (processed relocSize) { DWORD entryCount (pBlock-BlockSize - sizeof(DWORD) * 2) / sizeof(WORD); PBASE_RELOCATION_ENTRY pEntry (PBASE_RELOCATION_ENTRY)((BYTE*)pBlock sizeof(BASE_RELOCATION_BLOCK)); for (DWORD i 0; i entryCount; i) { if (pEntry[i].Type 0) continue; // 填充项跳过 if (pEntry[i].Type 3) { // IMAGE_REL_BASED_HIGHLOW32位 DWORD* pPatch (DWORD*)(pImageBase pBlock-PageRVA pEntry[i].Offset); *pPatch delta; } // 64位需处理 Type 10 (DIR64)此处略 } processed pBlock-BlockSize; pBlock (PBASE_RELOCATION_BLOCK)((BYTE*)pBlock pBlock-BlockSize); } }这段代码里delta是核心Type 3对应 32 位下的 HIGHLOW 修正64 位程序要处理Type 10的 DIR64修正的是 8 字节。BlockSize包含块头本身所以条目数要减去两个 DWORD。如果relocSize为 0说明这个 DLL 不支持重定位只能加载到ImageBase指定地址这种情况在系统 DLL 里常见自己编译的 DLL 一般都有重定位表。2.4 导入表把依赖函数地址填进 IAT导入表解析比重定位直观但细节更多。每个导入描述符对应一个依赖 DLL里面有OriginalFirstThunkINT和FirstThunkIAT。遍历 INT 拿到函数名或序号用GetProcAddress从真实加载的依赖模块里取地址写进 IAT。void ProcessImports(BYTE* pImageBase, DWORD importRva) { if (importRva 0) return; PIMAGE_IMPORT_DESCRIPTOR pDesc (PIMAGE_IMPORT_DESCRIPTOR)(pImageBase importRva); while (pDesc-Name ! 0) { const char* dllName (const char*)(pImageBase pDesc-Name); HMODULE hDep LoadLibraryA(dllName); // 依赖模块仍需真实加载 if (!hDep) { /* 记录失败后续排查 */ pDesc; continue; } PIMAGE_THUNK_DATA pInt (PIMAGE_THUNK_DATA)(pImageBase pDesc-OriginalFirstThunk); PIMAGE_THUNK_DATA pIat (PIMAGE_THUNK_DATA)(pImageBase pDesc-FirstThunk); while (pInt-u1.AddressOfData ! 0) { FARPROC funcAddr NULL; if (pInt-u1.Ordinal IMAGE_ORDINAL_FLAG) { // 按序号导入 funcAddr GetProcAddress(hDep, (LPCSTR)(pInt-u1.Ordinal 0xFFFF)); } else { PIMAGE_IMPORT_BY_NAME pName (PIMAGE_IMPORT_BY_NAME)(pImageBase pInt-u1.AddressOfData); funcAddr GetProcAddress(hDep, pName-Name); } if (!funcAddr) { /* 函数找不到必须处理否则调用必崩 */ } pIat-u1.Function (ULONG_PTR)funcAddr; pInt; pIat; } pDesc; } }注意OriginalFirstThunk为 0 时要回退到FirstThunk有些加壳或手工构造的 DLL 会这样。另外LoadLibraryA加载依赖模块这一步无法避免因为依赖 DLL 本身还是走正常加载流程只是你自己的主 DLL 不落地。如果依赖模块也被杀软监控这里仍可能触发告警这是内存加载方案的边界不是 bug。3. 手写一个最小可用的内存加载器从文件读取到函数调用3.1 整体流程与内存布局把前面两块拼起来完整流程是读文件到缓冲区 → 解析 NT 头 → 分配SizeOfImage大小的可执行内存 → 拷贝头部和节区 → 处理重定位 → 处理导入表 → 调用DllMain可选→ 取导出函数。内存布局上分配基址就是pImageBase所有 RVA 都相对它计算。typedef struct _MEMORY_MODULE { BYTE* base; DWORD size; BOOL initialized; } MEMORY_MODULE; MEMORY_MODULE* LoadDllFromMemory(const char* dllPath) { HANDLE hFile CreateFileA(dllPath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); if (hFile INVALID_HANDLE_VALUE) return NULL; DWORD fileSize GetFileSize(hFile, NULL); BYTE* fileBuf (BYTE*)malloc(fileSize); DWORD read 0; ReadFile(hFile, fileBuf, fileSize, read, NULL); CloseHandle(hFile); PIMAGE_DOS_HEADER pDos (PIMAGE_DOS_HEADER)fileBuf; if (pDos-e_magic ! IMAGE_DOS_SIGNATURE) { free(fileBuf); return NULL; } PIMAGE_NT_HEADERS pNt (PIMAGE_NT_HEADERS)(fileBuf pDos-e_lfanew); if (pNt-Signature ! IMAGE_NT_SIGNATURE) { free(fileBuf); return NULL; } DWORD imageSize pNt-OptionalHeader.SizeOfImage; BYTE* pImage (BYTE*)VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!pImage) { free(fileBuf); return NULL; } memset(pImage, 0, imageSize); // 拷贝头部 memcpy(pImage, fileBuf, pNt-OptionalHeader.SizeOfHeaders); // 拷贝节区 PIMAGE_SECTION_HEADER pSec IMAGE_FIRST_SECTION(pNt); for (WORD i 0; i pNt-FileHeader.NumberOfSections; i) { if (pSec[i].SizeOfRawData 0) continue; memcpy(pImage pSec[i].VirtualAddress, fileBuf pSec[i].PointerToRawData, pSec[i].SizeOfRawData); } // 重定位与导入 ProcessRelocations(pImage, (BYTE*)pNt-OptionalHeader.ImageBase, pNt-OptionalHeader.DataDirectory[5].VirtualAddress, pNt-OptionalHeader.DataDirectory[5].Size); ProcessImports(pImage, pNt-OptionalHeader.DataDirectory[1].VirtualAddress); free(fileBuf); MEMORY_MODULE* mod (MEMORY_MODULE*)malloc(sizeof(MEMORY_MODULE)); mod-base pImage; mod-size imageSize; mod-initialized FALSE; return mod; }VirtualAlloc用PAGE_EXECUTE_READWRITE是为了简化生产环境应该先PAGE_READWRITE写完再VirtualProtect成PAGE_EXECUTE_READ减少被扫描的特征。SizeOfHeaders拷贝后节区之间的空隙已经被memset清零不会残留文件数据。3.2 调用 DllMain 与导出函数DLL 加载后DllMain是否调用取决于你的需求。如果 DLL 依赖DllMain里的初始化比如 TLS 初始化、全局对象构造必须调用否则后续函数可能行为异常。调用时机是在重定位和导入都完成之后。typedef BOOL (WINAPI *DllMainFunc)(HINSTANCE, DWORD, LPVOID); BOOL CallDllMain(MEMORY_MODULE* mod, DWORD reason) { PIMAGE_DOS_HEADER pDos (PIMAGE_DOS_HEADER)mod-base; PIMAGE_NT_HEADERS pNt (PIMAGE_NT_HEADERS)(mod-base pDos-e_lfanew); DWORD entryRva pNt-OptionalHeader.AddressOfEntryPoint; if (entryRva 0) return TRUE; // 无入口点视为成功 DllMainFunc pDllMain (DllMainFunc)(mod-base entryRva); return pDllMain((HINSTANCE)mod-base, reason, NULL); } FARPROC GetExportFunc(MEMORY_MODULE* mod, const char* funcName) { PIMAGE_DOS_HEADER pDos (PIMAGE_DOS_HEADER)mod-base; PIMAGE_NT_HEADERS pNt (PIMAGE_NT_HEADERS)(mod-base pDos-e_lfanew); DWORD expRva pNt-OptionalHeader.DataDirectory[0].VirtualAddress; if (expRva 0) return NULL; PIMAGE_EXPORT_DIRECTORY pExp (PIMAGE_EXPORT_DIRECTORY)(mod-base expRva); DWORD* pNames (DWORD*)(mod-base pExp-AddressOfNames); WORD* pOrdinals (WORD*)(mod-base pExp-AddressOfNameOrdinals); DWORD* pFuncs (DWORD*)(mod-base pExp-AddressOfFunctions); for (DWORD i 0; i pExp-NumberOfNames; i) { const char* name (const char*)(mod-base pNames[i]); if (strcmp(name, funcName) 0) { return (FARPROC)(mod-base pFuncs[pOrdinals[i]]); } } return NULL; }AddressOfNameOrdinals里的序号是函数表索引不是导出序号别搞混。如果 DLL 只按序号导出NumberOfNames可能为 0这时要遍历NumberOfFunctions用序号取。调用DllMain时传DLL_PROCESS_ATTACH卸载时传DLL_PROCESS_DETACH但内存加载的卸载不能调FreeLibrary只能VirtualFree所以DllMain的 detach 逻辑要自己调。3.3 一个完整的调用示例假设有一个测试 DLL 导出Add函数编译后放在磁盘上用上面的加载器调用。// 测试 DLL 源码单独编译成 test.dll __declspec(dllexport) int Add(int a, int b) { return a b; } // 主程序调用 int main() { MEMORY_MODULE* mod LoadDllFromMemory(test.dll); if (!mod) { printf(load failed\n); return -1; } CallDllMain(mod, DLL_PROCESS_ATTACH); typedef int (*AddFunc)(int, int); AddFunc add (AddFunc)GetExportFunc(mod, Add); if (!add) { printf(export not found\n); return -1; } printf(result %d\n, add(3, 4)); // 输出 7 CallDllMain(mod, DLL_PROCESS_DETACH); VirtualFree(mod-base, 0, MEM_RELEASE); free(mod); return 0; }这个示例能跑通说明重定位和导入表处理正确。如果Add内部调用了printf导入表里会有msvcrt.dll的依赖ProcessImports会把它加载进来。如果Add里用了全局变量重定位会修正它的地址。任何一步出错表现都是崩溃或结果错误不会给你明确报错所以调试时要在每个阶段后打印关键值。4. 避坑与排查内存加载 DLL 最常见的五类翻车4.1 加载后调用崩溃错误码 0xC0000005现象GetExportFunc返回了非空地址但一调用就访问违例。原因通常是重定位没做或做错代码里引用的全局变量地址还是旧的ImageBase偏移。解决在ProcessRelocations里打印delta和修正次数确认delta不为 0 时确实有修正。另一个可能是节区没拷贝完整SizeOfRawData为 0 的节区如.bss要清零而不是跳过。4.2 导入函数地址为 0调用时跳到 NULL现象ProcessImports里GetProcAddress返回 NULLIAT 填了 0。原因可能是依赖 DLL 名字大小写不对或者依赖模块本身加载失败。解决打印每个依赖 DLL 名和失败函数名用LoadLibraryA的返回值判断依赖是否加载成功。如果依赖是 API Set如api-ms-win-*.dllLoadLibraryA可能失败需要走LoadLibraryEx或者直接用GetModuleHandle从已加载模块里找。4.3 64 位 DLL 用 32 位加载器处理重定位类型不匹配现象32 位程序加载 64 位 DLL或者反过来解析头部时OptionalHeader.Magic不匹配后续字段偏移全错。原因是没有检查IMAGE_NT_OPTIONAL_HDR32_MAGIC和IMAGE_NT_OPTIONAL_HDR64_MAGIC。解决加载前先判断pNt-FileHeader.Machine和当前进程位数是否一致不一致直接拒绝。64 位下重定位类型是 10修正 8 字节32 位是 3修正 4 字节不能混用。4.4 DllMain 里调用了依赖其他 DLL 的代码初始化顺序错乱现象CallDllMain返回成功但后续导出函数行为异常比如全局对象未构造。原因是DllMain里可能间接调用了还没解析的导入函数或者 TLS 回调没执行。解决内存加载不处理 TLS 回调如果 DLL 依赖 TLS需要在DllMain之前手动遍历 TLS 目录并调用回调。另外DllMain里不要做复杂操作微软官方也建议DllMain只做最小初始化。4.5 杀软仍然拦截因为依赖模块加载触发了规则现象主 DLL 没落地但LoadLibraryA加载依赖模块时被拦截。原因是依赖模块本身在磁盘上加载行为被监控。解决这是方案边界不是代码问题。如果依赖模块也可控可以递归用内存加载处理依赖但工作量和复杂度会上升。实际项目中通常只对核心 DLL 做内存加载依赖模块用系统自带的减少触发面。5. 进阶技巧用内存加载做插件热更新与导出表校验内存加载 DLL 最实用的场景之一是插件热更新。传统做法是替换磁盘文件再重启进程而内存加载可以在不重启的情况下加载新版本插件。具体做法是插件 DLL 编译时带版本号导出函数主程序定期检查新版本下载到内存后直接加载调用新版本的初始化函数旧版本通过VirtualFree释放。这样用户无感知也不需要写临时文件。另一个技巧是导出表校验。内存加载前先解析导出表确认目标函数存在且地址在合法范围内再执行重定位和导入。这样可以避免加载一个损坏或恶意的 DLL 导致进程崩溃。校验逻辑包括NumberOfFunctions和NumberOfNames是否合理、AddressOfFunctions的 RVA 是否在SizeOfImage内、函数地址是否落在可执行节区。BOOL ValidateExports(BYTE* pImage, PIMAGE_NT_HEADERS pNt) { DWORD expRva pNt-OptionalHeader.DataDirectory[0].VirtualAddress; DWORD expSize pNt-OptionalHeader.DataDirectory[0].Size; if (expRva 0 || expSize sizeof(IMAGE_EXPORT_DIRECTORY)) return FALSE; if (expRva expSize pNt-OptionalHeader.SizeOfImage) return FALSE; PIMAGE_EXPORT_DIRECTORY pExp (PIMAGE_EXPORT_DIRECTORY)(pImage expRva); if (pExp-NumberOfFunctions 65535 || pExp-NumberOfNames pExp-NumberOfFunctions) return FALSE; DWORD* pFuncs (DWORD*)(pImage pExp-AddressOfFunctions); for (DWORD i 0; i pExp-NumberOfFunctions; i) { if (pFuncs[i] 0) continue; if (pFuncs[i] pNt-OptionalHeader.SizeOfImage) return FALSE; } return TRUE; }这个校验不能防所有问题但能挡住大部分畸形 PE。我自己的习惯是任何从外部来源拿到的 DLL先跑一遍校验再走加载流程。内存加载 DLL 这套东西写一次能复用很久但每次遇到新 DLL 都可能冒出新的边界情况所以调试信息一定要打全别省那几个printf。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。