wp2shell (CVE-2026-63030) : la RCE pré-auth qui touche le cœur
de WordPress
wp2shell (CVE-2026-63030) enchaîne une confusion de routes dans l'API REST de WordPress et une injection SQL jusqu'à la RCE pré-authentifiée, sur une install par défaut. Mécanisme réel, détection, mitigation.
own2pwn··15 min de lecture
Une seule requête. Un POST vers /wp-json/batch/v1, un corps JSON un peu biscornu, et le serveur répond un 200 OK parfaitement banal. Trente secondes plus tard, il y a un compte administrateur de plus dans la base, un plugin inconnu déposé sous wp-content/plugins/, et un shell qui répond à id. Pas de mot de passe. Pas de plugin tiers vulnérable. Pas de thème exotique. Juste WordPress, dans sa version par défaut. C'est wp2shell, et c'est la faille qui a fait tourner les équipes sécurité le 17 juillet 2026.
Détection seule, sans rien exploiter : on lit la version de votre WordPress et on la compare aux versions vulnérables (CVE-2026-63030). Un domaine que vous administrez. Rien n'est stocké.
Cadre légal : à lire avant tout
wp2shell, c'est quoi au juste ?
Contrairement à ce que le nom laisse croire, wp2shell n'est pas un plugin, ni un énième outil « admin-vers-shell » qui suppose que vous ayez déjà les identifiants. C'est le nom de baptême donné par Searchlight Cyber (via son unité Assetnote) à une RCE pré-authentifiée dans le cœur de WordPress, divulguée le 17 juillet 2026. « Pré-auth » veut dire ce que ça veut dire : un attaquant anonyme, sans le moindre compte, obtient l'exécution de code sur le serveur.
Techniquement, wp2shell n'est pas une faille mais un enchaînement de deux bugs du noyau WordPress :
- CVE-2026-63030, une confusion de routes dans l'endpoint batch de l'API REST (
/wp-json/batch/v1). C'est le maillon qui transforme un bug « théorique » en RCE exploitable sans compte. - CVE-2026-60137, une injection SQL dans le traitement de
author__not_inparWP_Query.
Prises séparément, ces deux failles sont gênantes. Chaînées, elles donnent une RCE complète sur une installation standard, sans aucun composant tiers. La confusion de routes a été trouvée par Adam Kues (Assetnote / Searchlight Cyber) et remontée via le programme HackerOne de WordPress ; l'injection SQL a été rapportée en parallèle par les chercheurs TF1T, dtro et haongo. WordPress a réagi en forçant les mises à jour automatiques sur les installs concernées.
Le périmètre exact des versions :
BRANCHE ÉTAT CORRIGÉ DANS
------------ ------------------------------------ ------------
< 6.8.0 non vulnérable à la chaîne --
6.8.0 – 6.8.5 injection SQL seule (pas de RCE) 6.8.6
6.9.0 – 6.9.4 RCE pré-auth COMPLÈTE 6.9.5
7.0.0 – 7.0.1 RCE pré-auth COMPLÈTE 7.0.2
7.1 beta RCE pré-auth COMPLÈTE 7.1 Beta 2Le bug de fond : la confusion de routes dans /wp-json/batch/v1
L'endpoint batch de l'API REST existe pour regrouper plusieurs appels en une seule requête HTTP : vous envoyez un tableau de sous-requêtes, WordPress les traite l'une après l'autre et vous renvoie un tableau de réponses. Pratique pour un front-end qui veut créer dix objets d'un coup. Le contrôleur, en interne, tient deux tableaux parallèles indexés en même temps : un pour la validation de chaque sous-requête, un pour le handler qui va la router.
Le bug tient en une phrase, celle du chercheur qui l'a reproduit : « une sous-requête dont le chemin échoue à wp_parse_url() est ajoutée à $validation mais pas à $matches, donc les tableaux se désynchronisent ». Autrement dit : il suffit de glisser une seule sous-requête au chemin volontairement malformé pour décaler les deux tableaux d'un cran. À partir de là, chaque sous-requête suivante est validée sous un index, mais exécutée sous le handler du voisin. La validation de méthode et d'authentification s'applique à une route ; le code, lui, tourne pour une autre.
Schématiquement (reconstruction)
foreach sur le même tableau de sous-requêtes, mais l'un skip les chemins invalides et l'autre non. Un tableau avance de N, l'autre de N-1.wp_parse_url() mais reste comptée ici.La forme de la requête n'a rien de sorcier : c'est le JSON batch standard, avec un chemin cassé glissé au bon endroit. On en montre la structure (l'endpoint est public et documenté), pas un exploit fonctionnel :
POST /wp-json/batch/v1 HTTP/1.1
Host: cible
Content-Type: application/json
{
"requests": [
{ "method": "GET", "path": "chemin-volontairement-invalide" },
{ "method": "GET", "path": "/wp/v2/users?author_exclude=..." }
]
}À noter : la route batch est aussi joignable via ?rest_route=/batch/v1, ce qui compte pour la détection et le filtrage (les deux formes doivent être surveillées).
Du décalage d'index à l'injection SQL (CVE-2026-60137)
Le décalage sert à faire livrer une sous-requête au mauvais handler. Concrètement, l'exploit envoie un GET /wp/v2/users portant un paramètre author_exclude. Ce paramètre n'existe pas dans le schéma de l'endpoint users, et normalement il serait ignoré. Mais grâce à la confusion de routes, la requête est dispatchée vers le handler des posts, où author_exclude est bien reconnu et mappé sur la variable de requête author__not_in de WP_Query.
Et c'est là que ça casse. author__not_in est censé être une liste d'identifiants numériques. Le cœur attend des entiers ; l'exploit lui passe une chaîne, qui n'est pas normalisée et finit interpolée directement dans la requête SQL. Injection SQL, sur le compte utilisateur du serveur WordPress, sans authentification. Le correctif est tout aussi parlant que le bug : il force le passage par wp_parse_id_list(), qui coerce l'entrée en une liste d'entiers propres. Une ligne de type-safety oubliée ; c'est souvent tout ce qui sépare une API d'une injection.
author_exclude est valide et devient author__not_in.wp_users.Du « SQL » au « shell » : la partie « 2shell »
Une injection SQL en lecture, c'est déjà une fuite de base complète. Mais wp2shell va jusqu'au bout : de la lecture SQL à l'exécution de code. La démarche, telle que documentée par le PoC public, exploite la mécanique interne de WordPress plutôt qu'une fonction dangereuse :
- Forger de fausses lignes
wp_postsvia une injection UNION, des lignes conçues pour être rendues à travers le mécanisme de cache oEmbed. - Récupérer les vrais identifiants des posts de cache par la même SQLi, puis les recaster en changesets du customizer et en éléments de menu de navigation.
- Créer un compte administrateur « fabriqué » en une seule requête batch empoisonnée.
- Se connecter avec cet admin et déposer un plugin webshell, l'étape qui donne l'exécution de commandes.
Une précondition qui limite la RCE (mais pas la SQLi)
Le code complet existe dans les PoC publics ; l'objectif ici est que vous compreniez la chaîne assez bien pour la reconnaître dans vos logs, pas pour la rejouer contre le premier WordPress venu.
Reproduire en lab : le PoC public
Un PoC indépendant, Icex0/wp2shell-poc (licence MIT), automatise la chaîne dans un outil sobrement nommé wp2shell.py. Sur votre lab, ses sous-commandes racontent bien la progression : un test non destructif, une lecture de base, puis l'exécution :
# détection non destructive : la cible est-elle vulnérable ?
./wp2shell.py check http://lab-wordpress
# confirmer l'injection SQL (toujours en lecture)
./wp2shell.py check http://lab-wordpress --confirm-sqli
# extraire des données de la base (union / error / blind)
./wp2shell.py read http://lab-wordpress --preset users
# exécution de commande via le pont pré-auth vers l'admin
./wp2shell.py shell http://lab-wordpress --cmd id
./wp2shell.py shell http://lab-wordpress -i # shell interactifSearchlight Cyber a aussi mis en ligne un vérificateur non offensif, wp2shell.com, qui dit simplement si une instance est exposée. Côté outillage défensif, la divulgation a déjà déclenché des templates Nuclei de détection et des règles de vérification authentifiées chez les éditeurs de scanners (Rapid7, entre autres). En revanche, au moment où on écrit, aucun module Metasploit ni entrée Exploit-DB spécifique à CVE-2026-63030 n'est confirmé. Attention à ne pas confondre avec le vieux module générique wp_admin_shell_upload, qui suppose, lui, d'être déjà admin.
Ne confondez pas wp2shell et WP-SHELLSTORM
CVE-2026-3844) en déposant des webshells type down.php. C'est une campagne d'exploitation de bugs connus, sans aucun rapport avec wp2shell : failles différentes, cœur non concerné. Les deux noms finissent juste en « shell ».Détecter l'exposition en blackbox, sans l'exploiter
Bonne nouvelle pour qui doit auditer un parc : toute la chaîne est conditionnée par la version du cœur. Inutile de tirer un payload pour savoir si une instance est exposée ; il suffit de lire sa version et de la comparer aux plages vulnérables. C'est exactement la logique de notre détecteur open-source, wp2shell-detect (Python, zéro dépendance, détection seule). On la déroule étape par étape.
1. Empreinter la version du cœur
WordPress laisse fuiter sa version par plusieurs canaux publics, en lecture seule. On les interroge et on croise :
- La balise generator de la page d'accueil :
<meta name="generator" content="WordPress 6.9.3">. - Le flux RSS (
/feed/) :?v=6.9.3dans la balise<generator>. /readme.html, qui afficheVersion 6.9.3en clair.- L'index
/wp-json/, parfois bavard sur la version dans sa description.
Le parsing doit être strict : on n'accepte qu'un X.Y ou X.Y.Z purement numérique, sinon on renvoie « inconnu » plutôt que de deviner. Une version mal lue, c'est un faux positif (ou pire, un faux négatif) garanti.
# plages vulnérables (inclusives), d'après la divulgation
RCE_RANGES = [((6, 9, 0), (6, 9, 4)), ((7, 0, 0), (7, 0, 1))]
SQLI_RANGES = [((6, 8, 0), (6, 8, 5))] # SQLi seule, pas de RCE
# corrigé dans 6.8.6 / 6.9.5 / 7.0.2
def parse_version(v):
m = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?$", v.strip()) # strict
return (int(m[1]), int(m[2]), int(m[3] or 0)) if m else None
def verdict(v):
if any(lo <= v <= hi for lo, hi in RCE_RANGES): return "RCE pré-auth"
if any(lo <= v <= hi for lo, hi in SQLI_RANGES): return "injection SQL"
return "hors plage (probablement patché)"Le cas de l'install durcie
readme.html. La version devient alors invisible en blackbox pur. Notre détecteur le dit franchement (« WordPress confirmé, version masquée ») au lieu de bluffer : confirmer wp2shell sans la version reviendrait à l'exploiter, ce qu'on refuse de faire pour une simple détection. Le réflexe sain : patcher quand même.2. Confirmer que la route batch est joignable
La version dit « vulnérable » ; reste à savoir si le point d'entrée de la chaîne est exposé ou déjà filtré au bord. On le vérifie sans jamais l'actionner, avec une requête OPTIONS : le routeur REST renvoie le schéma de la route (les méthodes autorisées) sans exécuter le handler. Un 200 avec Allow: POST confirme que /wp-json/batch/v1 répond ; un 403 signale qu'une mitigation est déjà posée.
# OPTIONS = introspection de la route, aucun handler déclenché, zéro payload
curl -s -X OPTIONS -i http://cible/wp-json/batch/v1 | grep -i '^Allow:'
# -> Allow: POST (route batch exposée) | 403/401 (filtrée au bord)3. Le verdict
version lisible ?
+- non -> WordPress, version masquée : patcher par précaution
+- oui -> 6.9.0-6.9.4 ou 7.0.0-7.0.1 ? -> RCE PRÉ-AUTH (critique)
6.8.0-6.8.5 ? -> INJECTION SQL (élevé)
sinon -> probablement patchéSur un parc entier, en une commande :
git clone https://github.com/own2pwn-fr/wp2shell-detect && cd wp2shell-detect
./wp2shell_detect.py --targets parc.txt # une URL (ou host) par ligne
# [!!] https://blog.exemple.fr -> VULNERABLE (pre-auth RCE) 6.9.3 CVE-2026-63030Le code sort en statut 2 dès qu'une cible est vulnérable : pratique pour un balayage nocturne en CI ou en cron. Rien dans tout ça ne touche à la faille ; ce sont des requêtes qu'un crawler banal pourrait émettre.
Détecter l'exploitation dans vos logs
La détection d'exposition répond à « suis-je vulnérable ». Vos logs, eux, répondent à « suis-je attaqué ». Aucun éditeur n'a publié de liste d'IOC définitive : la faille a quelques jours. Mais le mécanisme dicte ses propres signatures. Ce qu'il faut chasser :
- Des requêtes anonymes sur l'endpoint batch :
POST /wp-json/batch/v1ou?rest_route=/batch/v1sans session admin. L'usage anonyme légitime de cette route est rare : traitez-le comme suspect par défaut. - Des corps batch avec un tableau
requestsdont un chemin est manifestement malformé (le déclencheur de désynchro). - Un
author_excludesur/wp/v2/users(illégitime pour ce schéma), surtout si sa valeur contient de la syntaxe SQL (UNION, apostrophes, marqueurs de commentaire). - Des indices post-exploitation : un compte administrateur apparu de nulle part, des lignes
wp_postsanormales liées au cache oEmbed, auxcustomize_changesetou auxnav_menu_item, et surtout un nouveau plugin avec un fichier PHP inconnu souswp-content/plugins/.
Deux passes simples sur vos accès et votre système de fichiers dégrossissent le travail :
# 1) hits anonymes sur la route batch (les deux formes)
grep -E '/wp-json/batch/v1|rest_route=/batch/v1' access.log \
| grep -iv 'wp-admin' | less
# 2) author_exclude passé à l'endpoint users (n'a rien à y faire)
grep -E '/wp/v2/users' access.log | grep -i 'author_exclude'
# 3) plugins déposés récemment (webshell suspect)
find wp-content/plugins -name '*.php' -mtime -7 -printf '%T+ %p\n' | sortMitiger : patcher d'abord, filtrer ensuite
La seule vraie réponse, c'est le correctif. Passez en WordPress 7.0.2 ou 6.9.5 (ou 6.8.6 pour la branche 6.8, qui ne portait que la SQLi). WordPress force les mises à jour automatiques sur les installs concernées, mais ne comptez pas dessus : vérifiez. Rapid7 le dit sans détour, les contournements ne remplacent pas le patch.
Si vous ne pouvez vraiment pas patcher tout de suite, on filtre au bord. Bloquez l'endpoint batch au niveau du reverse-proxy, dans ses deux formes :
# nginx : couper l'accès anonyme à l'API batch en attendant le patch
location = /wp-json/batch/v1 { return 403; }
# et la forme via query string
if ($arg_rest_route = "/batch/v1") { return 403; }Plus fin, sans casser un éventuel usage légitime authentifié : un mu-plugin (must-use) qui rejette les requêtes batch anonymes avant qu'elles n'atteignent le contrôleur. À déposer dans wp-content/mu-plugins/ :
<?php
// wp-content/mu-plugins/000-block-anon-batch.php
// Mesure temporaire : rejette /wp-json/batch/v1 pour les non-connectés.
add_filter('rest_pre_dispatch', function ($result, $server, $request) {
if (strpos($request->get_route(), '/batch/v1') === 0 && !is_user_logged_in()) {
return new WP_Error('rest_forbidden', 'Batch API disabled', ['status' => 403]);
}
return $result;
}, 0, 3);Enfin, restreignez l'accès anonyme à l'API REST de façon générale (via un plugin de sécurité) et, en post-incident, auditez les comptes admin, les plugins inconnus, faites tourner les salts et les identifiants, et passez en revue les tables de cache/oEmbed. Cette faille est un rappel utile : la gestion des vulnérabilités ne se joue pas au moment de la CVE, mais dans votre capacité à savoir, en quelques minutes, où vous tournez la version vulnérable.
Ce que wp2shell dit de votre surface d'attaque externe
Le vrai piège avec une CVE cœur de WordPress, ce n'est pas le WordPress que vous administrez et surveillez. C'est l'autre : le blog corporate monté il y a trois ans pour une campagne, le sous-domaine events. d'une filiale, l'install de test qu'un prestataire n'a jamais éteinte. Ces instances-là ne sont dans aucun inventaire, et ce sont exactement celles qui traînent en 6.9.3 pendant que vos assets connus, eux, sont patchés le jour même. Impossible de corriger ce que l'on ne sait pas exposer : c'est tout le sujet de la découverte d'assets.
C'est précisément le travail d'une plateforme d'External Attack Surface Management : découvrir en continu tous vos WordPress exposés (y compris ceux que personne ne gère), empreinter leur version, et remonter en priorité ceux qui portent une CVE critique comme wp2shell. Un scan qui teste /wp-json/batch/v1 et compare la version détectée au seuil corrigé transforme une divulgation de vendredi soir en une liste d'URLs à patcher lundi matin. C'est ce que fait notre EASM. Et là où la détection automatisée s'arrête, un pentest web blackbox confirme l'exploitabilité réelle, sans faux positif. Pour la frontière exacte entre les deux, on l'a détaillée dans pentest vs scan de vulnérabilité.
À retenir
- wp2shell (
CVE-2026-63030) est une RCE pré-authentifiée du cœur de WordPress, pas un plugin : aucune dépendance tierce, install par défaut suffisante. - Elle chaîne deux bugs : une confusion de routes dans
/wp-json/batch/v1(désync$validation/$matches) et une injection SQL viaauthor__not_in(CVE-2026-60137). - Versions à risque de RCE : 6.9.0–6.9.4 et 7.0.0–7.0.1. La 6.8.x n'a que la SQLi. Correctifs : 6.8.6 / 6.9.5 / 7.0.2.
- Détection : hits anonymes sur la route batch,
author_excludesur/wp/v2/users, nouveaux comptes admin et plugins PHP inconnus. - Mitigation : patcher avant tout ; à défaut, bloquer
/wp-json/batch/v1(WAF ou mu-pluginrest_pre_dispatch). Un objet-cache externe gêne la RCE mais laisse la fuite SQL. - Le risque réel, c'est le WordPress que vous ne savez pas exposer : la découverte d'assets et l'empreinte de version font la différence entre un patch le lundi et une compromission.
Vous ne savez pas combien d'instances WordPress portent votre nom sur internet ? C'est exactement la première chose que remonte own2pwn. Sinon, parlez d'une exposition précise avec un humain via la page contact.
Articles liés
appsec
Combien coûte un test d'intrusion web ? Prix et facteurs en 2026
Prix d'un pentest web en 2026 : fourchettes réelles et sourcées, modèle au TJM, facteurs qui font varier le coût (périmètre, blackbox vs whitebox, retest), comment lire un devis et réduire la facture sans sacrifier la qualité.
appsec
Pentest en entreprise : pourquoi, quand et comment le commander
Pourquoi une entreprise commande un pentest : risque réel, conformité NIS2 et DORA, assurance cyber, exigences clients. Quand le faire, comment choisir son prestataire (OSWE, PTES, retest, NDA), et ce que ça n'est pas.
appsec
Bug bounty : définition, plateformes, et quand il a du sens
Bug bounty : ce qu'est un programme de prime aux bugs, comment il marche (scope, récompenses, triage), les plateformes (YesWeHack, HackerOne, Bugcrowd, Intigriti), sa différence de fond avec un pentest, et le moment où l'ouvrir sans se faire submerger.