SSRF en un webhook de media que bypassa su guard DNS por TOCTOU (check-then-use sin pinning)
El webhook de media valida el host resuelto en el check, pero el fetch real re-resuelve el mismo hostname sin fijar la IP entre medias: alcanza infraestructura interna.
Aprende a encontrar este bug
Un hackeo real de HackerOne, reproducido para que lo practiques.
Crea tu cuenta y practica bugs reales que se pagaron. Descarga el entorno, encuéntralo y aprende la técnica exacta — tu camino a tu primer bounty.
hunters entrenando
labs de hackeos reales
completaciones
pagados por estos bugs
Acceso a todos los labs · sin permanencia · cancela cuando quieras
Hackers que lo han resuelto· 1
Objetivos
Logro que recibirás
Cuando resuelvas este lab desbloqueas este logro compartible
SSRF en un webhook de media que bypassa su guard DNS por TOCTOU (check-then-use sin pinning)
Writeups de la comunidad
Contenido del lab
Escenario
Convergia es una plataforma SaaS omnicanal: SMS/MMS, email y chat de distintos operadores externos convergen en una única bandeja de agentes. Los operadores entregan mensajes entrantes por webhook y pueden adjuntar "media" — la foto de una entrega, un justificante, un adjunto de soporte — como URLs remotas que la plataforma descarga server-side para renderizarlas dentro de la conversación del agente.
Eres pentester con una cuenta de agente en Convergia. Tu misión: demostrar que ese descargador de media, pensado para traer imágenes inocuas de operadores externos, puede acabar golpeando infraestructura interna que nunca debería ser alcanzable desde fuera de la red del producto — y traerte de vuelta un secreto que vive ahí dentro.
Qué tienes
- Cuenta demo:
attacker@convergia.local/password123— rol agente: puedes ver la bandeja, leer conversaciones y sus adjuntos. Es tu punto de partida para el read-back final, no es la vulnerabilidad. - Acceso a la bandeja completa de Convergia: páginas de Integraciones, Servicios internos y Changelog, más un historial ya seedeado de conversaciones (algunas con media pública ya adjunta, prueba de que el flujo de descarga funciona en producción sin ser el bug).
- Un segundo servicio, ya desplegado y expuesto en el mismo origen bajo
/toolkit: un toolkit de servidor DNS controlable, con una UI que trae un hostname listo y un formulario para "armar" qué IP responde a cada consulta.
Guía paso a paso
Encuentra el webhook y confirma que descarga server-side. En Integraciones ves los dos canales de operador activos, la ruta pública
POST /api/v1/channels/inbound/:provider, el shape exacto del payload ({from, to, body, received_at, media:[...]}) y un botón "Send test message" que dispara un ejemplo por ti. Lánzalo, o construye tu propio POST con unmediaapuntando a cualquier URL pública: la respuesta es 200 y aparece de inmediato una conversación nueva en tu bandeja con la media ya adjunta. Confirmas que el servidor hace unfetchreal, server-side, sobre cualquier URL que le mandes — y sin sesión: el webhook es público.Apunta directo al interno y localiza el guard. En Servicios internos el directorio lista "Ops Metrics" con su dirección exacta (
http://10.31.9.10:8080/ · internal network only), junto a 2-3 backends de acompañamiento. Pruebamediaapuntando esa misma dirección: el adjunto que se genera no es la respuesta del servicio, es un error que nombra la IP resuelta y su rango (media host 10.31.9.10 resolved to 10.31.9.10 (non-routable range: private (10.0.0.0/8)) — refusing to fetch). Conclusión: hay un guard, y valida la IP a la que resuelve el hostname, no la cadena de la URL — el camino directo está cerrado.Detecta que check y descarga son dos resoluciones separadas, y arma el rebind. El Changelog (v2.0.5, "Media validation pipeline hardening") lo dice con toda honestidad: "media validation and media download run as separate steps; a re-fetch may occur after validation". Repite el mismo experimento del paso 2 contra el segundo operador ("Hardened v2" en su ficha de Integraciones): el adjunto nunca trae datos internos, aunque el hostname resuelva exactamente lo mismo — ese operador resuelve una única vez y conecta directamente a esa IP (la fija/pinea), así que no hay una segunda resolución que aprovechar. La diferencia entre los dos operadores es la prueba de que fijar la IP tras el check es justo lo que falta en el primero.
Abre el toolkit DNS del lab en
/toolkit— un hostname ya preparado (mms-fetch.rbnd.test) y un formularioarm {host, safe_ip, internal_ip}. Ármalo con la IP pública que trae por defecto (198.51.100.20, pasa el check del guard) como primera respuesta, y la IP de "Ops Metrics" del paso 2 (10.31.9.10) como respuesta a partir de la segunda consulta, con TTL=0.Dispara el webhook contra el hostname armado — la vuln. Manda el mensaje al primer operador (el que NO fija la IP,
sablewire) conmediaapuntando al hostname que acabas de armar en el puerto 8080. El guard resuelve el hostname para el check → obtiene la IP pública → pasa. Elfetchreal vuelve a resolver el mismo hostname (nadie fijó el resultado del check anterior) → esta vez el toolkit responde la IP interna → el fetch conecta directamente contra "Ops Metrics" y captura su respuesta completa.Confirma el punto ciego y lee el adjunto — la flag. Antes de dar el paso 4 por bueno, refresca el visor de consultas del toolkit (
/log): verás dos entradas para el mismo hostname, una con la IP pública y la siguiente con la interna — la prueba de que hubo dos resoluciones reales y de que el fetch cogió la segunda. Luego ve a tu bandeja, abre la conversación que acaba de crear el webhook y descarga su adjunto: es el JSON completo de "Ops Metrics", con varios campos de acompañamiento (queue_depth,node_id,internal_signing_key,smtp_relay_password) y el camposervice_token, que contiene la flag.
Canales de feedback (no vuelas a ciegas)
- El webhook responde 200 de inmediato y la conversación aparece en la bandeja sin recargar nada (paso 1).
- El adjunto de un intento bloqueado nombra la IP resuelta y su rango exacto — nunca un 400 genérico (paso 2).
- El visor de consultas del toolkit lista cada A-query respondida en orden, con su IP — confirma en vivo si hubo una o dos resoluciones (paso 3-4).
- El adjunto capturado renderiza el JSON completo del servicio interno, no un resumen — la flag está ahí en claro (paso 5).
Decoys (descártalos por razonamiento acotado)
- El guard directo (funciona). Cualquier
mediaque resuelva a un rango no enrutable (privado, loopback, link-local — incluida la IP de metadata en la nube — CGNAT) se bloquea con el error que nombra la IP y el rango. Un único intento con la IP del directorio de servicios basta para confirmarlo: el guard no está roto, así que ir directo al interno no es el camino. - El operador "Hardened v2" (pinning). Comparte el mismo guard, pero tras validar conecta a la IP exacta que resolvió — no vuelve a preguntar al DNS. Armar el mismo rebind y dispararlo contra este operador nunca trae datos internos: confirma que el fallo no es "cualquier operador se puede rebindear", es específicamente la ausencia de fijar la IP tras el check en el operador estándar.
- Esquemas fuera de http(s).
file://,data:,gopher://, etc. se rechazan con400 unsupported media schemeantes de tocar ningún DNS. Un intento confile:///etc/passwdlo confirma: no hay atajo por esquema, el único camino es http(s) más la ventana entre dos resoluciones DNS.
La falla del laboratorio
Clase: CWE-918 (Server-Side Request Forgery) con CWE-367 (Time-of-Check Time-of-Use) como causa raíz, más CWE-350 (Reliance on Untrusted Inputs — una decisión de seguridad basada en una resolución DNS que el atacante controla) y CWE-306 (Missing Authentication, la condición habilitante: el webhook de entrada no exige sesión). OWASP A10:2021 (Server-Side Request Forgery) + A04:2021 (Insecure Design).
El guard (assertMediaTargetAllowed) es correcto: resuelve el hostname con dns.lookup(host, {family:4}) y clasifica la IP resultante contra la lista completa de rangos no enrutables (RFC1918, loopback, link-local incl. 169.254.169.254, CGNAT, reservados) antes de dejar pasar nada. El problema no está en esa clasificación — está en lo que ocurre después: el flujo estándar (fetchMedia) llama a fetch(url) directamente sobre la URL original, y ese fetch vuelve a resolver el hostname por su cuenta, en un instante distinto y sin conocer el resultado del check anterior. Nada conecta la IP que el guard validó con la IP a la que realmente se conecta la descarga.
Esa ventana es explotable porque el atacante controla el servidor DNS autoritativo del hostname del webhook (el toolkit del lab, o en el mundo real cualquier hostname bajo su propio dominio) y puede responder lo que quiera en cada consulta, con TTL=0 para evitar cualquier caché. Basta con servir una IP pública inocua a la primera consulta (la que hace el guard) y la IP interna real a partir de la segunda (la que hace el fetch) para que el check y el uso diverjan.
La mitigación existe en el propio código, aplicada al segundo operador (fetchMediaPinned): resolver una sola vez, validar esa dirección, y conectar explícitamente a esa IP (con el header Host original para no romper el vhost), en vez de volver a pasar el hostname a una librería HTTP que lo re-resolverá por su cuenta. Es la diferencia entre "confiar en que el nombre siga significando lo mismo" y "fijar lo que significaba en el momento en que lo comprobé".
Lección: cualquier control de seguridad que decida algo a partir de resolver un nombre (DNS, pero el patrón aplica igual a symlinks, rutas de fichero o tokens de un solo uso) deja de ser válido en el instante en que ese nombre puede volver a resolverse de otra forma. Si el check y el uso son dos operaciones distintas, hay que fijar el resultado del check y usar exactamente ese valor — nunca volver a preguntar por el nombre original.
Laboratorio basado en la reproducción anonimizada (empresa ficticia Convergia) de una técnica real de bug bounty sobre un guard SSRF basado en resolución DNS, bypasseado mediante DNS rebinding (TOCTOU) contra el downloader de media de un webhook de mensajería.