TLDR

  • ReDoS è un denial of service causato da una regex che esplora troppe combinazioni prima di dichiarare che un input non corrisponde.
  • I pattern più sospetti combinano quantificatori annidati o alternative sovrapposte, soprattutto su input controllati dall'utente.
  • Misurare il tempo dopo una chiamata regex non la interrompe: quando arriva il log, la CPU è già stata occupata.
  • La difesa reale è riscrivere il pattern, limitare l'input, testare i casi quasi validi e applicare limiti del motore come protezione aggiuntiva.

Perché conta ora

Le regex sono dappertutto: validazione di form, routing, parser, filtri antispam, log, Web Application Firewall, regole di sicurezza e plugin WordPress.

Proprio per questo sono facili da dimenticare.

Una regular expression sembra una riga compatta che restituisce vero o falso. Dietro quella riga, però, un motore può provare una quantità enorme di percorsi prima di ammettere che l'input non corrisponde. Se l'input arriva da una richiesta HTTP, quella riga compatta può diventare un denial of service.

SecuPress ha riportato il tema nel contesto PHP e WordPress mostrando le cosiddette Evil Regex. Il meccanismo è noto da anni, ma resta attuale perché continuiamo a copiare pattern da repository, risposte online e assistenti AI senza misurarne il costo.

Una regex può essere sintatticamente corretta, produrre il risultato atteso nei test e restare comunque pericolosa.

Il problema appare nel caso che quasi nessuno prova: una stringa molto simile a quella valida, con un carattere sbagliato alla fine.

Cos'è ReDoS

ReDoS significa Regular Expression Denial of Service.

È un attacco di disponibilità in cui un input controllato dall'utente forza un motore regex con backtracking a esplorare un numero enorme di combinazioni. La CPU resta occupata, la richiesta non termina e i worker disponibili si esauriscono.

Non serve sempre un input gigantesco. Con alcuni pattern bastano poche decine di caratteri per far crescere il lavoro in modo esplosivo.

Il classico esempio è:

^(a+)+$

Il gruppo interno a+ può consumare una o più lettere a. Il quantificatore esterno + ripete quel gruppo. La stessa sequenza può quindi essere divisa in moltissimi modi.

Con un input composto solo da a, il match riesce e il problema può restare nascosto. Aggiungendo un carattere finale non valido:

aaaaaaaaaaaaaaaaaaaaaaaa!

il motore arriva quasi in fondo, fallisce e torna indietro. Prova a redistribuire le a tra le ripetizioni, fallisce ancora e continua finché non esaurisce le possibilità o incontra un limite.

La parte costosa non è trovare un match.

È dimostrare che non esiste.

Quando una regex diventa sospetta

Due forme meritano attenzione immediata.

La prima è la ripetizione dentro un'altra ripetizione:

([a-zA-Z]+)*$

La seconda è un'alternativa in cui più rami possono riconoscere lo stesso prefisso:

(a|aa)+$

Non significa che ogni pattern con parentesi e quantificatori sia vulnerabile. I motori moderni applicano ottimizzazioni e il contesto conta. Significa che il pattern deve essere analizzato e misurato, non promosso in produzione perché "sembra funzionare".

I segnali più comuni sono:

  • quantificatori annidati come (x+)+, (x*)* o forme equivalenti;
  • alternative sovrapposte dentro gruppi ripetuti;
  • wildcard troppo permissive seguite da condizioni che possono fallire tardi;
  • regex costruite incorporando dati dell'utente;
  • input senza limite di lunghezza;
  • pattern copiati senza test su casi di mancata corrispondenza.

Una regex può inoltre diventare vulnerabile per injection. Se il codice usa direttamente un nome, una ricerca o un filtro fornito dall'utente per costruire il pattern, l'attaccante non controlla solo il testo da analizzare: controlla una parte del programma che lo analizzerà.

Non tutti i motori si comportano allo stesso modo

Parlare di "regex" come se esistesse un solo motore porta fuori strada.

PCRE2, Perl e molti motori usati da linguaggi e framework supportano funzionalità potenti attraverso il backtracking. Su pattern ambigui possono quindi incontrare tempi di esecuzione estremi, anche se alcune forme semplici vengono ottimizzate.

RE2 adotta una scelta diversa: garantisce un tempo asintoticamente lineare rispetto alla lunghezza dell'input e limita l'uso della memoria. Per mantenere questa proprietà rinuncia a costrutti come backreference e look-around generalizzati.

La conseguenza pratica è semplice: una regex sicura su un motore non va considerata automaticamente sicura su un altro. Pattern, runtime e limiti vanno valutati insieme.

La correzione migliore è spesso più semplice

Nel caso didattico:

^(a+)+$

il gruppo esterno non aggiunge alcun valore. La forma equivalente utile è:

^a+$

Quando la riscrittura non è così ovvia, PCRE2 offre gruppi atomici e quantificatori possessivi. Questi costrutti impediscono al motore di tornare dentro una parte del match già accettata.

Per esempio, un quantificatore possessivo:

a++

consuma le a senza restituirle durante il backtracking.

Non è una toppa da applicare alla cieca. Rendere atomico un gruppo può cambiare quali stringhe vengono accettate. Prima si definisce il linguaggio che il pattern deve riconoscere, poi si riscrive e si confrontano i risultati su casi validi e non validi.

Per email, URL, HTML e altri formati complessi, spesso la scelta migliore è non usare una regex artigianale. Parser e funzioni dedicate hanno semantica più chiara e ricevono più attenzione di una riga pescata da Internet.

PHP e WordPress: i limiti sono una cintura

PHP espone due direttive note:

  • pcre.backtrack_limit, con valore predefinito documentato pari a 1.000.000;
  • pcre.recursion_limit, con valore predefinito documentato pari a 100.000.

Ridurre questi valori può limitare il costo di un match, ma non esiste un numero magico valido per ogni sito. Un limite troppo basso può rompere elaborazioni legittime; uno troppo alto può lasciare troppo spazio a un pattern patologico.

Soprattutto, il codice deve gestire l'errore:

$result = preg_match($pattern, $input, $matches);

if ($result === false) {
    error_log('Regex failure: ' . preg_last_error_msg());
    // La richiesta va fermata o gestita in modo controllato.
}

Misurare il tempo dopo preg_match() non è un timeout. Se la chiamata impiega venti secondi, il controllo arriva dopo venti secondi. È telemetria utile, ma non impedisce il blocco.

La sequenza difensiva corretta è:

  1. imporre una dimensione massima all'input prima della regex;
  2. evitare pattern costruiti con testo non fidato;
  3. riscrivere le espressioni ambigue;
  4. impostare limiti PCRE compatibili con il carico reale;
  5. gestire esplicitamente gli errori del motore;
  6. osservare durata, CPU e tasso di fallimento in produzione.

In WordPress bisogna guardare soprattutto form, shortcode, filtri sui contenuti, validatori personalizzati e plugin che analizzano grandi blocchi di testo.

JavaScript: il blocco ferma anche l'event loop

Nel server JavaScript una regex sincrona costosa può bloccare l'event loop. Finché il match non restituisce, altre richieste e timer restano in attesa.

Un timeout applicativo avviato nello stesso thread non è necessariamente una protezione: anche il callback del timeout deve aspettare che il motore regex liberi l'event loop.

Se devi valutare pattern non fidati, la separazione deve essere reale: worker terminabile, processo isolato o un motore con garanzie come RE2, verificando prima la compatibilità della sintassi.

Per pattern scritti dal team restano valide le misure più economiche:

  • limite sulla lunghezza del subject;
  • niente concatenazione diretta di input nel pattern;
  • test di crescita del tempo;
  • lint e analisi statica come segnale aggiuntivo;
  • benchmark sul runtime realmente usato in produzione.

Come testare senza ingannarsi

Un test che verifica solo il risultato logico non basta.

Per ogni regex esposta a input esterno prepara almeno quattro famiglie:

  1. input valido corto;
  2. input valido vicino alla dimensione massima;
  3. input non valido che fallisce subito;
  4. input quasi valido che fallisce all'ultimo carattere.

Poi aumenta gradualmente la lunghezza e osserva la curva. Se raddoppiare l'input moltiplica il tempo in modo sproporzionato, fermati e analizza il pattern.

Il test va eseguito in un ambiente isolato, con un limite esterno capace di terminare il processo. Non lanciare stringhe sempre più grandi contro un servizio condiviso per vedere "fin dove regge": quello non è testing, è un incidente con documentazione.

Gli strumenti automatici possono individuare forme note, ma producono falsi positivi e falsi negativi. Il controllo finale resta composto da semantica del pattern, comportamento del motore e misure temporali.

Cosa controllare nel codice

Cerca i punti in cui una regex incontra dati esterni:

  • campi di registrazione e ricerca;
  • header HTTP, user agent e URL;
  • contenuti importati o caricati;
  • log analizzati al volo;
  • regole personalizzabili dagli utenti;
  • filtri antispam e WAF;
  • parser di markup, shortcode o template;
  • dipendenze che validano email, URL e identificatori.

Per ogni punto chiedi:

  1. Chi controlla il pattern?
  2. Chi controlla il testo?
  3. Quanto può essere lungo l'input?
  4. Cosa succede quando il match supera il limite?
  5. Il fallimento viene distinto da "nessuna corrispondenza"?
  6. Esiste un test sul caso quasi valido?

Se non sai rispondere, la regex non è piccola. È solo scritta su una riga.

Errori comuni

Il primo errore è affidarsi alla validazione client-side. Un attaccante può inviare direttamente la richiesta al server e saltare il browser.

Il secondo è aggiungere un cronometro attorno alla chiamata e chiamarlo timeout. Un cronometro descrive il danno dopo che è avvenuto.

Il terzo è abbassare pcre.backtrack_limit senza controllare il valore restituito. Il match può fallire per limite raggiunto e il codice può trattarlo come un normale "non trovato", nascondendo sia bug sia tentativi di abuso.

Il quarto è pensare che il WAF risolva tutto. Anche il WAF usa pattern e può diventare parte della superficie ReDoS.

Il quinto è copiare una regex per email lunga mezza pagina. Se nessuno nel team riesce a spiegarne ogni ramo, nessuno sa quanto costa quando fallisce.

Il punto

Le regex non sono stringhe decorative.

Sono piccoli programmi eseguiti su dati che spesso arrivano da fuori. Hanno una sintassi, un motore, una complessità e un consumo di risorse.

ReDoS funziona perché il codice sembra troppo piccolo per meritare un threat model.

Ma un attaccante non misura il numero di righe. Misura quanta CPU può ottenere con una richiesta.

Quando una regex protegge un endpoint, la domanda non è soltanto "riconosce l'input giusto?".

La domanda completa è: "quanto lavoro deve fare per rifiutare quello sbagliato?".

FAQ

Che cos'è un attacco ReDoS?

È un denial of service che sfrutta una regular expression inefficiente. Un input costruito per fallire tardi costringe un motore con backtracking a provare moltissimi percorsi, consumando CPU.

Quali regex sono più esposte al backtracking catastrofico?

Sono sospetti i quantificatori annidati, come (a+)+, e le alternative che possono riconoscere lo stesso prefisso, come (a|aa)+. Il rischio concreto dipende anche dal motore e dal resto del pattern.

Limitare la lunghezza dell'input basta contro ReDoS?

No, ma riduce il margine dell'attaccante. Va abbinato alla riscrittura della regex, a limiti di esecuzione del motore e a test con input quasi validi.

pcre.backtrack_limit risolve una regex vulnerabile in PHP?

No. Interrompe alcuni match troppo costosi e limita il danno, ma la regex resta inefficiente. Il codice deve anche gestire il fallimento e controllare preg_last_error().

RE2 è immune da ReDoS?

RE2 garantisce tempo lineare rispetto alla lunghezza dell'input, evitando il backtracking esponenziale. Non è però un sostituto perfetto di PCRE: non supporta costrutti come backreference e look-around.

Fonti