2026-03-04 | Pinperepette

Il Compilatore E' un Traditore

Scrivi memset per pulire la RAM. Compili con -O2. Guardi il disassembly. La chiamata non c'e'. Il compilatore l'ha eliminata. E non ti ha detto niente.

Compiler Dead Store Elimination Undefined Behavior Reverse Engineering

// Il Tradimento

Sezione 00. L'antefatto

Sto riscrivendo un modulo di autenticazione. La password arriva in chiaro via TLS, la copio in un buffer locale, la valido, la confronto con l'hash nel database, poi faccio memset(buf, 0, len) per pulire la RAM. Roba standard. Il buffer e' azzerato, la password non resta in memoria. Vado a dormire tranquillo.

Poi un giorno mi viene la curiosita'. Compilo con -O2 e apro il disassembly con objdump. Cerco la mia chiamata a memset. Non c'e'. Cerco meglio. Non c'e'. Sparita. Il binario fa il setup dello stack, chiama authenticate, e poi dritto al ret. La password resta in memoria finche' qualcos'altro la sovrascrive. Potrebbe essere un millisecondo, potrebbe essere un'ora.

La iena si affaccia: "Tutto bene? Hai la faccia di uno che ha visto un fantasma." "Quasi. Ho scoperto che il compilatore mi frega di nascosto."

E no, non e' un bug. Il compilatore ha applicato una regola perfettamente legittima: se una variabile non viene letta dopo una scrittura, quella scrittura e' inutile. Si chiama dead store elimination. Il tuo memset scrive su un buffer che nessuno leggera' piu'. Per il compilatore e' come passare lo straccio in una stanza che stai per demolire. Perche' perdere cicli?

E questo e' solo l'inizio. Il compilatore elimina anche null check, rimuove controlli di overflow e riordina le istruzioni. Tutto legale secondo lo standard. Tutto devastante per la sicurezza. E zero warning. Il codice compila, linka, gira. Solo che non fa quello che pensi.

0
Eliminazioni nel binario
0
Versione Clang
-O2
Flag di ottimizzazione
0
Warning generati

// La Password Fantasma

Sezione 01. Dead store elimination

Partiamo dal caso classico. Funzione che riceve una password, la usa, poi la cancella dalla RAM con memset. Lo fanno tutti, lo dicono tutti i manuali.

1#include <string.h>
2
3void authenticate(const char *pw);
4
5void process_password(const char *input) {
6 char password[64];
7 strncpy(password, input, 63);
8 password[63] = '\0';
9
10 authenticate(password);
11
12 // Pulisci la password dalla RAM
13 memset(password, 0, 64);
14}

Compili con clang -O0, guardi il disassembly, memset c'e'. Perfetto, il buffer viene azzerato. Tutto regolare.

Disassembly di process_password compilata con -O0: la chiamata a memset e' presente
clang -O0: la chiamata a memset c'e'. Il buffer viene azzerato come previsto.

Ora compili con clang -O2. Stessa funzione, stesso sorgente. Riapri il disassembly.

Disassembly di process_password compilata con -O2: la chiamata a memset e' sparita
clang -O2: memset e' sparito. La password resta in memoria.

Puf. memset non esiste piu'. Il compilatore l'ha segata. Il ragionamento e' freddo e logico: password e' un array locale sullo stack. Dopo memset, la funzione ritorna e lo stack frame viene distrutto. Nessuno leggera' mai quei 64 byte azzerati. Scrivere zero in un buffer che nessuno leggera'? Operazione inutile. Dead store. Via.

Il problema e' che la password resta li sullo stack. In chiaro. Finche' un'altra funzione non alloca quello stesso spazio e lo sovrascrive, quei byte sono li ad aspettare. Un attaccante con accesso alla memoria del processo (core dump, /proc/<pid>/mem, cold boot attack, un exploit di un altro bug qualsiasi) la trova intatta. Grazie compilatore.

Le soluzioni esistono. memset_s (C11 Annex K) e' garantito non eliminabile dallo standard. explicit_bzero (BSD/Linux) e' una funzione opaca che il compilatore non puo' ottimizzare via. Su Windows c'e' SecureZeroMemory. L'alternativa portable e' un barrier con volatile: scrivi tramite un puntatore volatile e il compilatore non puo' eliminare la scrittura. Ma la soluzione vera e' sapere che il problema esiste.

// Il Null Check che Non Esiste

Sezione 02. Undefined behavior e le sue conseguenze

Secondo caso, e qui il compilatore diventa proprio stronzo. Scrivi una funzione che legge un valore da un puntatore, poi controlla se il puntatore e' NULL. Sembra ragionevole. Lo trovi in mille codebase.

1int read_value(int *ptr) {
2 int val = *ptr; // dereferenzia ptr
3 if (ptr == NULL) // controlla se e' NULL
4 return -1; // gestione errore
5 return val + 1;
6}

Per un umano quel check e' una rete di sicurezza. Per il compilatore e' un puzzle logico. Riga 2: *ptr dereferenzia il puntatore. Se ptr fosse NULL, sarebbe undefined behavior. Lo standard C dice che il compilatore puo' assumere che l'UB non accada mai. Quindi se la riga 2 viene eseguita, ptr non puo' essere NULL. Quindi il check alla riga 3 e' sempre falso. Quindi il compilatore lo elimina. Sillogismo perfetto, conseguenza devastante.

Disassembly di read_value con -O2: il null check e' stato eliminato, restano solo 3 istruzioni
clang -O2: il check su NULL e' sparito. Restano 3 istruzioni: leggi, incrementa, ritorna.

Tre istruzioni. Leggi, aggiungi 1, ritorna. Il null check non esiste nel binario. Il return -1 non esiste nel binario. Chiami read_value(NULL) e il programma crasha. Proprio la cosa che il check doveva impedire.

"Ma io l'ho scritto il check!" Si', nel sorgente. Il binario pero' non e' il sorgente. Il binario e' quello che il compilatore decide di produrre. E le regole dicono: UB non accade, il check e' inutile, lo tolgo. Fine della discussione.

Cos'e' l'undefined behavior? E' il contratto tra te e il compilatore. Lo standard C dice: se fai certe cose (dereferenziare NULL, overflow di interi con segno, accedere fuori dai limiti di un array), il comportamento e' indefinito. Il compilatore puo' fare qualsiasi cosa: crashare, ignorare, formattarti il disco. In pratica usa l'UB come informazione: se un'operazione e' UB, il codice che la presuppone non puo' essere raggiunto. E lo elimina. Bello eh?

La fix e' banale: controlla prima, dereferenzia dopo. if (ptr == NULL) return -1; int val = *ptr; In questo ordine il compilatore non ha scuse. Ma devi saperlo. E lui non te lo dice.

// L'Overflow Invisibile

Sezione 03. Signed overflow e la trappola del check

Terzo caso, e ormai dovresti avere il sospetto che finira' male. Devi allocare un buffer con un margine di sicurezza. Controlli l'overflow prima di chiamare malloc.

1#include <stdlib.h>
2
3void *safe_alloc(int size) {
4 if (size + 100 < size) // overflow?
5 abort(); // errore: overflow
6 return malloc(size + 100);
7}

L'idea: se size e' vicino a INT_MAX, size + 100 wrappa a un numero negativo, il check lo becca e chiama abort(). Sembra solido.

Peccato che size e' int, cioe' signed. In C, l'overflow di un intero con segno e' undefined behavior. Il compilatore assume che UB non accada mai. Quindi size + 100 non puo' essere minore di size. Quindi il check e' sempre falso. Via il check, via abort(). In una riga ha disattivato la tua difesa.

Disassembly di safe_alloc con -O2: il check sull'overflow e la chiamata ad abort sono stati eliminati
clang -O2: il check sull'overflow e abort() sono spariti. La funzione fa solo add e jmp malloc.

Il binario fa tre cose: add 100, estendi a 64 bit, jmp malloc. Basta. Se size vale INT_MAX, il risultato wrappa e malloc alloca un buffer minuscolo. Scrivi oltre i limiti. Buffer overflow. Il check difensivo non ha fermato un bel niente.

Ricapitoliamo. Quello che scrivi nel sorgente contro quello che trovi nel binario.

Cosa scriviCosa produce il compilatorePerche'
memset(buf, 0, len)NienteDead store elimination
if (ptr == NULL)NienteUB: ptr gia' dereferenziato
if (size + 100 < size)NienteUB: signed overflow

Tre protezioni scritte nel sorgente. Zero nel binario. Zero warning. Complimenti.

// Il Riordino

Sezione 04. Instruction reordering e timing attacks

Il compilatore non si limita a cancellare roba. Riordina le istruzioni per sfruttare la pipeline della CPU. Due operazioni sequenziali nel sorgente possono invertirsi nel binario. Di solito non importa. Ma quando fai sicurezza, l'ordine conta eccome.

Caso concreto: server HTTPS, devi confrontare l'HMAC del client con quello del server. Se usi memcmp, hai un problema.

1// SBAGLIATO: memcmp esce al primo byte diverso
2if (memcmp(received_hmac, computed_hmac, 32) == 0) {
3 accept_request();
4}

memcmp confronta byte per byte e ritorna appena trova una differenza. Primo byte sbagliato? Torna in 2 nanosecondi. Primi 31 giusti e solo l'ultimo sbagliato? Ci mette di piu'. Un attaccante misura il tempo e deduce quanti byte sono corretti. Prova tutti i valori del primo byte, trova quello che allunga la risposta. Ripete per il secondo. 256 × 32 = 8192 tentativi e ha l'intero HMAC. Si chiama timing attack e funziona da dio.

La soluzione standard e' un confronto constant-time: confronti tutti i byte, accumuli le differenze con OR, e ritorni solo alla fine. Nessun early exit, stesso tempo sia che sbagli tutto sia che indovini quasi tutto.

1// Confronto constant-time
2int secure_compare(const unsigned char *a,
3 const unsigned char *b,
4 size_t len) {
5 unsigned int result = 0;
6 for (size_t i = 0; i < len; i++) {
7 result |= a[i] ^ b[i];
8 }
9 return result;
10}

Indovina. Il compilatore con -O2 puo' autovettorizzare questo loop con istruzioni SIMD, processando 16 o 32 byte alla volta. Su hardware moderno la versione vettorizzata e' quasi sempre constant-time. Ma "quasi" in crittografia non vale niente. Su certe microarchitetture le SIMD hanno timing variabile. Il compilatore non sa cos'e' un side channel. Lui ottimizza per la velocita', non per la tua paranoia.

Il riordino colpisce anche la pulizia dei segreti. Pulisci un buffer e poi lo liberi? Il compilatore puo' spostare il free prima del memset perche' non vede dipendenza. Oppure eliminare la pulizia del tutto, tanto la memoria sta per essere liberata. In entrambi i casi: il segreto resta in memoria.

Non e' solo colpa del compilatore. Anche la CPU riordina le istruzioni a runtime (out-of-order execution). In un sistema multi-thread, un thread puo' vedere le scritture di un altro in ordine diverso. Per questo esistono i memory barriers: __asm__ __volatile__("" ::: "memory") dice al compilatore "non spostare niente oltre questo punto" e alla CPU "completa tutte le scritture prima di andare avanti".

// Come Sopravvivere

Sezione 05. Le difese reali

Ok, il compilatore fa il suo lavoro. Non e' cattivo, e' che ragiona secondo lo standard e lo standard se ne frega della tua sicurezza. Il vero problema e' che scrivi C pensando che le istruzioni vengano eseguite come le hai scritte. Non funziona cosi'. Ma le difese ci sono.

ProblemaSoluzione
memset eliminato memset_s (C11), explicit_bzero (BSD/Linux), SecureZeroMemory (Windows), barrier con volatile
Null check eliminato Controllare prima di dereferenziare. Mai *ptr prima di if (ptr == NULL)
Overflow check eliminato __builtin_add_overflow (GCC/Clang), aritmetica unsigned, safe integer libraries
Riordino istruzioni volatile, memory barriers, __asm__ __volatile__("" ::: "memory")
Timing attacks CRYPTO_memcmp (OpenSSL), timingsafe_bcmp (BSD), funzioni di libreria crittografica

E poi le difese generali. -fno-strict-overflow dice al compilatore di non sfruttare l'UB di signed overflow. -fsanitize=undefined aggiunge check runtime che trappolano l'UB quando accade. Ma la vera difesa e' una sola: leggere il disassembly. objdump -d e' il test finale. Se la tua protezione non c'e' nel binario, non esiste. Punto.

1# Compila con protezioni anti-UB
2clang -O2 -fno-strict-overflow -fsanitize=undefined -o safe auth.c
3
4# Verifica che le protezioni siano nel binario
5objdump -d safe | grep memset
6
7# Con explicit_bzero, la pulizia non viene eliminata
8objdump -d safe | grep explicit_bzero

Regola d'oro: non fidarti del sorgente. Fidati del binario. Il sorgente e' quello che volevi fare. Il binario e' quello che succede davvero.

// I Danni Veri

Sezione 06. CVE reali, non esercizi accademici

"Si' ok, bel disassembly, ma nella pratica succede davvero?" Eccome. Memoria non azzerata che contiene segreti e' la causa di CVE con exploit pubblici e danni reali in produzione. Non nel 2011. Adesso.

MongoBleed — CVE-2025-14847

Dicembre 2025. Mandi un messaggio zlib malformato a MongoDB e il server ti restituisce pezzi di heap non inizializzata. Dentro: credenziali, token di sessione, PII dai documenti BSON. Il bug e' nella decompressione: riporta una dimensione sbagliata, il server copia piu' byte di quanti ne abbia scritti, e quei byte extra sono heap riciclata dalle operazioni precedenti.

PoC pubblico dal 26 dicembre. Exploitation in-the-wild dal 29. Censys conta 87.000 istanze MongoDB esposte su Internet. Heartbleed versione 2025: stessa meccanica, stesso impatto, dieci anni dopo. Non abbiamo imparato niente.

rsync — CVE-2024-12085 + CVE-2024-12084

Gennaio 2025. I ricercatori di Google scoprono che rsync leaka un byte alla volta di stack non inizializzato durante il confronto dei checksum. Il parametro s2length non viene validato: lo manipoli e forzi un confronto tra un checksum e memoria non inizializzata. Un byte per richiesta. Lento, ma deterministico.

Il leak da solo (CVE-2024-12085) sarebbe roba minore. Ma quelli di Google lo concatenano con un buffer overflow separato (CVE-2024-12084): usano i byte leakati per calcolare gli offset e triggerare l'overflow in modo affidabile. Risultato: RCE come guest anonimo su qualsiasi server rsync. E la cosa bella? L'intera catena si spezza compilando con un singolo flag: -ftrivial-auto-var-init=zero. Inizializza a zero tutte le variabili locali. Senza il flag, lo stack e' pieno di residui. Con il flag, il leak non produce niente di utile e la catena si rompe.

Il parallelo con memset. -ftrivial-auto-var-init=zero risolve il problema al contrario: invece di cancellare la memoria dopo l'uso (memset, che il compilatore puo' eliminare), la inizializza prima dell'uso (che il compilatore non puo' eliminare perche' il valore zero potrebbe essere letto). Stessa difesa, approccio opposto, a prova di ottimizzazione.

KeePass — CVE-2023-32784

Maggio 2023. La master password di KeePass 2.x resta in chiaro nella memoria del processo. Il campo di input usa un text box custom (SecureTextBoxEx) che per ogni carattere digitato crea una stringa residua. Digiti "Password" e in RAM restano: *a, **s, ***s, ****w, *****o, ******r, *******d. Ricostruire la password e' un esercizio per principianti.

Il motivo: .NET gestisce le stringhe come oggetti immutabili. Create, non puoi sovrascriverle. memset su una stringa .NET non ha senso: il garbage collector puo' spostarla e lasciare copie al vecchio indirizzo. La password e' recuperabile da un dump del processo, dal pagefile, dall'hiberfil.sys, o da un dump della RAM. Non servono nemmeno privilegi elevati: basta un malware che chiama MiniDumpWriteDump e hai la master password del password manager. Ironia livello Dio.

CVEDoveCosa leakaCome
CVE-2025-14847MongoDBCredenziali, token, PIIHeap non inizializzata via zlib
CVE-2024-12085rsyncStack residuo (1 byte/req)Checksum length non validato
CVE-2023-32784KeePass 2.xMaster passwordStringhe .NET immutabili in RAM

Lo schema e' sempre quello. Un buffer contiene un segreto. Il codice prova a cancellarlo. Qualcosa impedisce la cancellazione: il compilatore che elimina memset, il runtime che non permette di sovrascrivere le stringhe, una variabile locale mai inizializzata. Poi arriva un altro bug (over-read, dump, leak) e quel buffer viene esposto. Il segreto esce. Ogni. Singola. Volta.

// Lo Scanner

Sezione 07. Rilevare le protezioni eliminate

Leggere il disassembly a mano va bene per un file. Ma su un progetto con centinaia di sorgenti non ti metti li' con objdump e una birra. Serve automazione. Questo script Python compila un sorgente con -O0 e -O2, estrae le call da ogni funzione, e confronta. Se una call di sicurezza sparisce con -O2, te lo dice.

1SECURITY_CALLS = {'memset', 'explicit_bzero', 'memset_s',
2 'abort', 'exit', '__stack_chk_fail'}
3
4def compile_and_disasm(source, opt_level):
5 obj = tempfile.mktemp(suffix='.o')
6 subprocess.run(['clang', opt_level, '-c', '-o', obj, source])
7 return subprocess.check_output(['objdump', '-d', '-r', obj])
8
9def extract_functions(disasm):
10 functions, current_fn, calls, saw_call = {}, None, [], False
11 for line in disasm.splitlines():
12 fn = re.match(r'^[0-9a-f]+ <_?(\w+)>:', line)
13 if fn:
14 if current_fn: functions[current_fn] = calls
15 current_fn, calls, saw_call = fn.group(1), [], False
16 elif saw_call:
17 reloc = re.search(r'RELOC_BRANCH\s+_*(\w+)', line)
18 if reloc:
19 callee = re.sub(r'_chk$', '', reloc.group(1))
20 if callee not in calls: calls.append(callee)
21 saw_call = False
22 elif re.search(r'\bcallq?\s+', line):
23 saw_call = True
24 return functions

Compila con entrambi i flag, fa objdump -d -r sui due object file (il -r serve per le relocation entries, cioe' i nomi veri delle funzioni chiamate), e per ogni funzione confronta le call. Se qualcosa del set SECURITY_CALLS c'e' con -O0 ma non con -O2, lo segnala. Semplice e brutale.

Output dello scanner: rileva memset eliminato dalla funzione process_password con -O2
Lo scanner rileva che memset e' stato eliminato da process_password con -O2.

L'output e' diretto: per ogni funzione, le call con -O0, le call con -O2, e cosa e' sparito. Zero disassembly da leggere a mano. Lo scanner fa il lavoro sporco al posto tuo.

Estendibile. SECURITY_CALLS e' un set. Ci butti dentro quello che vuoi: OPENSSL_cleanse, sodium_memzero, i tuoi wrapper custom. Lo scanner le traccia tutte. Mettilo nella CI e dormi piu' tranquillo.

"Il codice che scrivi non e' il codice che gira."

Il compilatore non e' cattivo. Fa il suo lavoro: produrre il binario piu' veloce possibile secondo lo standard. Il problema sei tu che pensi in operazioni sequenziali mentre lui pensa in regole formali. E le regole dicono che il tuo memset e' un dead store, il tuo null check e' ridondante, e il tuo overflow check e' impossibile. Tutto legale. Tutto silenzioso. Tutto nel tuo binario di produzione adesso.

Signal Pirate