简介:这份资源面向具备一定Windows底层开发基础的程序员与安全研究人员,聚焦内存DLL与内存加载EXE这一高级主题,解决如何绕过磁盘文件、直接在进程内存中加载并调用可执行代码的问题。压缩包共17个文件,约942KB,包含cpp与h源码、rc资源脚本、def导出定义、dsp与dsw工程文件,以及dll、lib、exp、exe等编译产物,另附ReadMe说明与批处理脚本,覆盖从源码到可运行模块的完整链路。资源通过接口函数封装内存加载能力,读者可借此理解PE结构解析、节区重定位、内存分配与线程创建等关键环节,并参考源码实现自己的加载器或调试工具。目前已有192人学习下载,适合希望深入进程注入、模块调用与内存执行机制的中高级开发者研读。
1. 内存加载 EXE 与 DLL:从「文件落地」到「进程内直接跑」的攻防视角
一个 EXE 双击就运行,这是 Windows 给所有人的默认路径。但如果你把 EXE 当成一段可读入内存的字节流,绕过CreateProcess直接在自己的进程里把它「跑起来」,事情就完全不一样了。内存加载 EXE、内存 DLL 加载、调用 EXE 导出函数,这套技术常出现在免杀、插件化、沙箱逃逸、红队工具链里,也出现在一些正经的模块热更新场景中。它解决的核心问题是:不把 PE 文件写到磁盘,或者写出去后立刻抹掉,让文件扫描和行为监控拿不到落地的样本。适合谁?做安全研究、做模块化架构、做逆向工具链的工程师。新手能跟着把最小加载器跑通,熟手能看到重定位、TLS、导入表这些边界在哪。下面按「原理 → 最小实现 → 参数 → 踩坑」推一遍。
2. 内存加载 EXE 的底层账本:PE 结构、重定位与导入表
2.1 为什么不能直接把 EXE 字节丢进 VirtualAlloc 就跳过去
PE 文件在磁盘上的布局和加载到内存后的布局是两套账。磁盘上按FileAlignment对齐,内存里按SectionAlignment对齐,通常一个 0x200 一个 0x1000。你fread进来的缓冲区是磁盘布局,直接jmp到入口点,代码段里的绝对地址全是错的。所以内存加载的第一步永远是「按节表把每个节搬到正确的 RVA 位置」,而不是整块拷贝。
另一个反直觉的点:EXE 和 DLL 在加载这件事上差别很大。DLL 有导出表,系统加载器会帮你做重定位和导入解析;EXE 默认假设自己独占地址空间,基址是0x400000,一旦你把它加载到别的基址,所有硬编码地址都要修。这就是为什么「内存加载 EXE」比「内存加载 DLL」难一个量级——DLL 可以借系统的手,EXE 基本得自己全包。
2.2 手动映射的最小步骤拆解
整个流程可以拆成六步,每一步对应 PE 结构里的一个字段:
- 读文件头,校验
IMAGE_DOS_HEADER.e_magic和IMAGE_NT_HEADERS.Signature。 - 按
SizeOfImage申请一块PAGE_EXECUTE_READWRITE内存,基址记为ImageBase。 - 遍历节表,把每个节的
PointerToRawData处SizeOfRawData字节拷到ImageBase + VirtualAddress。 - 如果
ImageBase和OptionalHeader.ImageBase不一致,走重定位表修正。 - 解析导入表,
LoadLibrary+GetProcAddress填 IAT。 - 处理 TLS 回调(如果有),最后跳到
AddressOfEntryPoint。
第 4 步和第 5 步是翻车重灾区。重定位表里存的是「哪些位置需要加上 delta」,delta = 实际基址 - 期望基址。导入表里存的是「哪个 IAT 项对应哪个 DLL 的哪个函数」,顺序错了就是一片 0xC0000005。
2.3 用一段最小 C 代码把映射逻辑立起来
下面这段是加载器的骨架,省略了错误处理,重点看结构:
#include <windows.h> #include <stdio.h> // 把磁盘布局的 PE 映射成内存布局,返回模块基址 LPVOID MapImage(LPVOID rawBuf) { PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)rawBuf; PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)rawBuf + dos->e_lfanew); // 1. 按 SizeOfImage 申请可执行内存 LPVOID base = VirtualAlloc(NULL, nt->OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!base) return NULL; // 2. 先拷贝头部(SizeOfHeaders 大小) memcpy(base, rawBuf, nt->OptionalHeader.SizeOfHeaders); // 3. 逐节拷贝到 VirtualAddress 偏移处 PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt); for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) { if (sec[i].SizeOfRawData == 0) continue; memcpy((BYTE*)base + sec[i].VirtualAddress, (BYTE*)rawBuf + sec[i].PointerToRawData, sec[i].SizeOfRawData); } return base; }逻辑说明:SizeOfHeaders是头部在内存里的总大小,通常 0x400 左右,先整块拷过去保证节表可读。逐节拷贝时用VirtualAddress而不是PointerToRawData,这是内存布局和磁盘布局的分水岭。参数上,VirtualAlloc的权限给PAGE_EXECUTE_READWRITE是为了省事,生产环境应该先PAGE_READWRITE写完再VirtualProtect成PAGE_EXECUTE_READ,否则某些 EDR 会直接标记。
提示:
SizeOfRawData可能大于VirtualSize,拷贝时以SizeOfRawData为准,但内存里实际有效的是VirtualSize,多出来的部分会被清零,不影响执行。
3. 内存 DLL 加载与调用 EXE 导出函数:两条落地路径
3.1 内存 DLL 加载:为什么它比 EXE 好做
DLL 的加载逻辑和 EXE 前五步几乎一样,区别在最后一步:DLL 不跳入口点,而是调用DllMain,并且要处理导出表让外部能GetProcAddress。常见做法是写一个MemoryLoadLibrary函数,返回一个自定义的模块句柄,再配一个MemoryGetProcAddress遍历导出表。
导出表的结构是三层:IMAGE_EXPORT_DIRECTORY里有三个数组——AddressOfFunctions(函数 RVA 数组)、AddressOfNames(函数名 RVA 数组)、AddressOfNameOrdinals(名字到函数索引的映射)。查函数时先遍历名字数组做字符串比较,拿到序号,再用序号去AddressOfFunctions取 RVA,加上基址就是函数地址。
FARPROC MemoryGetProcAddress(LPVOID base, const char* name) { PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base; PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)base + dos->e_lfanew); DWORD expRva = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress; if (!expRva) return NULL; PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)((BYTE*)base + expRva); DWORD* funcs = (DWORD*)((BYTE*)base + exp->AddressOfFunctions); DWORD* names = (DWORD*)((BYTE*)base + exp->AddressOfNames); WORD* ords = (WORD*)((BYTE*)base + exp->AddressOfNameOrdinals); for (DWORD i = 0; i < exp->NumberOfNames; i++) { char* fn = (char*)((BYTE*)base + names[i]); if (strcmp(fn, name) == 0) { // 注意:ords[i] 是函数数组的索引,不是序号本身 return (FARPROC)((BYTE*)base + funcs[ords[i]]); } } return NULL; }参数说明:base是MapImage返回的基址,name是导出函数名。ords[i]容易踩坑——它是AddressOfFunctions的索引,不是导出序号,直接拿它当序号用会取错函数。如果 DLL 是转发导出(AddressOfFunctions里的 RVA 落在导出目录范围内),还要再解析一层字符串,这是进阶内容。
3.2 调用 EXE 导出函数:EXE 也能有导出表
很多人以为只有 DLL 有导出表,其实 EXE 也可以有,只要链接时导出符号。dumpbin /exports target.exe能看到导出列表。调用方式和 DLL 一样:内存加载 EXE 后,用MemoryGetProcAddress拿函数地址,然后按函数签名转成函数指针调用。
typedef int (*AddFunc)(int, int); // 假设 target.exe 导出了 Add 函数 LPVOID mod = MemoryLoadLibrary("target.exe"); AddFunc add = (AddFunc)MemoryGetProcAddress(mod, "Add"); if (add) { int r = add(3, 4); // 直接调用 EXE 里的函数 printf("result = %d\n", r); }逻辑说明:MemoryLoadLibrary内部完成映射、重定位、导入解析,但不跳入口点,所以 EXE 的main不会执行,你只是把它当成一个「带代码的库」用。参数上,函数指针的类型必须和导出函数的调用约定一致,__cdecl和__stdcall混了就是栈不平衡,表现为调用后崩溃或返回值错乱。
注意:如果 EXE 的入口点有全局初始化逻辑(比如 CRT 初始化),不跳入口点会导致导出函数里用到全局变量时拿到未初始化值。稳妥做法是手动调用
DllMain或 CRT 初始化函数,但这需要逆向确认入口点结构。
3.3 重定位与导入解析的完整补全
前面MapImage只做了映射,重定位和导入解析还没写。重定位的核心是遍历.reloc节,每个块覆盖 4KB 范围,块内每个 WORD 的低 12 位是偏移,高 4 位是类型(IMAGE_REL_BASED_HIGHLOW最常见)。对每个需要修正的位置,加上delta = 实际基址 - 期望基址。
void DoReloc(LPVOID base, DWORD delta) { PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base; PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)base + dos->e_lfanew); DWORD relRva = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress; if (!relRva || delta == 0) return; PIMAGE_BASE_RELOCATION rel = (PIMAGE_BASE_RELOCATION)((BYTE*)base + relRva); while (rel->SizeOfBlock) { DWORD count = (rel->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* items = (WORD*)((BYTE*)rel + sizeof(IMAGE_BASE_RELOCATION)); for (DWORD i = 0; i < count; i++) { if ((items[i] >> 12) == IMAGE_REL_BASED_HIGHLOW) { DWORD* patch = (DWORD*)((BYTE*)base + rel->VirtualAddress + (items[i] & 0xFFF)); *patch += delta; } } rel = (PIMAGE_BASE_RELOCATION)((BYTE*)rel + rel->SizeOfBlock); } }参数说明:delta为 0 时直接返回,说明加载到了期望基址,不需要修正。items[i] & 0xFFF是块内偏移,加上rel->VirtualAddress才是完整 RVA。64 位程序要用IMAGE_REL_BASED_DIR64,修正的是 8 字节,类型判断写错会只改一半地址,表现为随机崩溃。
导入解析相对直白:遍历IMAGE_DIRECTORY_ENTRY_IMPORT,每个IMAGE_IMPORT_DESCRIPTOR对应一个 DLL,Name字段是 DLL 名,FirstThunk是 IAT 的 RVA。对每个 IAT 项,如果最高位没置位,低 31 位是IMAGE_IMPORT_BY_NAME的 RVA,读出函数名后GetProcAddress填回去。
4. 内存加载 EXE 的避坑与排查:五个血泪现场
4.1 现象:加载后调用导出函数直接 0xC0000005
原因:重定位没做,或者 delta 算错。EXE 默认基址0x400000,你VirtualAlloc拿到的基址大概率不是这个值,所有绝对地址都偏了。排查方法:在MapImage后打印实际基址和OptionalHeader.ImageBase,如果不等且没走重定位,必崩。解决:补上DoReloc,并确认.reloc节存在——有些 EXE 链接时开了/FIXED会去掉重定位表,这种只能强制加载到期望基址,用VirtualAlloc的lpAddress参数指定。
4.2 现象:导入表解析后调用MessageBoxA弹不出来
原因:IAT 填错了位置。OriginalFirstThunk和FirstThunk是两个不同的数组,前者指向名字表,后者是实际要填的 IAT。常见错误是遍历时用了FirstThunk去读名字,结果读到的是未初始化的 0。解决:读名字用OriginalFirstThunk,写地址用FirstThunk,两者在磁盘上可能指向同一块,但内存加载后FirstThunk已被你改写,必须分开处理。
4.3 现象:DLL 加载成功但DllMain没执行
原因:DllMain不是通过导出表暴露的,它在AddressOfEntryPoint里。你只做了映射和导入解析,没跳入口点,DllMain自然不会跑。解决:在MemoryLoadLibrary最后调用入口点,参数传base、DLL_PROCESS_ATTACH、NULL。注意DllMain里如果有LoadLibrary等操作,可能触发加载器锁死锁,这是 Windows 加载器的经典坑。
4.4 现象:TLS 回调没执行,全局变量是脏值
原因:PE 的 TLS 目录里存了回调函数数组,系统加载器会逐个调用,你手动加载时跳过了这一步。表现是某些用__declspec(thread)的变量拿到随机值。解决:解析IMAGE_DIRECTORY_ENTRY_TLS,拿到AddressOfCallBacks,遍历调用每个回调,参数是base、DLL_PROCESS_ATTACH、NULL。顺序要在DllMain之前。
4.5 现象:加载 64 位 EXE 时重定位后地址只改了一半
原因:重定位类型判断写成了IMAGE_REL_BASED_HIGHLOW(3),64 位应该是IMAGE_REL_BASED_DIR64(10)。前者修正 4 字节,后者修正 8 字节,用错类型高位地址不变,跳转时高位丢失。解决:根据OptionalHeader.Magic判断是PE32还是PE32+,分别处理。排查时用调试器看修正后的地址,如果高 32 位和预期不符,基本就是这个原因。
5. 进阶技巧:用内存加载做模块热更新与导出函数转发
把内存加载用到正经工程里,最值钱的场景是模块热更新。传统 DLL 更新要停进程、替换文件、重启,内存加载可以做到「新版本字节流读进来,映射成新模块,把旧模块的导出函数指针切过去,旧模块延迟释放」。关键点是函数指针切换要原子,用InterlockedExchangePointer改一个全局函数表,调用方通过表间接调用,切换瞬间不影响正在执行的调用。
另一个技巧是导出函数转发。如果你加载的 EXE 导出函数内部又调用了别的 DLL,而那个 DLL 你想用自己的版本替换,可以在导入解析阶段做手脚:遇到目标 DLL 名时,不调LoadLibrary,而是返回你自己内存加载的模块句柄。这样整个依赖链都在内存里,磁盘上不留任何 DLL 文件。
验证加载是否成功,我一般用三步:先用dumpbin /headers确认目标 PE 有重定位表和导出表;再用调试器在入口点下断,看是否命中;最后写一个最小调用用例,传固定参数看返回值是否符合预期。三步都过,基本可以认为加载器是可靠的。
我自己踩得最狠的一次是忘了处理SizeOfHeaders和节数据的重叠——某些加壳 PE 的头部大小超过第一个节的VirtualAddress,先拷头部再拷节会把头部覆盖掉,表现为节表读出来是乱码。后来养成习惯:先拷节数据,再拷头部,或者严格按SizeOfHeaders和节边界做区间判断。这个习惯帮我省了至少两次通宵排查。希望帮到你。
本文还有配套的精品资源,点击获取