进程空洞化(Process Hollowing)
概念
进程空洞化(Process Hollowing)是一种典型的高级进程注入技术。它不是简单地把一段 shellcode 写入已有进程,而是:
创建一个合法进程
→ 以挂起状态启动
→ 移除或覆盖原始映像
→ 写入恶意 PE 映像
→ 修改线程入口
→ 恢复线程执行
最终结果通常是:
进程名、父子关系和初始命令行看起来正常
但进程地址空间中实际运行的是另一份映像
因此,进程空洞化的关键不只是“跨进程写内存”,而是利用一个正常进程的外壳承载并执行另一份 PE 映像。它可以降低陌生恶意进程直接出现在进程列表中的可见性,是比普通内存写入更完整、更复杂的一类注入手法。
基本原理
Windows 进程启动后,系统会为它创建地址空间并映射原始 PE 文件。进程空洞化会在目标进程尚未真正执行前,破坏这份原始映像,再将攻击者准备好的映像放入目标进程地址空间。
典型流程如下:
CreateProcessW(CREATE_SUSPENDED)
→ NtUnmapViewOfSection / ZwUnmapViewOfSection
→ VirtualAllocEx / NtAllocateVirtualMemory
→ WriteProcessMemory / NtWriteVirtualMemory
→ 写入 PE 头、节区和重定位信息
→ SetThreadContext / NtSetContextThread
→ ResumeThread
不同实现可能不完全调用同一组 API。有的实现不会完整卸载原映像,而是直接覆盖原有区域;有的实现使用 NtMapViewOfSection 映射恶意映像;也有的实现通过 CreateProcessAsUserW 或令牌创建目标进程。判断重点是行为关系,而不是是否出现某个固定 API。
典型执行阶段
1. 创建挂起进程
攻击者先创建一个看起来正常的目标进程,并使用挂起标志阻止其主线程立即执行:
CreateProcessW
+ CREATE_SUSPENDED
常见目标通常是系统目录下的常见可执行文件,例如:
svchost.exe:常见系统服务宿主;explorer.exe:桌面和用户会话相关进程;RuntimeBroker.exe:常见用户会话宿主;dllhost.exe:COM 代理宿主;taskhostw.exe:任务和组件宿主。
目标选择的核心不是进程名本身,而是进程是否常见、是否长期运行、是否具备合适的用户会话或权限,以及其启动是否容易与正常系统活动混淆。
检测时应关注:
可疑进程
→ CreateProcess(系统目录中的常见进程)
→ CREATE_SUSPENDED
单独创建挂起进程并不等于进程空洞化,调试器、安装程序和兼容性组件也可能正常使用该标志。需要继续观察后续是否存在卸载映像、跨进程写入和线程上下文修改。
2. 移除或覆盖原始映像
目标进程创建后,攻击者可能先查询其原始映像基址,再将原始映像从进程地址空间中卸载:
NtQueryInformationProcess
→ 获取 PEB 或映像基址
→ NtUnmapViewOfSection
也可能不调用 NtUnmapViewOfSection,而是直接在目标地址空间分配区域并覆盖原有内容。这个阶段的意义是清除或破坏目标进程原本应运行的代码,为后续映射恶意映像腾出空间。
高价值信号是:
CREATE_SUSPENDED
+ NtUnmapViewOfSection
+ 目标进程后续出现大量内存写入
3. 分配内存并写入恶意 PE
攻击者随后把恶意 PE 的内容写入目标进程,通常包括 PE 头、节区、导入表修复所需数据以及重定位后的代码:
VirtualAllocEx / NtAllocateVirtualMemory
→ WriteProcessMemory / NtWriteVirtualMemory
→ 写入 PE Header 和各个 Section
与普通 shellcode 注入相比,这里写入的不是一小段独立机器码,而可能是一份完整的 PE 映像。常见后续操作包括:
- 按目标地址重新计算重定位;
- 修复导入地址表;
- 写入节区并设置不同的内存保护属性;
- 修改 PEB 中的映像相关信息;
- 准备新的进程参数或环境数据。
因此,遥测上可能看到多次、分区域、跨页面的内存写入,而不是单次写入一小段载荷。
4. 修改线程入口并恢复执行
恶意映像写入后,攻击者需要让挂起的主线程从新的入口点开始执行:
GetThreadContext / NtGetContextThread
→ 修改 Instruction Pointer
→ SetThreadContext / NtSetContextThread
→ ResumeThread
在 x64 环境中通常关注 RIP,在 x86 环境中通常关注 EIP。有些实现会让线程先进入一个引导代码,再由引导代码完成导入解析、重定位或跳转到真正入口。
因此,下面的组合比单独的 ResumeThread 更值得关注:
挂起线程
+ 修改线程上下文
+ 目标进程内存发生 PE 结构写入
+ 恢复线程后从非原始映像区域执行
与普通写内存、远程注入的区别
普通跨进程写内存
普通写内存通常是对一个已经运行的目标进程申请或寻找内存,然后写入数据:
OpenProcess
→ VirtualAllocEx
→ WriteProcessMemory / NtWriteVirtualMemory
它可能用于写入配置、参数、DLL 路径或 shellcode。WriteProcessMemory 本身只代表发生了跨进程写入,后续是否执行还要看 APC、远程线程或线程上下文等行为。
传统远程线程注入
传统远程线程注入通常针对一个已经运行的目标进程:
打开目标进程
→ 分配内存
→ 写入 shellcode 或 DLL 路径
→ CreateRemoteThread / NtCreateThreadEx
目标进程原本的映像一般仍然存在,攻击者只是额外在其中执行一段代码。检测上重点关注远程线程创建、可执行内存、跨进程写入和异常模块加载。
APC 注入
APC 注入也是对已有目标进程进行操作,但执行触发方式不同:
写入目标进程内存
→ QueueUserAPC
→ 目标线程进入 alertable 状态后执行
它通常借用目标进程已有线程,不一定创建新的远程线程,因此在基础进程树和线程事件中可能比传统远程线程注入更隐蔽。
进程空洞化
进程空洞化的关键特征是目标进程通常从一开始就以挂起状态创建,原始映像随后被卸载、覆盖或替换:
创建挂起进程
→ 移除或覆盖原始映像
→ 写入完整 PE 映像
→ 修改主线程入口
→ 恢复执行
对比来看:
| 方式 | 目标进程状态 | 写入内容 | 主要执行方式 | 典型检测重点 |
|---|---|---|---|---|
| 普通写内存 | 通常已运行 | 参数、数据或 shellcode | 不一定执行 | 跨进程写入和访问权限 |
| 远程线程注入 | 通常已运行 | shellcode、DLL 路径或载荷 | 创建远程线程 | CreateRemoteThread、可执行内存 |
| APC 注入 | 通常已运行 | APC 回调或载荷 | 借用已有线程 | QueueUserAPC、线程归属和后续执行 |
| 进程空洞化 | 通常先挂起创建 | 完整 PE 或主要映像内容 | 修改主线程入口后恢复 | 挂起创建、卸载映像、PE 写入、上下文修改 |
因此,进程空洞化不是简单的“把代码写进别的进程”,而是同时改变了目标进程的映像内容、内存布局和初始执行路径。这也是它被视为高级注入手法的原因。
为什么更隐蔽
进程名和进程树借用
进程列表中看到的可能是一个签名正常、路径正常的系统进程。恶意代码并不一定以自身文件名长期运行,初步排查如果只看进程名和父子关系,可能无法发现异常。
磁盘映像与内存映像不一致
进程启动时的文件路径可能指向正常系统文件,但进程内存中的 PE 头、模块列表、入口地址或节区内容已经发生变化。
这会形成:
磁盘上看到的是合法映像
内存中执行的是另一份映像
因此,进程空洞化通常需要结合磁盘映像和内存映像进行判断。
避免直接暴露恶意进程
传统注入也能借用目标进程,但进程空洞化从进程创建早期就替换了原始执行内容,目标进程可能从一开始就没有运行过其文件中原本的入口代码。这种方式更适合隐藏加载器身份、保持进程树外观以及承载多阶段载荷。
需要注意,隐蔽性不是绝对的。进程空洞化往往会留下更完整的行为链,尤其是挂起创建、卸载映像、批量写入和入口修改的连续组合。
检测方法
1. 关联进程创建与挂起行为
建立进程创建事件与线程状态的关联,重点关注:
可疑父进程
→ 创建常见系统进程
→ CREATE_SUSPENDED
→ 短时间内跨进程内存操作
高风险调用者包括用户目录、临时目录、下载目录中的程序,以及 Office、脚本解释器、压缩软件或异常安装程序派生的进程。
2. 监控原映像卸载
重点关注:
NtUnmapViewOfSection / ZwUnmapViewOfSection
单独出现该调用可能来自调试器、兼容层或正常加载逻辑,但如果它作用于刚刚创建且处于挂起状态的子进程,随后又出现内存分配和写入,关联价值很高。
3. 检查跨进程内存写入与保护属性
关注以下组合:
VirtualAllocEx / NtAllocateVirtualMemory
+ WriteProcessMemory / NtWriteVirtualMemory
+ VirtualProtectEx / NtProtectVirtualMemory
如果写入范围覆盖 PE 头和多个节区,或者出现从可写到可执行的内存保护变化,应检查这些区域是否属于目标进程的合法模块。
4. 检查线程上下文与入口地址
关注:
GetThreadContext / NtGetContextThread
→ SetThreadContext / NtSetContextThread
→ ResumeThread
重点比较:
- 恢复线程前后的
RIP/EIP; - 入口地址是否仍位于原始主模块的合法代码节;
- 入口地址是否位于匿名内存、私有提交内存或异常映射区域;
- 目标进程的 PEB、模块列表和实际内存映射是否一致。
5. 内存取证与映像一致性检查
这是识别进程空洞化的关键方法之一。可检查:
- 进程主模块路径与内存中主模块内容是否一致;
- PE 头是否存在于预期基址;
- 内存中的
MZ/PE结构是否对应磁盘文件; - 主模块节区是否被改写、缺失或映射属性异常;
MEM_PRIVATE区域是否包含完整 PE 映像;- 线程入口是否指向未被正常模块覆盖的内存区域;
- VAD、模块列表和实际映射之间是否存在不一致。
常见异常表现是:
进程路径正常
+ 主模块校验异常
+ 内存中出现另一份 PE 结构
+ 主线程入口不在原始模块范围内
6. 关联后续行为
进程空洞化完成后,应继续查看目标进程是否出现:
- 异常网络连接;
- 加载未签名或路径异常的模块;
- 创建服务、计划任务或其他持久化;
- 读取凭据、浏览器数据和其他用户目录;
- 连接 C2 或执行命令解释器;
- 创建子进程并继续注入其他目标。
目标进程后续行为可以帮助判断写入的是测试代码、合法插件,还是已经开始运行恶意载荷。
重点检测链
高置信度进程空洞化链
可疑进程
→ CreateProcess(CREATE_SUSPENDED)
→ NtUnmapViewOfSection
→ VirtualAllocEx / NtAllocateVirtualMemory
→ WriteProcessMemory / NtWriteVirtualMemory
→ SetThreadContext / NtSetContextThread
→ ResumeThread
无明显卸载调用的变体
CreateProcess(CREATE_SUSPENDED)
→ 目标进程大范围内存写入
→ PE 结构或节区出现于私有内存
→ 主线程入口被修改
→ ResumeThread
检测规则不应强制要求 NtUnmapViewOfSection 一定出现,否则容易漏掉直接覆盖原映像或使用节区映射的变体。
结论
普通写内存解决的是:
如何把数据或代码放入另一个进程
远程线程、APC 和线程劫持解决的是:
如何让目标进程执行这些代码
而进程空洞化进一步解决了:
如何让一个看起来正常的进程,从启动阶段就承载并执行另一份完整映像
因此,进程空洞化的高级性主要体现在三个方面:
- 借用正常进程的名称、路径和进程树外观;
- 替换目标进程的原始映像和执行入口,而不只是追加一段代码;
- 需要同时处理 PE 映像、内存布局、重定位、线程上下文和执行恢复。
检测时不应只搜索 WriteProcessMemory 或 CreateRemoteThread,而应把进程创建方式、目标进程初始状态、映像卸载、内存写入、线程入口和后续行为串成一条时间线。