Un previsualizador de URLs recibe un enlace y devuelve una captura. Es una función tan común
que casi nadie la revisa: alguien pega una URL, el servidor la busca, el servidor devuelve el
render. Y el servidor, a diferencia de quien pegó el enlace, vive dentro de la red.
El filtro que se escribe para eso es casi siempre el mismo: si la URL contiene localhost o
127., se rechaza. Lo escribí, y después escribí el parche que la literatura llama correcto.
Medí las dos cosas con el mismo ataque.
El resultado incómodo no es que el filtro falle. Es que el test que se escribe para
demostrar que el filtro funciona pasa en los dos parches, en el roto y en el sano, con la
misma salida.
El servicio
Dos servidores efímeros en loopback: uno interior, que devuelve una marca que no debería
salir nunca, y uno público. El previsualizador recibe una URL, la busca y devuelve el
cuerpo. Cinco vías intentan alcanzar el interior; la primera es la obvia y las otras cuatro
son el mismo destino escrito de otra manera, o alcanzado de rebote.
El test que miente
Los tres sujetos empiezan midiendo su propia política antes de que se dispare nada:
POLITICA · destino_permitido, medido antes de atacar: 127.0.0.1:80 -> False (esperado: False en todo parche) 93.184.216.34:443 -> True (esperado: True en todo parche) Esa salida es idéntica en el parche vulnerable, en el parche ingenuo y en el canónico. Se
bloquea loopback, se permite una IP pública. Si tu suite comprueba esto, tu suite está verde
en los tres y no distingue nada. La política es una función; el ataque no pregunta por la
función, pregunta por la representación.
Tres corridas, un comando
python3 exploit_poc.py # servicio vulnerable python3 exploit_poc.py --app patch_secure.py --naive # el parche obvio python3 exploit_poc.py --app patch_secure.py # el parche canónico | vía | vulnerable | parche obvio | parche canónico |
|---|---|---|---|
| URL literal al interior | entra | bloqueada | bloqueada |
IP decimal 2130706433 | entra | entra | bloqueada |
IP hexadecimal 0x7f000001 | entra | entra | bloqueada |
IPv4 mapeada en IPv6 [::ffff:127.0.0.1] | entra | entra | bloqueada |
| redirección 302 hacia el interior | entra | entra | bloqueada |
| total | 5 de 5 · RC 0 | 4 de 5 · RC 0 | 0 de 5 · RC 1 |
El parche obvio bloquea una. La única que bloquea es la que el atacante no usa.
Por qué la lista negra no puede ganar
Es una asimetría de información, no un descuido. El filtro compara cadenas; el atacante
elige la cadena. 127.0.0.1, 2130706433, 0x7f000001 y [::ffff:127.0.0.1] son cuatro
maneras de escribir la misma dirección, y la cuarta ni siquiera es IPv4: es IPv4 dentro de
IPv6, y el sistema operativo la conecta igual. Para bloquearlas por texto hacen falta reglas
para cada notación que exista hoy, más las que aparezcan.
Y el 302 no es una notación: es una decisión del servidor remoto. El filtro revisa la URL
que le dieron y el cliente HTTP obedece la siguiente, que nadie revisó. Una lista negra sobre
la primera URL no tiene nada que decir sobre la segunda.
El parche que sí
El orden se invierte: primero se resuelve, después se decide.
def _resolver(host, port): infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) return sorted({info[4][0].split("%")[0] for info in infos}) direcciones = _resolver(nombre, puerto) if not direcciones: raise DestinoNoPermitido(f"{nombre} no resuelve a ninguna direccion") for direccion in direcciones: if not ipaddress.ip_address(direccion).is_global: raise DestinoNoPermitido( f"{nombre} resuelve a {direccion}, que no es una direccion global") return _pedir_fijado(direcciones[0], puerto, partes) Tres cosas hacen que esto funcione donde la lista negra no:
- Se juzga la dirección, no el texto.
is_globales una propiedad del espacio de direcciones, así que la notación deja de importar: las cuatro formas caen en la misma comprobación, porque las cuatro resuelven al mismoip_address. - Se comprueban todas las respuestas, no la primera. Un nombre puede resolver a varias direcciones; validar una y conectar otra es el mismo agujero con más pasos. Por eso el conjunto se reduce con
split("%")—fuera el identificador de zona— y se recorre entero. - Se conecta a la IP validada, no al nombre.
_pedir_fijadoabre la conexión contradirecciones[0]y manda el nombre sólo en la cabeceraHost. Si se volviera a resolver al conectar, entre la comprobación y la conexión cabe un cambio de DNS.
Y la redirección se rechaza: en modo estricto un 3xx lanza y no se sigue, porque la
segunda URL es un destino nuevo y haber validado el primero no dice nada del segundo. Seguir
el salto y re-validarlo también es correcto; tiene más caminos que revisar, y el camino que no
se paga es el que se olvida.
El control que hace honesta la medición
El parche canónico no bloquea todo: 93.184.216.34:443 -> True sigue permitido, y esa
línea se mide antes de atacar, en el mismo bloque que en el parche roto. Es la diferencia
entre «cerré las cinco vías» y «rompí el previsualizador», que también daría cinco bloqueos y
el mismo código de salida.
Un parche que rechaza cualquier URL es trivial de escribir y no sirve para nada. El número que
lo distingue no está en el total de bloqueos: está en la línea de política que se lee igual en
el parche roto.
Reproducirlo
Cinco segundos, biblioteca estándar, sin red externa, sin estado entre corridas:
git clone --depth 1 https://github.com/alexisromtz-lab/offensive-reproducers cd offensive-reproducers/002-ssrf-pdf-renderer python3 exploit_poc.py --app patch_secure.py --naive # RC 0 · 4 de 5 entran python3 exploit_poc.py --app patch_secure.py # RC 1 · 0 de 5 Si auditas esto mañana
Cuando alguien diga «validamos la URL», las tres preguntas son: ¿se juzga el texto o la
dirección resuelta?, ¿se comprueban todas las respuestas del DNS o sólo la primera?, ¿qué pasa
con la segunda URL de una redirección? Con una respuesta negativa, el localhost bloqueado no
significa nada — y la suite seguirá en verde.
