
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$ (-m 35500).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.)