Hoy presentamos Spectrum:una nueva función de Cloudflare que brinda protección contra ataques DDoS, equilibrio de carga y aceleración de contenido a cualquier protocolo de control de transmisión (TCP).

CC BY-SA 2.0 Imagen de Staffan Vilcans

Apenas empezamos a desarrollar Spectrum, nos topamos con un serio obstáculo técnico: Spectrum exige que aceptemos conexiones en cualquier puerto TCP válido, de 1 a 65535. En nuestros servidores perimetrales Linux es imposible “aceptar conexiones entrantes en ningún número de puerto”. Esta no es una limitación específica de Linux: es una característica de la interfaz de programación de aplicaciones (API) de los socket de BSD, lo esencial para las aplicaciones de red en la mayoría de los sistemas operativos. Debíamos resolver dos problemas subyacentes que se superponen para ofrecer Spectrum:

  • Cómo aceptar conexiones TCP en todos los números de puerto del 1 al 65535
  • Cómo configurar un único servidor Linux para aceptar conexiones en una gran cantidad de direcciones IP (tenemos varios miles de direcciones IP en nuestros rangos de difusión por proximidad (anycast))

Asignación de millones de IP a un servidor

Los servidores perimetrales de Cloudflare tienen una configuración casi idéntica. En nuestros comienzos, solíamos asignar direcciones IP /32 (y /128) específicas para la interfaz de red de bucle invertido[1]. Esto funcionaba bien cuando teníamos solo algunas docenas de direcciones IP, pero no se pudo adaptar a medida que fuimos creciendo.

Luego apareció el truco “AnyIP”. AnyIP nos permite asignar prefijos IP enteros (subredes) a la interfaz de bucle invertido, expandiéndose desde direcciones IP específicas. Ya existe un uso común de AnyIP: su computadora tiene 127.0.0.0/8 asignado a la interfaz de bucle invertido. Desde el punto de vista de su equipo, todas las direcciones IP de 127.0.0.1 a 127.255.255.254 corresponden a la máquina local.

Este truco es aplicable más allá del bloque 127.0.0.1/8. Para considerar todo el rango de 192.0.2.0/24 como asignado a nivel local, ejecute:

ip route add local 192.0.2.0/24 dev lo

Luego, puede enlazar sin problemas al puerto 8080 en una de estas direcciones IP:

nc -l 192.0.2.1 8080

Lograr que IPv6 funcione resulta un poco más difícil:

ip route add local 2001:db8::/64 dev lo

Lamentablemente, no se pueden simplemente enlazar a estas direcciones IP v6 adjuntas como en el ejemplo v4. Para que esto funcione, debe utilizar la opción de socket IP_FREEBIND, que requiere más privilegios. Para completar, también hay un sysctl net.ipv6.ip_nonlocal_bind, pero recomendamos que no se toque.

Este truco AnyIP nos permite tener millones de direcciones IP asignadas a nivel local a cada servidor:

$ ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536
    inet 1.1.1.0/24 scope global lo
       valid_lft forever preferred_lft forever
    inet 104.16.0.0/16 scope global lo
       valid_lft forever preferred_lft forever
...

Enlace a TODOS los puertos

El segundo problema serio es la capacidad de abrir sockets TCP para cualquier número de puerto. En Linux, y por lo general en cualquier sistema compatible con la API de sockets de BSD, solo puede enlazar a un número de puerto TCP específico con una sola llamada al sistema de enlace. No es posible establecer un enlace a varios puertos en una sola operación.

Una solución obvia sería establecer 65535 enlaces, uno para cada uno de los 65535 puertos posibles. En realidad, esta podría haber sido una opción, pero con consecuencias terribles:

Internamente, el núcleo Linux almacena sockets de escucha en una tabla hash indexada por números de puerto, LHTABLE, utilizando exactamente 32 cubos:

/* Yes, really, this is all you need. */
#define INET_LHTABLE_SIZE       32

Si hubiéramos abierto puertos de 65k, las búsquedas en esta tabla se desacelerarían considerablemente: cada cubo de la tabla hash contendría dos mil elementos.

Otra manera de resolver nuestro problema sería utilizar las características de NAT enriquecidas de iptables: podríamos reescribir el destino de los paquetes entrantes en alguna dirección/puerto específico, y nuestra aplicación se enlazaría a eso.

Sin embargo, no queríamos hacer esto, ya que para ello se debe activar el módulo conntrack de iptables. Históricamente encontramos algunos casos de rendimiento avanzado, y conntrack no puede hacer frente a algunos de los ataques grandes de DDoS a los que nos enfrentamos.

Además, con el enfoque de NAT perderíamos la información de la dirección IP de destino. Para corregir este inconveniente, hay una opción de socket poco conocida SO_ORIGINAL_DST, pero el código no parece muy alentador.

Afortunadamente, tenemos una manera de lograr nuestros objetivos que no implica el establecimiento de un enlace a todos los puertos de 65k, o la utilización de conntrack.

Firewall al rescate

Antes de seguir avanzando, reconsideremos el flujo general de paquetes de red en un sistema operativo.

Por lo general, hay dos capas diferentes en la ruta del paquete entrante:

  • Firewall de IP
  • Pila de red

Estas son distintas desde el punto de vista conceptual. El firewall de IP suele ser un software sin estado (por ahora ignoremos el reensamblaje de fragmentación de IP y conntrack). El firewall analiza los paquetes IP y decide si los ACEPTA o los RECHAZA. Tenga en cuenta: en este nivel estamos hablando de paquetes y números de puerto - no de aplicaciones o sockets.

Luego está la pila de red. Este gigante tiene mucho estado. Su tarea principal es enviar paquetes IP entrantes a sockets, que luego son administrados por las aplicaciones del espacio del usuario. La pila de red administra las abstracciones que se comparten con el espacio del usuario. Vuelve a ensamblar los flujos TCP, se encarga del enrutamiento y sabe qué direcciones IP son locales.

El polvo mágico

Fuente: instantánea de YouTube

En algún momento hablamos del módulo de iptables TPROXY. Se suele pasar por alto ladocumentación oficial:

TPROXY
This target is only valid in the mangle table, in the 
PREROUTING chain and user-defined chains which are only 
called from this chain.  It redirects the packet to a local 
socket without changing the packet header in any way. It can
also change the mark value which can then be used in 
advanced routing rules. 

Puede encontrar más documentación en el núcleo:

Cuanto más lo pensábamos, más aumentaba nuestra curiosidad...

Entonces... ¿Qué hace en realidad TPROXY?

Revelación del truco de magia

El código TPROXY es sorprendentemente trivial:

case NFT_LOOKUP_LISTENER:
  sk = inet_lookup_listener(net, &tcp_hashinfo, skb,
				    ip_hdrlen(skb) +
				      __tcp_hdrlen(tcph),
				    saddr, sport,
				    daddr, dport,
				    in->ifindex, 0);

Permítame leerle esto en voz alta: en un módulo de iptables, que forma parte del firewall, llamaremos a inet_lookup_listener. Esta función toma src/dst port/IP 4-tuple y devuelve el socket de escucha que puede aceptar esa conexión. Esta es una funcionalidad esencial del envío del socket de la pila de red.

Una vez más: el código de firewall asigna una rutina de envío de socket.

Luego, TPROXY en realidad hace el envío del socket:

skb->sk = sk;

Esta línea asigna un una estructura struct sock de socket a un paquete entrante y completa el envío.

Sacar el conejo de la galera

CC BY-SA 2.0 Imagen de Angela Boothroyd

Armado con TPROXY, podemos hacer fácilmente el truco de enlace a todos los puertos. Esta es la configuración:

# Set 192.0.2.0/24 to be routed locally with AnyIP.
# Make it explicit that the source IP used for this network
# when connecting locally should be in 127.0.0.0/8 range.
# This is needed since otherwise the TPROXY rule would match
# both forward and backward traffic. We want it to catch 
# forward traffic only.
sudo ip route add local 192.0.2.0/24 dev lo src 127.0.0.1

# Set the magical TPROXY routing
sudo iptables -t mangle -I PREROUTING \
        -d 192.0.2.0/24 -p tcp \
        -j TPROXY --on-port=1234 --on-ip=127.0.0.1

Además de poner esto en marcha, debe iniciar un servidor TCP con la opción de socket mágica IP_TRANSPARENT. Nuestro ejemplo a continuación necesita escuchar en tcp://127.0.0.1:1234. La página man para IP_TRANSPARENT muestra lo siguiente:

IP_TRANSPARENT (since Linux 2.6.24)
Setting this boolean option enables transparent proxying on
this socket.  This socket option allows the calling applica‐
tion to bind to a nonlocal IP address and operate both as a
client and a server with the foreign address as the local
end‐point.  NOTE: this requires that routing be set up in
a way that packets going to the foreign address are routed 
through the TProxy box (i.e., the system hosting the 
application that employs the IP_TRANSPARENT socket option).
Enabling this socket option requires superuser privileges
(the CAP_NET_ADMIN capability).

TProxy redirection with the iptables TPROXY target also
requires that this option be set on the redirected socket.

Aquí presentamos un servidor simple en Python:

import socket

IP_TRANSPARENT = 19

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.IPPROTO_IP, IP_TRANSPARENT, 1)

s.bind(('127.0.0.1', 1234))
s.listen(32)
print("[+] Bound to tcp://127.0.0.1:1234")
while True:
    c, (r_ip, r_port) = s.accept()
    l_ip, l_port = c.getsockname()
    print("[ ] Connection from tcp://%s:%d to tcp://%s:%d" % (r_ip, r_port, l_ip, l_port))
    c.send(b"hello world\n")
    c.close()

Después de ejecutar el servidor, puede conectarse a este desde direcciones IP arbitrarias:

$ nc -v 192.0.2.1 9999
Connection to 192.0.2.1 9999 port [tcp/*] succeeded!
hello world

Lo más importante es que el servidor informará que la conexión de hecho se dirigió a 192.0.2.1 puerto 9999, aunque nadie realmente escucha esa dirección IP y puerto:

$ sudo python3 transparent2.py
[+] Bound to tcp://127.0.0.1:1234
[ ] Connection from tcp://127.0.0.1:60036 to tcp://192.0.2.1:9999

¡Y listo! Esta es la forma de establecer un enlace a cualquier puerto en Linux, sin usar conntrack.

Eso es todo amigos

En esta publicación, describimos cómo utilizar un módulo de iptables no muy conocido, diseñado originalmente para ayudar con la conexión proxy transparente, para algo ligeramente diferente. Con la ayuda de este podemos hacer cosas que creíamos imposibles usando la API de sockets BSD estándar, y evitar así la necesidad de un parche de núcleo personalizado.

El módulo TPROXY es bastante inusual - en el contexto del firewall de Linux ejecuta acciones que normalmente hace la pila de red Linux. La documentación oficial es bastante escasa, y no creo que muchos usuarios de Linux comprendan el máximo el rendimiento de este módulo.

Se puede decir que TPROXY permite que nuestro producto Spectrum funcione sin inconvenientes en el núcleo vainilla. Es otro factor para resaltar la importancia de comprender iptables y la pila de red.


¿Le interesa hacer un trabajo de socket de bajo nivel? Únase a nuestro equipo de reconocimiento internacional en Londres, Austin, San Francisco, Champaign y nuestra selecta oficina en Varsovia, Polonia.


  1. La asignación de direcciones IP a la interfaz de bucle invertido, junto con la configuración adecuada de rp_filter y BGP nos permite manejar rangos IP arbitrarios en nuestros servidores perimetrales. ↩︎