schrodingers-toctou: The binary you run is not the program you wrote
摘要
该研究展示编译器优化可在源代码未写的情况下凭空生成对内存的重复读取(invented load),使原本按教科书方式做了快照、校验、再使用的 TOCTOU 防御代码在二进制层面失效,形成可利用的缓冲区溢出。作者在 GCC、Clang、MSVC 等多个编译器及 x86-64、ARM、MIPS 等架构上验证了该现象,并在 QEMU、Linux、edk2、TPM、seL4、Xen、SGX、U-Boot、glibc、systemd、git、SQLite、FreeType、libtiff、binutils、ClamAV、YARA、WAMR、ImageMagick、FreeBSD 等 100+ 安全关键项目中找到 300+ 处此类漏洞,影响涵盖虚拟机逃逸、内核提权、固件持久化、enclave 破坏等。作者指出该问题无法通过源码审查可靠发现,现有防护手段(volatile、READ ONCE、barrier)均不彻底,并提供了自动化审计提示文件供读者检查自身代码。
荐读理由
用仓库里的AUDIT-PROMPT.md就能扫自己代码里的这类编译器发明加载漏洞,省得被优化器悄悄埋雷
原文
Schrödinger's TOCTOU
"...the definition of 'sane compiler' grows ever looser."
The binary you run is not the program you wrote. The compiler optimizer rewrites your source in ways you never see — and some of those changes can silently and legally turn seemingly secure code into vulnerable binaries. The same line can be safe under one compiler and exploitable under another, with nothing in the source to tell you which: a vulnerability held in superposition, collapsed only when you build. Schrödinger's TOCTOU explores compiler-invented loads and their widespread implications for time-of-check to time-of-use (TOCTOU) vulnerabilities — found across open-source kernels, hypervisors, enclaves, firmware, and libraries. Everywhere we look, seemingly secure code is left exposed to the whims of the compiler. But those are a sample, not a boundary; the same bugs are very likely in your code too.
Challenge
Start with something easy.
How many times does this function load *p?
unsigned int g(unsigned short *p)
{
short t = *p; /* copy *p into a local for safekeeping */
return (unsigned short)t - t;
}
Hint: the answer is 1 — the source loads *p a single time into t.
Paste it into Compiler Explorer (arm gcc 14.2.0, -O2) and count the loads from r0, which holds p:
g:
ldrh r2, [r0] # load *p, once
ldrsh r0, [r0] # load *p, twice
subs r0, r2, r0
bx lr
One load in the source, two in the binary. The second is an invented load — a read the compiler manufactured that you never wrote. It is legal under the C abstract machine, which assumes memory cannot change between two reads. But when that memory is attacker-writable, the assumption becomes an exploit: the invented load can fall after a security check, silently reopening a time-of-check to time-of-use (TOCTOU) window the programmer believed they had closed. The value you validated and the value you use are no longer guaranteed to be the same — even though you never wrote code that re-read it.
A buffer overflow from thin air
The challenge proves the invented load exists; let's see how that turns into memory corruption.
In a TOCTOU vulnerability, a program checks that a value is safe, then uses the value. However, a window for exploitation exists if an attacker can change the value in the sliver of time between those two reads – the harmless value passes the check while the dangerous one is the one that gets used:
if (shared->len <= 20) // CHECK reads shared->len
// ** attacker modifies shared->len **
memcpy(out, shared->data, shared->len); // USE reads it again: buffer overflow
The textbook fix is to snapshot first: copy any data the attacker might tamper with into a local the attacker can't reach, and then trust nothing but that local. Once len is in a local it is frozen — an attacker racing the shared memory can no longer touch it — so the check and the copy are guaranteed to see the same value. That is how the code in receive below fixes the TOCTOU: it snapshots the message, validates the snapshot, and publishes the validated copy into slot for a consumer to forward:
#include <string.h>
struct message {
int len; /* payload length */
char data[20]; /* payload */
};
struct message slot; /* the most recently validated message */
char out[20]; /* fixed 20-byte destination */
void receive(struct message *shared) {
struct message local = *shared; /* 1. snapshot untrusted input */
if (local.len <= 20) /* 2. validate the snapshot */
slot = local; /* 3. publish the validated copy */
}
void forward(void) { /* the time of use, later */
memcpy(out, slot.data, slot.len); /* slot.len was checked <= 20 ... right? */
}
By the source, this is correct. len is read exactly once — into the snapshot — so the value that clears the <= 20 check is the value published into slot. The TOCTOU window is closed and the code is safe.
Except it isn't. Under x86-64 gcc -O2, receive reads it from the original shared memory twice: once as a scalar to gate the check, and again as part of the bulk copy that gets published into slot:
receive:
cmp DWORD PTR [rdi], 20 ; READ #1: the CHECK reads shared->len directly
movdqu xmm0, XMMWORD PTR [rdi] ; READ #2: the bulk copy re-reads it (len is byte 0)
mov rax, QWORD PTR [rdi+16] ; (the bulk copy's tail: struct bytes 16-23)
jg .L1 ; len > 20? skip the publish
mov QWORD PTR slot[rip+16], rax ; (publish that tail)
movaps XMMWORD PTR slot[rip], xmm0 ; and publish the TOCTOU-vulnerable snapshot
.L1:
ret
forward:
movsx rdx, DWORD PTR slot[rip] ; copy size = slot.len, the unchecked READ #2 value
mov esi, OFFSET FLAT:slot+4 ; src = slot.data
mov edi, OFFSET FLAT:out ; dst = out[20]
jmp memcpy ; copies slot.len bytes into out[20]
The check runs on READ #1; the value that lands in slot.len is READ #2. An attacker who flips len between them passes a safe value to the <= 20 check while an oversized one is published into slot — and forward then copies that many bytes into out[20], the exact overflow the snapshot was meant to prevent, reintroduced by the optimizer.
This is turned into a complete proof-of-concept in poc/example.c, where the code uses the canonical TOCTOU-hardened approach: an untrusted message struct gets snapshotted into local so that it cannot be modified, the snapshot's local.len is validated against the buffer capacity, and only the validated copy is published into slot; a consumer later copies slot.len payload bytes into a fixed buffer. Simultaneously, an attacker races shared->len. An unexpected invented load from the compiler re-reads shared->len for the bulk publish, so slot.len carries the attacker's oversized value even though the check passed — reintroducing the TOCTOU the programmer was trying to defend against, and creating a seemingly impossible buffer overflow — from thin air.
Cause
By the time C reaches machine code, it's been reshaped by frontend lowering, IR optimizations, register allocation, and backend codegen — a deep, multi-stage pipeline making decisions you can't see. There is no one stage to blame. The invented load is an emergent property of the whole pipeline, not a bug in any part of it.
At this point: compilers can emit invented loads, and the very idiom meant to prevent the bug — snapshot, validate, use — is what reintroduces it. The next step (to know whether we are actually vulnerable) is to characterize when it happens. Turns out that's hard.
In cat-states/, we search for the proofs-of-concept that show it is real — and that it is everywhere:
| Mechanism | Toolchains | Targets |
|---|---|---|
| Rematerialization | GCC, Clang, ICX, ICC, MSVC | x86-64, i386, m68k, VAX, MSP430 |
| Width-mismatch reload | GCC | ARM, MIPS, MIPS64, RV64, s390x |
| Bulk-vs-scalar overlap | GCC, Clang, ICX, MSVC | x86-64, ARM, AArch64, AVR, Xtensa, SPARC, PPC64, s390x, MIPS64, RV64, m68k, MSP430, VAX, HPPA |
| Cross-class reload | GCC | x86-64, s390x |
| CISC mem-op fold | GCC, Clang | m68k, MSP430, s390x, VAX, 6502 |
| Byte-order reload | GCC | s390x |
Each PoC above pins down a single point where the load can appear; alpha-lab/ charts the space around it to find where the edges fall — a three-stage pipeline driven from a single .c file. The matrix runner sweeps the compiler × architecture × flag matrix on Compiler Explorer; the load detector runs each resulting binary under Unicorn and catches any byte read twice; and the flag minimizer delta-debugs each hit down to the minimal flag set that flips a secure build into a double-read TOCTOU.
The result: no single compiler, flag, or pass is to blame — the double-read emerges from the complex interaction of many compiler layers, each making locally valid decisions. The effect is non-linear: small changes in source, flags, or target can cascade into different outcomes. The only reliable way to know whether a given line is vulnerable is to compile it and look.
The cat is alive — and it's not. Until you build, a call site that snapshots, validates, and uses a local copy is neither safe nor vulnerable — it is both, and the compiler, its version, the target, and the flags decide which. The build is the measurement, and it collapses the superposition one way or the other. This is a Schrödinger TOCTOU: a check on a value the programmer believed frozen, that the C standard quietly permits the compiler to re-read from attacker-controlled memory. The box stays closed until someone, somewhere, picks a toolchain and opens it.
Effect
The pattern appears nearly everywhere — woven into the most carefully reviewed code in the world through simple idiomatic C.
The problem is virtually intractable. The same snippet of code can be vulnerable or not vulnerable depending on the precise combination of compiler × version × architecture × flags — and there are more such combinations than there are atoms in the observable universe. Bounding it for even a single codebase is a near-hopeless search; doing it across the ecosystem is far worse.
Even deciding whether a single call site is safe resists inspection: a possible barrier like the kernel's copy_from_user only forecloses the bug after ~six layers of inlining, macros, and CONFIG/CPU-feature forks bottom out in an opaque asm — and the same source line is no barrier at all in other configurations. Reading the call site tells us nothing.
The only path forward is automation. A heuristic-based analysis was run across prominent open-source targets — hypervisors, TEE/enclave runtimes, firmware, kernel subsystems, protocol libraries — and found 300+ Schrödinger TOCTOUs across 100+ security-critical projects: sites where the C standard permits the compiler to re-read attacker-writable memory between a check and its use. The automated analysis identifies the trust boundaries, searches for the Schrödinger pattern, and assesses likelihood/impact/risk.
The results show that seemingly innocuous compiler-invented loads easily cascade into devastating consequences.
The compiler doesn't invent a load so much as the capability that load hands an attacker:
compiler-invented root — siw, VMBus, systemd, af-packet, snd-pcm, seL4
compiler-invented platform persistence — edk2, coreboot, U-Boot, OpenSBI
compiler-invented enclave breach — SGX, Keystone, OpenEnclave, OP-TEE
Each of these can be catastrophic on its own, but the breadth is what unsettles: the same shape turns up everywhere the analysis looks, in code that shares nothing but the idiom:
| Target | Site | Impact |
|---|---|---|
| QEMU | ahci_populate_sglist |
guest AHCI PRDT length latched once → OOB read / attacker-directed host DMA |
| Linux / RDMA | siw_rqe_get |
software-RDMA num_sge reused → kernel OOB write |
| edk2 / UEFI | SmmLockBoxRestore |
SMM buffer length reused → OOB write into SMRAM (ring -2) |
| TPM 2.0 | CryptParameterDecryption |
in-place decrypt length reused → OOB write in the TPM root-of-trust |
| seL4 | decodeUntypedInvocation |
retype object-window reused → kernel compromise |
| Xen | guest_walk_tables |
guest PTE reused on the walk → privilege escalation |
| SGX | edger8r ECALL bridge | [in]/[in,out] length reused for malloc/memcpy_s → enclave heap overflow (every ECALL) |
| ARM TF-A | spmc_ffa_fill_desc |
FF-A descriptor field reused to size memcpy → heap overflow in the EL3 secure monitor |
| Linux / Hyper-V | Hyper-V VMBus __vmbus_on_msg_dpc |
host msgtype reused to index handler table → wild indirect call in the guest kernel |
| U-Boot | virtqueue_get_buf |
virtio used-ring id reused as array index → heap OOB read/write in the bootloader |
| glibc | _dl_check_map_versions |
dynamic-loader VERNEED version index reused as a write subscript → OOB write in ld.so when mapping a crafted shared library |
| systemd | sd_journal_enumerate_fields |
journal field size reused across alloc/copy → heap OOB write in journalctl/coredumpctl (frequently root) |
| git | read_table_of_contents |
object-store chunk offset reused as a chunk base/size → OOB read parsing a crafted .idx / multi-pack-index / commit-graph (shared repo / forge backend) |
| SQLite | btreeComputeFreeSpace |
B-tree freeblock offset reused as a page index → OOB read of an mmap'd database page |
| FreeType | ft_var_readpackedpoints |
variable-font packed point-count reused → heap OOB write rendering a crafted font (ubiquitous: Android / Chrome / desktop) |
| libtiff | NeXTDecode |
NeXT-RLE span offset/length reused → heap OOB write decoding a crafted TIFF (default mmap'd read mode) |
| binutils / ld | sframe_decode |
SFrame FDE count reused as alloc size and fill bound → heap OOB write in the linker on a crafted object |
| ClamAV | autoit EA05 csize |
AutoIt csize reused as alloc size and copy length → heap OOB write in the scanner |
| YARA | pe_parse_exports |
PE export count reused as a loop bound → OOB read scanning a crafted sample |
| WAMR | _vprintf_wa |
guest %s offset re-read past the sandbox arena → OOB read leaking host memory to the wasm guest |
| ImageMagick | ReadSUNImage |
SUN-raster length reused as alloc size and copy length → heap OOB write → RCE decoding a crafted image (LTO builds) |
| FreeBSD | virtqueue_dequeue |
host-written virtio used-ring id reused as an unbounded array index → descriptor double-free / UAF in the kernel |
Everything is vulnerable. And everything is not. In every situation, the source does the right thing: snapshot the untrusted input, validate the copy, use the copy. But in each, the C standard quietly permits the compiler to optionally undo that process, and create a TOCTOU out of thin air. Whether a given site is exploitable is not a property of the source: it is decided by the compiler, its version, the architecture, the flags, and it collapses one way only when you build. Until then each one is both — a vulnerability held in superposition, indistinguishable at the source level from code that is genuinely fine. Each is a Schrödinger TOCTOU — and the table above is what they look like at scale.
The unsettling part is not that these particular projects are flawed — it is that the pattern turns up nearly everywhere the analysis looks, woven into the most carefully reviewed code in the world through nothing more than idiomatic C. The 100+ repositories are a sample, not the boundary: the same latent bug almost certainly reaches your own codebase.
The full audit and impact analysis is in observer-effect/ and its REPORT.md.
Solutions
There are none.
But here are some things we can try anyway.
The reflex solution is to try to pin the load — volatile, READ_ONCE, an atomic, a "memory"-clobber barrier(). Those are spec-sound and survive -O3, LTO, and inlining; where a bare read is found, they are the correct patch. Unfortunately, they patch the wound, but not the cause:
volatilelaunders away silently. It qualifies the lvalue access, not the object, the pointer, or the region. Avolatile T *pread through a plain lvalue gives zero protection, and the qualifier is dropped with no diagnostic when it passes throughmemcpy'sconst void *— there is no volatile-preservingmemcpy. The barrier you wrote evaporates at the call you didn't.READ_ONCEdoesn't scale. "UseREAD_ONCE" really means: annotate every attacker-reachable access of every field, forever, and position a fence between the read and all of its uses. Miss one and the discipline is void. It cannot be enforced at scale, and it regresses silently.A
barrier()'s correctness lives frames from the source line. Deciding whether onecopy_from_user(&local, uptr, n)even carries a"memory"clobber means tracing five inlined layers and an out-of-line call from generic C into arch-specific asm, resolving a fistful ofCONFIG/CPU-feature/__builtinforks. And even once found, the clobber names no read: a step out of place it pins nothing; a step the other way it forces the very reload it should stop.
But more importantly: the source never asks for a reload to begin with. This is the deeper issue. The programmer wrote local.len and meant local.len: one value, read once. If we say x we mean x, not "x, but y if the compiler likes that instead." The reload is invented beneath the abstract machine, so the code that needs the annotation looks identical to the code that doesn't — there is no signal at the site that a barrier is required. You cannot remember to guard a read you never wrote.
The full catalog of defenses — with their strengths and failures — is in the barriers report.
Open the box
The TOCTOU-from-thin-air pattern is everywhere. Check if your code has it.
Check your own code with observer-effect/AUDIT-PROMPT.md, which will look for the trust boundaries, search for the Schrödinger pattern, prune based on spec-compliant barriers, and assess likelihood/impact/risk. Hand it to your preferred coding agent with your source in context and point it at a subsystem:
cd ~/your-project # the codebase you want audited
claude -p "$(cat path/to/observer-effect/AUDIT-PROMPT.md)
Audit drivers/net/ for invented-load TOCTOUs."
It depends on nothing else in this repo — copy the one file and go.
Future
Schrödinger's TOCTOU dissects one specific instantiation of some random optimization allowed by the 500-page C-specification. But it's just scratching the surface: there is so much ground left to explore. This repository will continue to poke, capture, and catalog the unexpected ways your favorite compiler undercuts you — silently, legally, and at every optimization level.
"... if gcc did that, much of the kernel would go down in flames."
— Paul E. McKenney, LKML, 2009-04-16 · lore
"People love to talk about 'safe C', but compiler people have actively tried to make C unsafer for decades. The C standards committee has been complicit."
— Linus Torvalds, 2025-02-21 · lore
"I would very much prefer a compiler switch that instructs the compiler to not do bloody stupid things like this instead of marking every other load/store in the kernel with volatile."
— Peter Zijlstra, 2015-06-17 · lore
"The spec is just so much toilet paper. The ONLY thing that matters is what real hardware does."
— Linus Torvalds, 2006-12-04 · lore
"By that argumentation we need to plaster half of the kernel with _ONCE() … Can we finally put a foot down and tell compiler and standard committee people to stop this insanity?
— Thomas Gleixner, 2019-08-16 · lore
"Compilers that 'optimize' things to touch fields that aren't touched by the source code are simply inherently buggy shit. I'm not at all interested in catering to their insanity... Claiming that they need to be marked volatile is a symptom of a diseased compiler writer."
— Linus Torvalds, 2014-12-04 · lore
"Insane? Probably so. But there are compiler guys who swear by it."
— Paul E. McKenney, LKML, 2008-02-04 · lore
"It's a good thing if they have tested all the code-paths, but they've invariably been tested with a compiler that doesn't go out of its way to try to generate "legal but idiotic" code. So the testing won't generally find cases where the compiler may have been allowed to do something else. ... Compiler people who don't realize this aren't compiler people. They're academics involved with mental masturbation."
— Linus Torvalds, LKML, 2007-01-04 · lore
"Of course, it is not the stupid compilers that worry me, but rather the smart ones..."
— Paul E. McKenney, LKML, 2013-10-09 · lore
"... we've had compiler writers that say "if you read the specs, that's ok". No, it's not ok. Because reality trumps any weasel-spec-reading."
— Linus Torvalds, LKML, 2019-08-16 · lore
"... the definition of 'sane compiler' grows ever looser."
— Paul E. McKenney, LKML, 2013-09-24 · lore
References
Whitepaper: (coming soon)
Slides: (coming soon)
Presentation: (coming soon)
Author
Schrödinger's TOCTOU is a research effort from Christopher Domas (@xoreaxeaxeax)
这条对你有帮助吗?
