PE Code Cave 检测原理与攻防边界:从文件字节到控制流验证
基于 339 个 Windows PE 样本的复测与误报分析 项目地址: https://github.com/mrxiaofaker/PE_CodeCave 当前分析版本: 0727f0d 这个项目最早不是为了做“漏洞扫描器”。 我一开始只是想用 C 写一个自己的 PE 分析工具。那时候我在学节表、RVA、文件偏移这些东西,觉得只看资料记不住,干脆把“找代码洞”做成一个能运行的程序。输入 PE,…
基于 339 个 Windows PE 样本的复测与误报分析
项目地址:https://github.com/mrxiaofaker/PE_CodeCave
当前分析版本:0727f0d
这个项目最早不是为了做“漏洞扫描器”。
我一开始只是想用 C 写一个自己的 PE 分析工具。那时候我在学节表、RVA、文件偏移这些东西,觉得只看资料记不住,干脆把“找代码洞”做成一个能运行的程序。输入 PE,解析节区,扫描连续的 0x00、0x90、0xCC,再把结果输出成 HTML 和 JSON。
我一开始觉得逻辑挺直观:可执行节里有连续空白字节,大小超过阈值,那就是 Code Cave。
我拿本地一个 Windows 应用目录跑了一批样本。339 个 PE,最后 339 个都有“代码洞”,其中 333 个直接顶到单文件 150 条上限。那一刻我第一反应不是“发现了什么”,而是这套判断标准肯定有问题。
这篇文章就是这次复盘的记录。我会从 PE 坐标、扫描代码、评分、异常模块一直写到真实误报和攻防判断。文中没有对第三方程序做注入验证,涉及执行的部分只使用自编译样本。
1. 我为什么先写解析器
项目结构不复杂:
main.c
|
+-- core/engine.c
|
+-- parser/pe_parser.c
+-- analyzer/cave_detector.c
+-- detector/anomaly_detector.c
+-- reporter/structured_report.c我没有一上来接第三方 PE 库。原因有两个:一是想自己把 DOS 头、NT 头、节表走过一遍;二是后面要改扫描逻辑时,不想被库的封装挡住。
解析部分主要做这些事:
检查
MZ和PE\0\0签名;通过
e_lfanew找到 NT 头;读取
NumberOfSections;遍历节表,保存节名、RVA、原始偏移、大小和权限;
计算每个节的熵值;
保存入口点和
ImageBase。
项目在 Visual Studio 2022 / MSVC 14.44 下可以完成 Debug|x64 编译。当前版本是 0 个错误,17 个警告。这些警告里还有 size_t 到 uint32_t 的截断、函数声明不一致之类的问题,所以我没有把“能编译”当成“结果一定对”。



2. 我最早在 PE 坐标上踩的坑
分析代码洞,绕不开三个地址:
VA = ImageBase + RVA
FOA = PointerToRawData + (RVA - VirtualAddress)
RVA = VirtualAddress + (FOA - PointerToRawData)这里最容易混的是 RVA 和 FOA。
我最开始把“文件中看到的偏移”和“加载后的 RVA”混在一起用。表面上看只是一个变量名的问题,实际会让上游扫到的位置和下游计算入口点距离时完全对不上。
为了把文件坐标和内存坐标分开,结构里保存了两个位置:
typedef struct {
uint32_t offset; // FOA
uint32_t rva;
uint32_t size;
uint32_t section_index;
uint32_t cave_type;
double suspicion_level;
char descriptor[256];
} code_cave_t;现在文件中能用 offset 定位,加载后用 rva 计算距离。节区间隙如果没有文件字节承载,offset 就写 0。
拿语料里的一个匿名样本举例,它的 .text 节是:
VirtualAddress = 0x00010000
PointerToRawData = 0x00000400
FOA = 0x00000619换算成 RVA:
RVA = 0x10000 + (0x619 - 0x400)
= 0x10219这个 0x10219 后面还会出现,它是我这次复测里最有代表性的一个误报。



3. 三种代码洞扫描
3.1 零填充
零填充检测在 analyzer/cave_detector.c 的 detect_zero_fill_caves。
实现是一个很直接的状态机:
for (uint32_t i = 0; i < size && *cave_count < max_cave; i++) {
if (data[i] == 0x00) {
if (!in_cave) {
cave_start = i;
cave_size = 1;
in_cave = true;
} else {
cave_size++;
}
} else {
if (in_cave && cave_size >= MIN_CAVE_SIZE) {
code_cave_t* cave = &caves[*cave_count];
cave->offset = base_offset + cave_start;
cave->rva = base_rva + cave_start;
cave->size = cave_size;
cave->section_index = section_index;
cave->cave_type = CAVE_TYPE_ZERO_FILL;
(*cave_count)++;
}
in_cave = false;
cave_size = 0;
}
}这段代码只判断“连续字节是不是达到最小长度”。它不知道当前地址是不是一条指令的起点,也不知道这段零字节是不是某条 MOV 指令的立即数。
MIN_CAVE_SIZE 设的是 5。5 字节刚好能放一条 E9 rel32 相对跳转,所以从攻击原语角度看是合理的。但同样因为大量 x86/x64 指令包含 4 字节位移和立即数,5 字节也会大量命中指令内部。






3.2 模式填充
模式填充检测在 detect_pattern_fill_caves。实现方式是对 0xCC、0x90、0xFF 各扫一遍:
const uint8_t patterns[] = { 0xCC, 0x90, 0xFF };
for (size_t p = 0; p < pattern_count; p++) {
uint8_t pattern = patterns[p];
bool in_cave = false;
for (size_t i = 0; i < size; i++) {
if (data[i] == pattern) {
if (!in_cave) {
cave_start = i;
cave_size = 1;
in_cave = true;
} else {
cave_size++;
}
} else if (in_cave) {
if (cave_size >= MIN_CAVE_SIZE) {
// 记录 PATTERN_FILL 候选
}
in_cave = false;
cave_size = 0;
}
}
}这三个模式的含义差很多:
0xCC是INT3,函数对齐和调试版本里很常见;0x90是NOP,经常出现在跳板和对齐区域;0xFF在 x86/x64 里太常见了,可能是操作码、ModRM,也可能是数据。
尤其是 0xFF,连续出现并不说明这块空间没人用。把它和 0x00 用同一套方式判断,误报不会少。
本次 339 个样本中,模式填充候选有 10,223 个。
3.3 节区间隙
节区间隙检测在 detect_section_gaps。
现在的逻辑先取当前节的虚拟结束位置,再和下一节的起始 RVA 比较:
uint32_t current_end =
current->virtual_address + current->virtual_size;
if (current_end > next->virtual_address) {
continue;
}
uint32_t gap_size =
next->virtual_address - current_end;如果间隙长度在 5 到 4096 字节之间,就记录候选,并尝试算文件偏移:
uint32_t gap_offset =
current_end - current->virtual_address;
cave->offset = gap_offset < current->raw_size
? current->raw_address + gap_offset
: 0;
cave->rva = current_end;这里的实现把 RVA 和 FOA 分开保存。节区间隙如果没有文件字节承载,FOA 写 0,RVA 仍然保留。
不过这段仍有两个限制:它按节表顺序比较,不保证节表已按 RVA 或 FOA 排序;另外 MAX_CAVE_SIZE 是 4096,更大的间隙会被忽略。
本次只剩下 29 个节区间隙候选。
4. 可疑度和报告分数怎么算
单个洞穴的分数由几个因素相加:
位于可执行节:
+0.4长度大于 32:
+0.3长度 17 到 32:
+0.2长度不大于 16:
+0.1零填充:
+0.2模式填充:
+0.1节区间隙:
+0.3节熵值低于 4.0:
+0.2
一个 5 字节的 .text 零填充候选,分数是:
0.4 + 0.1 + 0.2 = 0.7也就是 70%。
这个计算里没有反汇编、引用关系、指令边界,只有节权限、大小、类型和熵。
到了报告层,再按数量做一次汇总:
cave_score =
min(20, cave_count * 1.25)
+ max_suspicion * 20
+ average_suspicion * 10
+ min(15, high_risk_caves * 3)
risk_score =
clamp(cave_score + anomaly_score + section_score, 0, 100)候选越多,文件分数越高。再加上单文件最多记录 150 个洞穴,结果很容易快速饱和。
所以报告里的“高危”只能表示值得先看,不能直接当成漏洞。
5. 339 个样本的结果
这次测试选了本地一个复杂 Windows 应用分发目录里的 339 个 EXE、DLL 和 SYS 文件。
统计口径是:
按目标绝对路径去重;
每个目标取一份完整 HTML 报告;
单文件最多记录 150 个候选,达到上限时把数量当作下界;
用 Windows Authenticode 检查采集时的签名状态。
结果如下:
项目 | 数量 |
|---|---|
唯一目标 | 339 |
有效签名 | 337 |
未签名 | 1 |
签名状态未知 | 1 |
出现候选 | 339 |
达到 150 条上限 | 333 |
评分不低于 60 | 333 |
评分不低于 80 | 48 |
记录候选总数 | 50,587 |
异常总数 | 1,057 |
候选类型:
类型 | 数量 |
|---|---|
零填充 | 40,335 |
模式填充 | 10,223 |
节区间隙 | 29 |
候选所在节区:
节区 | 数量 |
|---|---|
| 45,243 |
| 4,631 |
| 120 |
| 57 |
| 5 |
长度分布也很集中:
长度 | 数量 |
|---|---|
5 字节 | 28,485 |
6 字节 | 7,148 |
7 字节 | 5,038 |
8 字节 | 1,790 |
9 字节 | 986 |
10 字节 | 994 |
28,485 个候选刚好 5 字节,占全部记录的 56% 左右。
我看到这个结果后基本确定,检测器已经把大量指令内部字节当成了洞穴。

还有一点值得放在一起看:339 个目标里有 337 个具备有效签名。签名不能证明程序运行时没被改,但至少说明,大量“高危代码洞”出现在正常签名组件里,不能直接下恶意结论。
6. 一个很典型的误报
样本 A 的第一条候选是:
FOA 0x00000619
RVA 0x00010219
长度 5
节区 .text
类型 ZERO_FILL
分数 70%只看报告,它很像一个标准 5 字节代码洞。
我把 RVA 0x10219 丢进反汇编,附近的指令是:
00010210 0F 57 C0
xorps xmm0, xmm0
00010213 48 C7 05 72 0A 20 00 00 00 00 00
mov qword ptr [rip + 0x200A72], 0
0001021E 48 8D 0D 6B 38 13 00
lea rcx, [rip + 0x13386B]命中的 5 个零字节在这里:
48 C7 05 72 0A 20 00 00 00 00 00
^^^^^^^^^^^^^
位移高字节 + 32 位立即数它不是函数末尾的填充,也不是两个函数之间的空白,而是一条正常 MOV 指令的一部分。RVA 0x10219 也不是这条指令的起点。
如果我直接把它换成 E9 rel32,原来的 MOV 就被破坏了。就算后面通过别的办法把控制流跳到这里,也需要先解决写入、映射和恢复原指令的问题。
这个例子基本解释了为什么扫描器会得到 100% 的命中率:它在看字节,不在看指令。


7. 异常模块在做什么
如果报告里只有坐标和长度,信息很多,但实际很难排序。anomaly_detector.c 负责把节区异常和洞穴分布拉进来。它的检测顺序是:
detect_entropy_anomalies();
detect_section_anomalies();
detect_cave_cluster_anomalies();
detect_entry_point_cave_cluster_anomalies();单文件最多记录 50 个异常。这个上限会影响结果:如果前面的熵值异常已经把数组占满,后面的洞穴聚集和入口点异常就可能写不进去。
目前一共有八类异常。
7.1 熵值异常
低熵可执行节的判断是:
section->is_executable &&
section->entropy < 4.0高熵可执行节是:
section->is_executable &&
section->entropy > 7.5相邻节熵差超过 4.0,也会记一条“熵值不连续”:
diff = abs(current->entropy - next->entropy);
if (diff > 4.0) {
emit("熵值不连续");
}问题在于 PE 节本来就有不同的熵分布。.text、.rdata、.data、.rsrc 和 .reloc 之间出现较大差值很正常,所以这一类异常的数量最多,本次有 303 条。
7.2 节结构异常
结构检查包括:
节同时可写可执行;
VirtualSize > 0但SizeOfRawData == 0;VirtualSize / SizeOfRawData大于 10 或小于 0.1。
这一段能发现一些值得看的节,但也会命中 .bss、TLS、未初始化数据和文件对齐带来的正常差异。
7.3 洞穴聚集
聚集检测会对每两个候选计算距离:
if (distance < 1024 &&
caves[i].section_index == caves[j].section_index) {
clusters[cluster_count]++;
}如果某个候选至少有一个相邻候选,cluster_count 就增加。最后 cluster_count > 3 时,输出“洞穴聚集”。
339 个样本全部触发了这条异常。
原因并不复杂。单个 .text 里一旦出现几十上百个 5 字节候选,1024 字节内找到邻居几乎是必然的。这个算法实际只说明“有很多候选靠得比较近”,没有证明它们属于同一段注入代码。
复杂度是 O(n²)。候选上限 150,所以运行成本还能接受,但“聚集”的含义比较弱。
7.4 入口点附近洞穴
入口点检测计算洞穴 RVA 和入口点 RVA 的绝对距离:
distance = abs(cave_rva - entry_point_rva);
if (distance < 256) {
emit("入口点附近洞穴");
}这个信号比单纯统计数量有用一些,因为入口点附近确实是补丁常见位置。
但它仍然没有回答:候选是不是指令起点、有没有被引用、是不是同一个函数、改了以后能不能恢复。
本次触发 214 次。
7.5 八类异常的实测分布
异常 | 数量 | 触发条件 |
|---|---|---|
洞穴聚集 | 339 | 同一节内 1024 字节以内存在邻近候选 |
熵值不连续 | 303 | 相邻节熵差大于 4.0 |
入口点附近洞穴 | 214 | 洞穴 RVA 与入口点距离小于 256 |
异常大小比例 | 129 | 虚拟大小与原始大小比例大于 10 或小于 0.1 |
纯虚拟节 | 40 | 虚拟大小大于 0,原始大小为 0 |
低熵可执行节 | 26 | 可执行节熵值小于 4.0 |
高熵可执行节 | 6 | 可执行节熵值大于 7.5 |
可写可执行节 | 0 | 同时具备写和执行标志 |
这些异常适合拿来排序,不适合单独定性。
8. 从代码洞到控制流,需要满足什么
从检测结果走到攻击面时,“代码洞”这个词很容易让人误解。
找到一个连续字节区,只能说明这里有候选空间。要变成真正的攻击面,至少还需要:
能写入;
映射后能执行;
有控制流能到达或转移过去;
执行完能恢复现场。
文件修改和内存修改也不一样。
文件修改会改变哈希和签名,重启后仍然存在;内存修改不动磁盘文件,但需要进程访问和写入能力,还会留下页权限、线程起点或映像差异等运行时证据。
8.1 5 字节相对跳转
x86/x64 里常见的相对跳转是:
E9 rel32 JMP rel32
E8 rel32 CALL rel32
EB rel8 JMP rel85 字节跳转的计算方式是:
rel32 = target_rva - (source_rva + 5)写成 C:
#include <stdint.h>
#include <string.h>
static int encode_rel32_jump(
uint8_t out[5],
uint32_t source_rva,
uint32_t target_rva)
{
int64_t delta =
(int64_t)target_rva - ((int64_t)source_rva + 5);
if (delta < INT32_MIN || delta > INT32_MAX) {
return -1;
}
out[0] = 0xE9;
int32_t rel = (int32_t)delta;
memcpy(out + 1, &rel, sizeof(rel));
return 0;
}这段只负责编码跳转,不包含打开进程、写内存、改权限或创建线程。
在我自己的测试里,只拿自编译的 EXE 副本做验证。原因很简单:对真实程序做注入,既没有必要,也会把文章重点从检测带偏。

9. 检测应该看什么
如果让我现在重新设计,我不会先算一个总分,而是先做证据分层。
9.1 文件层面
FOA 和 RVA 都要有;
候选必须能落回节区;
检查指令边界;
检查代码和数据交叉引用;
排除异常目录、重定位和跳转表;
与同组件历史版本比较。
9.2 运行层面
可执行页是否从只读变成可写;
是否出现未映射的可执行页;
线程起点是否在正常模块函数之外;
内存中的入口点和 IAT 是否与磁盘一致;
是否有跨模块跳到节尾或填充区的控制流。
我现在更愿意把结果分成五级:
SIGNAL
CANDIDATE
MAPPED
REACHABLE
OBSERVED只有走到 OBSERVED,也就是确实看到写入、权限变化或执行行为,才值得进入高优先级告警。
SIGNAL 和 CANDIDATE 本来就是供研究用的中间结果,不应该被直接包装成“高危漏洞”。





10. 怎么复现我的结果
10.1 编译
我用的是 Visual Studio 2022,Debug|x64。当前版本编译结果:
0 个错误
17 个警告10.2 扫描
选择单个 PE 或目录扫描,生成 HTML 报告。
看报告时我会先确认三件事:
FOA 能不能换算回 RVA;
候选是不是在节的原始数据范围内;
这个位置在反汇编里是不是一条指令的起点。
只要第三步不成立,后面分数再高也没有太大意义。
10.3 受控验证
如果要验证执行,我只用自己编译的程序:
编译 Release x64;
记录原始节表和入口点;
复制一份测试文件;
在副本上写入相对跳转;
用调试器观察 RIP;
恢复原始字节;
对比哈希和程序输出。
这一步能帮助理解文件修改、页权限和控制流之间的关系,也能暴露很多“看起来可行、实际跑不通”的问题。




11. 最后
这次复测让我改变了一个很基础的想法。
只看连续字节时,很容易把零填充、连续 NOP 和函数对齐留下的空隙都当成代码洞。真正分析时要先问:这段字节属于哪条指令?有没有被引用?是文件里的内容,还是只存在于虚拟空间?就算能写进去,控制流怎么过去?
339 个样本全部出现候选,不代表 339 个样本都有问题。这组结果更像一张体检报告:扫描器确实能看到空白,但它还不会区分“空白”和“未使用”。
对逆向分析来说,找到候选只是开始。真正有价值的部分,是继续证明哪些候选不成立,以及剩下的候选为什么成立。
参考
项目 PE_CodeCave:https://github.com/mrxiaofaker/PE_CodeCave
Microsoft PE Format:https://learn.microsoft.com/windows/win32/debug/pe-format
Microsoft Authenticode:https://learn.microsoft.com/windows-hardware/drivers/install/authenticode
Corkami PE101:https://github.com/corkami/pics/blob/master/binary/pe101.pdf
Corkami PE102:https://github.com/corkami/pics/blob/master/binary/pe102.pdf
Corkami PE 文档:https://github.com/corkami/docs/blob/master/PE/PE.md
0xrick PE 格式系列:https://0xrick.github.io/win-internals/pe1/
MalwareTech PE File Format:https://www.malwaretech.com/2013/11/portable-executable-file-format.html
Elastic Process Injection Techniques:https://www.elastic.co/blog/ten-process-injection-techniques-technical-survey-common-and-trending-process
MITRE ATT&CK T1055:https://attack.mitre.org/techniques/T1055/
x64dbg 文档:https://help.x64dbg.com/en/latest/
Capstone:https://www.capstone-engine.org/
Ghidra:https://ghidra-sre.org/