XSS stored nel 2025: come identificarlo, sfruttarlo e costruire difese efficaci in PHP
Il 14 aprile 2025 stavo completando un penetration test di tre giornate commissionato da un gruppo editoriale lombardo che gestisce una rete di portali informativi regionali - 4 testate giornalistiche con redazioni distribuite, circa 280.000 visitatori unici mensili aggregati, una piattaforma CMS proprietaria sviluppata internamente in Laravel 10 più un layer di API per mobile app dedicate. L'assessment era focalizzato principalmente sul layer pubblico (frontend dei portali e API mobile), ma durante gli ultimi 30 minuti del secondo giorno ho deciso di testare anche l'area amministrativa interna dove giornalisti e redattori gestiscono i contenuti. In quel breve sforzo aggiuntivo ho identificato una vulnerabilità XSS stored particolarmente interessante: il payload si attivava nel back-office, ma l'iniezione poteva essere eseguita da qualunque utente con ruolo "redattore" (ruolo distribuito a oltre 60 persone fra dipendenti e collaboratori esterni freelance), e il payload rimaneva attivo fino a quando un utente con ruolo "direttore editoriale" apriva l'editor di un articolo specifico per revisione.
La vulnerabilità specifica era interessante perché aveva superato tre livelli di sanitizzazione applicativa. Il campo vulnerabile era "nota editoriale interna" - un textarea visibile solo nel back-office dove il redattore poteva aggiungere commenti per il direttore (tipo "verificare fonte citata al paragrafo 3" o "contattare l'ufficio stampa del comune"). Il contenuto veniva salvato nel database, poi mostrato nel back-office in un contesto HTML dove il direttore lo vedeva. Il codice applicativo usava tre librerie di sanitizzazione diverse a tre punti del flusso: HTMLPurifier a input, htmlspecialchars() al render, e DOMPurify JavaScript client-side. La catena di bypass che ho costruito sfruttava sottili differenze fra i tre: HTMLPurifier accettava un certo pattern SVG, htmlspecialchars() lo passava perché già "sanitizzato" dal layer precedente, DOMPurify in certa versione aveva un gap noto CVE-2023-26159 non patched nel client del cliente. Il risultato era iniezione di JavaScript eseguito nel contesto del direttore editoriale - con accesso a session, cookie, possibilità di emettere comandi amministrativi via API.
In quattro giornate di remediation affiancata al team interno, abbiamo risolto la vulnerabilità specifica, risolto altri due XSS residui trovati durante l'audit estensivo, introdotto una Content Security Policy strict in production, aggiornato la libreria DOMPurify alla versione patched, e riscritto il layer di sanitizzazione in modo consistente basato su un singolo componente centralizzato invece di tre librerie miste. Il report finale consegnato al CTO ha incluso proof-of-concept dettagliati e roadmap di remediation. Costo consulenziale dell'intervento: 6.800 euro.
Questo articolo descrive la classe di vulnerabilità XSS stored con focus sulle tecniche moderne di exploit e sulle difese che funzionano davvero in produzione, basato sull'esperienza di circa 25 assessment in cui ho trovato XSS in applicazioni PHP negli ultimi otto anni. Il principio guida è uno: XSS non è morto - è semplicemente più sottile e richiede tecniche di bypass più sofisticate per sfruttarlo, e tecniche di difesa più sistematiche per prevenirlo.
Perché XSS stored è più pericoloso di XSS reflected in contesti enterprise
Le vulnerabilità Cross-Site Scripting si dividono in due categorie principali. XSS reflected: il payload è inviato come parte della richiesta HTTP e riflesso nella risposta immediata. Per sfruttarlo, l'attaccante deve convincere la vittima a cliccare un link malevolo - phishing richiesto. XSS stored: il payload viene memorizzato in persistente storage (database, filesystem), poi restituito a ogni visita di utenti successivi. Non richiede social engineering - basta che la vittima visiti la pagina che contiene il payload memorizzato.
In contesti enterprise, XSS stored è significativamente più pericoloso per tre ragioni. Prima ragione: nessuna interazione dell'attaccante con la vittima. Una volta iniettato, il payload attende pazientemente di essere triggerato da vittime ignare. Seconda ragione: persistenza temporale. Un payload stored può restare attivo per mesi o anni prima che qualcuno se ne accorga, accumulando vittime nel frattempo. Terza ragione: scalabilità dell'attacco. Un singolo payload può colpire centinaia o migliaia di vittime se il contenuto compromesso è una pagina pubblica - mentre ogni XSS reflected richiede un click individuale.
La pagina OWASP su Cross-Site Scripting Stored è documentata in dettaglio e rappresenta il riferimento canonico per comprensione della minaccia. Le statistiche storiche indicano che XSS è stata fra le prime 3 vulnerabilità OWASP Top 10 per oltre 15 anni - ha perso rilevanza relativa solo recentemente grazie a framework moderni con escaping default (Vue, React, Angular encodano di default) ma resta prevalente in applicazioni legacy e in aree admin dei framework moderni dove gli escape default a volte vengono bypassati con v-html, dangerouslySetInnerHTML, etc.
Se gestisci un'applicazione PHP con area amministrativa, contenuti user-generated, o campi liberi che finiscono in HTML rendering, nel mio profilo professionale trovi il dettaglio degli assessment XSS che ho condotto in contesti PMI italiane, con metodologia di test approfondita anche sulle aree che i penetration test standard spesso trascurano.
Le tre generazioni di difese XSS e perché nessuna singola è sufficiente
Le difese contro XSS hanno tre generazioni successive, ciascuna con pattern di utilizzo specifici. La regola operativa moderna è che nessuna singola difesa è sufficiente - servono tutte e tre in combinazione per una postura robusta.
Prima generazione - Input validation / sanitization. Rimuovere o trasformare contenuto potenzialmente pericoloso prima che raggiunga lo storage. Librerie tipiche: HTMLPurifier in PHP, DOMPurify in JavaScript. Il limite: la sanitization è intrinsecamente fragile - ogni parser ha bug, ogni lista di tag/attributi considerati safe può essere bypassata con encoding creativi, ogni nuova versione di browser può introdurre vettori imprevisti. La storia di HTMLPurifier, OWASP AntiSamy, DOMPurify è piena di CVE per bypass successivi. Usare sanitization come difesa primaria è un errore strategico.
Seconda generazione - Output encoding. Quando il dato viene mostrato in HTML, applicare encoding context-appropriate che renda innocuo qualunque contenuto. Esempio classico: htmlspecialchars() in PHP trasforma <script> in <script> che il browser mostra come testo invece di eseguire. Laravel e Symfony hanno template engine (Blade, Twig) che applicano encoding di default - il pattern corretto è usare {{ $variable }} (auto-encoded) invece di {!! $variable !!} (raw HTML). L'output encoding è più affidabile della sanitization ma ha un limite: context matters. HTML encoding è diverso da JavaScript encoding è diverso da URL encoding. Encoding sbagliato per il context non protegge. Ogni template manager richiede consapevolezza di cosa sta facendo.
Terza generazione - Content Security Policy (CSP). Direttive HTTP che istruiscono il browser su cosa considerare valido per esecuzione e caricamento risorse. CSP strict con script-src 'self' 'nonce-random' blocca completamente l'esecuzione di qualunque inline JavaScript, rendendo inutile qualunque XSS stored o reflected anche se bypass di sanitization avviene. CSP è la difesa più robusta disponibile oggi ma richiede configurazione attenta e spesso incompatibile con pattern legacy (inline event handlers, inline scripts).
L'implementazione corretta che applico in produzione usa tutte e tre le generazioni insieme. Sanitization ragionevole a input (blocca payload ovvi), output encoding rigoroso al render (difesa primaria), CSP strict come safety net (blocca esecuzione anche se le prime due falliscono). Questo modello multi-layer è l'unico che fornisce difesa robusta nel 2026.
Le tecniche moderne di bypass: polyglot payloads, mutation XSS, CSP bypass
Gli attaccanti moderni hanno tecniche sofisticate per bypassare le difese convenzionali. Elenco le tre più rilevanti per chi fa penetration testing nel 2026.
Polyglot payloads. Payload costruiti per funzionare in multipli contexts simultaneamente (HTML + JavaScript + URL), sfruttando ambiguità di parsing. Il payload canonico di Mathias Karlsson del 2014, adattato per 2026, è qualcosa tipo:
jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()//>\x3eIl payload sopravvive a molti filtri perché i parser HTML, JavaScript, URL, CSS lo interpretano diversamente. Le librerie di sanitization mature lo bloccano, ma variant continuano a emergere.
Mutation XSS. Tecnica scoperta da Mario Heiderich che sfrutta il fatto che il DOM può modificare l'HTML al parse time, producendo pattern che erano sicuri nella stringa ma diventano pericolosi nel DOM. Esempio: <noscript><p title="</noscript><img src=x onerror=alert()>"></p></noscript> - la sanitization vede un attributo title innocuo, ma il browser al parse re-interpreta la struttura e produce un img con onerror eseguito. Difese: DOMPurify è particolarmente attento a questo pattern ma non elimina al 100%, CSP strict resta la copertura ultima.
CSP bypass tramite script injection allowed. Quando CSP permette script-src 'self', può essere bypassata trovando un endpoint dell'applicazione stessa che serve contenuto JavaScript controllato parzialmente dall'utente (es. endpoint che restituisce JSON in callback JSONP). Pattern di difesa: CSP con script-src 'self' 'nonce-random' invece di solo 'self' - ogni script legittimo deve avere il nonce corrente, script iniettati via XSS non lo hanno e vengono bloccati.
Content Security Policy strict: la configurazione che funziona in produzione
Una CSP strict production-ready richiede disciplina applicativa ma è raggiungibile. Il pattern che applico ha quattro livelli.
Livello 1: CSP report-only per valutazione iniziale:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-{random}' 'strict-dynamic';
style-src 'self' 'nonce-{random}';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self';
frame-ancestors 'none';
form-action 'self';
report-uri /csp-reportL'header Content-Security-Policy-Report-Only non blocca violazioni ma le segnala via endpoint /csp-report - permette di raccogliere dati sui pattern legittimi dell'applicazione prima di attivare blocking.
Livello 2: analisi dei report per 1-2 settimane. Ogni report CSP indica quale regola è stata violata da quale script. Tipicamente emergono: inline script legacy da riscrivere, script da CDN di terze parti da includere esplicitamente, pattern di rendering che violano encoding.
Livello 3: refactoring applicativo per eliminare i pattern non compatibili con CSP strict. Tipicamente: spostare inline onclick="..." a event listener esterni, eliminare eval() e new Function(), centralizzare le chiamate JavaScript in file esterni invece di inline.
Livello 4: attivazione CSP in blocking mode (Content-Security-Policy senza -Report-Only). Monitoring continuo per catturare regressioni.
Il nonce 'nonce-{random}' deve essere generato univocamente a ogni request e incluso in tutti i tag <script> legittimi. In Laravel il pattern è middleware che genera il nonce e lo espone alle view:
class CspNonceMiddleware
{
public function handle(Request $request, Closure $next)
{
$nonce = bin2hex(random_bytes(16));
View::share('cspNonce', $nonce);
$response = $next($request);
$response->headers->set(
'Content-Security-Policy',
"script-src 'self' 'nonce-{$nonce}' 'strict-dynamic'"
);
return $response;
}
}Le view usano il nonce esplicitamente:
<script nonce="{{ $cspNonce }}">
// codice inline legittimo
</script>Il pattern si integra con i principi di sicurezza delle sessioni PHP che ho descritto in un articolo dedicato, dove CSP è uno dei controlli che fortificano il modello di autenticazione contro exfiltration di session token via JavaScript iniettato.
Sanitization centralizzata: una singola strada per tutti i contenuti user-generated
L'errore architetturale che rende fragile la difesa XSS è la sanitization frammentata - come nel caso del cliente editoriale con tre librerie diverse a tre punti del flusso. Il pattern corretto è sanitization centralizzata: un singolo componente applicativo responsabile di ogni trasformazione di contenuto user-generated, usato ovunque nel codice.
Il componente che ho scritto per il cliente editoriale è una classe UserContentSanitizer con metodi espliciti per ogni context target:
namespace App\Infrastructure\Security;
final class UserContentSanitizer
{
public function __construct(
private readonly HTMLPurifier $purifier
) {}
public function sanitizeRichTextForArticle(string $raw): string
{
// articolo pubblico - permette rich text editoriale
return $this->purifier->purify($raw, $this->articleConfig());
}
public function sanitizeAdminNote(string $raw): string
{
// nota interna - solo testo, niente HTML
return strip_tags($raw);
}
public function escapeForHtml(string $raw): string
{
// plain text che andrà in HTML context
return htmlspecialchars($raw, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
public function escapeForJsonAttribute(string $raw): string
{
return htmlspecialchars(
json_encode($raw, JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_QUOT),
ENT_QUOTES
);
}
}Ogni context applicativo usa il metodo appropriato - niente più htmlspecialchars() sparso nel codice, niente più bypass involontari. Il sanitizer è un singolo punto di review per audit di sicurezza.
Testing automatico per prevenzione regressioni XSS
La prevenzione XSS richiede disciplina continua nel tempo - una modifica apparentemente innocente può reintrodurre vulnerabilità già risolte. Il pattern di testing che applico include due livelli. Primo livello: test unitari del sanitizer, che verificano che ogni metodo sanitizza correttamente una batteria di payload noti (raccolta dai payload lists OWASP, PortSwigger, exploit database). Secondo livello: test di integrazione end-to-end che iniettano payload XSS nei form dell'applicazione e verificano che il render finale non contenga HTML pericoloso.
Il pattern si integra con i principi di testing con Testcontainers per PHP che ho descritto in un articolo dedicato, dove i test end-to-end girano contro database reale con dati realistici.
Il risultato finale dell'intervento sul cliente editoriale lombardo dopo sei mesi di operatività in produzione con le nuove difese è stato il seguente. Tutte e tre le vulnerabilità XSS stored identificate durante l'assessment risolte con proof tecnico di chiusura (riesecuzione dei payload originali con risposta bloccata). CSP strict in blocking mode attiva su tutti i portali del gruppo, con circa 40-60 violazioni CSP al giorno tutte riconducibili a tentativi malevoli esterni (nessuna violazione su traffico legittimo post-tuning). Nessun report di XSS da parte di utenti o penetration tester successivi nei sei mesi di monitoring. Tempo di bootstrap della richiesta HTTP: overhead di CSP nonce generation misurato a circa 0,4 ms, trascurabile sul tempo totale. Costo consulenziale dell'intervento: 6.800 euro. Zero incidenti di produzione attribuibili a XSS nei sei mesi di monitoring.
Se gestisci un'applicazione PHP con area amministrativa o contenuti user-generated, la probabilità che esistano XSS stored non rilevati nel tuo codebase è statisticamente alta se non hai mai commissionato un assessment mirato su questa classe di vulnerabilità. L'investimento è contenuto (2-4 giornate di penetration test dedicato) e il beneficio difensivo è strutturale. Se vuoi confrontarti sul tuo caso specifico con un assessment XSS mirato della tua applicazione, contattami per una consulenza preliminare: in due-tre giornate di penetration test focalizzato sulla classe XSS identifico tutti i punti potenzialmente vulnerabili, produco proof-of-concept dimostrativi, e guido il team nella remediation strutturale con il pattern multi-layer di sanitization centralizzata, output encoding disciplinato, e CSP strict calibrato sul tuo stack applicativo specifico.