← 文章 / 编程开发
Lobsters 4小时前 · 2026-08-07 07:11:30 · 0 阅读

薛定谔的TOCTOU:你运行的二进制文件并非你写下的程序

"……『理智的编译器』这个定义,正变得越来越宽松。"

你运行的二进制文件,并不是你写下的程序。编译器优化器会以你永远看不到的方式重写你的源码——其中一些改动,可以悄无声息且合法地将看似安全的代码变成有漏洞的二进制文件。同一行代码,在一个编译器下安全,在另一个编译器下却可能被利用,而源码中没有任何迹象能告诉你这一点:一个漏洞处于叠加态,只有在你构建时才会坍缩。Schrödinger's TOCTOU 探讨了编译器凭空发明的加载操作及其对检查时间与使用时间(TOCTOU)漏洞的广泛影响——这些漏洞遍布于开源内核虚拟机监控器飞地固件中。我们审视过的每一处,看似安全的代码都暴露在编译器的随意摆布之下。但这只是样本,并非边界;同样的 bug 极有可能也存在于你的代码中

挑战

从简单的开始。

这个函数加载了 *p 多少次?

```c unsigned int g(unsigned short *p) { short t = *p; /* 将 *p 复制到局部变量以确保安全 */ return (unsigned short)t - t; } ```

提示:答案是 1 —— 源代码只从 *p 加载了一次到 t

把这段代码粘贴到 Compiler Explorer(使用 arm gcc 14.2.0-O2 选项),然后数一下从 r0(即 p)加载的次数:

```c g: ldrh r2, [r0] # 加载 *p,第一次 ldrsh r0, [r0] # 加载 *p,第二次 subs r0, r2, r0 bx lr ```

源代码中只有一次加载,而二进制里却有两次。第二次是编译器凭空生成的加载——编译器制造了一个你从未写过的读取操作。这在 C 语言抽象机中是合法的,因为它假设两次读取之间内存不会发生变化。但当这块内存可以被攻击者写入时,这个假设就变成了漏洞:凭空生成的加载可能发生在安全检查之后,悄悄重新打开了一个程序员以为已经关闭的检查时间到使用时间(TOCTOU)窗口。你验证过的值和实际使用的值不再保证相同——尽管你从未写过重新读取它的代码。

凭空出现的缓冲区溢出

这个挑战证明了凭空生成的加载确实存在;现在来看看它如何导致内存损坏。

在 TOCTOU 漏洞中,程序先检查某个值是否安全,然后再使用它。但如果攻击者能在两次读取之间的极短时间内改变这个值,就会留下可利用的窗口——无害的值通过了检查,而实际使用的是危险的值:

```c if (shared->len <= 20) // 检查阶段读取 shared->len // ** 攻击者修改了 shared->len ** memcpy(out, shared->data, shared->len); // 使用阶段再次读取:缓冲区溢出 ``` 教科书式的修复方法是**先做快照**:将攻击者可能篡改的数据复制到攻击者无法触及的本地变量中,然后只信任这个本地副本。一旦 `len` 被存入本地变量,它就冻结了——攻击者即使与共享内存竞态也无法再修改它——因此检查与复制操作保证看到的是同一个值。下面 `receive` 中的代码正是这样修复 TOCTOU 的:它对消息做快照,验证快照,再将验证通过的副本发布到 `slot` 中供消费者转发: ```c #include struct message { int len; /* 载荷长度 */ char data[20]; /* 载荷 */ }; struct message slot; /* 最近验证通过的消息 */ char out[20]; /* 固定 20 字节的目标缓冲区 */ void receive(struct message *shared) { struct message local = *shared; /* 1. 对不可信输入做快照 */ if (local.len <= 20) /* 2. 验证快照 */ slot = local; /* 3. 发布验证通过的副本 */ } void forward(void) { /* 后续的使用时刻 */ memcpy(out, slot.data, slot.len); /* slot.len 已检查过 <= 20 ... 对吧? */ } ``` 从源码看,这是正确的。`len` 只读取一次——在快照中——因此通过 `<= 20` 检查的那个值,就是发布到 `slot` 中的值。TOCTOU 窗口被关闭,代码是安全的。 然而事实并非如此。在 **x86-64 gcc** 的 `-O2` 优化下,`receive` 会从**原始**共享内存中读取 `len` [两次](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states/README.md#the-memcpy-cat-state):一次作为标量用于条件判断,另一次作为[批量拷贝](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states/buffer_tail_bulk_overlap.c)的一部分发布到 `slot` 中:
receive:
        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)
  • 编译器引入的平台持久化漏洞edk2corebootU-BootOpenSBI
  • 编译器引入的飞地突破SGXKeystoneOpenEnclaveOP-TEE

  • 每一种单独来看都可能是灾难性的,但真正令人不安的是其广度:同样的模式在分析触及的每一处代码中反复出现,这些代码除了编程惯用法之外毫无共同之处:

    目标 位置 影响
    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 中。

    解决方案

    *没有解决方案。*

    但这里有一些我们可以尝试的方法。 直觉上的解决方案是尝试固定加载——使用 volatileREAD_ONCEatomic"memory"-clobber barrier()。这些方法符合规范,并且能经受住 -3、LTO 和内联的考验;在发现裸读取的地方,它们是正确的补丁。不幸的是,它们只是治标不治本:
    • **volatile 会悄无声息地失效。** 它限定的是 *左值访问*,而不是对象、指针或区域。通过普通左值读取 volatile T *p 不会提供任何保护,而且当它通过 memcpy 传递时,限定符会在**没有任何诊断信息**的情况下被丢弃。
    原始来源: Lobsters

    评论 (0)