
Erkennt vom Compiler erzeugte Speicherzugriffe, die sicheres C in TOCTOU-Schwachstellen verwandeln. Umfasst automatisierte Quellcode-Audits, Unicorn-basierte Binäranalyse sowie Compiler-/Architektur-/Flag-Sweeps über 100+ Projekte.
"...die Definition eines 'vernünftigen Compilers' wird immer lockerer."
Die Binärdatei, die du ausführst, ist nicht das Programm, das du geschrieben hast. Der Compiler-Optimierer schreibt
deinen Quellcode auf Arten um, die du nie siehst — und einige dieser Änderungen können
still und legal scheinbar sicheren Code in
verwundbare Binärdateien verwandeln. Dieselbe Zeile kann
unter einem Compiler sicher und unter einem anderen ausnutzbar sein,
und nichts im Quellcode verrät dir, welche der beiden Möglichkeiten zutrifft: eine Schwachstelle in
Superposition, die erst beim Erstellen kollabiert. Schrödinger's TOCTOU untersucht
vom Compiler erfundene Loads und ihre weitreichenden
Auswirkungen auf Time-of-Check-to-Time-of-Use (TOCTOU)-Schwachstellen — zu finden
in Open-Source-Kernels,
Hypervisoren,
Enklaven,
Firmware
und Bibliotheken.
Überall, wo wir hinschauen, bleibt scheinbar sicherer Code den Launen des
Compilers ausgesetzt. Aber das ist eine Stichprobe, keine Grenze; die
gleichen Fehler sind sehr wahrscheinlich auch in deinem Code.
"Fang mit etwas Einfachem an."
Wie oft lädt diese Funktion *p?```c
unsigned int g(unsigned short *p)
{
short t = p; / copy *p into a local for safekeeping */
return (unsigned short)t - t;
}
Hinweis: Die Antwort ist 1 — der Quellcode lädt `*p` genau einmal in `t`.
Füge es in [Compiler Explorer](https://godbolt.org/z/c5K9P4dPd) ein (`arm gcc 14.2.0`,
`-O2`) und zähle die Ladevorgänge aus `r0`, das `p` enthält:```asm
g:
ldrh r2, [r0] # load *p, once
ldrsh r0, [r0] # load *p, twice
subs r0, r2, r0
bx lr
Ein Load im Quellcode, zwei im Binary. Der zweite ist ein erfundener Load — ein Lesevorgang, den der Compiler erzeugt hat und den du nie geschrieben hast. Er ist unter der C abstrakten Maschine legal, die annimmt, dass sich Speicher zwischen zwei Lesevorgängen nicht ändern kann. Aber wenn dieser Speicher für Angreifer beschreibbar ist, wird die Annahme zu einem Exploit: Der erfundene Load kann nach einer Sicherheitsprüfung fallen und so ein Zeitfenster zwischen Prüfung und Verwendung (Time-of-Check to Time-of-Use, TOCTOU) lautlos wieder öffnen, von dem der Programmierer glaubte, es geschlossen zu haben. Der Wert, den du validiert hast, und der Wert, den du verwendest, sind nicht mehr garantiert identisch — obwohl du nie Code geschrieben hast, der ihn erneut liest.
Die Challenge beweist, dass der erfundene Load existiert; schauen wir uns an, wie daraus Speicherkorruption wird.
Bei einer TOCTOU-Schwachstelle prüft ein Programm, ob ein Wert sicher ist, und verwendet dann den Wert. Jedoch existiert ein Fenster für Ausbeutung, wenn ein Angreifer den Wert in dem winzigen Zeitraum zwischen diesen beiden Lesevorgängen ändern kann – der harmlose Wert besteht die Prüfung, während der gefährliche verwendet wird:```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
Der Lehrbuch-Fix besteht darin, **zuerst einen Snapshot zu erstellen**: Kopiere alle Daten, mit denen ein Angreifer manipulieren könnte, in einen lokalen Bereich, den der Angreifer nicht erreichen kann, und vertraue anschließend nichts außer diesem lokalen Bereich. Sobald `len` in einem lokalen Bereich liegt, ist es eingefroren — ein Angreifer, der mit dem gemeinsamen Speicher um die Wette läuft, kann es nicht mehr berühren —, sodass die Prüfung und die Kopie garantiert denselben Wert sehen. Genau so behebt der Code in `receive` unten das TOCTOU-Problem: Er erstellt einen Snapshot der Nachricht, validiert den Snapshot und veröffentlicht die validierte Kopie in `slot`, damit ein Consumer sie weiterleiten kann:```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? */
}
Dem Quellcode nach ist das korrekt. len wird genau einmal gelesen — in den Snapshot —, sodass der Wert, der die <= 20-Prüfung erfüllt, der Wert ist, der in slot veröffentlicht wird. Das TOCTOU-Fenster ist geschlossen und der Code ist sicher.
Nur ist er das nicht. Unter x86-64 gcc -O2 liest receive es zweimal aus dem ursprünglichen Shared Memory: einmal als Skalar, um die Prüfung zu steuern, und erneut als Teil der Bulk-Kopie, die in slot veröffentlicht wird:```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]
Die Prüfung läuft bei READ #1; der Wert, der in `slot.len` landet, ist READ #2. Ein
Angreifer, der `len` zwischen diesen beiden dreht, übergibt einen sicheren Wert an die `<= 20`-Prüfung
während ein übermäßig großer Wert in `slot` veröffentlicht wird – und `forward` dann so viele
Bytes in `out[20]` kopiert, genau der Overflow, den der Snapshot verhindern sollte,
durch den Optimierer wieder eingeführt.