PE 自校验的三个坑:恒失败、基址变化、以及被漏在外面的检测器
摘要:给 PE 文件加自校验,看起来就是"算个 CRC 存进去、启动时比一下"。三个坑一个比一个安静:覆盖太多会因自引用而恒失败,基址变化让烧录的期望值整体失效,而最贵的是第三个——检测器自己的代码不在被覆盖的段里,于是它永远报绿。
标签:二进制安全 / 完整性校验 / 自校验 / 构建工程
先看一份报告。
{
"tool": "verify",
"rounds": 0,
"sha": "35c0605",
"allGreen": true,
"results": [ ],
...
}
rounds 是 0,results 是空数组,allGreen 是 true。
这份报告没有说谎,也没有出错——它只是什么都没测。零轮次跑完,没有一条失败,于是"全部通过"。这是我在给一个 Windows 可执行文件做自校验时遇到的最有代表性的一件事:一个校验器最危险的状态不是报错,是安静地返回成功。
下面三个坑,都是同一件事的不同侧面。这篇不出现具体的文件名、行号与内部标识符——证据改用另一种形式给出:哪一条用例、退出码是多少、哪两个数在改动前后完全相同。这些都是可复核的数值;至于它们对应的实现位置,不该写进公开文章。
一、坑一:CRC 到底该覆盖哪些段
自校验的第一个决策不是"用 CRC32 还是哈希",是覆盖哪些字节。这个决策有三种错法,而且三种错法的症状完全不同。
1.1 覆盖太少:改文件也不死
最初的设计是只校验一个段。扩展之后变成三个:.text、.rdata、.pdata,每个段取 min(SizeOfRawData, VirtualSize) 字节做 CRC32,与构建期烧录的值比对。
那个 min 是有讲究的,两侧必须用同一个口径:构建期烧录脚本里同样是取 min(virtual_size, size_raw)。文件里最后一个段的 raw 长度常常按 512 字节对齐,而 VirtualSize 是真实长度,取大的那个会把文件尾部填充也算进去,两侧一对不上就红。
只覆盖一个段的后果很直接:改其他段不会有任何反应。这不是理论推演——后面第三节会看到,"检测器自己的代码在哪个段"这件事当初没人问过,而答案恰好是"不在被覆盖的那三个段里"。
1.2 覆盖太多:自引用让校验恒失败
反过来把范围扩大,第一个撞上的就是自引用。
CRC 的期望值要存在文件里某个地方,而那段存储如果也被 CRC 覆盖,就变成"用 A 校验 A":你写期望值的时候改变了被校验的字节,于是期望值立刻失效。这是一个死循环,表现出来是每次构建都校验失败,而且每次算出来的值都不一样。
这个项目的做法是给烧录表单独开一个只读段,并且明确写下"烧录表自身不被任何段 CRC 覆盖(避免自引用)"。烧录表每项固定长度,一项里有一段标记和 4 个字节的期望 CRC。
放弃的是什么:烧录表本身不受保护。改掉表里的 4 字节期望值,比改代码容易得多,而且改完之后所有校验都通过。这是一个真实的、无法靠"再包一层"解决的缺口——包一层就要再存一层的期望值,递归没有底。我接受它,并且在文档里写清楚它存在,而不是假装它不存在。
1.3 覆盖到了不该覆盖的地方:编译器的常量折叠
这一条是我觉得最阴的,因为它跟安全无关,纯粹是编译器。
烧录值是 const 全局数组。C 编译器看到 const 全局、又看到初始值全 0,很自然地把读取折叠成常量 0——它不知道这个数组的字节会在构建之后被一个脚本改写。于是运行期读到的永远是 0,与算出来的 CRC 永远不等,校验恒失败。
代码里的对策只有一行注释和一次强制转换:读取必须走 volatile 指针,否则 /O2 会把 const 全局的 crc 字段常量折叠成 0,运行期永远比对失败。
volatile 在这里的语义不是"多线程可见性",是"别替我假设这个值"。用错语义但用对了效果,这是 C 里常见的将就。
放弃的是什么:放弃了这条读取路径上的常量折叠与内联。每次校验多一次内存访问,编译器不能再把整张表当编译期常量。代价很小,但这个"很小"是建立在"只有四到六个表项"的前提上的。
1.4 找不到目标:不报错,静默放行
这是三个错法里最危险的一个,因为它看起来是绿的。
写入侧的逻辑是:按名字找到目标段,算 CRC,烧进去。如果找不到呢?构建脚本的动作是打印一行 section absent — skip,把前一项的值置 0,然后继续下一个目标——CRC 字段保持 0,不报错,退出码 0。
更糟的是这个分支会被"正常"触发。项目的节名从某个阶段起是每构建随机的(为了避免节名本身透露用途),于是写入侧如果还按固定的默认名去找,永远查找失败,永远走 skip 分支。代码注释把这件事说得很直白:这会导致"静默不校验(CRC 字段恒为 0,不报错)",并且"这比不修更危险"。
修法不是加一个 if,是加一层逻辑名到实际名的映射:用逻辑名去查本次构建生成的随机名,查不到才退回默认名。
同一个"静默放行"模式在运行侧也出现了一次:目标段的长度为 0 时,代码把这一项直接标记为通过。
if (len == 0u) { found |= (1u << j); /* 空段视为通过 */ break; }
这个设计是不是合理可以讨论,但它的存在说明一件事:"没找到"和"通过了"在代码里只差一个 found 位,而在报告里完全一样。
二、坑二:ASLR 让"文件字节"和"内存字节"不是一回事
第二个坑的根源是一句很容易被忽略的话:你校验的是内存,而烧录值是照文件算的。
镜像加载到内存里之后,基址不确定(ASLR),凡是指向绝对地址的字段都要被加载器改写。.rdata 里的常量指针、导入地址表,全都在加载时被覆盖成运行期的真实地址。于是同一个段,文件里的字节和进程里的字节天然不同——这不是被篡改,这是正常加载。
对策是掩码:文件侧和运行侧用同一套规则,把这些"必然会被改写"的字段在参与 CRC 之前归零。
规则写在两处的注释里,措辞几乎一样。运行侧的注释是:
把
[rva, rva+len)内会被加载器/重定位改写的字节在 mask 中置 0xFF:1. 基址重定位字段:type 10 (DIR64) 8 字节 / type 3 (HIGHLOW) 4 字节;2. IAT(目录项 12):loader 启动时写入已解析的导入函数地址。规则与构建脚本完全一致。
文件侧说的是"用同一规则把这类字段归零后再算 CRC:两遍计算的都是'未被重定位改写的字节'"。
落实到最后是一行按位与。运行期用 base[off + i] & ~mask[i] 逐字节喂进流式 CRC;文件侧是把 raw[i] 置 0。两边的结果必须逐字节相同,否则就是"恒失败"。
2.1 掩码漏了一段,就是"恒失败"
这里出过一次真实的、可复现的失败,值得单独讲,因为它说明掩码这件事的难点不在算法,在穷举。
跨平台改造之后,掩码的区间来源从"解析 PE 重定位表"换成平台层统一提供。PE 只有一张 IAT,所以在 Windows 上"取 IAT 区间"这个单区间接口够用。到了 ELF 那边,loader 会填两个 GOT 类节:.got 和 .got.plt。单区间接口只掩了其中一个,于是构建侧掩了两个、运行侧掩了一个,两侧口径不一致——运行期直接报完整性校验失败。
这段是代码注释里原话记录的(包括"实测"两个字),修法是把接口改成多区间,掩全部。
这个坑的教训不是"要注意跨平台",是:掩码规则的正确性无法靠推理保证,只能靠"两侧算同一份文件"的对照实验。因为漏一个区间不会让代码看起来有错,只会让运行期报一个跟你改动完全无关的错。
2.2 掩码用 static,去把间接化表搞坏了
第二个失败更间接,也更值得记。
掩码缓冲区是 256KB(因为段 CRC 的上限按 256KB 算)。它原本分配在栈上。改造过程中有人担心 256KB 栈溢出,把它改成了 static——安全得多,栈上不再吃 256KB。
结果门禁立刻抓到了两条失败:间接调用失效、看门狗稳定性失败(以 219 退出)。根因是 256KB 的 static 缓冲进入 .bss,内存布局变化,间接化表和页的关系受影响。
于是改回栈上。注释里给的理由很有意思:原实现能工作,说明 256KB 栈在本项目的实际栈大小下是安全的(Windows 默认 1MB 栈,看门狗线程与主线程各自独立)。
这里的因果关系值得停下来看一眼:一个"为了更安全"的改动(少用栈),通过"改变二进制布局"这条链,破坏了一个完全不相关的机制(间接化)。在一个自校验系统里,任何改变字节布局的改动都是被校验对象的一部分——你没法既改代码又假设校验不受影响。
三、坑三:你把检测器漏在了外面
前两个坑都是"校验算错了"。第三个坑是"校验根本没跑到",而它的表现和第一条一样:全绿。
3.1 校验器不在自己的覆盖范围内
最初的目标是三个段:.text、.rdata、.pdata。守卫检查器本身的代码在哪?在一个项目自定义的段里。
也就是说:改守卫检查器的代码,三个段的 CRC 一个都不会变。 这不是猜测,是一次演练里量出来的。一条"改检测器 + 重算 CRC"的用例,记录是:改动之后重算 CRC,得到的值与改动之前完全相同(同一个十位数,20 亿量级),字段写着 crc_unchanged_by_patch=True;紧邻一行的结论是"重算前后 CRC 完全没变 → 这次改动根本不进 .text"。
更直接的证据是下一条用例:完全不重算 CRC,仍然 exit 1。也就是说重算这一步对这个目标本来就是多余的——它压根不在校验范围里。
这条缺口的成因不是疏忽,是范围定义时没问"检测器自己算不算被保护对象"。修复方式也不是把函数搬进 .text:仓库有一条"只增函数、不重排"的硬约束,搬函数会让布局漂移,而布局漂移在这个项目里是真实成本——见上一节那个 static 缓冲的故事。
所以做法是"加目标"而不是"搬家":把检测器所在的段加成第四个 CRC 目标。收口之后,同样三个用例的退出码从 1 翻成 2989。2989 是 0xBAD,在这个项目里表示"判定链把它拦下来了"。
3.2 加了段,还是漏:校验器落在掩码区里
把检测器所在的段加进 CRC 目标之后,还有第二层漏。
校验函数自己、以及另一个校验函数,落在自解密模块的掩码区内。掩码区的字节在算 CRC 之前先被清零,所以"改它们不影响 CRC"。其中 check_self 更是"校验器不在自己覆盖范围内"的自我指涉缺口。
这是自校验的经典困局:为了防"改代码",你校验代码;但校验代码本身也是代码,而它要么被覆盖(自引用,坑一的 1.2)、要么不被覆盖(缺口,这里)。两个选项都是错的,只能选一个并承认它。
这个项目的选择是加第二道完全不依赖 CRC 的检测:判定点序言快照。启动时把若干判定函数的开头若干字节拍一张快照(固定 16 字节),运行期逐字节比对。它不读 CRC、不读烧录表,所以"落进掩码区"和"重算 CRC"这两种规避对它都无效。
3.3 快照拍到的是"已经被改过的字节"
第二道做出来了,还是全绿。原因在时机。
快照原本是惰性建立的——第一次调用检查函数时才拍。而第一次调用发生在看门狗线程的首轮,按 ±40% 抖动后的间隔算,大约是 3 到 5 秒。在这几秒里改动判定函数,快照拍到的就是已经改过的字节,之后每次比对都"一致",于是"比对恒真",注释用的词是"假防护"。
修法是主动预热:在启动流程里、看门狗启动之前就把快照拍好。因为调用方约定"登记必须在 start_watchdog 之前完成",这个位置拍到的快照必然已包含全部登记项,且看门狗尚未运行,窗口最小。
残余窗口如实写在代码里:从进程启动到 start_watchdog 之间仍然可以改,这个窗口通常不到 1 秒,且此时进程尚未进入受保护逻辑,记入已知限制。
我特意把这段完整写出来,因为它示范了一种态度:缺口不是靠"我们修好了"结束的,是靠"我们把剩下的窗口量化并登记"结束的。
3.4 最后一道:CRC 引擎自己也在被校验的段里
还有一层必须交代。CRC 引擎的实现(那一组步进函数)本身也在被校验的那个段内。也就是说,校验器无法完全自校验——改掉引擎,算出来的 CRC 是错的,但它是"自己算错的",你没有一个独立的第三方来仲裁。
代码的原话是:这与既有的 .text 校验是同一量级的残余边界,不是本次新增;彻底自引用超出本轮范围,如实入册。
四、方案与代价
把三节的方案收拢,一共五件事,每件都有一条明确的放弃项:
第一,烧录表独立成段且不被覆盖。 放弃:烧录值本身可被改写。
第二,重定位与 IAT 掩码,两侧同一规则。 放弃:这些区域内部的完整性完全不受保护——掩掉的字节改成什么都无所谓。这是自校验里最实在的盲区,而它无法避免,因为加载器本来就要改它们。
第三,覆盖目标里加入检测器自身所在的段。 放弃:"只增函数、不重排"的约束意味着不能把检测器搬进 .text,所以覆盖面靠"加段"实现,段数会随检测器增多而增长。
第四,链式 CRC。 第 0 项存原始 CRC,第 1 项起存 crc32(raw_le4 || prev_le4)——把当前段的 CRC 与前一个槽位的烧录值折叠在一起。效果是"改一处须重算整链"。放弃:局部热修能力。想单独替换一个段而不动其他段的烧录值,现在做不到——构建脚本必须整链重算。
第五,不依赖 CRC 的判定点快照 + 主动预热。 放弃:完全无窗口。残余窗口不到 1 秒,已登记。
写完这五条,我发现它们的结构是一样的:每一层都在补上一层的洞,同时制造一个新的洞,然后把这个新洞写进文档。 这不是失败主义,这是自校验这件事的真实形状。
五、先证伪再相信:验收判据
回到开头那份 rounds: 0 的报告。三个坑讲完,验收方法其实只有一条原则:先制造一个必然失败的情况,看它会不会失败。 具体拆成六条。
判据一:校验器必须能报出"我什么都没校验"。
这是那份报告教给我的。rounds: 0 加 allGreen: true 在逻辑上不矛盾,在工程上是一场灾难——它让"没测"和"测了且通过"在报告里长得一样。所以报告里必须区分三种状态:通过、失败、没有可校验的对象。前面提到的 section absent — skip 和"空段视为通过",都是这个反模式的实例:它们把"找不到"折叠成了"通过"。
判据二:每一个正向结论都要配一个负向对照。
演练记录里有一组很干净的对照:一条"改的不是受保护目标、然后重算 CRC"的用例,退出码是 1(说明改动生效了),一条"改的是受保护目标、同样重算 CRC"的用例,退出码是 2989(说明被拦下)。两个用例的动作在形式上一致——都是"改一处、然后把 CRC 全部重算"——区别只在于改的是不是被覆盖的目标。一个 1 一个 2989,两行摆在一起,你才知道那个 2989 是拦截而不是崩溃。
如果只有后者那一行,2989 有第二种解释:"程序在改动之后随机崩了"。有了前面那一行,"崩了"这个解释被排除了。
判据三:读数为 0 时,先证明读取链路是通的。
这条我抄自项目里的一次真实误判。当时观测到守卫命中数为 0,结论是"守卫没生效"。后来发现根因是一行 continue:解密失败时整轮跳过,后面的守卫检查根本没执行。也就是说"命中 0"不是因为守卫弱,是因为守卫没跑。
那次排查里真正起作用的是一个正向对照:另一个计数器读到 3(非零),证明读取链路是通的,于是"读到 0"才是事实而不是测量失败。
判据四:防护生效的证据必须是"覆盖范围变了",不是"我加了代码"。
检测器那道缺口的证据链是完整的:改动前,改检测器 → CRC 前后完全相同(crc_unchanged_by_patch=True);改动后,同样三个用例退出码从 1 变成 2989。同一个动作,两个结果,中间只差一次覆盖范围的调整。
反向的例子是"我加了一条检测项"——这句话不构成证据,因为它没说这条检测项在什么情况下会返回什么。
判据五:链式校验的验收方式是"改最早的一环"。
链式 CRC 的设计意图是:第 0 项折原始 CRC,第 1 项起把当前段的 CRC 与前一个槽位的烧录值折叠,"改早先任一槽/段,其后全部条目断链"。那么它的验收就不该是"我改一个段之后它还报错"——那是单段 CRC 就能做到的。验收应该是改最早的那一个,看后面几项是否全部失配。如果只有第一项红、后面照旧通过,说明链没接上,你只是多了一层普通的段校验。
这条判据的普适形式是:验收要打在你新加的那一层独有的性质上,不是打在老层已经能提供的东西上。
判据六:把剩余的洞写成数字,不要写成"已知限制"。
"残余窗口 <1s"、"CRC 引擎与被校验段同段"、"校验器本身落在掩码区内、CRC 抓不到"——这三个都是有具体位置和具体范围的。写成"存在已知限制"等于没写,因为下一个人无法判断这个限制会不会影响他的场景。
六、结语
三个坑,一句话各一条:
坑一是"范围"问题——覆盖太少等于没有,覆盖到自己是死循环,找不到目标时静默放行。三条里最难的是第三条,因为它长得跟成功一模一样。
坑二是"两遍"问题——文件侧和运行侧必须用同一份规则算同一批字节,任何一侧多掩一个区间、少掩一个区间,症状都是"运行期校验失败",而错误信息指向的地方跟你的改动毫无关系。
坑三是"谁来看守看守者"问题——这个问题没有解,只有取舍。你可以让校验器覆盖自己(自引用,恒失败),也可以不覆盖(缺口,恒通过),还可以加一层不依赖前一层的新机制(然后这一层又有自己的洞)。项目选的是第三条路,代价是三层机制、每层都有登记在册的残余窗口。
如果只让我留一句:在你相信一个自校验之前,先让它失败一次。 拿一份 allGreen: true 的报告,先去看 rounds 是几、results 有几条。如果这两个数字是 0 和 0,你手里的不是证据,是一个空数组。
本文所述均为脱敏后的通用做法,与任何具体站点、账号、客户无关;文中代码为节选与示意,标识符已改为代号;请勿用于未授权目标。