
Detecta carregamentos de memória inventados pelo compilador que transformam C seguro em vulnerabilidades TOCTOU. Inclui auditorias automatizadas de código-fonte, análise binária baseada em Unicorn e varreduras de compilador/arquitetura/flags em mais de 100 projetos.
"...a definição de 'compilador sensato' fica cada vez mais frouxa."
O binário que você executa não é o programa que você escreveu. O otimizador do compilador reescreve
seu código-fonte de maneiras que você nunca vê — e algumas dessas mudanças podem
silenciosa e legalmente transformar código aparentemente seguro em
binários vulneráveis. A mesma linha pode ser
segura com um compilador e explorável com outro,
sem nada no código-fonte que lhe diga qual delas: uma vulnerabilidade mantida em
superposição, colapsada apenas quando você compila. Schrödinger's TOCTOU explora
loads inventados pelo compilador e suas amplas
implicações para vulnerabilidades do tipo time-of-check to time-of-use (TOCTOU) — encontradas
em código aberto:
kernels,
hipervisores,
enclaves,
firmware,
e bibliotecas.
Onde quer que olhemos, código aparentemente seguro fica exposto aos caprichos do
compilador. Mas esses são uma amostra, não um limite; os
mesmos bugs estão muito provavelmente no seu código também.
"Comece com algo fácil."
Quantas vezes esta função carrega *p?```c
unsigned int g(unsigned short *p)
{
short t = p; / copy *p into a local for safekeeping */
return (unsigned short)t - t;
}
Dica: a resposta é 1 — o código-fonte carrega `*p` uma única vez em `t`.
Cole-o no [Compiler Explorer](https://godbolt.org/z/c5K9P4dPd) (`arm gcc 14.2.0`,
`-O2`) e conte as cargas de `r0`, que contém `p`:```asm
g:
ldrh r2, [r0] # load *p, once
ldrsh r0, [r0] # load *p, twice
subs r0, r2, r0
bx lr
Uma carga no código-fonte, duas no binário. A segunda é uma carga inventada — uma leitura que o compilador fabricou e que você nunca escreveu. Ela é legal sob a máquina abstrata de C, que assume que a memória não pode mudar entre duas leituras. Mas quando essa memória é gravável por um atacante, a suposição se torna uma exploração: a carga inventada pode cair depois de uma verificação de segurança, reabrindo silenciosamente uma janela de tempo-de-verificação para tempo-de-uso (TOCTOU) que o programador acreditava ter fechado. O valor que você validou e o valor que você usa não são mais garantidamente os mesmos — mesmo que você nunca tenha escrito código que o lesse novamente.
O desafio prova que a carga inventada existe; vejamos como isso se transforma em corrupção de memória.
Em uma vulnerabilidade TOCTOU, um programa verifica que um valor é seguro e então usa o valor. No entanto, existe uma janela para exploração se um atacante puder alterar o valor no ínterim entre essas duas leituras – o valor inofensivo passa na verificação enquanto o perigoso é o que é usado:```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
A correção clássica é **tirar um snapshot primeiro**: copie quaisquer dados que o atacante possa
adulterar para uma área local que o atacante não possa alcançar, e então confie apenas
nessa área local. Uma vez que `len` está em uma variável local, ele fica congelado — um atacante disputando a
memória compartilhada não pode mais tocá-lo — portanto, a verificação e a cópia têm garantia
de ver o mesmo valor. É assim que o código em `receive` abaixo corrige o TOCTOU:
ele tira um snapshot da mensagem, valida o snapshot e publica a cópia validada
em `slot` para um consumidor encaminhar:```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? */
}
Pelo código-fonte, isso está correto. len é lido exatamente uma vez — para o snapshot —
então o valor que satisfaz a verificação <= 20 é o valor publicado em slot.
A janela TOCTOU está fechada e o código está seguro.
Exceto que não está. Sob x86-64 gcc -O2, receive o lê da
memória compartilhada original duas vezes: uma
como um escalar para controlar a verificação, e novamente como parte de a cópia em
massa que é publicada em 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]
A verificação roda na LEITURA #1; o valor que chega a `slot.len` é a LEITURA #2. Um
atacante que altera `len` entre elas passa um valor seguro para a verificação `<= 20`
enquanto um valor superdimensionado é publicado em `slot` — e `forward` então copia
essa quantidade de bytes para `out[20]`, exatamente o estouro que o snapshot deveria evitar,
reintroduzido pelo otimizador.