Stored XSS en el HTML exportado de transcripciones de un helpdesk
La app viva escapa el chat, pero el HTML exportado por el agente inserta el mensaje sin escapar ni CSP: un bot agente lo abre, ejecuta el XSS y exfiltra una nota interna que porta la flag.
Learn to find this bug
A real HackerOne report, reproduced for you to practice.
Create your account and practice real bugs that got paid. Download the environment, find it and learn the exact technique — your path to your first bounty.
hunters training
labs from real reports
completions
in bounties practiced
Access to all labs · no commitment · cancel anytime
Hunters who solved it
Be the first to solve it
Your name could show up here, at the very top.
Objectives
Achievement you'll earn
Solve this lab to unlock this shareable achievement
Stored XSS en el HTML exportado de transcripciones de un helpdesk
Community writeups
Contenido del lab
Escenario
Deskoria es un helpdesk SaaS: su producto, Deskoria Desk, ofrece un widget de LiveChat embebible donde cualquiera puede abrir una conversación y escribir sin cuenta, y una consola interna donde los agentes de soporte gestionan esas conversaciones y exportan las transcripciones a HTML para adjuntarlas a tickets o pasarlas al siguiente turno.
Tienes ámbito staging como pentester. No dispones de credenciales de staff: tu única superficie es la entrada pública del chat. La misión es hacer que un dato que solo ve el equipo de soporte acabe en tus manos.
Qué tienes
- El widget de LiveChat en la landing (botón "Probar el LiveChat (sin cuenta)"), tu único punto de entrada.
- La capacidad de descargar tu propia transcripción en varios formatos.
- Un buzón público y un visor legible donde puedes recibir y leer lo que un payload te mande.
- Nada del lado de staff: las contraseñas de los agentes son aleatorias por arranque y no forman parte de la solución. La cadena no se resuelve con credenciales, sino con un input.
Guía paso a paso
Recon de la entrada anónima. Abre el LiveChat sin registrarte y, con la pestaña Network abierta, mira qué peticiones dispara el widget al cargar y al enviar un mensaje. Los nombres de campo que necesitas (quién eres, en qué conversación estás, qué escribes) te los da la propia app, sin adivinar nada. Tu mensaje viaja a la conversación y se guarda tal cual.
Sonda el chat en vivo (primer decoy). Escribe algo que "signifique" HTML, no solo texto plano, y observa cómo lo trata la app cuando te lo devuelve. La vista en vivo lo muestra escapado: aparece como texto literal. Con esa sola prueba puedes concluir que ese render es seguro. La pregunta deja de ser "¿es vulnerable el chat?" y pasa a ser "¿en qué otro sitio termina apareciendo lo que escribí?".
Busca el otro sitio. La app no solo pinta tu conversación en pantalla: también te deja llevártela. Hay más de una forma de obtener una copia de tu propio chat. Genera esas copias y compáralas — no todas tratan tu mensaje igual. Una de ellas es un candidato mucho mejor para que tu input "signifique" HTML de verdad.
Confirma el sink con tu propia copia. Abre esa copia y míralas de cerca: contenido y cabeceras. Si tu payload aparece intacto, sin neutralizar, y nada en la respuesta le impone una política que frene la ejecución (no hay
Content-Security-Policy), tienes tu sink: abrir ese fichero en un navegador dispara lo que plantaste. Verifícalo con tu propia copia antes de involucrar a nadie.Entiende el gating. Tu copia dispara el payload, pero el dato que buscas no está en ella: es material del equipo de soporte y tu versión no lo incluye (lo dice ella misma). Ese material solo se materializa cuando un agente genera su versión de la copia. La deducción clave: no necesitas leer el dato tú — necesitas que sea un agente quien abra un artefacto que tú controlas.
Arma el payload. Planta en un mensaje una etiqueta que ejecute JavaScript al renderizarse (por ejemplo, una imagen rota con un manejador
onerror) que lea el contenido de la página y lo mande al buzón público que controlas. Ese mensaje queda persistido crudo, listo para el sink.Encola la conversación (paso ciego con feedback). En tu conversación hay una forma de pedir que el equipo la revise; úsala para que entre en la cola de los agentes. Un agente acabará procesándola: verás cambiar el estado de la conversación, tu señal de que el bot la tomó. Cuando abra su copia (que sí lleva las notas internas), tu payload correrá en su sesión con el dato de staff presente en la página.
Recoge la exfiltración. Revisa el visor del buzón. Entre lo que llega, uno de los mensajes traerá la nota interna del equipo, y dentro de ella está la cadena
FLAG{...}. Distingue ese beacon (el que trae la nota) de tu propia copia de prueba.
Canales de feedback (no vuelas a ciegas)
- El render del chat en vivo te confirma en una prueba que ese sink está escapado y no es el camino.
- Comparar las distintas copias exportadas (contenido + cabeceras) te dice cuál trata tu mensaje como HTML y no impone CSP.
- El estado de la conversación (nuevo → revisado) confirma que el bot agente la procesó, sin dejarte a ciegas en el paso que depende de él.
- El visor del buzón te deja leer exactamente lo que el payload envió.
Decoys (descártalos por razonamiento acotado)
- El chat en vivo escapa. El widget del visitante y la consola de agente pintan tu mensaje con neutralización de HTML. Una sola prueba refuta que el XSS viva en la app viva.
- La transcripción por email escapa. Pedir la copia por correo aplica entity-encoding y muestra tu payload como texto. Mismo codebase, distinto renderizador: el defecto no es una política global rota.
- El export en modo texto es inerte. Descargar la transcripción en formato de texto plano devuelve
text/plain; el payload aparece como texto y no ejecuta. Refuerza que el problema es el contexto HTML del export, no el almacenamiento.
La falla del laboratorio
Clase: Cross-Site Scripting almacenado (CWE-79) cuyo sink no es la aplicación viva, sino un artefacto exportado offline. La app viva neutraliza correctamente el input, pero el generador del HTML de la transcripción interpola el cuerpo del mensaje y el nombre del visitante crudos, sin escape (CWE-116, Improper Encoding or Escaping of Output; CWE-838, Inappropriate Encoding for Output Context), y ese HTML se sirve sin Content-Security-Policy (CWE-1021), de modo que nada frena la ejecución de script al abrirlo. Mapea a OWASP A03:2021 — Injection.
El punto pedagógico es el contraste honesto dentro del mismo codebase: el widget del visitante, la consola de agente, la transcripción por email y el export en texto plano tratan bien el input; solo el export HTML descargable lo emite en contexto de marcado sin neutralizarlo. Por eso "el chat viejo escapa" no cierra el caso: hay que preguntarse en qué otros contextos (offline, exportados, adjuntos) se re-emite el mismo dato controlado por el usuario.
El impacto se materializa porque una víctima privilegiada —un agente de soporte— abre ese artefacto en su navegador, con su sesión y con notas internas que un visitante jamás debería ver ya materializadas en la página. El export del propio visitante no incluye esas notas, así que dominar el vector con la copia propia no basta: el único camino es plantar el XSS y conseguir que sea el agente quien exporte-con-notas y abra el HTML. Eso convierte un XSS aparentemente "muerto" en una exfiltración de datos de staff (Information Disclosure) que porta la flag.
Remediación: aplicar entity-encoding a todo dato controlado por el usuario en todos los renderizadores de salida (no solo en el email); servir el HTML exportado con una Content-Security-Policy restrictiva o como descarga (Content-Disposition: attachment / text/plain) para evitar el render inline con scripting; y no materializar datos staff-only en un artefacto que también contiene contenido del usuario sin neutralizarlo antes.
Laboratorio basado en la reproducción anonimizada (empresa ficticia Deskoria) de una técnica real de bug bounty sobre un artefacto de transcripción exportado por un producto de LiveChat/helpdesk.