十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

内存加载DLL/EXE:手动映射PE的完整实现与踩坑指南

内存加载DLL/EXE:手动映射PE的完整实现与踩坑指南 简介面向Windows开发者与安全研究人员的动态链接库源码工程包集中演示内存动态链接库、内存加载可执行程序以及调用可执行程序接口函数的完整思路。工程绕过常规磁盘加载流程直接在进程内存中解析便携式可执行文件结构、分配区块并完成重定位再以导出函数方式向外部暴露调用入口适用于进程注入、干净环境准备、安全分析等场景。压缩包内共17个文件包含C源程序、头文件、资源脚本、工程与工作区配置、动态链接库定义文件、静态链接库、动态链接库、可执行程序及清理临时文件的批处理脚本全部内容压缩后仅942KB结构紧凑便于整体保存。目前已有192人浏览学习。通过阅读源码和比对编译产物可掌握映像加载、导入表修复、线程启动等底层技术也可将工程迁移到自研项目中作为基础框架使用。1. 内存加载 DLL/EXE不碰磁盘也能把代码跑起来内存加载 DLL/EXE 听起来很“底层”但它最常出现在正经工程里把一个主程序和几十个依赖 DLL 打包成单个文件发布的绿色工具被文档进程锁住的插件 DLL 想热更新安全研究里想观察某份第三方组件的初始化行为。你会很自然地说把文件字节读进内存不就能 LoadLibrary 了现实是 LoadLibrary 只吃磁盘路径拿到字节也得先写临时文件文件锁、杀软监控、路径依赖一样都躲不掉。于是“内存加载”这个需求就变成了一种手动映射Manual Mapping方案自己顶替 Windows 加载器分配内存、映射节区、修重定位、填 IAT最后把入口点调起来。这套代码你写一次能同时服务 DLL 和 EXE前者叫“内存 DLL 加载”后者叫“内存加载 EXE”本质是同一个 PE 装载过程。下文给你一个能直接下手的最小实现包含原理、边界和坑。2. 为什么 LoadLibrary 做不到PE 装载到底做了什么2.1 官方 LoadLibrary 路线的硬边界Windows 加载一个 DLL内部走的是LdrLoadDll → NtCreateSection → NtMapViewOfSection第一步就需要一个文件句柄。换句话说系统加载器在入口点上就绑定磁盘对象。你可能想绕过把内存数据传给NtCreateSection的SectionHandle文档上这条路同样需要一个已创建的文件映射。所以纯内存字节流根本进不了官方装载流程网上所有“内存 DLL”方案最终都落到手动映射。手动映射不是黑魔法它只是把系统加载器那套动作自己复刻一遍。一个 PE 从字节流变成一个可调用模块系统至少做六件事校验 DOS/NT 头确定 32 位还是 64 位按SizeOfImage分配一整块连续内存把文件中的每个节区复制到对应的VirtualAddress如果实际基址与 PE 头里的ImageBase不一致遍历重定位表修一遍绝对地址解析导入表为每个依赖 DLL 调LoadLibrary再把函数真实地址写回 IAT处理 TLS 回调最后调入口点DLL 的DllMainEXE 的mainCRTStartup。这套动作就是下文代码骨架的依据。2.2 手动映射与 LoadLibrary 的对照维度LoadLibrary 路径加载手动映射内存加载输入磁盘路径内存字节指针文件占用锁有DLL 被锁定直到 FreeLibrary无字节可随时释放GetModuleHandle 查找能按模块名找到模块不在系统模块链表里找不到FreeLibrary 卸载正常卸载不能走官方卸载需自行清理依赖 DLL系统自动解析仍可调用 LoadLibraryA 加载被依赖项TLS 回调自动执行手动触发容易漏掉FindResource 资源可用默认不可用需额外处理这张表决定了你什么时候该选手动映射追求不落盘、想绕过文件锁、做壳或打包器选它只是正常加载插件老老实实用 LoadLibrary 就好别给自己找麻烦。2.3 先写一个头校验函数识别合法 PE 输入动手映射之前第一步永远是验证输入字节流确实是个 PE否则后续所有指针解引用都会崩得毫无头绪。最小校验代码#include windows.h #include cstdint bool IsValidPE(const void* data, size_t size) { if (!data || size sizeof(IMAGE_DOS_HEADER)) return false; const auto dos static_castconst IMAGE_DOS_HEADER*(data); if (dos-e_magic ! IMAGE_DOS_SIGNATURE) return false; // MZ const size_t ntOffset dos-e_lfanew; if (ntOffset sizeof(IMAGE_NT_HEADERS) size) return false; const auto nt reinterpret_castconst IMAGE_NT_HEADERS*( static_castconst uint8_t*(data) ntOffset); return nt-Signature IMAGE_NT_SIGNATURE; // PE\0\0 }这里e_lfanew是 PE 头在文件中的偏移必须按这个偏移跳转不能假设它一定是 0x40。IMAGE_NT_SIGNATURE是0x00004550对应 ASCII 的PE\0\0。校验通过后下一步才允许去读节表、导入表这些结构。日常排错时如果发现加载内存中报“不是有效的 Win32 应用程序”九成是这里第一步就没过先检查源字节是不是被截断过。3. 内存加载的核心实现手动映射 DLL 全流程3.1 分配基址与映射节区先整体 RW再逐节改属性拿到合法 PE 头后第一步是分配一整块SizeOfImage大小的内存。这里有个常见做法值得说明不按节区逐个 VirtualAlloc而是先整块MEM_COMMIT | MEM_RESERVE再用PAGE_READWRITE把所有数据复制进去最后按节区属性重新VirtualProtect。好处是地址连续、代码简单坏处是中间态内存可读写如果你在做保护类工具需要自己保证这块内存在完成装载前不被外部读取。void* MapImageSections(const uint8_t* raw) { const auto dos reinterpret_castconst IMAGE_DOS_HEADER*(raw); const auto nt reinterpret_castconst IMAGE_NT_HEADERS*(raw dos-e_lfanew); const auto opt nt-OptionalHeader; void* base VirtualAlloc(nullptr, opt.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!base) return nullptr; // 头部一次性复制覆盖 DOS/NT 头和节表占用的空间 memcpy(base, raw, opt.SizeOfHeaders); // 节区逐个映射注意 VirtualAddress 是 RVA要加到 base 上 const auto* sec IMAGE_FIRST_SECTION(nt); for (int i 0; i nt-FileHeader.NumberOfSections; i, sec) { if (sec-SizeOfRawData 0) continue; memcpy(static_castuint8_t*(base) sec-VirtualAddress, raw sec-PointerToRawData, sec-SizeOfRawData); } return base; }SizeOfImage是 PE 装载后占用的总内存大小SizeOfHeaders是所有头加节表的总大小这两者一般不同。VirtualAddress是相对基址的偏移不是文件偏移PointerToRawData才是文件里的偏移。手动映射最容易错的就是把这两个字段混用一旦混用IAT 解析和重定位会全部算到错误地址上。最后别忘了按节区Characteristics可执行、可写、可读重新设置内存保护属性否则很多 CPU 指令在执行时会触发 DEP 异常。3.2 重定位把绝对地址改成实际基址下的新地址如果VirtualAlloc返回的基址恰好等于 PE 头里的ImageBase可以跳过这步但实际运行时很少这么巧所以必须处理重定位。重定位表是IMAGE_DATA_DIRECTORY的索引1里面是一串IMAGE_BASE_RELOCATION块每块描述一页内存里的修补位置。bool ProcessRelocations(void* base, const IMAGE_NT_HEADERS* nt) { const auto dir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (dir.VirtualAddress 0 || dir.Size 0) return true; // 无重定位表 const auto delta reinterpret_castintptr_t(base) - static_castintptr_t(nt-OptionalHeader.ImageBase); if (delta 0) return true; DWORD rva dir.VirtualAddress; while (rva dir.VirtualAddress dir.Size) { auto* blk reinterpret_castIMAGE_BASE_RELOCATION*( static_castuint8_t*(base) rva); if (blk-VirtualAddress 0) break; const size_t count (blk-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(uint16_t); auto* entries reinterpret_castuint16_t*(blk 1); for (size_t i 0; i count; i) { const int type entries[i] 12; const int offset entries[i] 0x0FFF; if (type IMAGE_REL_BASED_HIGHLOW || type IMAGE_REL_BASED_DIR64) { auto* patch reinterpret_castuintptr_t*( static_castuint8_t*(base) blk-VirtualAddress offset); *patch delta; } } rva blk-SizeOfBlock; } return true; }delta的意义是“实际基址减去默认基址”。每个被标记的位置存的都是绝对地址加上delta就是新基址下的正确地址。这里两个按类型判断的分支分别对应 32 位和 64 位 PEHIGHLOW是 32 位修补DIR64是 64 位修补。很多源码只写了HIGHLOW拿到 x64 程序上直接加载崩溃就是这个原因。3.3 解析导入表 IAT让 DLL 能调到系统 API重定位完成后导入表里每个函数地址还都是文件名和函数名占位不能直接调用。手动映射里处理导入表最省事的方案是对被依赖的 DLL 仍然用官方LoadLibraryA然后用GetProcAddress拿到函数地址回填 IAT。注意这一步加载依赖 DLL 是允许走磁盘的因为你要加载的是内存里的主模块不是系统 DLL 本身。bool ProcessIAT(void* base, const IMAGE_NT_HEADERS* nt) { const auto dir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (dir.VirtualAddress 0) return true; auto* imp reinterpret_castIMAGE_IMPORT_DESCRIPTOR*( static_castuint8_t*(base) dir.VirtualAddress); for (; imp-Name ! 0; imp) { const char* dllName reinterpret_castconst char*( static_castuint8_t*(base) imp-Name); HMODULE hDll LoadLibraryA(dllName); if (!hDll) return false; auto* thunk reinterpret_castIMAGE_THUNK_DATA*( static_castuint8_t*(base) imp-FirstThunk); for (; thunk-u1.AddressOfData ! 0; thunk) { uintptr_t funcAddr; if (thunk-u1.Ordinal IMAGE_ORDINAL_FLAG) { // 按序号导入直接用 Ordinal 调用 GetProcAddress funcAddr reinterpret_castuintptr_t( GetProcAddress(hDll, reinterpret_castLPCSTR( thunk-u1.Ordinal 0xFFFF))); } else { auto* byName reinterpret_castIMAGE_IMPORT_BY_NAME*( static_castuint8_t*(base) thunk-u1.AddressOfData); funcAddr reinterpret_castuintptr_t( GetProcAddress(hDll, byName-Name)); } if (!funcAddr) return false; thunk-u1.Function funcAddr; } } return true; }注意IMAGE_THUNK_DATA在不同位数下宽度不同编译时用 64 位进程就按 64 位编译这套代码不要跨位加载。按序号导入在处理一些老 VC 运行时 DLL 时会出现IMAGE_ORDINAL_FLAG是0x8000000064 位下是0x8000000000000000判断高位即可。如果解析过程中某个依赖 DLL 找不到返回false后要记得释放前面分配的内存。4. 从内存加载 EXE 到调用 EXE入口点、导出表与调用方式4.1 先想清楚你要“运行”EXE 还是“调用”EXE 里的函数“内存加载 EXE”这个标题下面其实藏着两种完全不同的需求。第一种是让 EXE 像正常进程那样从入口点开始跑常见于打包器、壳、启动器场景第二种是 EXE 本身被改造过导出了一些业务函数你需要的是“调用 EXE 里的函数”类似GetProcAddress但目标是内存里的模块。二者的前置装载流程完全一样区别只在最后一步前者调AddressOfEntryPoint后者查导出表。实际工程里第二种更可控。很多团队会把一个带 GUI 的 EXE 改造成“可调用 EXE”保留main入口但把核心逻辑挪到一个导出函数里此时内存加载 EXE 就退化成加载一个 DLL。下面分别讲两条路。4.2 调用入口点TLS 回调与 DllMain 的触发顺序先看入口点调用。无论 DLL 还是 EXE规范顺序都是先执行 TLS 回调再调用入口点。手动映射最容易踩的坑是只调入口点、忘记 TLS 回调结果线程局部存储没有初始化后面用到__declspec(thread)或 OpenMP 的代码直接崩。bool RunEntryPoint(void* base, const IMAGE_NT_HEADERS* nt, DWORD reason) { // 先跑 TLS 回调 const auto tlsDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; if (tlsDir.VirtualAddress ! 0 tlsDir.Size ! 0) { auto* tls reinterpret_castIMAGE_TLS_DIRECTORY*( static_castuint8_t*(base) tlsDir.VirtualAddress); auto* callbacks reinterpret_castPIMAGE_TLS_CALLBACK*(tls-AddressOfCallBacks); if (callbacks) { while (*callbacks) { (*callbacks)(base, reason, nullptr); callbacks; } } } // DLL入口点是 DllMainEXE入口点是 mainCRTStartup const auto entryRVA nt-OptionalHeader.AddressOfEntryPoint; if (entryRVA 0) return true; // 纯资源 DLL无入口点 using EntryProc BOOL(WINAPI*)(HINSTANCE, DWORD, LPVOID); auto entry reinterpret_castEntryProc( static_castuint8_t*(base) entryRVA); return entry(static_castHINSTANCE(base), reason, nullptr); }对 DLLDLL_PROCESS_ATTACH返回FALSE表示初始化失败调用方要检查返回值并回滚清理。对 EXE入口点是mainCRTStartup它不会像 DllMain 那样返回 FALSE而是走到ExitProcess所以“内存加载 EXE”如果要接管它的生命周期一般是在新线程里调入口点而不是在当前线程直接调否则当前线程会被 CRT 初始化逻辑吞掉。4.3 解析导出表用 ManualGetProcAddress 调用 EXE 的导出函数如果你的目标是调用 EXE 里的函数就不动入口点而是走导出表。导出表结构是IMAGE_EXPORT_DIRECTORY里面同时保存函数地址 RVA 表、名字表、序号表。手动版的GetProcAddress按名字查一次即可。void* ManualGetProcAddress(void* base, const IMAGE_NT_HEADERS* nt, const char* name) { const auto dir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; if (dir.VirtualAddress 0) return nullptr; auto* exp reinterpret_castIMAGE_EXPORT_DIRECTORY*( static_castuint8_t*(base) dir.VirtualAddress); auto* names reinterpret_castDWORD*( static_castuint8_t*(base) exp-AddressOfNames); auto* ordinals reinterpret_castWORD*( static_castuint8_t*(base) exp-AddressOfNameOrdinals); auto* functions reinterpret_castDWORD*( static_castuint8_t*(base) exp-AddressOfFunctions); for (DWORD i 0; i exp-NumberOfNames; i) { const char* expName reinterpret_castconst char*( static_castuint8_t*(base) names[i]); if (strcmp(expName, name) 0) { const DWORD funcRVA functions[ordinals[i]]; return static_castuint8_t*(base) funcRVA; } } return nullptr; }导出表解析非常稳定32 位和 64 位 PE 共用同一套结构主要差异只是AddressOfFunctions里的 RVA 宽度由OptionalHeader.Magic决定。调用方把上面这个函数的返回值转成对应函数指针直接调即可。注意不要用GetProcAddress((HMODULE)base, name)模块不在系统链表里返回的一定是NULL。4.4 资源、异常与 C 运行时的三处暗坑装载完成不代表一切正常。最典型的三个问题FindResource找不到资源资源目录是系统从模块链表里查的手动映射模块不在链表里所有基于HMODULE的资源 API 全部失效。方案是把资源节提取到独立映射里或者干脆把资源数据当作普通字节传入。异常处理链异常手动映射的 EXE 如果用了 C 异常__CxxFrameHandler依赖模块在LdrpModuleList里注册的展开信息没有注册会导致throw直接崩。这个问题没有优雅解法稳妥方案是让 EXE 编译时禁用 C 异常或改用 C 接口。CRT 全局状态重复如果主程序本身也用/MD手动映射 EXE 里又会初始化一份新的msvcp140.dll静态数据strtok、rand()、std::string的空闲列表会出现“两套数据”。测试时会发现内存加载的 EXE 和磁盘直接跑的结果不一致多数是这个问题。5. 验证内存加载成果对拍测试与 3 个高频崩溃原因5.1 磁盘加载与内存加载的对拍验证代码写完了怎么证明“内存加载 EXE”真的和磁盘加载行为一致常见做法是对拍同一份字节流分别用 LoadLibrary 和手动映射加载调用同一个导出函数比较返回值是否一致。typedef int (*AddFn)(int, int); int main() { // 读取字节到 vector std::ifstream f(sample.dll, std::ios::binary); std::vectoruint8_t bytes((std::istreambuf_iteratorchar(f)), std::istreambuf_iteratorchar()); // 磁盘加载 HMODULE disk LoadLibraryA(sample.dll); auto diskFn reinterpret_castAddFn(GetProcAddress(disk, add)); // 内存加载 void* mem LoadFromMemory(bytes.data(), bytes.size()); // 封装前文逻辑 auto memFn reinterpret_castAddFn(ManualGetProcAddress(mem, add)); printf(disk%d mem%d\n, diskFn(3, 5), memFn(3, 5)); return 0; }对拍输出的两个数字必须完全一致。不一致时按优先级排查先确认ProcessRelocations是否真的跑过在调试器里看delta是否为 0再确认ProcessIAT是否把每个FirstThunk都填上非零地址最后确认入口点使用的线程环境是不是同一个线程调用。5.2 三个高频崩溃原因与切入点第一个64 位镜像崩溃在重定位。崩溃点在函数指针调用处调用栈指向一个看起来完全正常地址但反汇编一看内容是垃圾。原因是前面用了只处理IMAGE_REL_BASED_HIGHLOW的代码加载 x64 PE。切入点是检查OptionalHeader.Magic0x20B是 PE32重定位必须处理IMAGE_REL_BASED_DIR64类型。第二个启动时报“动态链接库初始化例程失败”对应OSError: [WinError 1114]。这个错误在内存加载场景里其实就是DllMain里抛了异常或返回了FALSE。大多数情况是 IAT 解析顺序不对依赖 DLL 还没加载完就调了入口点。切入点是给入口点调用包一层 SEH把异常码打印出来。第三个延迟导入Delay-Load Import没处理。IAT 解析只处理了IMAGE_DIRECTORY_ENTRY_IMPORT但很多工程代码用了/DELAYLOAD这些函数地址在延迟导入目录IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT里首次调用时会走__delayLoadHelper2它内部依赖模块链表里的HMODULE在手动映射模块上直接失败。切入点是内存加载前将延迟导入目录里的模块也提前用LoadLibraryA加载并回填IAT跳过 helper 逻辑。本文还有配套的精品资源点击获取
返回列表