Saltar al contenido
crawlforgeEnglish
Se rompió5 de agosto de 202610 min de lecturaRead in English

El arreglo de seguridad que no aguantó

Cerramos el agujero por el que un sitio auditado podía apuntar la herramienta a la red interna de quien la usa. Una segunda revisión demostró que el arreglo no valía: cuatro servicios públicos de DNS lo atraviesan sin registrar nada.

El 4 de agosto la herramienta empezó a comprobar el estado de los enlaces externos, y lo hace por defecto. Es una función corta de describir: por cada enlace que sale de tu sitio, una petición HEAD para saber si el destino sigue vivo, sin leer nunca el cuerpo y sin seguir redirecciones.

También cambia lo que la herramienta es. Hasta ese día solo pedía páginas tuyas. Desde ese día pide URLs que ha escrito otro.

Lo que encontró la revisión

El único filtro antes de pedir una URL a un tercero era el esquema. Si empezaba por http o por https, se pedía. No había nada más.

Con eso, un sitio bajo auditoría puede poner esto en cualquiera de sus páginas:

<a href="http://169.254.169.254/latest/meta-data/">.</a>

Y la herramienta lo pide desde el ordenador de quien está auditando. Da igual que la respuesta no se guarde: en el fichero del rastreo queda el estado, el tiempo de respuesta y un mensaje de error lo bastante preciso como para distinguir una conexión rechazada de un tiempo agotado.

Eso es un mapa. Dice qué direcciones tienen algo escuchando y cuáles no, y con cuánta latencia responden. Repartiendo enlaces por un rango privado entero, un sitio malicioso dibuja la red de la oficina de una consultoría con un rastreo rutinario y sin que suene nada.

El sitio del agujero es lo que lo empeora: el .sqlite del rastreo es exactamente el fichero que el producto está pensado para enviar al cliente.

La primera criba

El arreglo salió en la 0.5.0. Antes de sondar una URL, mirar su host: se rechaza toda dirección que no sea globalmente enrutable, y se rechazan los nombres localhost, *.local y *.internal. Después entraron .lan, home.arpa, .corp, fritz.box y los nombres cortos que resuelven dentro de un clúster o de una instancia de nube.

La criba no es incondicional, y eso está decidido: se apaga cuando el sitio auditado es él mismo local, porque auditar un astro dev en localhost o el pre de un cliente en la LAN de la oficina significa que quien lanzó el rastreo ya está dentro de esa red. La excepción de la excepción es el rango de metadatos de nube, que se criba siempre. Un rastreo de localhost desde un runner de CI es una cosa que pasa, y esa dirección responde con las credenciales de la instancia.

Salió a la calle con un comentario en el código que decía la verdad: la criba es léxica, y un nombre que resuelva a una dirección privada pasa. Estaba escrito antes de publicarlo. Se publicó igual.

La segunda revisión

Existen servicios públicos de DNS comodín. Se montaron para que un desarrollador pruebe subdominios sin tocar /etc/hosts, llevan años en pie y no hay que registrar nada ni ser dueño de nada para usarlos:

  • localtest.me y lvh.me resuelven a 127.0.0.1.
  • nip.io y sslip.io resuelven a la dirección que escribas dentro del propio nombre. 192.168.1.10.nip.io resuelve a 192.168.1.10, y 10.0.0.5.sslip.io a 10.0.0.5.

Ninguno de esos cuatro nombres es una dirección privada. Ninguno termina en .local ni se llama localhost. La criba los deja pasar todos, porque lee texto y el texto es impecable.

Esto no se dedujo, se ejecutó: un servicio escuchando en loopback, la criba encendida, la sonda de nuestro propio binario. HTTP 200.

Y el remate fue 169.254.169.254.nip.io. Ese nombre atraviesa también la excepción de metadatos de nube, la única que el código declaraba innegociable, añadida esa misma mañana.

Por qué el segundo intento fue peor que el primero

El error de diseño no fue publicar una criba parcial sabiendo que lo era. Fue lo que se hizo cuando aparecieron los primeros huecos: reforzarla. .lan, home.arpa, .corp, unos cuantos nombres cortos más. Cada añadido es correcto por separado y ninguno acerca la solución, porque los cuatro responden a la misma pregunta —cómo se llama este host— y la respuesta no está dentro de esa pregunta.

Reforzar algo que funciona a medias se parece muchísimo a arreglarlo. Da trabajo, da diff, deja tests en verde y el hueco sigue donde estaba. Cuatro días antes nos había pasado lo mismo con las páginas huérfanas, donde la primera corrección tocó el sitio en el que se veía el daño en vez del sitio que lo causaba. Se repite porque el arreglo del síntoma siempre queda más cerca de la mano.

El arreglo que sí

Un nombre no dice a dónde apunta. Lo dice el resolutor, y solo después de haber resuelto. Así que la decisión se movió ahí: la resolución pasa ahora por un resolutor propio que filtra las direcciones antes de que ninguna llegue a un socket. El texto del host ya no decide nada.

Con una regla que parece excesiva y no lo es. Si una sola de las direcciones de un nombre está fuera de límites, se rechaza el nombre entero. Quedarse con las públicas y tirar las privadas suena más fino y en realidad le deja elegir al DNS, que es media parte de un ataque de rebinding: el nombre devuelve dos direcciones, tú te quedas con la buena, y la siguiente consulta devuelve solo la otra.

La criba léxica se queda de primera línea, y no por prudencia. Una IP escrita a mano dentro de un href nunca llega a un resolutor, así que hay un caso entero que solo ve ella.

En la misma revisión salieron cinco cosas más por la misma puerta, y una merece contarse porque explica por qué el perímetro tiene que estar en un solo sitio: la clave follow_external del fichero de configuración se consultaba únicamente en el camino de la sonda, así que un rastreo con ese alcance llegaba a las direcciones que la sonda tenía prohibidas, y encima con un GET completo cuyo cuerpo se parseaba y se guardaba. Al ejecutar el caso, el título y el <h1> de un panel de Kibana interno acabaron escritos en el fichero del rastreo.

Cómo comprobar el tuyo

Si mantienes cualquier cosa que pide URLs por cuenta de otro —un validador de enlaces, un previsualizador de tarjetas sociales, un webhook con URL configurable, un importador de feeds—, comprobar esto cuesta un minuto:

curl -s -o /dev/null -w '%{http_code}\n' \
  'https://tu-servicio.example/comprobar?url=http://127.0.0.1.nip.io:8080/'

Y la que de verdad duele, si tu servicio corre en una instancia de nube:

http://169.254.169.254.nip.io/latest/meta-data/

Si tu filtro mira el nombre del host, las dos entran. Que te devuelva cualquier cosa distinta de un rechazo significa que tienes el mismo diseño que teníamos nosotros hace una semana.

Lo que aprendimos cabe en una frase, y la escribimos en el código para que nadie la deshaga dentro de un año: una criba léxica no puede defender de un nombre.

Se construye en público

Cada dos semanas: mediciones, fallos y ejemplos de uso. Nada más.

← Volver a la bitácora