薛定谔的TOCTOU:你运行的二进制文件并非你写下的程序
"……『理智的编译器』这个定义,正变得越来越宽松。"
你运行的二进制文件,并不是你写下的程序。编译器优化器会以你永远看不到的方式重写你的源码——其中一些改动,可以悄无声息且合法地将看似安全的代码变成有漏洞的二进制文件。同一行代码,在一个编译器下安全,在另一个编译器下却可能被利用,而源码中没有任何迹象能告诉你这一点:一个漏洞处于叠加态,只有在你构建时才会坍缩。Schrödinger's TOCTOU 探讨了编译器凭空发明的加载操作及其对检查时间与使用时间(TOCTOU)漏洞的广泛影响——这些漏洞遍布于开源内核、虚拟机监控器、飞地、固件和库中。我们审视过的每一处,看似安全的代码都暴露在编译器的随意摆布之下。但这只是样本,并非边界;同样的 bug 极有可能也存在于你的代码中。
挑战
从简单的开始。
这个函数加载了 *p 多少次?
提示:答案是 1 —— 源代码只从 *p 加载了一次到 t。
把这段代码粘贴到 Compiler Explorer(使用 arm gcc 14.2.0 和 -O2 选项),然后数一下从 r0(即 p)加载的次数:
源代码中只有一次加载,而二进制里却有两次。第二次是编译器凭空生成的加载——编译器制造了一个你从未写过的读取操作。这在 C 语言抽象机中是合法的,因为它假设两次读取之间内存不会发生变化。但当这块内存可以被攻击者写入时,这个假设就变成了漏洞:凭空生成的加载可能发生在安全检查之后,悄悄重新打开了一个程序员以为已经关闭的检查时间到使用时间(TOCTOU)窗口。你验证过的值和实际使用的值不再保证相同——尽管你从未写过重新读取它的代码。
凭空出现的缓冲区溢出
这个挑战证明了凭空生成的加载确实存在;现在来看看它如何导致内存损坏。
在 TOCTOU 漏洞中,程序先检查某个值是否安全,然后再使用它。但如果攻击者能在两次读取之间的极短时间内改变这个值,就会留下可利用的窗口——无害的值通过了检查,而实际使用的是危险的值:
```c if (shared->len <= 20) // 检查阶段读取 shared->len // ** 攻击者修改了 shared->len ** memcpy(out, shared->data, shared->len); // 使用阶段再次读取:缓冲区溢出 ``` 教科书式的修复方法是**先做快照**:将攻击者可能篡改的数据复制到攻击者无法触及的本地变量中,然后只信任这个本地副本。一旦 `len` 被存入本地变量,它就冻结了——攻击者即使与共享内存竞态也无法再修改它——因此检查与复制操作保证看到的是同一个值。下面 `receive` 中的代码正是这样修复 TOCTOU 的:它对消息做快照,验证快照,再将验证通过的副本发布到 `slot` 中供消费者转发: ```c #includereceive:
cmp DWORD PTR [rdi], 20 ; 读取 #1:CHECK 直接读取 shared->len
movdqu xmm0, XMMWORD PTR [rdi] ; 读取 #2:批量拷贝重新读取它(len 是字节 0)
mov rax, QWORD PTR [rdi+16] ; (批量拷贝的尾部:结构体字节 16-23)
jg .L1 ; len > 20?跳过发布
mov QWORD PTR slot[rip+16], rax ; (发布那个尾部)
movaps XMMWORD PTR slot[rip], xmm0 ; 并发布这个存在 TOCTOU 漏洞的快照
.L1:
ret
forward:
movsx rdx, DWORD PTR slot[rip] ; 拷贝大小 = slot.len,即未检查的读取 #2 的值
mov esi, OFFSET FLAT:slot+4 ; src = slot.data
mov edi, OFFSET FLAT:out ; dst = out[20]
jmp memcpy ; 将 slot.len 字节拷贝到 out[20]
检查操作基于读取 #1;最终写入 slot.len 的值来自读取 #2。攻击者如果在两次读取之间篡改 len,就能让安全值通过 <= 20 检查,同时将超大的值发布到 slot 中——随后 forward 会将该长度的字节拷贝到 out[20],这正是快照原本要防止的精确溢出,却被优化器重新引入了。
在 poc/example.c 中,这被转化为一个完整的概念验证。代码采用了经典的 TOCTOU 加固方案:将不可信的 message 结构体快照到 local 中,使其无法被修改;然后验证快照的 local.len 是否在缓冲区容量范围内;最后仅将验证过的副本发布到 slot 中。消费者随后将 slot.len 个有效载荷字节拷贝到固定缓冲区。与此同时,攻击者竞态修改 shared->len。编译器意外引入的加载操作在批量发布时重新读取了 shared->len,导致 slot.len 携带了攻击者的超大值——尽管检查已经通过——从而重新引入了程序员原本要防御的 TOCTOU 漏洞,凭空制造出一个看似不可能的缓冲区溢出。
原因
当 C 代码最终变成机器码时,它已经经历了前端降级、IR 优化、寄存器分配和后端代码生成的多阶段深度改造——整个流程中做出的决策你根本看不见。没有哪个阶段该单独背锅。这种"凭空加载"是整个流水线涌现出的特性,而非某个环节的 bug。
至此,编译器确实可能生成凭空加载,而原本用来防止这类 bug 的惯用做法——快照、验证、使用——反而又把 bug 引了回来。下一步(判断我们是否真的存在漏洞)是搞清楚它何时发生。结果发现这很难。
在 cat-states/ 中,我们寻找概念验证代码,证明这种现象真实存在——而且无处不在:
| 机制 | 工具链 | 目标平台 |
|---|---|---|
| 重物化 | GCC, Clang, ICX, ICC, MSVC | x86-64, i386, m68k, VAX, MSP430 |
| 宽度不匹配重载 | GCC | ARM, MIPS, MIPS64, RV64, s390x |
| 批量与标量重叠 | GCC, Clang, ICX, MSVC | x86-64, ARM, AArch64, AVR, Xtensa, SPARC, PPC64, s390x, MIPS64, RV64, m68k, MSP430, VAX, HPPA |
| 跨类重载 | GCC | x86-64, s390x |
| CISC 内存操作折叠 | GCC, Clang | m68k, MSP430, s390x, VAX, 6502 |
| 字节序重载 | GCC | s390x |
上述每个 PoC 都锁定了一个可能出现加载操作的具体位置;
alpha-lab/ 则绘制了其周围的空间,以确定边界落在何处——这是一个由单个 .c 文件驱动的三阶段流水线。
矩阵运行器 在 Compiler Explorer 上扫描编译器 × 架构 × 标志矩阵;
加载检测器 在 Unicorn 下运行每个生成的二进制文件,并捕获任何被读取两次的字节;
而 标志最小化器 则对每个命中结果进行 delta 调试,将其缩减到能将安全构建翻转成双重读取 TOCTOU 的最小标志集。
结果:没有哪个编译器、标志或优化阶段是唯一的罪魁祸首——双重读取源于多个编译器层之间复杂的相互作用,每一层都在做出局部合理的决策。这种效应是非线性的:源代码、标志或目标上的微小变化,都可能级联成不同的结果。判断某一行代码是否易受攻击的唯一可靠方法,就是编译它,然后观察结果。
猫既活着,又死了。 在构建之前,一个对本地副本进行快照、验证和使用的调用点,既不安全也不脆弱——它同时处于两种状态,而编译器、其版本、目标以及标志决定了最终是哪一种。构建过程就是一次测量,它让叠加态坍缩到其中一种结果。这就是薛定谔的 TOCTOU:程序员认为已冻结的值,C 标准却默许编译器从攻击者控制的内存中重新读取。盒子一直保持关闭,直到某人在某处选择一个工具链,将其打开。
影响
这种模式几乎无处不在——通过简单的惯用C语言,编织进全球最经得起审查的代码中。
这个问题几乎无法根治。同一段代码是否安全,完全取决于编译器×版本×架构×编译选项的精确组合——而这样的组合数量比可观测宇宙中的原子还多。即便只为单一代码库划定边界,也近乎无望;在整个生态系统中排查,更是难上加难。
就连判断单个调用点是否安全,都难以通过审查实现:像内核的copy_from_user这类看似能起屏障作用的函数,在经历了约六层内联、宏展开和CONFIG/CPU特性分支后,最终落入不透明的asm汇编中,才排除了这个漏洞——而同样的源代码行在其他配置下根本毫无屏障作用。只看调用点本身,我们一无所获。
唯一的出路是自动化。基于启发式分析,我们对主流开源目标——包括虚拟机监控器、TEE/可信执行环境运行时、固件、内核子系统、协议库——进行了扫描,在100多个安全关键项目中发现了300多个薛定谔式TOCTOU漏洞:这些位置,C标准允许编译器在检查和使用之间重新读取攻击者可写的内存。自动化分析识别了信任边界,搜索了薛定谔模式,并评估了可能性、影响和风险。
结果表明,看似无害的编译器引入的加载操作,很容易级联成灾难性后果。
编译器发明的与其说是加载,不如说是这个加载赋予攻击者的能力:
- **编译器引入的虚拟机逃逸** — [QEMU](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-qemu-v11.0.1.md#candidate-1--ahci-prdtl-highest-impact)、[Xen](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-xen-ptwalk-RELEASE-4.21.1.md#candidate-1--guest_walk_tables-pte-walk)、[bhyve](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-bhyve-release-15.0.0.md#candidate-1--ahci-prdt-byte-count-write-path-oob-write)、[KVM](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-linux-v7.0-kvm-host.md#candidate-1--svm-nested-vmcb12-save-area-cache-flagship)、[ACRN](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-acrn-v3.3.md#candidate-1--nested-ept-shadow-walk)
- **编译器引入的提权漏洞** — [siw](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-linux-v7.0-rdma-rxe-siw.md#candidate-1--siw-siw_rqe_get-num_sge-headline)、[VMBus](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-linux-v7.0-hyperv-vmbus.md#candidate-2--msgtype-dispatch-index)、[systemd](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-systemd-v260.md#candidate-1--sd_journal_enumerate_fields-sz-field-payload-size-alloc-vs-copy)、[af-packet](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-linux-v7.0-af-packet.md#candidate-1--tp_len-tx-packet-length)、[snd-pcm](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-linux-snd-pcm-v7.0.md#candidate-1--snd_pcm_indirect_playback_transfer-appl_ptr-snapshot-used-for-diff-and-stored-baseline)、[seL4](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/observer-effect/audits/audit-sel4-15.0.0.md#candidate-1-flagship--untyped-retype-object-window)
每一种单独来看都可能是灾难性的,但真正令人不安的是其广度:同样的模式在分析触及的每一处代码中反复出现,这些代码除了编程惯用法之外毫无共同之处:
| 目标 | 位置 | 影响 |
|---|---|---|
| QEMU | ahci_populate_sglist |
guest AHCI PRDT 长度仅锁存一次 → 越界读取 / 攻击者导向的主机 DMA |
| Linux / RDMA | siw_rqe_get |
software-RDMA num_sge 被复用 → 内核 OOB 写入 |
| edk2 / UEFI | SmmLockBoxRestore |
SMM 缓冲区长度被复用 → 向 SMRAM 的 OOB 写入(ring -2) |
| TPM 2.0 | CryptParameterDecryption |
原地解密长度被复用 → TPM 信任根中的 OOB 写入 |
| seL4 | decodeUntypedInvocation |
retype 对象窗口被复用 → 内核被攻破 |
| Xen | guest_walk_tables |
遍历过程中客户机 PTE 被复用 → 权限提升 |
| SGX | edger8r ECALL 桥接 | [in]/[in,out] 长度被用于 malloc/memcpy_s → 飞地堆溢出(每次 ECALL) |
| ARM TF-A | spmc_ffa_fill_desc |
FF-A 描述符字段被用于确定 memcpy 大小 → EL3 安全监视器中的堆溢出 |
| Linux / Hyper-V | Hyper-V VMBus __vmbus_on_msg_dpc |
主机 msgtype 被复用于索引处理函数表 → 客户内核中产生通配间接调用 |
| U-Boot | virtqueue_get_buf |
virtio 已用环形缓冲区 id 被复用作数组索引 → 引导加载程序中发生堆 OOB 读写 |
| glibc | _dl_check_map_versions |
动态加载器 VERNEED 版本索引被复用作写入下标 → 映射精心构造的共享库时,ld.so 中发生OOB 写入 |
| systemd | sd_journal_enumerate_fields |
日志字段大小在分配/复制过程中被复用 → journalctl/coredumpctl(常以 root 运行)中发生堆 OOB 写入 |
| git | read_table_of_contents |
对象存储块偏移被复用作块基址/大小 → 解析精心构造的 .idx / multi-pack-index / commit-graph(共享仓库/ forge 后端)时发生OOB 读取 |
| SQLite | btreeComputeFreeSpace |
B-tree 空闲块偏移量被复用为页面索引 → 对 mmap 数据库页面的越界读取 |
| FreeType | ft_var_readpackedpoints |
可变字体压缩点计数被复用 → 渲染构造字体时发生 堆越界写入(普遍存在于 Android / Chrome / 桌面端) |
| libtiff | NeXTDecode |
NeXT-RLE 跨度偏移量/长度被复用 → 解码构造的 TIFF 时发生 堆越界写入(默认 mmap 读取模式) |
| binutils / ld | sframe_decode |
SFrame FDE 计数被同时复用为分配大小和填充边界 → 处理构造对象时链接器发生 堆越界写入 |
| ClamAV | autoit EA05 csize |
AutoIt csize 被同时复用为分配大小和拷贝长度 → 扫描器中发生 堆越界写入 |
| YARA | pe_parse_exports |
PE 导出计数被复用为循环边界 → 扫描构造样本时发生 越界读取 |
| WAMR | _vprintf_wa |
guest %s 偏移量重新读取越过沙箱区域 → 越界读取导致 wasm 访客泄漏主机内存 |
| ImageMagick | ReadSUNImage |
SUN 栅格长度同时用作分配大小和拷贝长度 → 堆越界写入 → 解码恶意图片时实现 RCE(LTO 构建) |
| FreeBSD | virtqueue_dequeue |
主机写入的 virtio 使用环 id 被复用为无界数组索引 → 内核中描述符双重释放 / UAF |
一切皆可攻击,一切又并非如此。在每种场景中,源码都做了正确的事:快照不可信输入、验证副本、使用副本。但 C 标准却悄悄允许编译器可选地撤销这个过程,凭空制造出一个 TOCTOU。某个站点是否可被利用并非源码的属性:它由编译器、版本、架构和编译标志共同决定,只有在构建时才会坍缩为一种结果。在此之前,每个站点都同时是漏洞与安全代码——处于叠加态的漏洞,在源码层面与真正安全的代码无法区分。每一个都是薛定谔的 TOCTOU——上表就是它们大规模呈现的样子。
令人不安的不是这些特定项目存在缺陷,而是这种模式几乎出现在分析触及的每个角落,仅仅通过地道的 C 语言就渗透进了全球审查最严格的代码。这 100 多个仓库只是样本,而非边界:同样的潜在漏洞几乎必然存在于你自己的代码库中。
完整的审计和影响分析位于 observer-effect/ 及其 REPORT.md 中。解决方案
但这里有一些我们可以尝试的方法。 直觉上的解决方案是尝试固定加载——使用*没有解决方案。*
volatile、READ_ONCE、atomic 或 "memory"-clobber barrier()。这些方法符合规范,并且能经受住 -3、LTO 和内联的考验;在发现裸读取的地方,它们是正确的补丁。不幸的是,它们只是治标不治本:
- **
volatile会悄无声息地失效。** 它限定的是 *左值访问*,而不是对象、指针或区域。通过普通左值读取volatile T *p不会提供任何保护,而且当它通过memcpy传递时,限定符会在**没有任何诊断信息**的情况下被丢弃。
