
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: