
安全なCコードをTOCTOU脆弱性に変える、コンパイラが生成したメモリロードを検出します。自動化されたソースコード監査、Unicornベースのバイナリ解析、100以上のプロジェクトにわたるコンパイラ/アーキテクチャ/フラグのスイープを含みます。
「...『正気のコンパイラ』の定義はますます緩くなっていく」
実行するバイナリは、あなたが書いたプログラムではない。コンパイラの最適化は、あなたのソースを
決して目にすることのない形で書き換え — そして、そうした変更の一部は
黙って、しかも合法的に、一見安全に見えるコードを
脆弱なバイナリへと変えてしまうことがある。同じコード行が
あるコンパイラでは安全で、別のコンパイラでは悪用可能
になり得る。ソースコードにはどちらになるかを示す手掛かりは何もない。脆弱性は
重ね合わせの状態にあり、ビルドしたときにのみ収束する。Schrödinger's TOCTOU は
コンパイラが作り出すロード と、それらが
time-of-check to time-of-use(TOCTOU)脆弱性に与える広範な影響を探求する — 見つかるのは
オープンソースの
カーネル、
ハイパーバイザ、
エンクレーブ、
ファームウェア、
そしてライブラリ。
どこを見ても、一見安全に見えるコードはコンパイラの気まぐれに
さらされたままになっている。しかし、これらは標本であって境界ではない。
同じバグはあなたのコードにも存在する可能性が非常に高い。
「簡単なものから始めよう。」
この関数は *p を何回ロードするだろうか?```c
unsigned int g(unsigned short *p)
{
short t = p; / copy *p into a local for safekeeping */
return (unsigned short)t - t;
}
ヒント: 答えは 1 です — ソースは `*p` を `t` に一度だけロードします。
それを [Compiler Explorer](https://godbolt.org/z/c5K9P4dPd) (`arm gcc 14.2.0`,
`-O2`) に貼り付けて、`p` を保持する `r0` からのロードを数えてください:```asm
g:
ldrh r2, [r0] # load *p, once
ldrsh r0, [r0] # load *p, twice
subs r0, r2, r0
bx lr
ソースには1つのロード、バイナリには2つのロードがある。2つ目は作り出されたロード — コンパイラが生成した、あなたが決して書いていない読み出しだ。これはCの 抽象機械の下では合法であり、2つの読み出しの間にメモリが変化しないと仮定する。しかし、 そのメモリが攻撃者によって書き換え可能な場合、その仮定は悪用へと変わる。つまり、 作り出されたロードはセキュリティチェックの後に落ちる可能性があり、静かに プログラマーが閉じたと思っていたtime-of-check to time-of-use (TOCTOU) ウィンドウを 再び開く。検証した値と使用する値は、もはや 同じであることは保証されない — たとえ、それを再読み出しするコードを書いていなくても。
このチャレンジは、作り出されたロードが存在することを証明している。それがどのようにメモリ破壊に つながるのかを見てみよう。
TOCTOU脆弱性では、プログラムは値が安全であることをチェックしてから、その値を 使用する。 しかし、攻撃者がその2つの読み出しの間のわずかな時間に値を変更できるなら、 悪用の余地が存在する。 – 無害な値は チェックを通過する一方、危険な値が実際に使用されることになる:```c if (shared->len <= 20) // CHECK reads shared->len // ** attacker modifies shared->len ** memcpy(out, shared->data, shared->len); // USE reads it again: buffer overflow
教科書的な修正方法は、**まずスナップショットを取る**ことです。攻撃者が改ざんし得るデータを、攻撃者の到達できないローカルにコピーし、そのローカル以外は一切信用しません。`len` がローカルに入れば、それは固定されます——共有メモリと競合する攻撃者はもはやそれに触れることはできません——したがって、チェックとコピーが同じ値を参照することが保証されます。これが、以下の `receive` のコードが TOCTOU を修正する仕組みです。メッセージのスナップショットを取り、そのスナップショットを検証し、検証済みのコピーを `slot` に公開して、コンシューマーが転送できるようにします:```c
#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? */
}
ソースコードによれば、これは正しい。len は正確に一度だけ読み取られる — スナップショットへ —
したがって、<= 20 チェックを通過する値は、slot に公開された値である。
TOCTOU のウィンドウは閉じられ、コードは安全である。
しかし実際はそうではない。x86-64 gcc -O2 の下では、receive は それを
元の共有メモリから2回読み取る: 1回は
チェックをゲートするスカラーとして、そして再び slot に公開される バルク
コピー の一部として:```nasm
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`](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/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/`](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states), we search for the proofs-of-concept that show
it is real — and that it is everywhere: