
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 do_action("{$status}_{$type}"); una riga falsificata con post_status=parse / post_type=request innesca parse_request, rieseguendo la pipeline del batch mentre l'identità admin assunta dal changeset (wp_set_current_user) è 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:
| 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' |
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.