
CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: RCE no autenticado en el núcleo de WordPress
Confusión de rutas del lote de la API REST (CVE-2026-63030) encadenada con una inyección SQL en
WP_Queryauthor__not_in(CVE-2026-60137) → ejecución remota de código pre-autenticación contra una instalación predeterminada de WordPress.Descubierto por Adam Kues (Assetnote / Searchlight Cyber), divulgado el 2026-07-17. Avisos: GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf.
| Cadena (RCE sin autenticación) | WordPress 6.9.0 - 6.9.4 y 7.0.0 - 7.0.1 |
| Solo SQLi (necesita un plugin/tema facilitador) | 6.8.0 - 6.8.5 |
| No afectado | ≤ 6.8 para la confusión del lote; 6.9.5 / 7.0.2 / 7.1-beta2 (parcheado) |
| Precondiciones | API REST accesible; sin caché de objetos persistente (Redis/Memcached); ≥1 entrada publicada |
| Autenticación requerida | ninguna |
| Impacto | sin autenticación → crear un nuevo administrador → ejecución de código (la SQLi también extrae el hash del administrador) |
https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6
requests y sin funciones rotas.shell sin credenciales forja un WP_Post falso mediante la confusión UNION de una sola entrada, tiende un puente hacia el personalizador para crear un administrador nuevo (POST /wp/v2/users), inicia sesión y despliega un webshell protegido por token. El volcado del hash del administrador por SQLi (read --preset users) se mantiene como segunda vía verificada.block_cannot_read) usado como check principal no destructivo.sqli) que los demás PoCs no tienen.$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)
Los seis PoCs públicos de los que se nutre esta herramienta no se incluyen aquí; están enlazados en Créditos.
Todo lo que sigue fue verificado en el laboratorio Docker local (consulta §4); las afirmaciones que no se ejecutaron en el laboratorio están etiquetadas como tales.
La cadena suelda dos errores independientes. Los números de línea provienen del código fuente real de WordPress 6.9.4 (extraído de 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 de tipo cadena se salta la protección is_array() (2404); implode(',', (array)"…") lo devuelve sin cambios (2408) y se concatena en bruto al SQL (2409). El hermano author__in (2415) vuelve a aplicar array_map('absint', …) dentro del implode y es seguro: ese array_map que falta es el error. El valor acaba como ... post_author NOT IN (<valor>) ..., por lo que 0) <sql>-- - cierra la lista y añade SQL.
Conseguir que llegue una cadena hasta ahí es la parte difícil: el endpoint REST de entradas asigna author_exclude → author__not_in (class-wp-rest-posts-controller.php:247) pero lo declara como 'type' => 'array' de enteros, por lo que el núcleo fuerza/rechaza una cadena:
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)
Por eso el error A por sí solo es únicamente "facilitado". El error B introduce de contrabando la cadena sorteando la validación en 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-petición WP_Error se inserta en $validation[] (1752) pero no en $matches[] (el continue en 1753 se salta la línea 1757), por lo que $matches se queda corto y $matches[$i] (1841) contiene el handler de la siguiente petición. La petición i se despacha con el handler de la petición i+1, llevando sus propios parámetros y su propio veredicto de validación (superada).
Origen de la regresión (verificado en el diff 6.8.3 → 6.9.4): en 6.8.3, el bucle inserta $matches[] = $match para todas las peticiones y las rutas incorrectas se descartan en el primer bucle: los arrays permanecen alineados, sin desincronización. La refactorización de 6.9.0 introdujo el desplazamiento. Es exactamente por eso que 6.8.x es "solo SQLi" y la cadena de RCE comienza en 6.9.0.
El parche añade $matches[] también para las entradas de error, refuerza la reentrada y analiza author__not_in con un auxiliar de listas de ID. (6.9.5 no estaba en Docker Hub cuando se hicieron las pruebas, así que esto proviene de los avisos, no de un diff de laboratorio.)