基于 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

候选所在节区:

节区

数量

.text

45,243

.rdata

4,631

.rsrc

120

.data

57

.pdata

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. 从代码洞到控制流,需要满足什么

从检测结果走到攻击面时,“代码洞”这个词很容易让人误解。

找到一个连续字节区,只能说明这里有候选空间。要变成真正的攻击面,至少还需要:

  1. 能写入;

  2. 映射后能执行;

  3. 有控制流能到达或转移过去;

  4. 执行完能恢复现场。

文件修改和内存修改也不一样。

文件修改会改变哈希和签名,重启后仍然存在;内存修改不动磁盘文件,但需要进程访问和写入能力,还会留下页权限、线程起点或映像差异等运行时证据。

8.1 5 字节相对跳转

x86/x64 里常见的相对跳转是:

E9 rel32    JMP rel32
E8 rel32    CALL rel32
EB rel8     JMP rel8

5 字节跳转的计算方式是:

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 报告。

看报告时我会先确认三件事:

  1. FOA 能不能换算回 RVA;

  2. 候选是不是在节的原始数据范围内;

  3. 这个位置在反汇编里是不是一条指令的起点。

只要第三步不成立,后面分数再高也没有太大意义。

10.3 受控验证

如果要验证执行,我只用自己编译的程序:

  • 编译 Release x64;

  • 记录原始节表和入口点;

  • 复制一份测试文件;

  • 在副本上写入相对跳转;

  • 用调试器观察 RIP;

  • 恢复原始字节;

  • 对比哈希和程序输出。

这一步能帮助理解文件修改、页权限和控制流之间的关系,也能暴露很多“看起来可行、实际跑不通”的问题。

11. 最后

这次复测让我改变了一个很基础的想法。

只看连续字节时,很容易把零填充、连续 NOP 和函数对齐留下的空隙都当成代码洞。真正分析时要先问:这段字节属于哪条指令?有没有被引用?是文件里的内容,还是只存在于虚拟空间?就算能写进去,控制流怎么过去?

339 个样本全部出现候选,不代表 339 个样本都有问题。这组结果更像一张体检报告:扫描器确实能看到空白,但它还不会区分“空白”和“未使用”。

对逆向分析来说,找到候选只是开始。真正有价值的部分,是继续证明哪些候选不成立,以及剩下的候选为什么成立。

参考

  1. 项目 PE_CodeCave:https://github.com/mrxiaofaker/PE_CodeCave

  2. Microsoft PE Format:https://learn.microsoft.com/windows/win32/debug/pe-format

  3. Microsoft Authenticode:https://learn.microsoft.com/windows-hardware/drivers/install/authenticode

  4. Corkami PE101:https://github.com/corkami/pics/blob/master/binary/pe101.pdf

  5. Corkami PE102:https://github.com/corkami/pics/blob/master/binary/pe102.pdf

  6. Corkami PE 文档:https://github.com/corkami/docs/blob/master/PE/PE.md

  7. 0xrick PE 格式系列:https://0xrick.github.io/win-internals/pe1/

  8. MalwareTech PE File Format:https://www.malwaretech.com/2013/11/portable-executable-file-format.html

  9. Elastic Process Injection Techniques:https://www.elastic.co/blog/ten-process-injection-techniques-technical-survey-common-and-trending-process

  10. MITRE ATT&CK T1055:https://attack.mitre.org/techniques/T1055/

  11. PE-sieve:https://github.com/hasherezade/pe-sieve

  12. Moneta:https://github.com/forrest-orr/moneta

  13. x64dbg 文档:https://help.x64dbg.com/en/latest/

  14. Capstone:https://www.capstone-engine.org/

  15. Ghidra:https://ghidra-sre.org/