2026-02-27 | Pinperepette

Il Font Ti Frega

CVE-2026-2441: un font CSS crasha Chrome e ti esegue codice. Lo zero-day smontato dal commit al PoC.

CVE-2026-2441 Use-After-Free Chrome Zero-Day

Se sei arrivato fin qui probabilmente hai letto anche gli altri articoli e hai capito il senso e la direzione di questo progetto. Se non lo hai fatto, te lo spiego al volo.

Questo e' un blog personale. Volutamente scritto in italiano, perche' mi piace la mia lingua e mi rilassa scriverla. Lo scopo e' divulgativo: smontare cose complesse e spiegarle in modo familiare, senza fuffa, con un occhio alla matematica che e' fondamentale e che adoro. Passeremo anche a temi piu' complessi, ma un passo alla volta. Cosi' quando ci si arriva, tutti capiscono perfettamente almeno il concetto.

// Apri Una Pagina, Perdi Tutto

Sezione 00. L'antefatto

La iena mi urla dall'altra stanza. "Il computer si e' rotto." Le dico di chiudere la tab. "L'ho fatto. Si e' chiuso tutto." Pausa. "Anche le altre tab. Anche la musica."

Arrivo zoppicando col braccio nel gesso. Schermo nero. Il renderer e' morto. Le chiedo che sito aveva aperto. "Un blog di ricette. Biscotti al miele." Classico. Mi siedo, apro il terminale con una mano sola, guardo i crash log. Il processo di rendering e' andato in segfault. In un parser CSS. Per un font. Un font. Come dire che ti sei rotto una gamba inciampando in un granello di polvere.

Controllo la versione di Chrome. 144.0.qualcosa. Non aggiornato. "Ma quando e' l'ultima volta che hai aggiornato Chrome?" La iena mi guarda come quando Panna sente un rumore e inclina la testa. "Aggiornato cosa?" Ecco. Siamo messi cosi'.

Il punto e' che un blog di ricette non crasha Chrome. Ma i blog di ricette sono pieni zeppi di banner pubblicitari. E i banner caricano codice da network pubblicitari di terze parti. E dentro quel codice qualcuno puo' infilare un CSS costruito ad arte. La iena cercava i biscotti al miele. Col miele delle sue api, ovviamente. I biscotti al miele le hanno crashato il browser. E su un Chrome non aggiornato, un crash e' solo l'antipasto.

La patch esisteva gia'. Google l'aveva rilasciata due settimane prima. CVE-2026-2441. CVSS 8.8. Use-after-free nel rendering CSS. Exploitata in the wild. Il primo zero-day di Chrome del 2026. Tutto per colpa di un font. Ma la iena non aggiorna niente. Mai. Per lei "aggiornamento" e' quando le galline cambiano il piumaggio.

Nell'articolo su Spectre il processore mentiva. Qui e' il browser che si fa fregare. La' il canale era la cache, qui e' la memoria heap. Ma il concetto e' lo stesso: una cosa che sembra innocua (un'istruzione, un font CSS) si trasforma in un'arma.

Chromium e' open source. Il commit del fix e' pubblico. Il proof-of-concept gira su GitHub. E io ho una mano sola e troppo tempo libero. Combinazione pericolosa.

Smontiamolo.

0
CVSS score
0
Il fix
0
Nel PoC
0
Per fregarti

// Il CSS Che Nessuno Usa

Sezione 01. @font-feature-values

Ok, partiamo dal pezzo di CSS che ha causato tutto 'sto casino. Nella specifica CSS Fonts Level 4 c'e' una regola che il 99.9% degli sviluppatori web non ha mai visto e non vedra' mai: @font-feature-values. Serve a dare nomi leggibili alle feature OpenType dei font. Se non sai cosa vuol dire, tranquillo, non lo sa neanche chi ha scritto il bug.

@font-feature-values MyFont {
  @styleset {
    fancy-ligatures: 1;
    old-style-nums: 3;
    swash-caps: 7;
  }
}

Sembra innocuo, no? Un mapping nome → numero. Chrome lo parsa, crea un oggetto CSSOM chiamato CSSFontFeatureValuesRule, e dentro ci mette una CSSFontFeatureValuesMap. Che e' un wrapper attorno a una HashMap in C++. La struttura dati piu' comune del mondo. Tipo, la prima cosa che studi al secondo anno di informatica.

E il bug e' proprio li'. Nella roba che danno per scontata.

Perche' JavaScript puo' accedere a questa mappa tramite il DOM: rule.styleset. Puo' iterarla con entries(), for...of, o forEach. Puo' modificarla con set() e delete().

E puo' fare le due cose contemporaneamente. Si'. Hai capito bene.

Regola aurea della programmazione: non modificare mai una collezione mentre ci stai iterando sopra. E' la prima cosa che insegnano nei corsi di informatica. E' l'ultima cosa che Chrome controllava in questo codice. L'ironia scrive da sola.

// Dentro la HashMap

Sezione 02. Come funziona una tabella hash in C++

Per capire il bug devi capire la struttura dati. Prometto che e' veloce. Una HashMap (in Chromium si chiama WTF::HashMap, e si', WTF sta per "Web Template Framework", non per quello che stai pensando) e' un array di bucket. Ogni chiave viene hashata, l'hash determina in quale bucket finisce la coppia chiave-valore.

01
Hash
chiave → indice nel bucket array
02
Store
coppia (key, value) nel bucket
03
Grow
troppi elementi → rehash
04
Rehash
nuovo array, vecchio liberato

Il punto critico e' il rehash. Quando la mappa supera un certo rapporto di carico (circa il 75% dei bucket occupati), alloca un nuovo array piu' grande, ricopia tutti gli elementi, e libera il vecchio array.

Fin qui tutto normale. Tutte le HashMap lo fanno. Il casino succede quando qualcun altro ha ancora un puntatore al vecchio array. Tipo, l'iteratore che JavaScript sta usando per leggere la mappa.

$$\text{load\_factor} = \frac{n_{\text{elementi}}}{n_{\text{bucket}}} > 0.75 \implies \text{rehash}() \implies \texttt{free}(\text{old\_storage})$$

Quando il load factor supera la soglia, il vecchio storage viene deallocato

// Il Puntatore Morto

Sezione 03. Il cuore della vulnerabilita'

Ok, qui entriamo nel vivo. Questo e' il codice reale di Chromium, la versione vulnerabile prima della patch. Le righe evidenziate sono quelle che contano:

Codice vulnerabile: css_font_feature_values_map.cc pre-patch, con puntatore raw FontFeatureAliases* aliases_

Vedi il problema? Riga 17: const FontFeatureAliases* aliases. Riga 42: const FontFeatureAliases* aliases_. Puntatore raw allo storage interno della HashMap. Non una copia. Non un riferimento contato. Un puntatore nudo. Tipo camminare sul tetto senza parapetto. Di notte. Col ghiaccio.

Quando JavaScript chiama map.entries(), Blink crea questa IterationSource e salva il puntatore allo storage attuale. Fin qui tutto bene. Il puntatore funziona, tutti contenti.

Ma se tra un .next() e l'altro JavaScript chiama map.set() o map.delete()... beh.

Passo 1. Crea iteratore

const iter = map.entries()
Blink salva aliases_ = &storage. Il puntatore e' valido.

Passo 2. Primo .next()

iter.next() → legge aliases_->begin(). Funziona. Il puntatore e' ancora vivo.

Passo 3. Mutazione

map.delete(key); map.set("x", [1,2,3])
La HashMap si rialloca. free(old_storage). Il puntatore aliases_ ora punta a memoria liberata.

Passo 4. Secondo .next()

iter.next() → legge aliases_->...USE-AFTER-FREE.
Il puntatore e' morto. La memoria sotto potrebbe contenere qualsiasi cosa.

Quattro passi. Dal CSS al crash. E dal crash, potenzialmente, a code execution. La iena cercava ricette di biscotti. Qualcun altro poteva prenderle il computer.

// Anatomia di un Use-After-Free

Sezione 04. Cosa succede quando leggi memoria morta

La iena a questo punto ha chiesto "ma non puo' semplicemente crashare e basta?". Bella domanda. No. E il motivo e' una delle cose piu' insidiose della gestione della memoria in C++.

Quando liberi un blocco di memoria con free(), quel blocco non scompare. Resta li'. L'allocatore lo segna come "disponibile" e lo rimette nel suo pool. Il contenuto non viene azzerato. Costerebbe troppo farlo ogni volta.

Quindi il tuo puntatore punta ancora allo stesso indirizzo. Ma ora quell'indirizzo puo' essere stato riassegnato a qualcun altro. E quando leggi attraverso il puntatore morto, stai leggendo i dati di qualcun altro. E' come tornare a casa tua dopo uno sfratto. La porta si apre, ma dentro ci abita qualcun altro.

PRIMA DEL REHASH (puntatore valido):
aliases_ →
storage HashMap
entry_a: 1
bucket 0
entry_b: 2
bucket 1
entry_c: 3
bucket 2
Storage originale. Il puntatore punta qui, tutto valido
DOPO IL REHASH (puntatore dangling):
aliases_ →
FREED
???
freed
???
freed
???
freed
Vecchio storage liberato. Il puntatore punta ancora qui, ma la memoria e' morta
DOPO LA RIALLOCAZIONE (memoria riusata):
aliases_ →
DATI ATTACCANTE
payload
controllato
fake_ptr
controllato
shellcode
controllato
Stessa zona di memoria, ora occupata dall'attaccante. aliases_ legge i SUOI dati

Questo e' il cuore dell'exploit. L'attaccante non scrive in memoria direttamente. Sarebbe troppo facile da bloccare. Invece libera la memoria per conto di Chrome (forzando il rehash), poi la rioccupa con i suoi dati (creando oggetti della stessa dimensione). Quando Chrome rilegge attraverso il puntatore morto, legge i dati dell'attaccante. Pensando siano i suoi.

La differenza con Spectre: nell'articolo su Spectre il processore leggeva memoria che non doveva toccare e lasciava tracce nella cache. Qui il browser legge la sua stessa memoria, ma il contenuto e' stato sostituito da un attaccante. La' era un bug di architettura hardware, qui e' un bug di gestione della memoria software. Ma il risultato e' lo stesso: dati controllati dall'attaccante finiscono dove non dovrebbero.

// Il Proof of Concept

Sezione 05. HTML + CSS + JS = crash

Basta teoria. Vediamo il PoC pubblicato su GitHub. La vulnerabilita' l'ha scoperta Shaheen Fazim, il PoC l'ha scritto un ricercatore che si firma huseyinstif. Usa tre strategie diverse per triggerare il bug. Le smontiamo una per una.

Strategia 1: entries() + mutazione

La piu' diretta e la piu' brutale. Crei un iteratore con map.entries(), poi tra un .next() e l'altro cancelli chiavi e ne aggiungi di nuove. Ogni set() con abbastanza elementi forza il rehash. Tre iterazioni e Chrome va giu'.

// 1. Ottieni la mappa CSS
const rule = sheet.cssRules[0];
const map = rule.styleset;

// 2. Crea iteratore. Blink salva il puntatore raw
const iterator = map.entries();

// 3. Itera e muta contemporaneamente
while (true) {
  const result = iterator.next();  // ← legge tramite puntatore
  if (result.done) break;

  map.delete(result.value[0]);   // ← muta la HashMap

  // Forza rehash: 512 inserimenti superano il load factor
  for (let i = 0; i < 512; i++) {
    map.set("spray_" + i, [i]);  // ← rehash → free(old) → UAF
  }
}

Strategia 2: for...of

Stessa logica, path diverso dentro Blink. Alcune versioni di Chromium usano un codice leggermente diverso per for...of rispetto a entries(), ma il puntatore raw e' lo stesso. Cinque iterazioni questa volta. Un po' piu' lento, stesso risultato.

Strategia 3: requestAnimationFrame

Questa e' la piu' cattiva. L'iteratore viene avanzato dentro un ciclo requestAnimationFrame, forzando il layout recalc tra un frame e l'altro con document.body.offsetWidth. Questo obbliga il CSS engine a rielaborare le regole, amplificando le possibilita' di corruzione. Piu' lenta (circa 10 iterazioni), ma molto piu' difficile da rilevare perche' sembra una normale operazione di rendering.

Le tre strategie di trigger: entries() vs for...of vs rAF

Heap grooming: il PoC non si limita a triggerare il crash. E' piu' furbo di cosi'. Crea 50 regole @font-feature-values aggiuntive prima dell'attacco. Servono a riempire l'heap con oggetti della stessa dimensione, cosi' quando lo storage originale viene liberato, la memoria viene riusata da un oggetto controllato. In pratica, l'attaccante prepara il terreno prima di sparare. Questo trasforma un crash casuale in una corruzione di memoria prevedibile. La differenza tra un fulmine e un cecchino.

// Da Crash a Code Execution

Sezione 06. L'escalation

"Ok, crasha. E allora?" La iena ha ragione per una volta. Un crash non e' un exploit. Un crash e' fastidioso, chiudi e riapri. Un exploit e' pericoloso, qualcuno ti entra nel computer. La distanza tra i due e' la differenza tra un ubriaco che sbatte contro un muro e un ladro che trova la porta aperta.

Ecco come un attaccante percorre quella distanza:

01
UAF
Il puntatore legge memoria freed
02
Heap Spray
Riempi la memoria freed con dati controllati
03
Type Confusion
Chrome interpreta i tuoi dati come un oggetto interno
04
RCE
Esecuzione codice nella sandbox

Passo 1: Trigger. L'iteratore legge attraverso il puntatore morto. Chrome non crasha immediatamente (non sempre, almeno). Dipende da cosa trova a quell'indirizzo.

Passo 2: Heap spray. L'attaccante crea centinaia di oggetti JavaScript della stessa dimensione del vecchio storage HashMap. L'allocatore riusa la memoria freed. Ora il puntatore morto punta a dati controllati dall'attaccante. E Chrome non ha idea che e' successo.

Passo 3: Type confusion. Chrome legge quei dati pensando siano una HashMap entry (chiave, valore, hash). Ma sono dati costruiti dall'attaccante. Se il "valore" e' un puntatore falso, Chrome lo seguira'. Se la "chiave" e' un indirizzo calcolato, Chrome lo dereferenziera'. Tipo dare in mano a qualcuno una mappa dove "casa" punta al covo dei ladri.

Passo 4: Code execution. Con un indirizzo controllato, l'attaccante puo' redirigere l'esecuzione verso il suo codice. Dentro la sandbox di Chrome, per ora. Ma la sandbox ha i suoi bug. E chi fa questo di mestiere li conosce.

Anatomia di un exploit UAF: dalla vulnerabilita' all'esecuzione

La sandbox come ultima difesa: anche con code execution, l'attaccante e' dentro la sandbox di Chrome. Per uscire serve un secondo bug (una sandbox escape, tipo la Mojo IPC o un bug kernel). Ma la storia insegna che le sandbox escape si trovano. NSO Group le concatenava cosi': un bug nel renderer + un bug nella sandbox + un bug nel kernel = compromissione totale da un link WhatsApp. Un link. Ti viene da ridere, se non fosse terrificante.

// Il Fix: Una Riga

Sezione 07. Commit 63f3cb48

E ora la parte che mi fa sempre ridere (e piangere). Il fix. Il commit 63f3cb48. Ecco il diff reale:

git diff del commit 63f3cb48: da FontFeatureAliases* (puntatore) a FontFeatureAliases (copia)

Vedi le righe rosse e verdi? const FontFeatureAliases* aliases diventa const FontFeatureAliases aliases. Un asterisco. Hanno tolto un *. Da puntatore a copia. L'ho fatto vedere alla iena. "Tutto 'sto casino per un asterisco?" Si'. Benvenuta nella sicurezza informatica.

E guarda il commento che i dev di Chromium hanno lasciato nel codice fixato:

Codice fixato: css_font_feature_values_map.cc post-patch, con deep copy FontFeatureAliases aliases_

Righe 40-45: "Create a copy to keep the iterator from becoming invalid if there are modifications to the aliases HashMap while iterating." E poi un TODO: "Implement live/stable iteration... avoiding taking a copy here." Tradotto: sappiamo che la deep copy e' un cerotto, ma per ora funziona.

Con la deep copy, l'iteratore lavora sulla sua copia personale della mappa. Se JavaScript muta l'originale, il rehash avviene sull'originale. La copia dell'iteratore resta intatta. Nessun puntatore morto. Nessun UAF. Fine del problema.

DOPO IL FIX (deep copy):
aliases_ (copia)
snapshot della mappa
|
originale (rehash)
puo' riallocarsi liberamente
L'iteratore lavora sulla sua copia. Il rehash dell'originale non lo tocca

Pero' la deep copy ha un costo: memoria e tempo. Ogni volta che JavaScript crea un iteratore, Chrome clona l'intera mappa. Per una mappa con 8 entry e' niente. Per una con migliaia, e' un overhead reale. Il commento nel bug tracker dice che c'e' "remaining work". Probabilmente un refactor piu' elegante con WeakPtr o una generazione di versione per invalidare l'iteratore senza copiare.

Ma quando il tuo zero-day e' attivamente exploitato in the wild, non ti metti a fare il refactor elegante. Copi e patchi. Domani pensi al bello.

Aspetto Prima (vulnerabile) Dopo (fix)
Tipo const FontFeatureAliases* const FontFeatureAliases
Semantica Puntatore raw → storage condiviso Deep copy → storage privato
Rehash Invalida il puntatore → dangling Non tocca la copia → safe
Costo Zero (nessuna copia) O(n) per entry nella mappa
Sicurezza Use-After-Free Safe

// La Cronologia

Sezione 08. Dal report alla patch

Due giorni. Da segnalazione a fix rilasciato. Dico, due giorni. Google quando vuole non scherza.

Data Evento
11 Feb 2026 Shaheen Fazim segnala la vulnerabilita' a Google
13 Feb 2026 Chrome 145.0.7632.75 rilasciato con il fix. Patch out-of-band.
17 Feb 2026 CISA aggiunge CVE-2026-2441 al catalogo KEV (Known Exploited Vulnerabilities)
20 Feb 2026 PoC pubblicato su GitHub
27 Feb 2026 Questo articolo. Un pirata con un braccio rotto lo smonta.

Google ha confermato che l'exploit era "in the wild" prima della patch. Qualcuno la usava gia'. Chi? Non si sa. Non lo dicono mai. Ma il pattern e' quello classico: zero-day nel renderer, CSS/font come vettore, nessuna interazione utente. Tipico di operazioni di sorveglianza mirata. NSO Group, Intellexa, e compagnia bella costruiscono catene di exploit esattamente cosi'. Roba da milioni di dollari, per spiare giornalisti e dissidenti. Con un font CSS.

// Perche' Succede Sempre

Sezione 09. Il problema strutturale

A questo punto ti starai chiedendo: ma e' un caso sfortunato? No. CVE-2026-2441 non e' un caso isolato. E' un pattern. Chrome ha avuto dozzine di UAF negli ultimi anni. Guarda il grafico qui sotto e capisci perche'. Blink e' scritto in C++. E il C++ non ha garbage collector.

Chrome zero-day per tipo di vulnerabilita' (2021-2026)

In un linguaggio con garbage collector (Java, Go, Python), la memoria viene liberata solo quando nessuno la referenzia piu'. Nessun dangling pointer. Nessun UAF. Il costo e' performance. Il GC deve tracciare tutte le referenze. Ma almeno non ti spari nei piedi.

In C++, il programmatore decide quando liberare. E' piu' veloce, ma ogni free() e' una promessa: "giuro che nessun altro ha un puntatore a questa memoria". Se sbagli, hai un UAF. E in un codebase di milioni di righe, qualcuno sbaglia sempre.

Google lo sa. Ha investito in MiraclePtr (puntatori che controllano se la memoria e' stata liberata), in Oilpan (garbage collector per il DOM), nella migrazione a Rust per i componenti nuovi. Ma Blink ha milioni di righe di C++ legacy. La migrazione e' lenta. E nel frattempo, un asterisco di troppo in un parser CSS per font diventa uno zero-day. La vita.

Il paradosso: la feature CSS piu' inutile del mondo (@font-feature-values, che quasi nessuno usa) ha prodotto lo zero-day piu' pericoloso del 2026 (finora). Piu' codice c'e', piu' superficie di attacco c'e'. E Chrome ha molto, molto codice. Tipo, piu' righe di codice del Signore degli Anelli ha parole. Di parecchio.

// Come Difendersi

Sezione 10. La pratica

La risposta breve, quella che ho dato alla iena: aggiorna Chrome. La versione 145.0.7632.75 o successiva contiene il fix. Fine. Vai. Fallo adesso. Ti aspetto.

La risposta lunga, per chi non si accontenta e vuole capire i layer di protezione:

Layer Protezione Cosa ferma
Aggiornamento Chrome auto-update Il bug stesso (deep copy)
Sandbox Processo renderer isolato Code execution resta confinata
Site Isolation Ogni sito in un processo separato Un sito malevolo non legge i dati di un altro
ASLR Indirizzi di memoria randomizzati L'attaccante non sa dove puntare
MiraclePtr Smart pointer che rileva UAF Crash controllato invece di exploitation
V8 Sandbox Isolamento del motore JavaScript Limita la primitiva di heap spray

Il punto: nessun layer e' perfetto da solo. La sicurezza moderna funziona per accumulo, tipo le cipolle di Shrek. Ogni layer alza il costo dell'exploit. Un UAF nel CSS da solo costa poco. Un UAF + sandbox escape + kernel exploit costa milioni. E quei milioni sono il budget di NSO Group, non del ragazzino nel seminterrato. Se non sei un dissidente politico o un giornalista investigativo, probabilmente non ti cercano con catene da tre bug. Ma aggiorna Chrome lo stesso. Dai.

// Il Font Non E' Innocuo

Sezione 11. Epilogo

Ricapitoliamo il viaggio, perche' e' stato un bel giro. Una regola CSS che nessuno usa (@font-feature-values) crea una mappa interna. La mappa usa una HashMap. La HashMap ha un iteratore con un puntatore raw. JavaScript puo' mutare la mappa durante l'iterazione. La HashMap si rialloca. Il puntatore diventa dangling. Chrome legge memoria liberata. Un attaccante riempie quella memoria con i suoi dati. Chrome li esegue.

Tutto questo succede quando apri una pagina web. Zero click. Nessun download. Nessun popup. Nessun avviso. Un font.

La mattina dopo le ho aggiornato Chrome io. 145.0.qualcosa. "Fatto, sei a posto." La iena ha annuito, con la stessa convinzione con cui annuisce quando le spiego la differenza tra HTTP e HTTPS. Cioe' zero. Poi e' tornata a scrollare TikTok.

Panna mi ha guardato dal divano. Ho la netta impressione che Panna capisca i dangling pointer meglio di quanto lasci credere. Ha quell'aria. Quella di chi sa che il puntatore e' morto ma non te lo dice perche' vuole vedere come va a finire.

Dopo un po' ha alzato gli occhi dal telefono. "Ma tu guadagni qualcosa a smontare 'ste cose?" Pausa. "Perche' le mie galline almeno le uova le fanno. Tu stai li' a smontare cose che la gente normale non sa neanche che esistono."

"Appunto. Per quello qualcuno le deve smontare."

"Si' va bene. Pero' i biscotti al miele li cerco da un altro sito."

Almeno quello l'ha capito.

"Un asterisco. Un puntatore. Un font che nessuno usa.
E il browser esegue il codice di qualcun altro."

CVE-2026-2441 ci ricorda che la superficie di attacco di un browser moderno non e' dove pensi. Non e' nel JavaScript. Non e' nell'HTML. E' in una funzione CSS per tipografi, in un puntatore nudo in un file che nessuno legge, in un rehash che nessuno ha previsto. Il codice piu' pericoloso e' quello che sembra innocuo.

Signal Pirate