Le 17 juillet 2026, WordPress a corrigé en urgence deux vulnérabilités permettant à un attaquant non authentifié d’exécuter du code à distance (RCE) sur une installation WordPress par défaut. La faille combine une confusion de route dans l’API REST batch (CVE-2026-63030) et une injection SQL dans le paramètre author__not_in de WP_Query (CVE-2026-60137).

Avec un parc estimé à plus de 500 millions de sites, WordPress a pris la mesure exceptionnelle de forcer les mises à jour automatiques.


1. Contexte et chronologie

WordPress fait tourner environ 42 % des sites dans le monde entier. Une vulnérabilité critique dans son cœur peut ainsi impacter un grand nombre de sites (modulo la configuration, les plugins ajoutés, etc.).

Voici la chronologie des faits :

  • Découverte par Adam Kues, chercheur de l’équipe Assetnote de Searchlight Cyber, identifiant la confusion de route. Une injection SQL a été rapportée en parallèle et indépendamment par l’équipe TF1T, dtro et haongo
  • Signalement responsable  via le programme HackerOne de WordPress
  • Publication le 17 juillet 2026, sous le nom de code wp2shell mais sans détails techniques ni PoC complet de la RCE permettant de laisser le temps aux administrateurs de mettre à jour.
  • Réaction éditeur avec la publication simultanée des versions 7.0.26.9.5 et 6.8.6, et activation des mises à jour automatiques forcées sur les instances affectées.
  • Protections tierces : Cloudflare a déployé des règles WAF pour son trafic proxifié avant même la divulgation ; Wordfence a publié une règle de pare-feu le 17 juillet 2026 ; Patchstack a émis une règle de mitigation virtuelle, Seckhmet bloque cette route par défaut.

2. Les deux CVE de la chaîne

wp2shell n’est pas une faille unique mais l’enchaînement de deux défauts combinés :

CVENatureCWEVersions concernéesCVSSCrédit
CVE-2026-63030Confusion de route / Broken Access Control sur /wp-json/batch/v1CWE-436 (Interpretation Conflict)6.9.0 – 6.9.4, 7.0.0 – 7.0.17.5 (Searchlight) / 9.1 (Patchstack)Adam Kues (Searchlight Cyber)
CVE-2026-60137Injection SQL via author__not_in (WP_Query)CWE-896.8.0 – 6.8.5, 6.9.0 – 6.9.4, 7.0.0 – 7.0.19.1 (WPScan) / 9.8 (Patchstack)TF1T, dtro, haongo

3. Anatomie technique de la vulnérabilité

Cette section reconstitue la chaîne à partir du code source de WordPress 7.0 :


3.1. La confusion de route dans l’API batch (CVE-2026-63030)

L’API REST expose depuis WordPress 5.6 un point de terminaison batch (/wp-json/batch/v1, ou /?rest_route=/batch/v1 sans permaliens) qui regroupe plusieurs sous-requêtes REST en un seul appel. Sa sécurité repose sur le fait que chaque sous-requête doit être validée, contrôlée en permission et dispatchée sous son propre handler.

Le défaut se trouve dans serve_batch_request_v1() au sein du fichier wp-includes/rest-api/class-wp-rest-server.php:1709.

Étape A : construction de la liste $requests (une entrée par sous-requête, erreur ou non) :

foreach ( $batch_request['requests'] as $args ) {
    $parsed_url = wp_parse_url( $args['path'] );

    if ( false === $parsed_url ) {
        $requests[] = new WP_Error( 'parse_path_failed', __( 'Could not parse the path.' ), array( 'status' => 400 ) );
        continue;                      // <-- une entrée WP_Error est empilée dans $requests
    }
    // ... construction d'un WP_REST_Request valide ...
    $requests[] = $single_request;
}


Étape B : construction désynchronisée de $matches et $validation (class-wp-rest-server.php:1744) :

$matches    = array();
$validation = array();

foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $validation[] = $single_request;   // (1) $validation reçoit une entrée
        continue;                          // (2) MAIS $matches n'en reçoit PAS
    }

    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;                   // $matches n'avance que pour les requêtes valides
    // ... contrôle allow_batch, has_valid_params(), sanitize_params() ...
    $validation[] = $error ? $error : true;
}

C’est ici que l’on voit apparaître la faille ! En effet, pour une sous-requête en erreur, $validation gagne une entrée mais pas $matches (à cause du continue). Les deux tableaux, censés rester alignés, se retrouvent décalés d’un cran dès la première erreur.


Étape C : dispatch indexé par la mauvaise clé (class-wp-rest-server.php:1820) :

foreach ( $requests as $i => $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        // ... renvoie l'erreur, continue ...
        continue;
    }
    // ...
    $match = $matches[ $i ];               // <-- INDEXÉ PAR $i (index de $requests)
    if ( is_wp_error( $validation[ $i ] ) ) {
        $error = $validation[ $i ];        // <-- $validation[$i], lui, reste aligné sur $requests
    }
    // ...
    list( $route, $handler ) = $match;
    $result = $this->respond_to_request( $single_request, $route, $handler, $error );
}

$matches a été rempli en tableau ($matches[]), sans trou pour les entrées en erreur, alors que la boucle de dispatch l’indexe avec $i, l’index de $requests (qui, lui, contient les trous). Ainsi, après une première sous-requête malformée, $matches[$i] pointe sur le handler de la sous-requête suivante.

Avec une première sous-requête volontairement malformée (chemin ///http://:, etc. que wp_parse_url() rejette) :

Index $i$requests[$i]$validation[$i] (aligné)$matches[$i] (compact, décalé)
0WP_Error (chemin malformé)WP_Error(non lu, continue)
1Requête A (ex. /wp/v2/users)validation de Ahandler de B ← décalage
2Requête B (ex. /wp/v2/posts)validation de Bhandler de C

La sous-requête A est donc validée et contrôlée en permission comme A, mais exécutée sous le handler de B. C’est la route confusion : le callback de permission qui garde B n’est jamais appelé, et le contrôle d’autorisation de A est appliqué à la place. Un serveur patché garde les tableaux alignés alors qu’un serveur vulnérable fait apparaître le handler de la sous-requête décalée.


3.2. Le contournement du schéma (chaînage du désalignement)

Un seul décalage ne suffit pas à atteindre le sink SQL : encore faut-il faire transiter une valeur non assainie jusqu’à WP_Query. Le PoC exploite le désalignement deux fois, de façon imbriquée :

  1. Batch externe : une requête POST /wp/v2/posts porte, dans son corps, un tableau requests. Comme elle a été validée comme une requête posts, son champ requests n’est jamais confronté au schéma du batch : la liste imbriquée peut donc contenir des sous-requêtes en GET (l’allow-list de méthodes du batch est contournée).
  2. Batch interne : une requête GET /wp/v2/users?author_exclude=<payload> est désynchronisée sur le get_items() du contrôleur posts. Or le schéma de la collection users n’a aucun paramètre author_exclude : la valeur traverse la validation intacte, en chaîne de caractères brute.

Sur le contrôleur posts, author_exclude est normalement typé comme un tableau d’entiers, wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php:3004 :

$query_params['author_exclude'] = array(
    'description' => __( 'Ensure result set excludes posts assigned to specific authors.' ),
    'type'        => 'array',
    'items'       => array( 'type' => 'integer' ),   // <-- normalement coercé en entiers
    'default'     => array(),
);

En temps normal, sanitize_params() forcerait donc author_exclude en tableau d’entiers, rendant l’injection impossible. C’est précisément l’étape de validation que la confusion de route court-circuite.

La valeur brute est ensuite recopiée telle quelle vers la variable interne WP_Query, même contrôleur, class-wp-rest-posts-controller.php:245 puis :268 :

$parameter_mappings = array(
    'author'         => 'author__in',
    'author_exclude' => 'author__not_in',       // <-- mapping vers le query var vulnérable
    // ...
);

foreach ( $parameter_mappings as $api_param => $wp_param ) {
    if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
        $args[ $wp_param ] = $request[ $api_param ];   // valeur « accepted as-passed »
    }
}

3.3. L’injection SQL dans WP_Query (CVE-2026-60137)

Le sink final se trouve dans WP_Query::get_posts() — wp-includes/class-wp-query.php:2399 :

if ( ! empty( $query_vars['author__not_in'] ) ) {
    if ( is_array( $query_vars['author__not_in'] ) ) {
        $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) ";   // <-- INJECTION
}

L’assainissement par absint (conversion en entier positif) est placé à l’intérieur du garde is_array(). Si author__not_in arrive sous forme de chaîne (et non de tableau), la branche is_array() est sautée, et la chaîne est directement implodée puis interpolée telle quelle dans la clause SQL post_author NOT IN (...).

Une valeur comme 0) OR SLEEP(3)-- - ou 0) AND (<condition>)-- - referme la liste IN() et appose du SQL arbitraire → injection SQL aveugle (blind), booléenne et temporelle, atteignable sans authentification.

3.4. De la SQLi à la RCE

La primitive non authentifiée s’arrête à la Blind SQLi permettant déjà de lire toute la base (notamment les hash de mots de passe administrateur dans wp_users). Searchlight a délibérément retenu le dernier maillon (SQLi -> exécution de code). Deux voies plausibles, non confirmées publiquement :

  • une écriture de fichier côté base (type SELECT ... INTO OUTFILE) déposant un webshell ;
  • une récupération de credentials admin (cassage hors ligne du hash) suivie d’un upload de plugin contenant un webshell. C’est la voie post-authentification implémentée par le PoC.

4. Versions affectées et correctifs

BrancheInjection SQL (CVE-2026-60137)Chaîne RCE wp2shell (CVE-2026-63030)Version corrigée
≤ 6.8.5Affectée (à partir de 6.8)Non affectée6.8.6
6.9.0 – 6.9.4AffectéeAffectée6.9.5
7.0.0 – 7.0.1AffectéeAffectée7.0.2
< 6.8Non affectéeNon affectée

5. Exploitation et impact

Ni authentification, ni plugin, ni permaliens (l’endpoint répond aussi via ?rest_route=/batch/v1) ne sont nécessaire à l’exploitation, une simple requête HTTP anonyme suffit.

Conséquences d’une exploitation réussie :

  • Prise de contrôle totale du site et de son contenu.
  • Accès à la base de données (identifiants, hachages, données personnelles).
  • Exfiltration de données, injection de contenu ou de JavaScript malveillant.
  • Installation de backdoors et de webshells persistants.
  • Mouvement latéral dans l’hébergement

6. Remédiation

Action prioritaire : mettre à jour

Vérifiez la version réellement installée (les mises à jour forcées peuvent échouer silencieusement : permissions, espace disque, ou sites où l’auto-update est désactivé).


Mesures d’atténuation temporaires si le patch ne peut être appliqué immédiatement

  1. Mise en place d’un WAF bloquant à la fois /wp-json/batch/v1 et ?rest_route=/batch/v1
  2. Désactiver l’accès anonyme à l’API REST (extension type Disable WP REST API)
  3. Ajouter le plugin de sécurité Seckhmet permettant de bloquer les accès à l’API

Ces mesures ne remplacent pas la mise en place des correctifs de sécurité.