wandres.dev
EBPF · programas en el kernel

Tipos de programa y hooks: dónde se engancha eBPF

El tipo de programa decide todo: qué contexto recibe, a qué gancho se ata, qué auxiliares puede llamar y qué significa su retorno. Recorrido por kprobe, tracepoint y fentry para observabilidad, XDP y tc para la red, y BPF LSM para la seguridad, con los helpers como única puerta al kernel.

⏱ 17 min

Un programa eBPF no flota en el vacío: nace atado a un punto concreto del kernel y solo cobra sentido allí. Ese punto —el gancho— y el tipo de programa que lo habita determinan absolutamente todo: qué datos recibe como contexto, qué funciones auxiliares tiene permitido invocar, y qué significa el número que devuelve. Un mismo lenguaje y una misma máquina virtual se convierten así en un rastreador de funciones, un cortafuegos a velocidad de línea o un monitor de referencia de seguridad, según dónde los enganches. Conocer el mapa de ganchos es conocer la superficie entera de lo que eBPF puede tocar.

🎯 Al terminar esta lección sabrás
  • Entender cómo el tipo de programa fija contexto, auxiliares y semántica del retorno.
  • Distinguir los ganchos de observabilidad: kprobe, tracepoint y fentry.
  • Conocer los ganchos de red XDP y tc y sus códigos de retorno.
  • Ver cómo BPF LSM convierte a eBPF en un monitor de referencia de seguridad.

El tipo de programa lo decide todo

Al cargar un programa declaras su tipo con BPF_PROG_TYPE_*. Ese enumerado es un contrato de cuatro cláusulas. Primero, el contexto: un kprobe recibe los registros de la CPU, un XDP recibe un xdp_md con punteros al paquete, un LSM recibe los argumentos de un hook de seguridad. Segundo, los auxiliares permitidos: no todos los programas pueden llamar a todos los helpers. Tercero, dónde puede engancharse. Cuarto, el significado del retorno: en XDP el retorno es un veredicto sobre el paquete; en un kprobe de traza, casi siempre da igual.

El anotador SEC() codifica tipo y gancho en una sola cadena, y libbpf la traduce al cargar:

SEC("kprobe/do_unlinkat")     /* tipo KPROBE, enganchado a do_unlinkat */
SEC("xdp")                     /* tipo XDP, se ata a una interfaz de red */
SEC("lsm/file_open")           /* tipo LSM, al hook de seguridad file_open */
SEC("tp/sched/sched_switch")   /* tipo TRACEPOINT, al cambio de contexto */

Los helpers: la única puerta al kernel

Un programa eBPF no puede llamar a funciones arbitrarias del kernel —eso rompería toda garantía del verificador—. Solo puede invocar auxiliares (helpers): funciones con un contrato estable, cada una con un número, que el kernel expone deliberadamente. Son la única puerta desde la jaula hacia el resto del núcleo, y están restringidas por tipo de programa: bpf_redirect no tiene sentido en un rastreador, y bpf_probe_read_user no se ofrece a un XDP.

u64 ahora  = bpf_ktime_get_ns();               /* reloj monotónico en ns */
u64 idpid  = bpf_get_current_pid_tgid();        /* pid y tgid empaquetados */
u32 pid    = idpid >> 32;
void *val  = bpf_map_lookup_elem(&mapa, &pid);  /* acceso a un mapa */
bpf_probe_read_kernel(&dst, sizeof(dst), src);  /* lectura segura del kernel */

Desde Linux 5.13 conviven con las kfuncs: funciones del propio kernel expuestas a eBPF mediante BTF, sin necesidad de congelar una API de auxiliar para siempre. Son más flexibles —un módulo puede definir las suyas— y hoy son la vía preferida para ampliar lo que un programa puede pedirle al núcleo.

Observabilidad: kprobe, tracepoint y fentry

Un kprobe engancha dinámicamente la entrada de casi cualquier función del kernel; su pareja kretprobe intercepta el retorno. Es potentísimo pero frágil: depende del nombre y la firma exacta de una función interna que puede cambiar entre versiones. Un tracepoint es lo contrario: un punto de instrumentación estático que los mantenedores declaran y prometen mantener estable, por lo que un programa atado a un tracepoint sobrevive a las actualizaciones.

La forma moderna de rastrear funciones es fentry/fexit, que usa trampolines BPF y BTF para engancharse con acceso tipado a los argumentos y, en fexit, también al valor de retorno —algo imposible en un kprobe clásico—:

#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

SEC("fentry/tcp_v4_connect")
int BPF_PROG(al_connect, struct sock *sk)
{
	u16 dport = BPF_CORE_READ(sk, __sk_common.skc_dport);
	bpf_printk("connect al puerto %d\n", bpf_ntohs(dport));
	return 0;
}

char LICENSE[] SEC("license") = "GPL";

La macro BPF_PROG desenvuelve los argumentos tipados; BPF_CORE_READ lee campos con reubicación CO-RE (nivel 50.5), de modo que el mismo binario funciona aunque el desplazamiento de skc_dport cambie entre kernels.

Red y seguridad: XDP, tc y LSM

XDP —eXpress Data Path— es el gancho más temprano de toda la pila de red: corre en el driver de la tarjeta, antes de que el kernel construya el sk_buff. Por eso alcanza velocidad de línea y es la base de la mitigación de DDoS. Su retorno es un veredicto:

SEC("xdp")
int cortafuegos(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 == bpf_htons(ETH_P_ARP))
		return XDP_DROP;      /* descarta antes de gastar un skb */
	return XDP_PASS;          /* deja seguir al resto de la pila */
}
🚦

Veredictos XDP

XDP_DROP descarta, XDP_PASS entrega a la pila, XDP_TX reenvía por la misma interfaz, XDP_REDIRECT lo manda a otra o a un socket, XDP_ABORTED señala error.

🧩

El gancho tc

En ingreso y egreso, sobre el sk_buff ya formado. Más contexto que XDP y capacidad de modificar y reenviar. Es el gancho que Cilium usa para implementar redes de Kubernetes.

El gancho tc (sched_cls, hoy con la API tcx desde Linux 6.6) actúa un paso más tarde, sobre el sk_buff ya construido: tiene menos velocidad bruta que XDP pero más contexto y puede reescribir y redirigir tráfico. Y en el otro extremo del sistema, BPF LSM engancha los hooks del Linux Security Module para decidir, en tiempo de ejecución, si una operación se permite:

SEC("lsm/file_open")
int BPF_PROG(protege_secretos, struct file *file)
{
	/* 0 autoriza; un negativo deniega con ese errno */
	if (mi_ruta_prohibida(file))
		return -EPERM;
	return 0;
}
flowchart LR
A[funcion del kernel] --> K[kprobe o fentry]
B[punto estatico] --> T[tracepoint]
C[llega un paquete] --> X[XDP en el driver]
D[skb en la pila] --> TC[tc y tcx]
E[hook de seguridad] --> L[BPF LSM]
K --> Z[programa eBPF]
T --> Z
X --> Z
TC --> Z
L --> Z

Encadenar programas: las tail calls

Un solo programa tiene límites de tamaño y de complejidad, y a veces el comportamiento depende de datos que solo se conocen en ejecución. La respuesta de eBPF son las tail calls: un programa puede saltar a otro —no llamarlo y volver, sino cederle el control por completo— usando un mapa especial, BPF_MAP_TYPE_PROG_ARRAY, indexado por un número que eliges en tiempo de ejecución:

struct {
	__uint(type, BPF_MAP_TYPE_PROG_ARRAY);
	__uint(max_entries, 8);
	__type(key, u32);
	__type(value, u32);   /* descriptores de otros programas */
} etapas SEC(".maps");

SEC("xdp")
int despachador(struct xdp_md *ctx)
{
	u32 etapa = clasifica(ctx);
	bpf_tail_call(ctx, &etapas, etapa);   /* si existe, no vuelve aquí */
	return XDP_PASS;                       /* solo si la ranura estaba vacía */
}

Con ellas se construyen máquinas de estados y planos de datos modulares —una etapa por protocolo, por ejemplo— sin inflar un único programa gigante que el verificador rechazaría por complejo. El espacio de usuario rellena el PROG_ARRAY con los descriptores de las etapas, y puede reprogramar el flujo cambiando una entrada del mapa, en caliente.

ℹ️
seccomp es BPF, pero clásico

Cuidado con una confusión común. El filtrado de llamadas al sistema de seccomp usa BPF, pero el clásico (cBPF), no eBPF: por razones de superficie de ataque, el veredicto de qué syscalls puede ejecutar un proceso se expresa en la máquina virtual antigua y limitada. No todo lo que dice BPF en Linux es la máquina extendida de este nivel.

Un mismo motor, mil sistemas distintos, según dónde lo enchufes

Detente en la unidad que se esconde bajo esta variedad, porque es la idea arquitectónica más elegante de todo eBPF. La máquina virtual es una sola. El verificador es uno solo. El JIT es uno solo. Lo único que cambia entre rastrear una función, filtrar un paquete a velocidad de línea y denegar la apertura de un fichero es dónde enganchas el mismo motor y qué contrato le impone ese punto. Piensa en lo que eso significa. La historia de los sistemas operativos está llena de subsistemas construidos por separado, cada uno con su propio lenguaje de configuración, su modelo de extensión y sus agujeros: iptables para la red, un módulo LSM compilado para la seguridad, ftrace y perf para la observabilidad, cada uno una isla con su jerga. eBPF los subsume a todos bajo un único principio: un motor de cómputo seguro y programable, y un conjunto de puntos de anclaje repartidos por el kernel donde ese motor puede injertarse. La red, la seguridad y la observabilidad dejan de ser tres reinos con tecnologías incompatibles y pasan a ser tres ubicaciones del mismo mecanismo. El contexto que recibes, los auxiliares que puedes llamar y el significado de tu retorno no son caprichos: son la firma del lugar donde te enchufas, la forma en que cada gancho declara qué poder te presta y qué responsabilidad te exige. Cuando internalices que el tipo de programa no es una categoría burocrática sino la definición precisa de un punto de contacto entre tu código y el kernel, dejarás de ver una lista de ganchos inconexos y verás lo que de verdad son: los enchufes de una plataforma universal donde un solo motor de cómputo puede reprogramar, con seguridad demostrada, casi cualquier comportamiento del sistema operativo.

⚔️ Recorre el mapa de ganchos
  1. Engancha fentry/tcp_v4_connect y observa los puertos de destino de las conexiones salientes de tu máquina en tiempo real.
  2. Reescribe el mismo rastreo como kprobe/tcp_v4_connect y explica por qué la versión fentry es más rápida y accede a los argumentos con tipos.
  3. Carga el programa XDP de arriba en tu interfaz con ip link set dev ... xdp obj ... y comprueba que descarta el tráfico ARP sin llegar a la pila.
  4. Argumenta, para un kprobe, un tracepoint y un programa LSM, qué contexto recibe cada uno y por qué el mismo helper no está disponible en los tres.