
Détecteur non destructif + laboratoire Docker pour wp2shell (CVE-2026-63030 confusion de route REST /batch/v1 + CVE-2026-60137 SQLi author__not_in) dans WordPress core 6.9.0-6.9.4 / 7.0.0-7.0.1
Un lab autonome, un détecteur non destructif et une preuve de concept complète de RCE pre-auth pour wp2shell — la chaîne de vulnérabilités de pré-authentification dans le cœur de WordPress :
| CVE | Composant | Classe | CVSS |
|---|---|---|---|
| CVE-2026-60137 | WP_Query::author__not_in | Injection SQL (CWE-89) | 9.1 |
| CVE-2026-63030 | Confusion de route REST /batch/v1 | conflit d'interprétation (CWE-436) → chaîne vers RCE | 7.5 |
Affecté : cœur de WordPress 6.9.0–6.9.4 et 7.0.0–7.0.1 (le point d'injection SQLi seul affecte également 6.8.0–6.8.5). Corrigé dans 6.8.6 / 6.9.5 / 7.0.2. Signalé par Adam Kues (Assetnote / Searchlight Cyber) ; SQLi également crédité à TF1T, dtro, haongo. Chaîne RCE sur configuration par défaut (oEmbed → changeset → ré-entrée) par Mustafa Can İPEKÇİ (nukedx).
Mettez à jour en premier. WordPress a déployé des mises à jour automatiques forcées pour ce problème. Ce dépôt existe pour vous aider à vérifier que votre propre parc est corrigé et à comprendre la faille — pas pour attaquer qui que ce soit. Voir SECURITY.md.
La primitive toujours vraie est une injection SQL non authentifiée, sans plugin, sur le cœur standard offrant
une lecture complète de la base de données (hachages de mots de passe admin, tout dans wp_options/wp_users). Cela seul
justifie le 9.1 et un correctif immédiat.
La RCE est réelle et fonctionne sur un WordPress par défaut — aucun privilège FILE, aucun cache d'objets persistant,
aucun plugin, aucune mauvaise configuration requis. La chaîne utilise l'injection SQL en lecture seule comme
primitive de falsification de lignes (UNION ALL SELECT injecte de fausses lignes wp_posts), puis exploite
le propre pipeline de rendu de contenu de WordPress pour convertir ces lignes falsifiées en écritures réelles en base de données
via la mise en cache oEmbed. À partir de là, l'élévation par changeset et le parse_request ré-entrant s'exécutent en contexte admin,
créant un nouveau compte administrateur — le tout à partir d'une seule requête HTTP non authentifiée.
POST /?rest_route=/batch/v1)1. Route confusion — double-nested batch desyncs $matches/$validation so a GET
/wp/v2/widgets runs under posts::get_items() (public), reaching
WP_Query's author__not_in with attacker-controlled input.
2. Row forgery — author__not_in is string-concatenated into SQL;
"1) AND 1=0 UNION ALL SELECT <23 cols> -- -" injects fake
WP_Post rows. per_page=-1 bypasses split_the_query (WP_Query
treats -1 as "no limit" → empty $limits → split=false →
full SELECT wp_posts.* → UNION columns match).
3. oEmbed write — forged posts carry [embed]<self-url>[/embed]; rendering via
context=view makes WordPress cache real oembed_cache posts in
the DB (turns read-only SQLi into writes with predictable IDs).
4. Elevation+re-entry — a forged customize_changeset (user_id = real admin) plus a
forged post_type=request row with parent loops drives an
in-process re-entrant parse_request in admin context.
5. Admin creation — POST /wp/v2/users in the same batch passes
current_user_can('create_users') → new administrator.
6. RCE — login → plugin webshell upload → command execution → cleanup.
La nouveauté réside dans la composition, et non dans un bug isolé — les gadgets individuels sont des comportements légitimes de WordPress. Les nommer (d'après l'analyse d'Adam Kues) rend lisible le graphe de lignes forgées dans exploit() et signale ce qu'il faut ré-auditer après le correctif des points d'entrée :
wp_update_post() et privilégie le post_type/post_status en mémoire, permettant à une
ligne oembed_cache d'être retypée en véritable post/customize_changeset.post_content — le filtre wp_insert_post_parent parcourt la chaîne
des parents ; lorsqu'un cycle est détecté, il appelle un second wp_update_post() qui corrige le parent sans
écraser post_content. C'est le maillon critique : il permet au post_content malveillant du changeset forgé
de survivre. (C'est pourquoi les lignes forgées utilisent des boucles de parent auto-référentielles ou mutuelles.)parse_request — publier un article déclenche do_action("{$status}_{$type}") ;
une ligne forgée avec post_status=parse / post_type=request déclenche parse_request, réexécutant le
pipeline de traitement par lots tant que l'identité d'administrateur assumée du changeset (wp_set_current_user) tient encore.Ces gadgets restent présents dans WordPress corrigé — seuls les deux points d'entrée (désynchronisation du traitement par lots +
contournement scalaire author__not_in) ont été fermés. Toute nouvelle primitive qui forge le cache d'articles en mémoire ou
écrit une ligne oembed_cache réactiverait la même queue de prise de contrôle administrateur.
Les quatre primitives d'écriture au moment du rendu ont été confirmées actives contre 7.0.1 — chacune forgée comme un article non authentifié, rendue via la confusion du traitement par lots, et l'écriture en BD résultante vérifiée par SQLi en aveugle :
| Primitive | Balisage déclencheur | Destination | Identifiant prédit | Vérifié |
|---|---|---|---|---|
| oembed | [embed]<url>[/embed] | ligne wp_posts (oembed_cache) | post_name = md5(url+attrs) | ID d'article créé |
| rss | wp:rss {feedURL} | site-transient wp_options | _site_transient_feed_<md5(url)> | option_id, 5192 B mis en cache |
| navigation | wp:navigation | ligne wp_posts (wp_navigation) | post_name = 'navigation' (fixe) | ID créé, slug navigation |
| calendar | wp:calendar | wp_options | wp_calendar_block_has_published_posts | option_id, valeur '1' |
Ce que chaque résultat prouve :
wp_posts avec un slug prédit par l'attaquant
(md5(url+serialize(attrs))) et un véritable ID auto-incrémenté. C'est la seule qui fournit
plusieurs lignes d'articles, à la demande et nommées par l'attaquant — c'est pourquoi la chaîne RCE l'utilise pour étayer
le graphe changeset/request forgé._site_transient_feed_<md5(url)> est entièrement
prévisible, et les octets stockés sont le corps du flux servi par l'URL de l'attaquant — c'est-à-dire que l'attaquant
contrôle à la fois la clé et la valeur. C'est une écriture dans wp_options (pas d'ID d'article), donc c'est une primitive
d'empoisonnement d'option plutôt qu'un substitut direct pour l'étayage du changeset.wp_navigation publié existe) avec un slug fixe, il ne peut donc étayer au plus qu'un seul
objet forgé, contrairement aux N lignes d'oEmbed.update_option se déclenche sans authentification, mais le nom de l'option
et sa valeur '1'/'0' sont fixes/dérivés de la BD, c'est donc une démonstration « l'écriture a lieu » sans
aucun contrôle de l'attaquant sur la clé ou la valeur.