多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

内存加载EXE与DLL:PE结构、重定位与导入表修复实战

内存加载EXE与DLL:PE结构、重定位与导入表修复实战 简介这是一份面向Windows底层开发与逆向工程学习者的内存加载技术源码包聚焦内存DLL与内存加载EXE两大核心主题解决传统磁盘加载方式在启动速度、权限绕过与进程注入场景下的局限。包内共17个文件约942KB以cpp与h源码为主体配合dsp、dsw工程文件、def导出定义、lib与exp导入库、rc资源脚本及dll、exe二进制样例另有bat清理脚本与ReadMe说明构成可直接编译调试的完整工程。源码围绕PE结构手动解析、节区与重定位处理、VirtualAllocEx与CreateRemoteThread等API调用以及接口函数封装展开展示如何将EXE二进制数据载入当前进程内存并定位入口点执行同时通过公开接口供外部程序调用。已有192人学习适合具备一定C与Windows API基础、希望理解内存加载机制与进程注入原理的开发者参考可据此掌握内存分配、代码解码与调用生命周期管理的实现思路。1. 内存加载 EXE 与 DLL把可执行文件塞进内存再跑起来你手上有一个DLL源码启动.zip里面大概率是一套演示「内存加载」的代码不落盘、不写临时文件直接把一个 EXE 或 DLL 的字节流读进内存手动完成映射、重定位、解析导入表最后调用它的入口点。这套东西在免杀、插件热更新、单文件打包、游戏外挂、红队工具里被反复使用也是很多人搜索「内存加载 EXE」「内存 DLL」「调用 EXE」时真正想找的东西。它解决的问题很具体常规LoadLibrary和CreateProcess都会在磁盘留下痕迹或者要求文件真实存在而内存加载让 PE 文件只存在于进程地址空间里。适合谁做安全工具、做插件框架、做单文件绿色软件、做逆向调试的工程师。这篇笔记按「PE 结构 → 手动映射 → 导入表修复 → 调用入口 → 踩坑」的顺序讲透代码用 C/C 写思路对 C#、Python 调 Win32 API 同样成立。2. 内存加载的底层账本PE 结构、重定位与导入表在写一行加载代码之前必须先把 PE 文件在内存里「长什么样」搞清楚。很多人直接抄一份MemoryLoadLibrary就上结果换个 EXE 就崩根因全在这一章。2.1 PE 文件的两套地址RVA 与文件偏移PE 文件在磁盘上是按FileAlignment通常 0x200对齐的加载到内存后按SectionAlignment通常 0x1000对齐。这意味着同一个数据在文件里的偏移和在内存里的 RVA 是两回事。加载器要做的第一件事就是按节表把每个节从文件偏移搬到正确的 RVA 位置。关键结构是IMAGE_DOS_HEADER→IMAGE_NT_HEADERS→IMAGE_SECTION_HEADER数组。OptionalHeader.ImageBase是首选加载基址SizeOfImage是映射后总大小AddressOfEntryPoint是入口点 RVA。这三个值决定了你要VirtualAlloc多大、从哪开始执行。// 读取 PE 头部拿到映射所需的关键字段 PIMAGE_DOS_HEADER dos (PIMAGE_DOS_HEADER)raw; if (dos-e_magic ! IMAGE_DOS_SIGNATURE) return NULL; // MZ PIMAGE_NT_HEADERS nt (PIMAGE_NT_HEADERS)(raw dos-e_lfanew); if (nt-Signature ! IMAGE_NT_SIGNATURE) return NULL; // PE\0\0 SIZE_T imageSize nt-OptionalHeader.SizeOfImage; LPVOID base VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); // base 是实际加载基址可能不等于 nt-OptionalHeader.ImageBase逻辑说明先校验 MZ 和 PE 签名防止把非 PE 数据当模块处理。VirtualAlloc一次申请SizeOfImage权限给PAGE_EXECUTE_READWRITE是为了后续写节、改导入表、执行代码都方便。参数上SizeOfImage已经包含了所有节对齐后的总大小不要自己用文件大小去算。2.2 节映射为什么不能直接 memcpy 整个文件新手最容易翻车的地方就是memcpy(base, raw, fileSize)一把梭。这样做的后果是节与节之间的空洞没清零RVA 和文件偏移错位导入表指向垃圾数据。正确做法是逐节拷贝。// 先拷贝头部SizeOfHeaders 大小 memcpy(base, raw, nt-OptionalHeader.SizeOfHeaders); PIMAGE_SECTION_HEADER sec IMAGE_FIRST_SECTION(nt); for (int i 0; i nt-FileHeader.NumberOfSections; i, sec) { if (sec-SizeOfRawData 0) continue; // 目标地址 基址 虚拟地址源地址 文件基址 文件偏移 memcpy((BYTE*)base sec-VirtualAddress, raw sec-PointerToRawData, sec-SizeOfRawData); }逻辑说明头部单独拷贝因为节表本身就在头部区域。每个节按VirtualAddressRVA落到内存源数据按PointerToRawData从文件取。SizeOfRawData是文件中对齐后的大小通常够用如果Misc.VirtualSize更大剩余部分因为VirtualAlloc已清零天然是 0。2.3 重定位ASLR 打开后必须修的那张表如果实际加载基址base和ImageBase不一致几乎总是不一致所有写死的绝对地址都错了必须按重定位表修正。重定位表在.reloc节数据目录第 5 项IMAGE_DIRECTORY_ENTRY_BASERELOC。INT64 delta (BYTE*)base - (BYTE*)nt-OptionalHeader.ImageBase; auto relocDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (delta ! 0 relocDir.Size 0) { auto block (PIMAGE_BASE_RELOCATION)((BYTE*)base relocDir.VirtualAddress); while (block-SizeOfBlock) { int count (block-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* entries (WORD*)(block 1); for (int i 0; i count; i) { int type entries[i] 12; // 高 4 位是类型 int offset entries[i] 0x0FFF; // 低 12 位是页内偏移 if (type IMAGE_REL_BASED_DIR64) { UINT64* patch (UINT64*)((BYTE*)base block-VirtualAddress offset); *patch delta; } } block (PIMAGE_BASE_RELOCATION)((BYTE*)block block-SizeOfBlock); } }逻辑说明每个重定位块以页4KB为单位块头记录页 RVA 和块大小后面跟一串 WORD 项。高 4 位是重定位类型x64 下最常见的是IMAGE_REL_BASED_DIR64值 10表示这里是个 64 位绝对地址加上 delta 即可。32 位程序对应IMAGE_REL_BASED_HIGHLOW值 3改 32 位。参数上delta用有符号 64 位避免基址回绕出错。2.4 导入表修复把外部 DLL 的函数地址填进来PE 加载后代码里调用MessageBoxA这类外部函数实际是通过 IAT导入地址表间接跳转。内存加载必须自己遍历导入表对每个依赖 DLL 调LoadLibraryGetProcAddress把真实地址写进 IAT。auto impDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; auto desc (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)base impDir.VirtualAddress); for (; desc-Name; desc) { const char* dllName (const char*)((BYTE*)base desc-Name); HMODULE h LoadLibraryA(dllName); if (!h) return NULL; // 依赖缺失直接失败 PIMAGE_THUNK_DATA orig (PIMAGE_THUNK_DATA)((BYTE*)base desc-OriginalFirstThunk); PIMAGE_THUNK_DATA iat (PIMAGE_THUNK_DATA)((BYTE*)base desc-FirstThunk); for (; orig-u1.AddressOfData; orig, iat) { FARPROC fn; if (orig-u1.Ordinal IMAGE_ORDINAL_FLAG) { fn GetProcAddress(h, (LPCSTR)(orig-u1.Ordinal 0xFFFF)); } else { auto ibn (PIMAGE_IMPORT_BY_NAME)((BYTE*)base orig-u1.AddressOfData); fn GetProcAddress(h, ibn-Name); } iat-u1.Function (ULONGLONG)fn; } }逻辑说明OriginalFirstThunk指向名称表只读保留原始信息FirstThunk指向 IAT要写入真实地址。两者并行遍历一个读名字一个写地址。序号导入时Ordinal高位带IMAGE_ORDINAL_FLAG取低 16 位当序号。参数上LoadLibraryA用 ANSI 版本即可DLL 名在 PE 里本就是 ANSI。提示如果目标 EXE 依赖的某个 DLL 在当前环境不存在导入修复会失败。内存加载不会像系统加载器那样弹「找不到 xxx.dll」你得自己决定是报错还是跳过。3. 从字节流到可执行手写一个内存加载器原理清楚后把上面的片段串成一个完整加载器。这一章给出可复现的骨架并说明每一步的取舍。3.1 完整加载流程与入口点调用把映射、重定位、导入修复封装成一个函数返回模块基址再单独提供「调用导出函数」和「执行 EXE 入口」两个能力。typedef struct { LPVOID base; PIMAGE_NT_HEADERS nt; } MemModule; MemModule* MemLoad(const void* raw) { auto dos (PIMAGE_DOS_HEADER)raw; auto nt (PIMAGE_NT_HEADERS)((BYTE*)raw dos-e_lfanew); LPVOID base VirtualAlloc(NULL, nt-OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!base) return NULL; // 1. 拷贝头部与节见 2.2 // 2. 重定位见 2.3 // 3. 导入修复见 2.4 // 4. 若存在 TLS 目录需处理 TLS 回调见 3.3 MemModule* m (MemModule*)malloc(sizeof(MemModule)); m-base base; m-nt nt; return m; } // 调用导出函数 FARPROC MemGetProc(MemModule* m, const char* name) { auto expDir m-nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; if (!expDir.Size) return NULL; auto exp (PIMAGE_EXPORT_DIRECTORY)((BYTE*)m-base expDir.VirtualAddress); DWORD* names (DWORD*)((BYTE*)m-base exp-AddressOfNames); WORD* ords (WORD*)((BYTE*)m-base exp-AddressOfNameOrdinals); DWORD* funcs (DWORD*)((BYTE*)m-base exp-AddressOfFunctions); for (DWORD i 0; i exp-NumberOfNames; i) { if (strcmp((char*)m-base names[i], name) 0) return (FARPROC)((BYTE*)m-base funcs[ords[i]]); } return NULL; }逻辑说明MemLoad只负责把模块「摆好」不执行任何代码。MemGetProc遍历导出表按名字匹配返回函数指针。参数上导出表里AddressOfNameOrdinals存的是索引要用它去AddressOfFunctions取 RVA别直接拿名字下标当函数下标这是常见错误。3.2 调用 EXE 入口DLL 和 EXE 的差别DLL 的入口点DllMain签名是BOOL DllMain(HINSTANCE, DWORD, LPVOID)EXE 的入口点通常是mainCRTStartup或WinMainCRTStartup签名是void entry(void)。内存加载 EXE 时直接调用入口点即可但要注意 CRT 初始化。// 执行 EXE 入口假设是控制台程序 typedef void (*ExeEntry)(void); ExeEntry entry (ExeEntry)((BYTE*)m-base m-nt-OptionalHeader.AddressOfEntryPoint); entry(); // 注意这会阻塞直到 EXE 主逻辑返回逻辑说明EXE 入口点内部会调用 CRT 初始化、解析命令行、执行main。直接调用它等于在当前进程里跑目标 EXE 的主逻辑。参数上如果目标 EXE 依赖GetCommandLine等 API 拿参数它拿到的是当前进程的命令行不是你想要的需要额外 hook 或改 PEB。注意内存加载 EXE 后目标代码和宿主共享同一个进程空间、同一个堆、同一套全局状态。两个 CRT 实例可能冲突尤其是malloc/free跨模块传递时这是血泪经验里最常见的崩溃源。3.3 TLS 回调与异常容易被忽略的两块带 TLS 的 PE很多加壳或用了__declspec(thread)的程序在加载时需要执行 TLS 回调否则线程局部变量没初始化一访问就崩。TLS 目录是数据目录第 9 项。auto tlsDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; if (tlsDir.Size) { auto tls (PIMAGE_TLS_DIRECTORY)((BYTE*)base tlsDir.VirtualAddress); auto callbacks (PIMAGE_TLS_CALLBACK*)tls-AddressOfCallBacks; if (callbacks) { for (; *callbacks; callbacks) { (*callbacks)(base, DLL_PROCESS_ATTACH, NULL); } } }逻辑说明AddressOfCallBacks指向一个以 NULL 结尾的回调数组每个回调按DllMain的签名调用。参数上第二个参数传DLL_PROCESS_ATTACH第三个传 NULL。这一步必须在导入修复之后、调用入口点之前做。4. 内存加载 EXE 的避坑清单五个真实翻车现场这一章全是踩过的坑每条按「现象 → 原因 → 解决」写。你如果照着第 3 章写完还是崩八成能在这里找到答案。4.1 现象加载成功但一调用就 0xC0000005原因最常见的是重定位没做或做错。目标 EXE 编译时开了 ASLR/DYNAMICBASEImageBase是 0x140000000而你VirtualAlloc拿到的基址几乎不可能正好是它。所有绝对地址全错一执行就访问违例。解决确认delta ! 0时重定位逻辑真的跑了。用调试器看.reloc节是否存在DataDirectory[5].Size是否大于 0。如果目标故意去掉了重定位表/FIXED那只能尝试按ImageBase申请内存用VirtualAlloc指定基址失败就放弃。4.2 现象导入表修复后调用某个 API 直接跳到垃圾地址原因OriginalFirstThunk为 0 的情况。有些链接器生成的 PEOriginalFirstThunk是空的只有FirstThunk有效。这时按OriginalFirstThunk遍历会读到 NULL循环直接不执行IAT 全是原始 RVA调用即崩。解决判断desc-OriginalFirstThunk为 0 时改用desc-FirstThunk作为名称表来源。但要注意此时FirstThunk既是名称表又是 IAT遍历时先读名字再覆盖顺序不能乱。4.3 现象EXE 入口跑完宿主进程跟着退出原因目标 EXE 的入口点内部调用了ExitProcess。内存加载是在宿主进程里跑ExitProcess会把整个宿主一起干掉。解决要么 hookExitProcess要么接受这个行为——内存加载 EXE 本来就适合「跑完即走」的场景。如果要在宿主里长期共存得拦截退出相关 API或者把目标 EXE 当 DLL 用只调它的导出函数。4.4 现象跨模块 free 崩溃堆校验失败原因目标模块和宿主各有一套 CRT 堆。目标模块malloc出来的指针宿主free或者反过来堆管理器不认识这块内存直接触发堆损坏检测。解决约定内存分配和释放必须在同一模块内完成。导出函数如果返回堆指针同时导出一个FreeBuffer函数。或者统一用VirtualAlloc/HeapAlloc这类系统级分配绕开 CRT 堆。4.5 现象加载带 TLS 的程序线程局部变量读到随机值原因TLS 回调没执行__declspec(thread)变量没初始化读到的是VirtualAlloc的零页或残留数据。解决按 3.3 处理 TLS 目录在导入修复后、入口调用前执行所有回调。注意回调可能不止一个要遍历到 NULL 为止。5. 进阶把内存加载做成可复用的插件框架前面讲的是「加载一个 PE」实际工程里更常见的是「加载一堆插件按需调用」。这一章给一个可落地的框架思路以及验证加载是否成功的方法。5.1 用导出函数约定做插件接口让每个插件 DLL 导出一个固定签名的函数比如PluginInit和PluginRun。宿主内存加载后用MemGetProc拿到函数指针直接调。这样插件不需要落盘宿主也不需要知道插件的具体实现。// 插件侧导出 extern C __declspec(dllexport) int PluginInit(void* hostApi) { // 保存宿主提供的 API 表 return 0; } extern C __declspec(dllexport) int PluginRun(const char* args) { // 插件主逻辑 return 0; } // 宿主侧调用 typedef int (*PluginInitFn)(void*); typedef int (*PluginRunFn)(const char*); PluginInitFn init (PluginInitFn)MemGetProc(mod, PluginInit); PluginRunFn run (PluginRunFn)MemGetProc(mod, PluginRun); if (init run) { init(hostApi); run(hello); }逻辑说明导出函数用extern C避免 C 名字修饰MemGetProc按名字精确匹配。参数上hostApi可以是一个函数指针结构体让插件回调宿主能力避免插件直接依赖宿主内部符号。5.2 验证加载成功的三个检查点不要等调用崩了才排查。加载完立刻做三个检查入口点 RVA 是否落在某个可执行节内、IAT 是否全部指向合法模块地址、导出表能否按名字找到至少一个函数。检查点方法失败含义入口点合法比对AddressOfEntryPoint是否在某节VirtualAddress ~ VirtualAddressVirtualSize区间节映射错位或 PE 头损坏IAT 已修复遍历导入表检查每个FirstThunk项是否指向已加载模块地址范围导入修复漏项或依赖缺失导出可解析调MemGetProc找一个已知导出名导出表 RVA 计算错误5.3 一个我常用的调试习惯内存加载出问题最有效的办法不是加打印而是把加载后的内存 dump 出来用 PE 工具对比原始文件。我一般会在MemLoad返回前把base起始的SizeOfImage字节写到临时文件然后用查看器打开看节区、导入表、重定位表是否和预期一致。这一步能快速区分「映射错了」还是「修复错了」。另外加载前先校验 PE 的CheckSum和节表边界防止畸形文件把VirtualAlloc的大小算爆。参数上SizeOfImage如果小于SizeOfHeaders直接判定文件损坏别往下走。这套内存加载方案值不值得做如果你在做插件化、单文件分发、或者安全工具它省掉的是磁盘落地和系统加载器的可见性代价是要自己维护 PE 加载的每个细节。我踩过的最大教训是别信「一份代码通吃所有 PE」每换一个目标程序重定位、TLS、导入表这三处都得重新验证一遍。希望帮到你。本文还有配套的精品资源点击获取
返回列表