简介:一份聚焦RunPE(进程空中技术)的C++源码示例,面向安全研究人员、逆向工程师及对Windows底层机制感兴趣的学习者。该技术可在不落地exe文件的前提下,将目标程序映像完整映射到已存在进程(承载进程)的内存空间中,绕过常规文件审查,常见于隐蔽执行、恶意软件分析、动态调试与免杀等场景。资源包为zip格式,仅包含1个cpp文件,压缩后大小约2KB,代码量非常精简,适合直接阅读关键API调用与整体执行流程。实现涉及进程创建、PE节区映射、内存块清理、入口点改写及线程恢复等技术点,对深入理解Windows进程模型很有帮助。目前已有1207人学习/下载,可作为入门RunPE的实战参考。通过研读这份源码,可以掌握选择承载进程、读取并复制映像节区、清空代码段、按PE结构写入目标内容、修改入口点以及利用NtResumeThread恢复执行的完整链路,同时为后续构建运行时的检测与防御思路提供可操作的实践样例。
1. 进程空中:让 exe 在合法进程的壳里“借尸还魂”
你以为的 exe 加载,是双击图标后乖乖等它弹窗。但有一种加载方式,exe 全程不碰磁盘,体面地住进另一个正在运行进程的地址空间里——进程空中(Process Hollowing)就是这招。它的做法是先创建一个挂起的合法进程,比如记事本,把它的内存映像“掏空”,再把目标 exe 的映像填进去,最后唤醒线程。整个过程在任务管理器里看起来还是记事本,实际上跑的是另一段代码。这项技术最早被用来做木马免杀,但在安全研究、软件自保护、内存补丁和逆向分析里,它同样是绕不开的机制。适合想真正理解 PE 加载原理、在做防御对抗验证、或者需要在不落盘的情况下加载自研工具的从业者。
2. 先搞懂机制:挂起进程、PE 重定位与内存布局
2.1 为什么会选择“挂起”这条路
因为 CreateProcess 给了你一个天赐的时间窗口。调用时传 CREATE_SUSPENDED 标志,系统会把进程创建到一半就停下来:主线程已生成、核心对象已建立,但入口点还没执行,模块加载也还没完成。这个状态下的进程就像一台发动机刚装好却没点火的空车,你可以在里面换零件。
常见做法是在这个时间窗里做四件事:拿到目标进程的 PEB 指针、把原始映像卸载、向地址空间写入新映像、修正线程上下文后恢复执行。每一步都不能出差,否则进程要么直接崩溃,要么在原地空转。
这里有个容易被忽略的前提:CreateProcess 创建的进程,是拿“宿主 exe 的路径”去创建的,所以宿主文件必须是系统里真实存在的合法文件。正统实现里标准宿主选择一个白名单 exe,比如记事本或注册表编辑器。
2.2 PE 文件在内存里的重定位逻辑
要往宿主进程空间里塞新映像,就必须理解 PE 在内存中的形态和磁盘上的形态之间的差异。磁盘上 PE 的节区是按下方的 FILE_ALIGNMENT 对齐的,通常是 0x200;映射进内存后则按 SectionAlignment 对齐,常见是 0x1000。这两个值不一样,导致 SizeOfImage 往往比磁盘上的文件大不少。写入时不能直接 CopyFile 到内存,要按内存对齐重新布局各节。
这里面有个关键点:PE 装载器拿到的是“目标 exe”的映像内容,但这段内容里面所有绝对地址(比如全局变量、函数指针)都是基于目标 exe 的 PreferredBase 算出来的。如果宿主进程空间的基址和目标 exe 的镜像基址不一样,就需要遍历重定位表,把每一条记录按差值修正。这一步做漏了,运行到一半就会出现访问违例。
我最常用的做法是先把源映像的 SizeOfImage 和基址读出来,然后用 VirtualAllocEx 在宿主进程里按 SizeOfImage 分配一块新内存。如果这块地址恰好落在目标 exe 的镜像基址上,就不需要处理重定位;落不在就先读重定位表,做一次全量修正。
2.3 完整流程的 API 调用链
整条链路的 API 顺序可以写成这样:
- CreateProcess(宿主路径, CREATE_SUSPENDED)
- NtQueryInformationProcess(ProcessBasicInformation) 拿到 PEB 地址
- 读 PEB.ImageBaseAddress,得到宿主进程的映像基址
- NtUnmapViewOfSection 把宿主映像“卸载掉”,在内存中腾出空间
- VirtualAllocEx 按新映像的 SizeOfImage 重新分配
- 把新映像的每个节区写入这段内存(按内存对齐)
- 写新映像的入口点地址到线程上下文(CONTEXT.Eax / Rip)
- 如果需要,把 PEB.ImageBaseAddress 改成新基址
- ResumeThread 唤醒线程
实际写代码时,第 4 步和 NtUnmapViewOfSection 的关系最容易被搞混。如果不卸掉旧映像,VirtualAllocEx 就没有足够的空间;如果只卸载不重新分配,后面写入时可能覆盖掉宿主自己的关键模块。稳妥的顺序是:先查询,再卸载,再分配,再写。
3. 代码落地:从 CreateProcess 到 ResumeThread 的完整实现
3.1 宿主选择与进程初始化
宿主进程的选择直接决定整个加载过程会不会被目标机器上的安全软件拦截。我的习惯是选那些默认位数一致、且没有控制台窗口闪烁的系统进程。下面这一段是整个过程的第一步:
// 创建挂起的宿主进程 STARTUPINFOA si = { sizeof(si) }; PROCESS_INFORMATION pi = { 0 }; BOOL ok = CreateProcessA( "C:\\Windows\\System32\\notepad.exe", // 宿主路径 NULL, // 命令行可为空 NULL, NULL, FALSE, CREATE_SUSPENDED, // 关键:创建后暂停 NULL, NULL, &si, &pi); if (!ok) { // 失败时 GetLastError() 常见值是 5(拒绝访问) return -1; }这里有几个参数值得讲清楚。CREATE_SUSPENDED 这个标志位决定了后续所有操作的对象是一个尚未运行的进程,这意味着此时修改内存不会触发同步问题。lpCommandLine 传 NULL 是安全做法,因为命令行本身不属于映像的一部分,但如果你想把参数传给目标 exe,可以在后面用写进程内存的方式单独处理,后面会讲。
宿主路径尽量用完整路径,别用相对路径。因为 CreateProcess 在解析相对路径时会受调用进程当前目录影响,一旦这个目录不是系统目录,可能直接报 ERROR_FILE_NOT_FOUND。另一个细节是,如果宿主是 64 位程序而你传入的参数里带 32 位路径重定向,会被系统自动转到 SysWOW64,拿到的 notepad.exe 是 32 位的,后面 64 位 shellcode 就全部白写。
3.2 核心:卸载宿主映像并写入新映像
拿到挂起的宿主进程后,第一件事是读出它当前的 PEB 地址,然后才能知道映像基址在哪。我这里用的是 NtQueryInformationProcess,因为它能直接返回 PEB 指针,而 Windows 没有公开的 Win32 API 干这件事。
// 查询 PEB 地址 PROCESS_BASIC_INFORMATION pbi = { 0 }; ULONG retLen = 0; PFN_NtQueryInformationProcess pNtQIP = (PFN_NtQueryInformationProcess)GetProcAddress( GetModuleHandleA("ntdll.dll"), "NtQueryInformationProcess"); NTSTATUS status = pNtQIP(pi.hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), &retLen); // 读取 PEB.ImageBaseAddress(偏移 0x10) BYTE* pebAddr = pbi.PebBaseAddress; BYTE* imageBase = NULL; SIZE_T bytesRead = 0; ReadProcessMemory(pi.hProcess, pebAddr + 0x10, // x64 PEB 中 ImageBaseAddress 偏移 &imageBase, sizeof(imageBase), &bytesRead);注意pebAddr + 0x10这个偏移,在 64 位系统上是对的,但 32 位系统的 PEB 布局略有差别。如果你在 64 位系统上用 32 位宿主,这个偏移要另查。我一般会把位数判定写进代码里:用 IsWow64Process 判断,然后分别选择偏移值。
拿到宿主基址后的动作是卸载映像:
// 卸载宿主原映像 PFN_NtUnmapViewOfSection pNtUnmap = (PFN_NtUnmapViewOfSection)GetProcAddress( GetModuleHandleA("ntdll.dll"), "NtUnmapViewOfSection"); pNtUnmap(pi.hProcess, imageBase);这里补充说明:NtUnmapViewOfSection 是 ntdll 里的导出函数,虽然没有官方文档,但它在所有主流 Windows 版本上稳定存在。卸载完成后,宿主进程的地址空间里从旧映像基址开始的区域就成了空头,接下来要用 VirtualAllocEx 重新分配。
// 按目标 exe 的 SizeOfImage 重新分配内存 LPVOID remoteImage = VirtualAllocEx( pi.hProcess, (LPVOID)srcNtHdr->OptionalHeader.ImageBase, srcNtHdr->OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);这个调用带两个 trick:第一个 trick 是请求地址直接填目标 exe 的 PreferredBase,这样写进去之后重定位表可以绕过去;第二个 trick 是 PAGE_EXECUTE_READWRITE,实际项目中我不想给内存页留太高的权限,但调试阶段这么写最省事。
3.3 写入各节区并修正线程上下文
映像在内存里不是连续的文件数据,而是按节区散的。要正确复现 PE 加载,必须逐节写入:
// 遍历节区表写入 PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(srcNtHdr); for (int i = 0; i < srcNtHdr->FileHeader.NumberOfSections; i++) { LPVOID dstAddr = (BYTE*)remoteImage + sec[i].VirtualAddress; LPVOID srcAddr = (BYTE*)srcBase + sec[i].PointerToRawData; WriteProcessMemory(pi.hProcess, dstAddr, srcAddr, sec[i].SizeOfRawData, NULL); }逐节写入的逻辑背后有一个关键概念:文件里的 PointerToRawData 是磁盘偏移,VirtualAddress 是内存偏移,两者之间差的就是对齐补丁和文件头。只写 SizeOfRawData 而不是 SizeOfRawData 和 VirtualSize 的较大值,是为了避免越界读源文件。但如果某个节区的 VirtualSize 大于 SizeOfRawData,多出来的区域就必须用 0 填充,不然那些未初始化的全局变量会带上脏数据。我一般会在循环外面先把整块内存清零,再往里写节区内容。
接下来是线程上下文的修正。线程创建出来时,它的入口点是宿主进程的原始入口,必须改写成目标 exe 的 AddressOfEntryPoint:
CONTEXT ctx = { 0 }; ctx.ContextFlags = CONTEXT_FULL; GetThreadContext(pi.hThread, &ctx); // x64 下入口点是 Rip,x86 下是 Eax #ifdef _M_X64 ctx.Rip = (DWORD64)remoteImage + srcNtHdr->OptionalHeader.AddressOfEntryPoint; #else ctx.Eax = (DWORD)remoteImage + srcNtHdr->OptionalHeader.AddressOfEntryPoint; #endif SetThreadContext(pi.hThread, &ctx); ResumeThread(pi.hThread);这里最重要的坑是:一定不能直接写死 Rip 等于 remoteImage。PE 的入口点不在映像开头,而在 OptionalHeader.AddressOfEntryPoint 指向的代码节里。记事本的入口点在磁盘上看通常是 0x1000 附近,但这个值每个 exe 都不同。要先把源 exe 的 PE 头解析出来,否则线程恢复后 CPU 会从 PE 头开始“执行”,立刻触发非法指令异常。
3.4 参数与环境的传递
目标 exe 往往需要命令行参数和环境变量。参数传递常见做法是先算出参数字符串总长度,在远程进程里 VirtualAllocEx 一块内存,把参数写进去,再把这个地址塞到 PEB 的 ProcessParameters 里。这一步比想象中繁琐,因为 ProcessParameters 结构里的 CommandLine 是一个 UNICODE_STRING,它包含 Length、MaximumLength 和 Buffer 三个字段,Buffer 是堆里的指针。你需要改的是两个地方:一个是 UNICODE_STRING 结构里的 Buffer 字段,另一个是实际参数内存里的内容。
还有一种更简单但不够优雅的思路:在宿主创建时直接把完整命令行传给 lpCommandLine。因为 Kernel32 在初始化时会把那个缓冲区映射到 PEB 里,所以创建进程的参数可以原样到达目标 exe。但这里有个限制:你必须知道宿主进程接受命令行参数的方式,而且记事本会弹出文件错误框。
环境变量一般不用额外传。子进程默认继承父进程环境块,除非你显式传 CREATE_UNICODE_ENVIRONMENT。如果你的目标环境需要在某个隐藏的机器上做调试,可以在创建进程的第六个参数传自定义环境块。这个参数平时没人管,但在注入场景里它是唯一能早期控制目标进程行为的地方。
4. 变体与扩展:挂起注入、反射加载和 32/64 位差异
4.1 进程空中与挂起注入的本质区别
很多人把进程空中、进程替换、挂起注入混成一个东西,其实边界很清晰。挂起注入只做一件事:在 CREATE_SUSPENDED 之后,把一段 shellcode 写进目标进程空间,然后改线程上下文把入口指到 shellcode,通常还需要用 LoadLibrary 或手动映射来加载一个 DLL。它不改写宿主映像,只往空间里塞东西。
进程空中更高一层:它把宿主进程本身“腾空”,然后让目标 exe 以“正规 PE 加载”的方式进驻。这意味着目标 exe 的加载过程几乎等同于自己跑——有完整的 PE 头、入口点、导入表解析,甚至 TEB/PEB 都完整。挂起注入对 DLL 这种“附加模块”场景更合适,但如果你要跑的是一个独立 exe,进程空中的完整度明显更高。
我通常在需要“原汁原味地跑一个独立程序”时选进程空中,再配合 Windows API 级别的重定位和导入表处理。如果只是往一个运行中的系统程序里插桩,挂起注入更轻量,翻车率低得多。
4.2 反射式加载:更灵活的入内存方案
反射式 DLL 加载和进程空中是两种互补的思路。反射式加载不依赖系统的 LoadLibrary,而是自己在内存里解析 DLL 的 PE 结构、完成重定位、解决导入表、调用 DllMain。好处是可以加载任意字节流,甚至可以从 socket 上吃数据,处理完直接在本地跑,全程无文件。坏处是导入表如果没处理好,目标进程里的 ntdll 和 kernel32 地址对不上,一调用 API 就炸。
工程上我见过三种组合方式:
- 先用进程空中起一个宿主,再用反射式加载把 DLL 载入这个宿主,用于多模块场景
- 直接用反射式加载把所有模块映射到当前进程,不新建进程
- 两种都不做,用最简单的 VirtualAllocEx + WriteProcessMemory 注入 shellcode,靠 shellcode 自查 PE 结构
第三种在对抗检测时最有用,因为它的内存特征和正常程序差别最小。但开发成本高,shellcode 里你没法用 C 标准库。
4.3 32/64 位架构下的关键差异
位数不一致是最大的坑,没有之一。64 位进程不能直接加载 32 位目标 exe,这跟内存地址空间的大小有关,也跟重定位算法有关。32 位镜像的 SizeOfImage 通常小一个数量级,而 64 位镜像的节区对齐高达 0x1000,里面的 IAT、延迟导入等结构指针宽度也不同。
我建议从 64 位宿主加载 64 位目标 exe 起步。最直接的原因是:你不需要处理 WoW64 层,地址空间干净,PE 解析逻辑只用写一套。如果你想折腾 32 位宿主加载 32 位 exe,只有一条路最稳妥——把整个注入代码编译成 32 位进程运行,然后调用 32 位系统 API。
混合位数可以做到,但需要借助 Wow64 的转换层。具体做法是 64 位注入器在宿主进程里创建远程线程,这个线程运行在 32 位模式下,然后在线程里手动加载 32 位 exe。复杂度接近翻倍,而且很多系统 API 在切层之后行为不一致。这类需求如果不是有明确业务驱动,建议直接换方案。
5. 避坑记录:权限、对齐和被 Windows 杀掉的那几次
5.1 32 位偏移值读错,宿主进程瞬间崩溃
现象:CreateProcess 成功后,GetThreadContext 也能成功,但在 ResumeThread 之后进程立刻退出,而且退出码是 0xC0000005。
原因:PEB 的 ImageBaseAddress 偏移写错了。我一开始把 x64 布局的偏移 0x10 用在了 32 位宿主上,读到的是一个无效指针,导致后面所有的 ReadProcessMemory 都拿到错误基址,写入位置完全错乱。
解决:在读取 PEB 之前用 IsWow64Process 判断宿主位数,分别给两个偏移值。32 位 PEB 布局里,ImageBaseAddress 偏移也是 0x8,跟 64 位不一样。修完之后进程能正常跑起来。
5.2 VirtualAllocEx 返回空,但宿主空间足够
现象:VirtualAllocEx 传了 PreferredBase,返回 NULL,GetLastError 是 ERROR_INVALID_ADDRESS。
原因:VirtualAllocEx 的请求地址必须按系统的分配粒度对齐——64 位系统是 64KB,而 PE 的 SectionAlignment 常见是 0x1000(4KB),PreferredBase 往往只对齐到 0x10000 的整数倍。如果目标 exe 的 PreferredBase 恰好不在分配粒度边界上,VirtualAllocEx 就会拒绝分配。
解决:先把 PreferredBase 向上对齐到 64KB 边界,再传给 VirtualAllocEx,然后把整个 PE 的重定位表完整处理一遍。如果目标 exe 编译时用了 /FIXED 标志,没有重定位表,那就只能让加载器自动选址,不能硬塞到别的基址上。
5.3 目标 exe 有 TLS 回调,加载后秒退
现象:宿主进程恢复后几毫秒内退出,而且没有异常代码,看起来像正常结束。
原因:PE 的 TLS(线程局部存储)回调函数会在入口点执行之前运行。如果它依赖进程的环境变量或者宿主模块的导入函数,而宿主映像已经被卸载,调用就会静默失败,进程走完 TLS 回调后认为初始化失败,直接 TerminateProcess。
解决:在写入映像之前,把目标 exe 的 TLS 目录解析出来,确保回调地址不被覆盖,并且在进程空中完成后手动重放一次 TLS 回调。另一个比较粗暴的规避方法是选择不带 TLS 回调的 exe,或者用 Section Alignment 压缩掉 TLS 目录。
5.4 杀软把 VirtualAllocEx 的行为当成注入
现象:WriteProcessMemory 调用后,安全软件直接拦截,甚至把宿主进程标记为威胁。
原因:安全软件监控了跨进程写内存的 API。很多杀软会对 WriteProcessMemory + CreateRemoteThread 的组合做启发式检测,并不一定要抓到样本特征。
解决:改用 NtWriteVirtualMemory 直接进内核态,绕过用户态 hook。另一种做法是先以挂起方式创建宿主,用写入器把自己的代码写到宿主 PEB 附近,然后让宿主内已有线程负责加载目标。后者改动大,但检测面更小。这个处理常见于安全研究场景,不是常规业务应该碰的东西。
5.5 恢复线程后宿主进程白屏无响应
现象:ResumeThread 成功,但进程没有任何窗口,CPU 占用 100%。
原因:目标 exe 的入口点期望它的创建方式来自 ShellExecute 或标准加载流程,而直接进内存的时候没有初始化进程堆、环境变量或标准句柄。当程序内部调用 GetCommandLine 或 GetModuleHandle(NULL) 时,拿到的值不对,进入死循环或者异常分支。
解决:确保在写上下文前,把 PEB 里 ProcessParameters 的 CommandLine 和 ImagePathName 同时作废。最简单粗暴的方式是把这两个 UNICODE_STRING 的 Length 和 MaximumLength 都置为零,让目标 exe 拿不到任何路径信息,走它自己的默认逻辑。
6. 验证与检测:复现后的三步确认法与蓝队视角
先说复现后的验证。我不会只看进程是否活着,因为宿主可能跑起来但功能完全不对。我的标准三步确认法是这样的:
第一步,用 Process Explorer 打开宿主进程的属性。如果进程空中成功,宿主进程路径列显示的还是原来的 notepad.exe,但下方加载的模块列表里出现了目标 exe 的模块名(如果目标 exe 有导出表或者依赖项,它们会出现在列表里)。这一步是最快的肉眼验证。
第二步,用 x64dbg 附加到进程,切到主线程,查看线程栈。如果目标 exe 是从入口点正常跑起来的,栈上会有 kernel32 的启动函数调用链;如果是硬改上下文塞进去的,栈顶大概率是系统调用返回地址,没有启动器框架。这一步能确认加载是否完整。
第三步,给目标 exe 的核心函数下断点,在进程空中完成前就把断点埋伏好。恢复线程后如果命中断点,说明代码确实在按 PE 逻辑执行。如果断点没命中,但宿主进程不死,说明程序可能走了异常处理分支,这时要看调试输出窗口里的异常序列。
从蓝队视角做检测的话,这个技术的特征有三条:一是 CREATE_SUSPENDED 之后很快就出现了 VirtualAllocEx + WriteProcessMemory 的组合,且分配的内存区域是 PAGE_EXECUTE_READWRITE;二是宿主进程的 PEB.ImageBaseAddress 被改写,原始模块路径与进程路径不一致;三是控制流从系统调用返回但目标地址不在任何已知模块范围内。
我写过一套轻量的检测脚本,逻辑不复杂:轮询系统进程列表,对每个进程读取 PEB,记录 ImageBaseAddress,再对比进程路径指向的模块基址。如果两者偏差超过阈值,就标记为可疑。这套逻辑放在企业级 EDR 里就是敏感 API 调用链的检测基线。
说到权限,进程空中对权限的要求比通常认为的低得多。同一用户下 CreateProcess 挂起子进程完全合法,不需要 SeDebugPrivilege。只有跨用户或 SYSTEM 级注入才需要提权。这也意味着,安全软件的防御重点不能只放在“无权限”判断上,得结合调用序列和行为特征。
从那以后,我每次做内存加载验证,都强制走一遍上面的三步确认法,先看模块列表,再看线程栈,最后落断点。这个东西一旦出错不会给你任何像样的报错,只会静默崩掉或者跑出鬼畜行为,手工确认是唯一的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取