首页 时政热点 科技头条 智能AI 安全攻防 数码硬件 开发者生态 归档 标签 关于

PE Code Cave 检测原理与攻防边界:从文件字节到控制流验证

摘要

基于 339 个 Windows PE 样本的复测与误报分析 项目地址: https://github.com/mrxiaofaker/PE_CodeCave 当前分析版本: 0727f0d 这个项目最早不是为了做“漏洞扫描器”。 我一开始只是想用 C 写一个自己的 PE 分析工具。那时候我在学节表、RVA、文件偏移这些东西,觉得只看资料记不住,干脆把“找代码洞”做成一个能运行的程序。输入 PE,...

RVA uint32_t FOA offset 339 PointerToRawData VirtualAddress rva cave Windows
2026-11-09 1 阅读 约8分钟阅读 先知社区
分享:
字号:
基于 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 头、节表走过一遍;二是后面要改扫描逻辑时,不想被库的封装挡住。 解析部分主要做这些事: 项目在 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; } } } 这三个模式的含义差很多: 尤其是 0xFF ,连续出现并不说明这块空间没人用。把它和 0x00 用同一套方式判断,误报不会少。 本次 339 个样本中,模式填充候选有 10,223 个。 3.3 节区间隙 节区间隙检测在 detect_section_gaps 。 现在的逻辑先取当前节的虚拟结束位置
这篇文章对您有帮助吗?

订阅66必读

每日精选科技资讯,直达你的邮箱