eBPF y XDP: Procesamiento de Paquetes de Alto Rendimiento y Mitigación de DDoS a Line Rate
Por Qué XDP Es Diferente de Todo lo Demás
El stack de red de Linux procesa paquetes a través de una cadena larga: hardware NIC → driver NIC → asignación de sk_buff → netfilter/iptables → buffer de recepción del socket → aplicación. Cada paso copia datos, asigna memoria y quema CPU.
XDP se hookea en el driver de NIC, antes de la asignación de sk_buff. Cuando llega un paquete, el driver llama a tu programa XDP con un puntero crudo a los datos del paquete. Tu programa retorna una de cinco acciones:
XDP_PASS // Continuar procesamiento normal (mandar al stack del kernel)
XDP_DROP // Dropear el paquete inmediatamente (sin asignación, sin copia)
XDP_TX // Rebotar el paquete por la misma NIC
XDP_REDIRECT // Mandar a otra NIC, CPU, o socket AF_XDP
XDP_ABORTED // Ocurrió un error, dropear y logear
Escribiendo Tu Primer Programa XDP
Un programa XDP mínimo que dropea todo el tráfico UDP en puerto 53 (ataques de amplificación DNS):
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <bpf/bpf_helpers.h>
SEC("xdp")
int xdp_dns_filter(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (eth->h_proto != __constant_htons(ETH_P_IP))
return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end)
return XDP_PASS;
if (ip->protocol != IPPROTO_UDP)
return XDP_PASS;
struct udphdr *udp = (void *)ip + (ip->ihl * 4);
if ((void *)(udp + 1) > data_end)
return XDP_PASS;
if (udp->source == __constant_htons(53))
return XDP_DROP;
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
Cada chequeo de bounds es obligatorio. El verificador BPF va a rechazar tu programa si accedés a ip->protocol sin probar primero que (void *)(ip + 1) <= data_end. Esto es molesto la primera vez y reconfortante todas las veces después — el verificador es la razón por la que los programas BPF no pueden crashear el kernel.
# Compilar a bytecode BPF
clang -O2 -target bpf -c xdp_filter.c -o xdp_filter.o
# Attachear a la interfaz (driver mode para performance)
ip link set dev eth0 xdpdrv obj xdp_filter.o sec xdp
# Verificar que está cargado
ip link show eth0
# Desattachear
ip link set dev eth0 xdp off
Mitigación de DDoS Real: Rate Limiting con BPF Maps
El filtro DNS de arriba es muy crudo. La mitigación real necesita rate limiting:
struct rate_limit_entry {
__u64 packet_count;
__u64 last_reset_ns;
};
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__uint(max_entries, 1000000);
__type(key, __u32);
__type(value, struct rate_limit_entry);
} rate_limits SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, __u64);
} config SEC(".maps");
SEC("xdp")
int xdp_rate_limiter(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (eth->h_proto != __constant_htons(ETH_P_IP))
return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end)
return XDP_PASS;
__u32 src_ip = ip->saddr;
__u32 config_key = 0;
__u64 *max_pps = bpf_map_lookup_elem(&config, &config_key);
__u64 threshold = max_pps ? *max_pps : 10000;
struct rate_limit_entry *entry = bpf_map_lookup_elem(&rate_limits, &src_ip);
__u64 now = bpf_ktime_get_ns();
if (entry) {
if (now - entry->last_reset_ns > 1000000000ULL) {
entry->packet_count = 1;
entry->last_reset_ns = now;
return XDP_PASS;
}
entry->packet_count++;
if (entry->packet_count > threshold)
return XDP_DROP;
} else {
struct rate_limit_entry new_entry = {
.packet_count = 1,
.last_reset_ns = now,
};
bpf_map_update_elem(&rate_limits, &src_ip, &new_entry, BPF_ANY);
}
return XDP_PASS;
}
El BPF_MAP_TYPE_LRU_HASH es clave aquí. Durante un DDoS, se va a ver millones de IPs de origen únicas (spoofed). Un hash map regular se llena y rechaza entradas nuevas, dejando pasar nuevas IPs de ataque sin trackear. El LRU map evicta las entradas más viejas — que son típicamente IPs de ataque que ya fueron rate-limited.
AF_XDP: Cuando Necesitás Procesamiento en Userspace
A veces se necesita hacer más que dropear o pasar. AF_XDP te permite redirigir paquetes a una aplicación userspace a través de un ring buffer zero-copy, bypasseando todo el stack de red del kernel:
struct {
__uint(type, BPF_MAP_TYPE_XSKMAP);
__uint(max_entries, 64);
__type(key, __u32);
__type(value, __u32);
} xsks_map SEC(".maps");
SEC("xdp")
int xdp_af_xdp_redirect(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (eth->h_proto != __constant_htons(ETH_P_IP))
return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end)
return XDP_PASS;
if (ip->protocol == IPPROTO_TCP) {
struct tcphdr *tcp = (void *)ip + (ip->ihl * 4);
if ((void *)(tcp + 1) > data_end)
return XDP_PASS;
if (tcp->dest == __constant_htons(8080)) {
return bpf_redirect_map(&xsks_map, ctx->rx_queue_index, XDP_PASS);
}
}
return XDP_PASS;
}
AF_XDP te da 8-12 millones de paquetes por segundo por core en userspace. Eso es como 10x más rápido que recvmsg() en un raw socket, porque no hay overhead de syscall, no hay asignación de sk_buff, y no hay copia de datos.
Dentro del Dataplane de Cilium
Cilium es el uso más sofisticado de BPF para networking en producción. Reemplaza kube-proxy completamente e implementa networking de Kubernetes en BPF.
Cuando un paquete sale del Pod A con destino a un servicio Kubernetes, el programa BPF de Cilium:
- Busca el destino en
SERVICE_MAPpara encontrar un backend pod - Hace DNAT (reescribe la IP destino a la IP del backend pod)
- Crea una entrada de connection tracking en
CT_MAP - Chequea
POLICY_MAPpara verificar que la network policy permite la conexión - Forwardea el paquete directamente a la interfaz veth del backend pod
Todo esto pasa en el kernel, sin context switches a userspace. Los benchmarks de Cilium muestran 2-3x mejor latencia y throughput comparado con kube-proxy basado en iptables.
Números de Producción
Después de deployar el rate limiter XDP en nuestros servidores edge (droplets DigitalOcean Premium CPU con NICs Mellanox):
| Métrica | Antes (iptables) | Después (XDP) | |---------|-------------------|---------------| | Max absorción DDoS | ~5 Gbps | 40+ Gbps | | CPU durante flood de 10 Gbps | 95% | 8% | | Latencia tráfico legítimo durante ataque | 200-500ms | <5ms | | Tiempo de update de reglas | ~50ms (iptables flush) | <1ms (map update) | | Memoria por IP trackeada | ~512 bytes (conntrack) | 16 bytes (BPF map) |
La diferencia de memoria importa. Durante un DDoS volumétrico con millones de IPs de origen spoofed, el conntrack de Linux consumía gigabytes de RAM y eventualmente OOM-killeaba procesos. El BPF LRU map usa 16 MB fijos sin importar el tamaño del ataque.
Cuándo NO Usar XDP
XDP es una herramienta quirúrgica, no una solución general:
- Filtrado a nivel aplicación (reglas WAF, inspección HTTP): Utilizar un reverse proxy. XDP opera en paquetes crudos antes del reensamblado.
- Inspección profunda de paquetes stateful: El límite de instrucciones BPF y la falta de asignación dinámica de memoria hacen impracticable el análisis stateful complejo.
- Firewalling simple: Si iptables maneja tu carga, utilizar iptables. XDP agrega complejidad.
XDP brilla para: mitigación de DDoS, load balancing, packet steering, telemetría/sampling, y cualquier cosa donde se necesita tomar decisiones por-paquete a millones de paquetes por segundo.
La curva de aprendizaje es empinada — se necesita entender C, el stack de red de Linux, las restricciones del verificador BPF y la arquitectura de drivers de NIC. Pero una vez que escribiste tu primer programa XDP que dropea 40 Gbps de tráfico de ataque usando el 3% de un core de CPU, nunca se va a mirar iptables de la misma forma.