简介这份RunPE进程空中技术示例源码面向安全研究、逆向工程与系统编程学习者聚焦“不将exe写入磁盘而直接在内存中加载执行”的核心问题。压缩包内共1个cpp源文件大小约2KB体量轻量适合作为理解Process Hollowing关键逻辑的阅读材料而非完整工程。已有1210人学习说明其在相关学习场景中具有一定参考价值。代码可结合描述中的承载进程选择、目标映像读取、节区写入、入口点修改与线程恢复等步骤阅读配合调试器观察内存布局、PE头变化和进程状态恢复过程进而理解Windows API调用与PE文件格式的配合方式。对希望建立RunPE基本认知、分析内存PE加载检测思路的读者可作为对照样本同时也能为安全软件识别异常进程行为、讨论注入防御策略提供具体切入点。1. 内存加载 exe真正的难点从来不是“跳过去”很多人第一次听说在内存里直接加载 exe 执行直觉会把它归到玄学一类觉得难点在于“怎么让 CPU 跳过去”。恰恰相反跳过去只要一个函数指针真正麻烦的是跳过去之前那几百项修补一个按 512 字节对齐的磁盘文件得先展开成按 4KB 对齐的虚拟映像再改掉映像里所有硬编码的绝对地址填满导入表里每一格函数指针跑完 TLS 回调登记异常表最后才轮得到入口点。系统加载器平时把这些活全干了所以你从来没看见过它。所谓内存加载就是把这套由内核与加载器代劳的流程在用户态自己重做一遍理解了这套修补你才知道为什么有的程序塞进去能跑有的必崩。2. PE 的两副面孔磁盘布局和内存布局差在哪2.1 同一节代码在文件里和在内存里差了多少PE 文件里有两套对齐规则一套管磁盘一套管内存。FileAlignment通常取 0x200512 字节SectionAlignment通常取 0x10004KB 一页。这意味着一个节的磁盘偏移和它的内存 RVA 之间不是固定差值而是一节一个样。举例来说.text节VirtualAddress 0x1000、PointerToRawData 0x400差值是 0xC00.rdata节VirtualAddress 0x3000、PointerToRawData 0x1600差值变成 0x1A00。差值是逐节变化的所以任何“整块拷进去再统一挪一挪”的想法从第一步就错了。先用一段脚本把两套布局并排打出来心里有数再动手import struct def dump_sections(path): with open(path, rb) as f: data f.read() pe_off struct.unpack_from(I, data, 0x3C)[0] # 0x3C 处存 e_lfanew指向 PE 签名 coff pe_off 4 # 跳过 4 字节 PE\0\0 签名 num_sec, struct.unpack_from(H, data, coff 2) # COFF 头偏移 2节数量 opt_size, struct.unpack_from(H, data, coff 16) # COFF 头偏移 16可选头长度 sec_off coff 20 opt_size # 节表紧跟在可选头之后 for i in range(num_sec): base sec_off i * 40 # 每个节表项固定 40 字节 name data[base:base 8].rstrip(b\x00).decode(ascii, replace) vsize, vaddr, rawsize, rawptr struct.unpack_from(IIII, data, base 8) print(f{name:8} RVA{vaddr:#08x} VSize{vsize:#08x} fRawPtr{rawptr:#08x} RawSize{rawsize:#08x}) dump_sections(demo.exe)这段脚本没有做任何校验纯粹用来观察。base 8开始依次是VirtualSize、VirtualAddress、SizeOfRawData、PointerToRawData四个 DWORD再往后是重定位信息、行号信息、节区特性标志40 字节正好排满。跑一次你就会发现RVA和RawPtr的差值没有规律VSize和RawSize也很少相等这就是后面所有麻烦的源头。2.2 VirtualSize 和 SizeOfRawData拷贝时听谁的SizeOfRawData是这一节在磁盘上实际占的字节数已经被FileAlignment上取整VirtualSize是它在内存里真正用到的字节数没对齐。拷贝时取两者较小值SizeOfRawData大多出来的是对齐填充搬过去没意义VirtualSize大说明这节尾部有一段只在内存里存在的区域磁盘上压根没存数据。后一种情况的极端形态是未初始化数据节SizeOfRawData 0VirtualSize可能是好几 KB里面全是全局变量的初始值 0。C 语言里“全局变量默认是 0”这条规则实现方式是加载器把这段区域清零并不能靠编译器自动生成。自己映射时如果只拷贝有内容的节就收工那些static数组、全局缓冲区里就是上一次进程留下的脏数据程序跑起来的现象是“偶尔算出个离谱的数”还很难复现纯属血泪经验。内存清零这件事有省力做法VirtualAlloc用MEM_COMMIT提交时系统给的是零页等于天然清零了。但如果你的加载器是在一块已经用过的内存上做二次映射就必须自己memset别指望运气。2.3 为什么必须按节映射不能整块拷除了对齐差值不固定还有三个原因。一是节与节之间存在空洞VirtualAddress往往不连续中间留白整块拷贝会把下一节的磁盘数据错位塞进这些空洞里本来该清零的地方变成了垃圾。二是有些节在内存里比磁盘上大前面已经说过空洞必须补零。三是SizeOfImage会大于最后一节的末尾它按SectionAlignment上取整加载器分配的是这个尺寸最后一节剩下的那点尾巴同样得是零。真正让“按节映射”成为硬要求的是重定位。映像里的绝对地址是编译器按每个节落在哪个 RVA 算好的只要有一个节被放错了位置那些预先算好的地址就全对不上重定位表里记的页内偏移也跟着错位。所以映射阶段唯一安全的做法就是老老实实按节表给出的 RVA 逐个搬。3. 系统加载器替你做了哪些事从基址到入口点的完整修补链3.1 ImageBase 只是一个愿望ASLR 才是现实PE 可选头里的ImageBase是链接器给的建议装载地址32 位程序常见 0x40000064 位常见 0x140000000。它只是个愿望如果这块地址已经被别的模块占了或者系统开了地址随机化加载器会把映像放到别处。判断依据是DllCharacteristics里的DYNAMICBASE位现在默认都是开的。放到别处之后映像里所有“绝对地址”就都错了。注意区分相对调用、相对跳转x86 的E8/E9、x64 的 RIP 相对寻址不受影响它们在编码时用的是位移跟基址无关会失效的是被写死在数据里、指令立即数里的那些绝对地址比如虚函数表的表项、全局函数指针数组、mov eax, 0x401000这种取全局变量地址的指令。编译器在生成时把这些位置一条条记进重定位表加载器的任务就是按表回填。3.2 重定位表的结构与回填公式重定位信息放在.reloc节按 4KB 页分块组织。每个块头 8 字节PageRVA这一块覆盖的页起始 RVA和SizeOfBlock整块字节数。紧随其后的是若干 2 字节条目高 4 位是类型低 12 位是页内偏移所以一页最多 4096 项。类型 0 叫IMAGE_REL_BASED_ABSOLUTE是纯填充什么都不用做类型 3HIGHLOW表示要往那个位置加一个 32 位增量类型 10DIR64表示加一个 64 位增量。// 省略错误处理。image 是实际加载基址preferred 是 PE 头里的 ImageBase BYTE* reloc image dir[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress; BYTE* end reloc dir[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size; SIZE_T delta (SIZE_T)image - (SIZE_T)preferred; // 实际基址减首选基址 while (reloc end) { IMAGE_BASE_RELOCATION* blk (IMAGE_BASE_RELOCATION*)reloc; if (blk-SizeOfBlock 0) break; // 损坏的表会导致死循环必须兜底 UINT count (blk-SizeOfBlock - 8) / 2; WORD* entry (WORD*)(reloc 8); for (UINT i 0; i count; i) { UINT type entry[i] 12; // 高 4 位重定位类型 UINT off entry[i] 0x0FFF; // 低 12 位页内偏移 BYTE* target image blk-VirtualAddress off; if (type IMAGE_REL_BASED_HIGHLOW) // 32 位映像里的绝对地址 *(UINT32*)target (UINT32)delta; else if (type IMAGE_REL_BASED_DIR64) // 64 位映像里的绝对地址 *(UINT64*)target (UINT64)delta; // 类型 0 是填充项直接跳过其余类型极罕见漏处理会在特定模块上炸 } reloc blk-SizeOfBlock; }三个容易翻车的点。第一delta必须用实际基址算有人在调试时把映像强行分配到ImageBasedelta正好是 0所有回填变成加 0测试一切正常换台机器上 ASLR映像落到别处代码立刻崩这种“我这儿好好的”最难查。第二回填时那片内存必须可写所以映射阶段整个映像先按PAGE_READWRITE申请回填完再按节特性改回去。第三SizeOfBlock异常时死循环会挂住整个进程加一行兜底不吃亏。3.3 导入表IAT 是填空题不是链接导入表是一串IMAGE_IMPORT_DESCRIPTOR每项 20 字节全 0 结尾。关键字段是OriginalFirstThunk指向 INT导入名称表、FirstThunk指向 IAT导入地址表和NameDLL 名 RVA。INT 和 IAT 在文件里内容相同都是同一组条目条目最高位为 1 表示按序号导入低 31 位是序号最高位为 0 表示它指向一个IMAGE_IMPORT_BY_NAME结构是 2 字节 Hint 加一个以 0 结尾的函数名。加载器干的事非常朴素遍历 INT查出每个函数的真实地址写进 IAT 对应位置。程序里每一次跨模块调用编译出来都是call [IAT 表项]这样的间接跳转所以 IAT 没填崩溃点必然是某条call指令错误码0xC0000005读的是个极小或者极大的地址。懂得了这一点看到崩溃地址落在映像的 IAT 区间基本可以直接判定导入解析漏了或者解析顺序错了比如某个 DLL 依赖另一个还没解析完的模块。3.4 常被漏掉的三份收尾工作第一份是 TLS 目录。数据目录里的IMAGE_DIRECTORY_ENTRY_TLS指向IMAGE_TLS_DIRECTORY其中AddressOfCallBacks是一个以 0 结尾的函数指针数组。这些回调在映像映射完成、入口点执行之前被逐个调用C 运行库的线程局部对象、某些加密库的初始化都挂在这里。自己映射时跳过它现象是全局的std::string是空的、日志系统没初始化、访问时崩在看起来毫无关系的地方。第二份是异常目录。x64 上.pdata里存的是每个函数的展开信息RUNTIME_FUNCTION数组栈展开、__try/__except、C 异常、甚至某些 CRT 初始化路径都依赖它。缺失时行为很怪正常流程一切正常一旦抛出异常或者触发一次栈回溯进程直接挂掉。第三份是 Load Config 目录里的安全 Cookie__security_cookie的初值在映像加载时由加载器从目录里取出来填进去没填的话栈保护检查会随机失败。资源节、清单manifest同理很多程序的界面和依赖声明都从资源里读缺了不影响启动但功能会缺一块。4. 自己把映像拼起来手动映射与进程镂空的关键代码4.1 分配空间与按节展开// 申请一整块 SizeOfImage 大小的可读写内存头部先整块搬过去 SIZE_T size opt-SizeOfImage; BYTE* image (BYTE*)VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); memcpy(image, raw, opt-SizeOfHeaders); // 头部含 DOS 头、NT 头、节表通常已对齐 // 逐节搬只搬 min(RawSize, VSize)其余部分靠 VirtualAlloc 的零页 for (WORD i 0; i file-NumberOfSections; i) { IMAGE_SECTION_HEADER* s sec[i]; if (s-SizeOfRawData 0) continue; // .bss 类节磁盘上没有内容跳过 SIZE_T n s-SizeOfRawData s-Misc.VirtualSize ? s-SizeOfRawData : s-Misc.VirtualSize; memcpy(image s-VirtualAddress, raw s-PointerToRawData, n); }SizeOfHeaders是可选头里的字段表示头部总长度同样按FileAlignment对齐所以直接整块搬是安全的。循环里那行min就是 2.2 节说的取舍写成三元表达式只是为了避免引入额外头文件。跳过SizeOfRawData 0的节是省事不是必须搬 0 字节也一样。这里用MEM_COMMIT提交换来零页如果你复用的是已有内存块务必补一次memset(image, 0, size)。4.2 重定位与内存属性恢复的顺序重定位代码在 3.2 已经给过这里说顺序。正确顺序是分配为PAGE_READWRITE→ 搬头部与各节 → 解析导入表并填 IAT → 执行重定位 → 跑 TLS 回调 → 按每节的Characteristics恢复属性IMAGE_SCN_MEM_EXECUTE对应PAGE_EXECUTE_READIMAGE_SCN_MEM_WRITE对应PAGE_READWRITE都没有就是PAGE_READONLY→ 跳入口点。重定位放在导入解析之后、属性恢复之前因为它要写.text和.data属性恢复放在最后因为前面每一步都在写内存。漏掉属性恢复的后果很典型映像整块是PAGE_READWRITE代码页不可执行跳到入口点第一条指令就是0xC0000005而且崩溃地址看起来完全正常就在入口点上特别迷惑。反过来属性恢复早了重定位写.text时同样会访问违例崩溃点落在.reloc循环里。4.3 导入解析的两条路径路径一省事对每个 DLL 名调LoadLibraryA对每个函数名调GetProcAddress把返回值填进 IAT。缺点是它走系统的模块管理链表目标 DLL 会被正正经经加载一遍该留下的记录一个不少。路径二彻底自己把目标 DLL 映射进来手工遍历它的导出目录查地址。导出目录有三张表——AddressOfFunctions函数 RVA 数组、AddressOfNames名字 RVA 数组、AddressOfNameOrdinals名字对应序号索引——按名字查就是在这三张表之间做一次映射。// 在已映射的模块里按名字找导出函数base 为该模块装载基址 FARPROC find_export(BYTE* base, const char* want) { IMAGE_EXPORT_DIRECTORY* exp RVA2PTR(base, dir[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress); DWORD* funcs RVA2PTR(base, exp-AddressOfFunctions); // 函数 RVA 数组 DWORD* names RVA2PTR(base, exp-AddressOfNames); // 名字 RVA 数组 WORD* ords RVA2PTR(base, exp-AddressOfNameOrdinals); // 名字 - 序号索引 for (DWORD i 0; i exp-NumberOfNames; i) { if (strcmp((char*)RVA2PTR(base, names[i]), want) 0) return (FARPROC)RVA2PTR(base, funcs[ords[i]]); // 注意用 ords[i] 索引 funcs } return NULL; }AddressOfNameOrdinals里存的是“序号索引”不是“序号”直接拿去当funcs下标是错的必须先经ords[i]转一次这个坑踩过的人都记得。另外两种边界NumberOfFunctions大于NumberOfNames说明存在只按序号导出的函数按名字查不到如果查到的 RVA 落在导出目录自身范围内那它不是代码而是一个DLLNAME.FuncName形式的转发字符串需要继续往下解析一层。系统里不少核心 DLL 大量使用转发导出不处理这一层解析会返回一个指向字符串的假地址。4.4 进程镂空借一个真实进程的壳手动映射有个绕不过去的尴尬新映像在进程里但它不在系统的模块链表上GetModuleHandle(NULL)拿到的是宿主程序GetModuleFileName返回的是宿主路径。很多程序启动第一件事就是读自己同目录下的配置、资源、语言包路径一错直接跑偏。进程镂空换了个思路以挂起状态创建一个真实进程它的 PEB、命令行、映像路径全都是目标程序自己的然后把原映像卸载掉在同一块地址上写入新映像修正 PEB 里的ImageBaseAddress最后恢复主线程。这样进程的“身份”是对的只是内存里的代码换了。代价是地址空间得容得下新映像首选基址被占时要先重定位而重定位又要求你完整实现了 3.2 那一套。两条路线共同的坑在入口点参数。exe 的入口从来不是main而是mainCRTStartup这类 CRT 启动桩它需要从栈上取argc/argv或者退而求其次调GetCommandLineA。用CreateThread起一个新线程跳进去栈上没有 CRT 期待的那套布局很多程序“能跑”只是因为 CRT 走了命令行分支一旦程序用到__argc/__argv或者 CRT 版本不同读到的就是垃圾值。这就是那类“跑起来了但某个功能莫名其妙崩”的根源。5. 排查清单内存加载器一跑就崩的五种典型第一种崩在入口点第一条指令错误码0xC0000005。原因基本是内存属性没恢复整块还是PAGE_READWRITE代码页不可执行。也可能入口点地址算错——AddressOfEntryPoint是 RVA直接当文件偏移用就会跳到一片数据上。解决恢复属性后打印每一节的基址、大小和保护属性再确认入口点 image AddressOfEntryPoint。第二种程序能启动但读错了配置或者界面语言不对。原因是映像不在模块链表里GetModuleHandle(NULL)、GetModuleFileName、GetCurrentDirectory相关的相对路径全部指向宿主。解决接受这个限制并让目标程序的所有路径走绝对路径或者改用进程镂空路线让进程身份本身是对的。硬把映像挂进链表也能做但加载器锁、堆锁和并发遍历会让问题变复杂不划算。第三种全局对象没构造崩在atexit或者某个std::string上。原因是只跳了入口点TLS 回调一个都没跑。解决解析 TLS 目录遍历AddressOfCallBacks里的函数指针逐个调用注意每个回调的签名是void(NTAPI*)(PVOID, DWORD, PVOID)三个参数分别是映像基址、DLL_PROCESS_ATTACH、保留值。这一步必须在入口点之前完成。第四种映射过程一切正常一执行就崩且只在个别程序上出现。先查位数是否匹配读可选头的Magic0x10B是 32 位0x20B是 64 位把 32 位映像塞进 64 位进程必崩。再查是不是漏了.pdata异常目录缺失时正常路径毫无症状一旦触发异常或栈回溯立刻挂。解决把异常目录、Load Config 目录一起纳入映射后的修补清单。第五种本地测试全绿换台机器就崩在数据访问上。八成是重定位的delta被当成了 0本地映像恰好落在ImageBase测试时一切正常换台机器开随机化就露馅。也可能是.reloc里有未处理的类型或者损坏的块。解决强制用VirtualAlloc指定一个远离ImageBase的地址做一次测试把所有重定位路径都跑一遍比任何调试都有效。6. 设计取舍内存加载永远只是“近似等价”的加载把整篇文章的机制串起来看会发现一件事内存加载器无论写得多完整都达不到系统加载器的等价程度。差异不在代码质量而在系统加载器的活有一部分根本不在映像里——它要把模块登记进进程的模块链表要让GetModuleHandle、GetProcAddress、EnumProcessModules这些查询接口能看见你要在进程的映像路径字段里留下记录要让调试器和崩溃转储能把地址翻译回符号名要参与签名校验与完整性检查。这些是“进程状态”层面的工作而不是“字节修补”层面的工作。自己映射的映像本质上是把字节摆对了但状态没登记。这个差异决定了它的适用边界。做打包器、做体积压缩、做插件热加载、做补丁保护需要的是“代码能跑”不需要“系统认为它是一个模块”这条路很合适。做内存取证时反过来用从进程转储里搜MZ头、按SectionAlignment反推节表、重建一个可离线分析的 PE 文件靠的也是同一套布局知识。而如果目标是让某个程序完全以一个独立进程的身份运行进程镂空那种“借真实进程壳”的思路反而更贴合需求因为身份是系统给的你只换了内容。从观测角度看这套技术的可观测面其实很稳定一块私有提交内存里出现了MZ头和完整的节表、一块映像的执行区域没有对应的磁盘文件、某个线程的起始地址不落在任何已登记模块的范围内、内存中某段数据的字节与磁盘上任何文件都对不上。这些特征不依赖具体实现是机制本身留下的痕迹。早些年我写过一个插件加载器图省事跳过了 TLS 回调本地跑了几十个测试插件全绿交付后第一个真实插件在初始化时崩了日志里什么都看不出来——因为日志系统自己的全局对象就没构造。查了两天才定位到那几行被我省略的回调遍历。从那以后我每次写这类加载器都会在重定位完成和属性恢复之后各插一个断点先把每节的基址、大小、保护属性、入口点地址完整 dump 一遍再往下走宁可多花十分钟对数也不想再经历一次“本地全绿、线上全崩”的黑匣子排查。这套习惯后来在别的领域也救过我很多次。希望帮到你。本文还有配套的精品资源点击获取