
Détecte les chargements mémoire inventés par le compilateur qui transforment du C sécurisé en vulnérabilités TOCTOU. Comprend des audits automatisés de code source, une analyse binaire basée sur Unicorn et des balayages compilateur/arch/flags sur plus de 100 projets.
"...la définition d'un 'compilateur sain d'esprit' ne cesse de s'assouplir."
Le binaire que vous exécutez n'est pas le programme que vous avez écrit. L'optimiseur du compilateur réécrit
votre source de manières que vous ne voyez jamais — et certains de ces changements peuvent
silencieusement et légitimement transformer un code d'apparence sécurisée en
binaires vulnérables. La même ligne peut être
sûre avec un compilateur et exploitable avec un autre,
sans que rien dans la source ne vous indique laquelle : une vulnérabilité maintenue en
superposition, qui ne se matérialise qu'à la compilation. Schrödinger's TOCTOU explore
les chargements inventés par le compilateur et leurs implications
étendues pour les vulnérabilités de type temps de vérification / temps d'utilisation (TOCTOU) — découvertes
dans des
noyaux
open source,
hyperviseurs,
enclaves,
firmware,
et bibliothèques.
Partout où nous regardons, un code d'apparence sécurisée reste exposé aux caprices du
compilateur. Mais ce ne sont que des exemples, pas une limite ; les
mêmes bugs se trouvent très probablement dans votre code aussi.
Commençons par quelque chose de facile.
Combien de fois cette fonction charge-t-elle *p ?```c
unsigned int g(unsigned short *p)
{
short t = p; / copy *p into a local for safekeeping */
return (unsigned short)t - t;
}
Indice : la réponse est 1 — la source charge `*p` une seule fois dans `t`.
Collez-le dans [Compiler Explorer](https://godbolt.org/z/c5K9P4dPd) (`arm gcc 14.2.0`,
`-O2`) et comptez les chargements depuis `r0`, qui contient `p` :```asm
g:
ldrh r2, [r0] # load *p, once
ldrsh r0, [r0] # load *p, twice
subs r0, r2, r0
bx lr
Une lecture dans la source, deux dans le binaire. La seconde est une lecture inventée — une lecture que le compilateur a fabriquée et que vous n'avez jamais écrite. Elle est légale selon la machine abstraite du C, qui suppose que la mémoire ne peut pas changer entre deux lectures. Mais lorsque cette mémoire est modifiable par un attaquant, l'hypothèse devient un vecteur d'exploitation : la lecture inventée peut survenir après un contrôle de sécurité, rouvrant silencieusement une fenêtre de type time-of-check to time-of-use (TOCTOU) que le programmeur croyait avoir fermée. La valeur que vous avez validée et celle que vous utilisez ne sont plus garanties d'être identiques — même si vous n'avez jamais écrit de code qui la relise.
Le défi prouve que la lecture inventée existe ; voyons comment cela se transforme en corruption de la mémoire.
Dans une vulnérabilité TOCTOU, un programme vérifie qu'une valeur est sûre, puis utilise la valeur. Cependant, une fenêtre d'exploitation existe si un attaquant peut modifier la valeur dans l'infime intervalle de temps entre ces deux lectures – la valeur inoffensive passe le contrôle tandis que la valeur dangereuse est celle qui est utilisée :```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
La correction classique consiste à **faire d'abord un instantané** : copier toutes les données que l'attaquant pourrait modifier dans une zone locale à laquelle l'attaquant ne peut pas accéder, puis ne faire confiance qu'à cette zone locale. Une fois `len` dans une variable locale, il est figé — un attaquant en course avec la mémoire partagée ne peut plus le toucher — si bien que la vérification et la copie sont garanties de voir la même valeur. C'est ainsi que le code dans `receive` ci-dessous corrige la faille TOCTOU : il prend un instantané du message, valide l'instantané, puis publie la copie validée dans `slot` pour qu'un consommateur la transmette :```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? */
}
D'après le code source, cela est correct. len est lu exactement une fois — dans l'instantané — donc la valeur qui satisfait la condition <= 20 est celle publiée dans slot. La fenêtre TOCTOU est fermée et le code est sûr.
Sauf que non. Sous x86-64 gcc -O2, receive le lit depuis la mémoire partagée originale deux fois : une fois comme scalaire pour contrôler le test, et une autre fois dans le cadre de la copie en bloc qui est publiée dans 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]
Le contrôle s'appuie sur la lecture n°1 ; la valeur qui aboutit dans `slot.len` est la lecture n°2. Un
attaquant qui fait basculer `len` entre les deux fait passer une valeur sûre au contrôle `<= 20`
tandis qu'une valeur surdimensionnée est publiée dans `slot` — et `forward` copie alors autant
d'octets dans `out[20]`, exactement le débordement que l'instantané était censé empêcher,
réintroduit par l'optimiseur.