icon

Développez.
Protégez.
Innovez.

Développez en toute tranquillité grâce à notre boite à outils ! Aspirons à créer des logiciels plus sûrs et des équipes plus autonomes et responsables face aux défis de la cybersécurité.

wp2shell - CVE-2026-60137 et CVE-2026-63030 - injection SQL non authentifiée sur WordPress Core

wp2shell : l'injection SQL non authentifiée qui n'a besoin d'aucun plugin

Temps de lecture estimé : 20 minutes

Il y a des bugs qui ont l'air inoffensifs sur le papier, et qui finissent par mordre quand même : CVE-2026-60137 et CVE-2026-63030 font partie de ce lot. Prises séparément, elles ne m'auraient probablement pas donné envie d'écrire cet article.
CVE-2026-60137, c'est un oubli de sanitisation sur un paramètre de WP_Query, déjà couvert par plusieurs contrôles qui lui donnent un air inoffensif, quand CVE-2026-63030 n'est jamais qu'un endpoint de batching.
Bref, sur le papier, rien qui vaille le coup de sortir le clavier.

Je vais être honnête sur ce que j'avais en tête en commençant à lire ces CVEs. L'énoncé officiel de CVE-2026-60137 précise "when a plugin or theme passes untrusted input to the parameter", et mon réflexe, forcément, a été de me dire : bon, ça reste un problème de tiers, le Core ne peut pas y arriver seul.

Et ... je me suis trompé. En creusant le sujet, j'ai fini par comprendre : chaînées, ces deux CVE donnent une injection SQL non authentifiée sur une installation WordPress toute bête, sans qu'un plugin ou un thème tiers n'ait besoin de s'en mêler.
Le Core de WordPress se suffit à lui-même, et c'est resté connu sous le nom du PoC qui l'a rendu public : wp2shell.
La façon dont je me suis planté est justement ce qui rend cette histoire intéressante à raconter, alors autant vous embarquer avec moi, avec les mêmes fausses pistes.

1 / 6

WP_Query : la conversion qui devrait toujours avoir lieu

Avant de sortir l'artillerie, il faut poser une première brique que le Core de WordPress utilise depuis des années sans (trop) de drame.

WP_Query est le composant interne, la classe qui va chercher vos contenus en base de données : la page d'accueil, une catégorie, un widget "derniers articles", tout passe par elle. Parmi le lot de paramètres qu'elle accepte, author__not_in sert à exclure les contenus d'une liste d'auteurs, par exemple :

new WP_Query( array(
    'author__not_in' => array( 3, 7 ), // exclut les articles des auteurs #3 et #7
) );

Ce paramètre est exposé publiquement via l'API REST de WordPress, sous le nom author_exclude. Un GET /wp/v2/posts?author_exclude=3,7 se traduit en interne en author__not_in => array(3, 7). Mais comment est-ce qu'une query string devient un tableau PHP propre ?
Il n'y a aucune magie côté WP_Query : elle ne sait pas parser une URL, elle reçoit un tableau déjà construit. Toute la conversion se fait en amont, dans la couche REST, et ça vaut le coup de la dérouler en détail parce que c'est exactement ce mécanisme que la faille va contourner.

Première étape, la requête brute. Quand un client appelle cette URL, PHP peuple $_GET['author_exclude'] avec la chaîne de caractères "3,7", pas un tableau. Une query string ne produit un tableau PHP natif que si le client utilise la syntaxe author_exclude[]=3&author_exclude[]=7.

Deuxième étape, le schéma. WP_REST_Posts_Controller::get_collection_params() déclare le contrat attendu pour ce paramètre :

$query_params['author_exclude'] = array(
    'description' => __( 'Ensure result set excludes posts assigned to specific authors.' ),
    'type'        => 'array',
    'items'       => array( 'type' => 'integer' ),
    'default'     => array(),
);

Troisième étape, la validation. Avant que le callback ne s'exécute, WordPress parcourt ce schéma et, pour un type => array, délègue à deux fonctions :

function rest_is_array( $maybe_array ) {
    if ( is_scalar( $maybe_array ) ) {
        $maybe_array = wp_parse_list( $maybe_array );   // "3,7" -> array( "3", "7" )
    }
    return wp_is_numeric_array( $maybe_array );
}

function rest_sanitize_array( $maybe_array ) {
    if ( is_scalar( $maybe_array ) ) {
        return wp_parse_list( $maybe_array );           // idem
    }
    if ( ! is_array( $maybe_array ) ) {
        return array();
    }
    return array_values( $maybe_array );
}

C'est ici, et seulement ici, que la chaîne "3,7" est éclatée en tableau. wp_parse_list() découpe sur les virgules et les espaces, et chaque élément obtenu est ensuite validé contre items => { type: integer }.

Quatrième étape, la consommation. Une fois cette conversion faite, $request['author_exclude'] contient réellement array(3, 7), des entiers. Le contrôleur peut alors se contenter d'une simple copie de valeur :

$parameter_mappings = array(
    'author_exclude' => 'author__not_in',
    // ...
);
foreach ( $parameter_mappings as $api_param => $wp_param ) {
    if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
        $args[ $wp_param ] = $request[ $api_param ];   // déjà un array( int ) à ce stade
    }
}
$query = new WP_Query( $args );

Retenez bien ça : quand on passe par une route REST correctement schématisée, WP_Query reçoit toujours author__not_in sous forme de tableau d'entiers, parce que la conversion type-safe a eu lieu avant que le contrôleur ne construise sa requête.

Mais WP_Query ne fait pas une confiance aveugle à ce qui lui arrive. Elle a son propre filet de sécurité de secours, indépendant de celui de la couche REST (une belle démonstration de "Défense en profondeur"). Et ce filet a un nom que vous croiserez partout dans le Core de WordPress : absint().

function absint( $maybeint ) {
    return abs( (int) $maybeint );
}

Deux opérations, en apparence toutes bêtes, qui font tout le travail. Le cast (int) ne lève jamais d'erreur et ignore tout ce qui suit la partie numérique reconnaissable en début de chaîne : (int) "7" donne 7, mais (int) "7); DROP TABLE wp_users;-- -" donne aussi 7, toute la portion non numérique est purement tronquée. abs() retire ensuite un éventuel signe négatif. Le résultat est donc toujours une chaîne de chiffres décimaux sans signe ni séparateur, un format qui ne peut structurellement contenir aucun métacaractère SQL. C'est ce qui permet de l'interpoler directement dans du SQL construit par concaténation, sans $wpdb->prepare(), tout en restant sûr : un identifiant assaini par absint() ne peut par construction pas s'échapper de la position où il est inséré.

C'est pour cette raison qu'absint() est le réflexe quasi systématique du Core de WordPress dès qu'un identifiant numérique doit être injecté dans une clause SQL construite à la main. Gardez ce réflexe en tête, il va nous servir pour repérer l'endroit précis où il a un trou.

2 / 6

Le batch REST : la cloison qui doit tenir

Deuxième brique, complètement différente de la première : le REST API batch de WordPress, l'endpoint /wp-json/batch/v1, introduit en WordPress 5.6. Son rôle : regrouper plusieurs appels REST indépendants en une seule requête HTTP.
Typiquement, l'éditeur de blocs qui doit créer et modifier plusieurs objets en une seule interaction utilisateur n'envoie pas dix requêtes séparées, il envoie un seul POST avec un tableau requests, chaque élément décrivant une sous-requête. Concrètement, ça ressemble à ça :

POST /wp-json/batch/v1
{
  "requests": [
    { "path": "/wp/v2/posts/12", "method": "PUT", "body": { "title": "..." } },
    { "path": "/wp/v2/posts/12/tags", "method": "POST", "body": { "tags": [4] } }
  ]
}

Deux mises à jour en un seul aller-retour HTTP : la première change le titre de l'article 12, la seconde lui ajoute un tag. Rien d'exotique. Mais pour que ça reste sûr, le serveur REST doit traiter chaque sous-requête comme si elle arrivait seule : retrouver sa route et son handler, vérifier que cette route autorise le mode batch, vérifier les permissions, sanitiser les paramètres selon le schéma déclaré par cette route précise, puis exécuter son callback. Chaque sous-requête doit rester strictement cloisonnée dans son propre contexte de validation.

Cette cloison va céder, mais pas tout de suite : il faut d'abord retourner du côté de WP_Query, parce que le trou qui rendra cette confusion vraiment dangereuse est un oubli bien plus discret.

3 / 6

CVE-2026-60137 : is_array() qui laisse filer les chaînes

Retour à WP_Query, donc, mais plus pour admirer la mécanique : cette fois on cherche l'endroit précis où elle part en vrille.

Direction wp-includes/class-wp-query.php, autour de la ligne 2403, là où WP_Query construit sa clause SQL pour author__not_in :

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) ";
}

Voyez-vous le souci avant que je le pointe du doigt ?

Ligne par ligne, voilà ce qui se passe. La condition is_array(...) décide si on nettoie la valeur ou pas. Si c'est un tableau, chaque élément passe par le absint() qu'on vient de croiser au chapitre précédent, ce qui garantit qu'on n'injecte jamais de métacaractère SQL dans la clause NOT IN (...) construite par simple concaténation, sans passer par $wpdb->prepare().

Le problème, c'est qu'absint() n'est appelé QUE dans la branche is_array(). Si author__not_in arrive sous forme de chaîne de caractères, ce bloc entier est sauté. La ligne suivante, (array) $query_vars['author__not_in'], transforme cette chaîne en tableau à un seul élément, sans jamais passer par absint, ni requête préparée. Cette chaîne brute atterrit alors directement dans le NOT IN (...).

C'est un classique du PHP faiblement typé : une sanitisation branchée sur le type de la donnée, pas sur la donnée elle-même. Le développeur a raisonné "si c'est un tableau, je nettoie chaque élément", en oubliant le cas "et si ce n'en est pas un".

Ce qui confirme ce diagnostic, c'est le cas jumeau juste en dessous. Le paramètre symétrique author__in (les auteurs à inclure, pas à exclure) fait ça bien, dans la même méthode, à quelques lignes d'écart :

$author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) );

Ici, array_map('absint', ...) s'applique après le cast (array), sans condition is_array() devant, donc une chaîne malveillante passée en author__in ressort assainie. ✅

Deux paramètres jumeaux, dans la même méthode, et un seul des deux a le garde-fou inconditionnel : pour moi ça sent l'oubli ponctuel.

On a donc un bug réel. Mais si vous vous arrêtez là, vous êtes en droit de vous demander : et alors, on en fait quoi ? Parce qu'en usage REST normal, personne ne peut le déclencher.

Les deux seuls consommateurs Core d'author_exclude, le contrôleur des articles et celui des commentaires, sont enregistrés en GET. Cette méthode passe systématiquement par la couche de validation de schéma REST, qui éclate et type la valeur en tableau d'entiers avant même que le contrôleur ne construise sa requête. Sur ce chemin direct, le cas "chaîne non typée" ne se présente donc jamais.
Ce bug, tout seul, est une arme sans gâchette : personne ne peut s'en servir.

Ce bug n'est pas condamné à rester lettre morte pour autant : deux chemins peuvent l'atteindre. Le premier, c'est un plugin ou un thème tiers qui appelle lui-même WP_Query avec une entrée non fiable, sans jamais repasser par cette couche de typage REST, une valeur piochée directement dans $_GET, dans $request->get_param() d'une route maison, ou dans un shortcode. C'est très exactement la réserve posée par l'énoncé officiel de la CVE, celle qu'on a citée en intro.

Il va falloir une deuxième faille pour lui trouver une porte d'entrée.

4 / 6

CVE-2026-63030 : la confusion de route

Bon, deuxième round, et cette fois on change complètement de terrain. Direction wp-includes/rest-api/class-wp-rest-server.php, méthode serve_batch_request_v1(). Elle traite les N sous-requêtes d'un lot en s'appuyant sur trois tableaux parallèles, indexés par position : $requests (les objets requête), $matches (la route et le handler trouvés pour chacune), $validation (le résultat de la validation).

Première boucle, celle qui valide chaque sous-requête, dans sa version d'origine :

foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $validation[] = $single_request;   // $matches[] n'est jamais alimenté ici
        continue;
    }
    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;
    // ... vérifications allow_batch, has_valid_params(), sanitize_params() ...
}

Deuxième boucle, celle qui exécute :

foreach ( $requests as $i => $single_request ) {
    if ( is_wp_error( $single_request ) ) { /* ... */ continue; }
    // ...
    $match = $matches[ $i ];   // indexé sur la position dans $requests
    list( $route, $handler ) = $match;
    $result = $this->respond_to_request( $single_request, $route, $handler, $error );
}

Le bug tient en une phrase, mais il faut la lire lentement. Quand une sous-requête a un path qui échoue au parsing d'URL (wp_parse_url()), elle devient une WP_Error avant d'atteindre match_request_to_handler().
Dans ce cas précis, $matches ne reçoit rien, alors que $validation reçoit bien l'erreur de son côté.
Résultat : dès qu'une sous-requête au path invalide précède, dans le lot, une sous-requête légitime, $matches prend un cran de retard sur $requests. Voilà comment la cloison entre sous-requêtes, censée garder chacune dans son propre contexte de validation, finit par céder.

En deuxième boucle, $matches[$i] renvoie alors le handler d'une autre position du lot que celle réellement traitée. Une requête, appelons-la la porteuse, se retrouve exécutée avec la route et le handler d'une autre, la donneuse. Et c'est là que ça devient exploitable : la validation par schéma REST (sanitize_params()) a bien eu lieu en première boucle, mais contre le schéma de la porteuse, alors que l'exécution effective se fait avec le handler de la donneuse, sans jamais repasser par une deuxième sanitisation. Si la donneuse s'attend à recevoir author_exclude déjà typé en tableau d'entiers par son propre schéma, ce qui la rend sûre en appel direct, et que la porteuse ne déclare pas ce paramètre dans le sien, la valeur brute traverse intacte.

Prenons un exemple fictif pour fixer les idées, indépendant de toute SQLi : un lot de quatre sous-requêtes, où la porteuse a l'air d'ajouter un tag à un article, et la donneuse est en réalité une suppression de compte utilisateur.

PositionSous-requêteBoucle 1 (validation)Boucle 2 (exécution)Résultat
0PUT /wp/v2/posts/12 (légitime)$matches[0] = son propre match$matches[0] = son propre match✅ Exécutée normalement
1Le déclencheur : path: "///" (invalide)WP_Error avant match_request_to_handler() : rien n'est ajouté à $matchesis_wp_error() → ignoréeAucun effet direct, mais $matches vient de prendre un cran de retard
2La porteuse : PUT /wp/v2/posts/12/tags (ajouter un tag)Résolue normalement, mais rangée en $matches[1] à cause du décalage$matches[2] = le match de la position 3⚠️ Exécutée avec le handler de la donneuse : DELETE /wp/v2/users/7 s'exécute à la place d'un ajout de tag
3La donneuse : DELETE /wp/v2/users/7 (supprimer un compte)Résolue normalement, rangée en $matches[2]$matches[3] n'existe pas💥 Warning PHP + HTTP 500 rest_invalid_handler

C'est tout le bug : un cran perdu en boucle 1, et la boucle 2 rejoue la mauvaise paire pour toutes les positions qui suivent. Bien sûr, cet exemple reste une simplification pédagogique : dans la vraie vie, la donneuse doit encore accepter d'exécuter son action avec les paramètres de la porteuse, ce qui n'est pas garanti pour n'importe quelle paire de routes. Mais le principe du décalage, lui, est exactement celui-ci.

Un seul point du code peut amorcer ce décalage : l'échec de wp_parse_url() sur un path. Pas une route inexistante, pas une méthode invalide, pas un body malformé. Juste un path qu'on ne peut pas parser comme une URL.

Et cette confusion, indépendamment de la moindre SQLi, permet déjà pas mal de dégâts par elle-même : contourner le schéma de validation de n'importe quel callback batchable, faire planter des sous-requêtes en série, ou faire fuiter, via les en-têtes de réponse, quelles routes sont réellement enregistrées sur un site donné. La SQLi n'est qu'un débouché parmi d'autres pour cette primitive de confusion, la plus générique et la plus dangereuse des deux failles de ce duo.

Est-ce que ça suffit à réveiller le bug endormi du chapitre précédent ? Presque. Il manque encore un verrou à faire sauter : le schéma du batch interdit le GET sur les sous-requêtes, et author_exclude n'existe qu'en GET. C'est là que ça devient vraiment tordu.

5 / 6

Le chaînage : le Core seul suffit

Le verrou qu'on vient de poser semble définitif : GET est interdit dans le batch, donc il faudrait un plugin tiers exposant le même pattern pour obtenir la SQLi.

Sauf que ce raisonnement, aussi solide soit-il en apparence, rate un détail qui change tout : la confusion de route est une primitive générique, capable de cibler n'importe quelle route, y compris l'endpoint /batch/v1 lui-même. Et c'est exactement ce qui permet de contourner l'interdiction du GET. 😈

image d'illustration animée pour le coté schizophrénique de la chose

Le principe tient en une phrase : puisqu'on peut faire exécuter n'importe quelle route avec le handler d'une autre, on utilise d'abord la confusion pour faire exécuter le handler batch lui-même. Ça engendre un second batch, imbriqué dans le premier, dont la liste de sous-requêtes n'a jamais été confrontée à l'interdiction du GET. Ce batch interne, désormais libre d'utiliser GET, sert de véhicule pour une deuxième confusion, celle qui atteint get_items().

Premier temps, la confusion externe. Le lot de premier niveau contient trois éléments : un déclencheur au path invalide, une porteuse POST /wp/v2/posts (une route de collection dont le schéma ne déclare pas de champ requests, puisque le body d'une sous-requête accepte n'importe quelle propriété sans les inspecter), et une donneuse POST /batch/v1, l'endpoint batch lui-même. Après décalage, la porteuse s'exécute avec le handler de la donneuse, c'est-à-dire serve_batch_request_v1(), appliqué à l'objet requête de la porteuse.

Trois protections tombent d'un coup : /batch/v1 n'a aucun contrôle de permission propre, donc l'exécution est non authentifiée ; le contrôle allow_batch a été vérifié en première boucle à la mauvaise position, donc consommé par la confusion ; et l'interdiction du GET ne s'applique qu'au corps de la porteuse posts, jamais confronté au schéma du batch.

Deuxième temps, la confusion interne. À l'intérieur de ce batch imbriqué, où GET est maintenant permis, on rejoue exactement le même mécanisme, sur des routes Core cette fois : un déclencheur, une porteuse GET /wp/v2/posts/999999?author_exclude=<payload> (une route item, qui ne déclare pas author_exclude dans son schéma, réservé aux routes de collection), et une donneuse GET /wp/v2/posts, la route de collection dont le handler est get_items(). Après ce second décalage, la porteuse item s'exécute avec get_items().

Son contrôle de permission autorise la lecture publique, donc toujours non authentifié. Et get_items() mappe author_exclude, jamais typé par aucun schéma sur ce chemin, vers author__not_in, direction WP_Query. On retombe pile dans la branche non couverte de la section précédente.

Voici, conceptuellement, ce que ça donne : un batch dans le corps d'une sous-requête d'un batch.

POST /wp-json/batch/v1
{
  "requests": [
    { "path": "///" },
    { "method": "POST", "path": "/wp/v2/posts",
      "body": {
        "requests": [
          { "path": "///" },
          { "method": "GET", "path": "/wp/v2/posts/999999?author_exclude=0,(SELECT SLEEP(3))" },
          { "method": "GET", "path": "/wp/v2/posts" }
        ]
      }
    },
    { "method": "POST", "path": "/batch/v1" }
  ]
}

Regardez bien : aucune des trois sous-requêtes du lot externe n'utilise GET, l'interdiction de premier niveau est respectée à la lettre. Le GET n'apparaît que dans le lot interne, transporté comme un simple body opaque que personne ne va jamais valider. C'est toute l'astuce, et je dois admettre qu'elle est difficile à voir avant de l'avoir déroulée une fois soi-même.

Cette chaîne a été rejouée en local à partir du PoC public Icex0/wp2shell-poc, sur une instance vulnérable, et la SQLi Core fonctionne. Le payload JSON ci-dessus reste une reconstruction conceptuelle fidèle au mécanisme, pas un copier-coller vérifié octet par octet du PoC original.

Et voilà comment un Core WordPress tout nu se retrouve à exécuter du SQL qu'il n'a jamais validé.

6 / 6

Impact : SQLi, pas RCE

Parlons franchement du nom, maintenant qu'on a vu le mécanisme en entier. wp2shell sonne comme une promesse de shell, donc de RCE WordPress Core, une exécution de code à distance. Et je comprends d'où vient ce nom : plusieurs analyses convergent sur un pivot possible depuis cette SQLi vers un empoisonnement du cache d'objets WP_Post via le mécanisme oEmbed, jusqu'à un compte administrateur. C'est un raisonnement offensif tout à fait valide, et c'est probablement lui qui a donné son nom à toute cette chaîne.

Et de toute façon, même si un tel pivot se confirmait, ce n'est pas mon terrain. Un pentester ou un chercheur offensif a pour métier de pivoter : prendre une brèche et voir jusqu'où elle mène, pour démontrer l'impact maximal. C'est un vrai travail de recherche, et celui derrière wp2shell l'est particulièrement.
L'AppSec, ou le développeur qui doit corriger, n'a pas cet objectif-là : son travail commence et s'arrête à la brèche initiale. Peu importe jusqu'où l'attaquant peut ensuite pivoter, fermez cette porte et toute la suite s'effondre avec elle.
Ce que ces deux CVE Core permettent, qui est documenté et vérifié, c'est d'abord une injection SQL non authentifiée.

Tout ce qui viendrait après (l'empoisonnement de cache, la réécriture oEmbed, jusqu'à un shell) dépend de facteurs qu'on ne maîtrise plus depuis le seul code touché par ces deux CVE. Ce n'est plus une propriété de la vulnérabilité.
Donc classer ça "SQLi" plutôt que "RCE", ce n'est pas sous-estimer le problème : c'est juste être précis sur ce qu'on corrige, et sur ce qu'on est réellement en train de mesurer.

Et justement, parlons maintenant des correctifs.

Correctifs & Remédiation

Pour WP_Query, la version vulnérable était celle-ci, avec son is_array() qui laisse passer les chaînes :

// AVANT (vulnérable)
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) ";
}

Le correctif Query: Force author__not_in values to be integers ne rajoute pas une branche de plus, il remplace toute la logique :

// APRÈS (corrigé)
$author__not_in_id_list = wp_parse_id_list( $query_vars['author__not_in'] );
if ( count( $author__not_in_id_list ) > 0 ) {
    sort( $author__not_in_id_list );
    $where .= sprintf(
        " AND {$wpdb->posts}.post_author NOT IN (%s) ",
        implode( ',', $author__not_in_id_list )
    );
    $query_vars['author__not_in'] = $author__not_in_id_list;
}

Ça marche parce que wp_parse_id_list() ne se pose plus la question du type avant de nettoyer : tableau ou chaîne, elle éclate la valeur et applique absint à chaque élément, systématiquement. Il n'y a plus de branche non couverte à oublier, la sanitisation n'est plus une option conditionnelle sur is_array().

Et c'est le bon réflexe à avoir ici. is_array() comme porte d'entrée du nettoyage, c'est un piège classique du PHP faiblement typé, celui-là même qu'on a croisé au chapitre 3 : on assainit le format qu'on connaît, on oublie qu'il peut aussi en arriver un autre. Un correctif qui se serait contenté d'ajouter un elseif ( is_string( ... ) ) aurait refermé ce trou précis sans rien garantir sur le prochain type inattendu. En remplaçant la condition par une fonction qui normalise d'abord, puis sanitise, sans jamais bifurquer sur le type de l'entrée, le correctif ferme toute la classe de bugs, pas seulement l'instance qu'on vient de trouver.

Pour le batch REST, la version vulnérable de la première boucle de serve_batch_request_v1() était celle-ci :

// AVANT (vulnérable)
foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $validation[] = $single_request;   // $matches[] n'est jamais alimenté ici
        continue;
    }
    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;
    // ...
}

Le correctif REST API: Ensure errors in batch requests propagate ajoute une seule ligne :

// APRÈS (corrigé)
foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $matches[]    = $single_request;   // <-- la ligne ajoutée
        $validation[] = $single_request;
        continue;
    }
    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;
    // ...
}

Le raisonnement est le même que pour WP_Query : $matches reçoit désormais une entrée à chaque itération, y compris en cas d'erreur sur le path. Les deux tableaux restent alignés position par position, le décalage n'a plus de prise, et donc la deuxième boucle ne peut plus rejouer le handler d'une autre sous-requête que celle réellement traitée.

Une ligne suffit à corriger le symptôme, mais je note qu'elle ne change rien à la fragilité de fond : $requests, $matches et $validation ne restent alignés que par convention, pas par construction. N'importe quel continue anticipé glissé dans l'une des trois boucles pourrait recréer le même décalage demain, sans qu'aucune erreur PHP ne le signale puisque $matches[$i] reste un index parfaitement valide, juste pointé sur la mauvaise entrée. Une structure unique, un seul tableau d'objets portant à la fois la requête, son match et sa validation, aurait rendu ce genre de désynchronisation structurellement impossible plutôt que de reposer sur la vigilance du prochain contributeur qui touchera cette méthode.

Le troisième correctif, REST API: sub-requests must always use dispatch, s'attaque à un autre problème, plus insidieux. Souvenez-vous du chapitre 5 : la confusion externe fonctionne en faisant pointer une sous-requête du batch vers /batch/v1 lui-même. Concrètement, ça revient à faire rappeler le serveur REST par lui-même, depuis l'intérieur d'un traitement de batch déjà en cours. Avant ce correctif, rien n'empêchait ça : le serveur acceptait de redémarrer tout son cycle de traitement depuis l'intérieur d'un cycle déjà en cours, sans jamais se rendre compte qu'il était en train de s'auto-invoquer.

Le correctif introduit un simple drapeau, is_dispatching(), qui passe à vrai dès qu'un cycle REST de premier niveau démarre et repasse à faux quand il se termine. WP_REST_Server::serve_request() et rest_api_loaded() (le point d'entrée qui charge toute l'API REST, avant même le premier die()) vérifient désormais ce drapeau tout au début : s'il est déjà à vrai, ils refusent de démarrer un second cycle et rendent la main immédiatement.

Concrètement, même si quelqu'un trouve un autre moyen de faire pointer une sous-requête vers /batch/v1 ou vers n'importe quelle route qui redéclencherait tout le pipeline REST, le serveur refuse désormais de s'auto-invoquer tant qu'un batch est en cours de traitement. Ce n'est pas ce correctif qui bloque la SQLi elle-même, c'est le rôle du $matches[] vu plus haut. Celui-ci ferme une porte différente, plus large : toute la famille de tours de passe-passe qu'on pourrait construire en faisant rappeler le serveur REST par lui-même.

Il vous reste donc plus qu'à mêtre à jours vos versions !

BrancheVersions affectéesVersion corrigée
6.8.x< 6.8.66.8.6
6.9.x< 6.9.56.9.5
7.0.x< 7.0.27.0.2

Conclusion

Alors, wp2shell mérite-t-il le bruit qu'il a fait ? Oui. Point.
Deux failles Core, une fois chaînées, donnent une injection SQL non authentifiée sur une installation standard, sans qu'un plugin ait besoin de s'en mêler. C'est le genre de chose qui devrait vous faire vérifier votre version dans l'heure qui suit la lecture de cet article.

Et ce qui me frappe le plus, avec un peu de recul, c'est que ça faisait longtemps qu'on n'avait pas vu une CVE aussi critique frapper directement le Core de WordPress. D'habitude, c'est le terrain des plugins. Parce que le Core a été audité un nombre incalculable de fois.
Ça ne veut pas dire qu'une application passé au peigne fin reste inviolable pour toujours. On continue d'y ajouter des fonctionnalités, et chaque ajout peut rouvrir ses propres effets de bord, même sur un terrain qu'on croyait déjà nettoyé.

Ce qui me reste en tête, ce n'est pas tant la SQLi que le mécanisme qui l'a rendue possible : un système batchable qui finit par exécuter un endpoint différente de celui qu'il vient de valider. Et ce problème-là n'a rien de spécifique à WordPress : il peut toucher n'importe quel système qui regroupe plusieurs requêtes en une seule, un batch, un resolver GraphQL, une couche BFF (un backend dédié à un frontend spécifique). Si votre code valide la requête A puis exécute la requête B, vous avez possiblement le même bug.


FAQ

Qu'est-ce que wp2shell sur WordPress ?

wp2shell est le nom donné au chaînage de deux vulnérabilités du Core de WordPress : CVE-2026-60137 (une sanitisation manquante dans WP_Query) et CVE-2026-63030 (une confusion de route dans l'API REST batch). Chaînées, elles produisent une injection SQL non authentifiée, sans qu'aucun plugin ou thème tiers ne soit nécessaire.

wp2shell est-il une RCE WordPress Core ?

Oui et Non. Malgré le nom, l'impact confirmé des CVEs est une injection SQL non authentifiée, pas une exécution de code à distance. Pour autant, de nombreux PoC arrivent à pivoter via d'autres fonctionnalités et obtiennent effectivement une RCE.

Faut-il un plugin vulnérable pour exploiter wp2shell ?

Non, et c'est le point central de ce dossier. Un plugin ou thème qui transmettrait une entrée non fiable à WP_Query offrirait la même SQLi de façon plus directe, avec une seule confusion de route au lieu de deux, mais ce n'est pas une condition nécessaire : la double confusion imbriquée décrite au chapitre 5 atteint le Core de WordPress seul.

Quelles versions de WordPress sont affectées par CVE-2026-60137 et CVE-2026-63030 ?

Les versions WordPress 6.8.x antérieures à 6.8.6, 6.9.x antérieures à 6.9.5, et 7.0.x antérieures à 7.0.2 sont concernées. Le correctif est livré dans les versions 6.8.6, 6.9.5 et 7.0.2.

Comment se protéger de wp2shell si je ne peux pas mettre à jour immédiatement ?

Il n'existe pas de contournement de configuration propre : désactiver l'endpoint batch casse l'éditeur de blocs et d'autres usages légitimes. La seule mesure fiable reste la mise à jour vers 6.8.6, 6.9.5 ou 7.0.2 selon votre branche.

photo de l'auteur
Auteur

Florian Seigle-Vatte

Avec près de 10 ans d'expérience en développement et une passion de longue date pour la cybersécurité, je travaille aux côtés d'éditeurs de logiciels pour renforcer leur sécurité applicative.