Skip to content

eBPF, integrado en Rust: Qué construimos y por qué

Red Sift desarrolló sus herramientas eBPF en Rust por seguridad y rendimiento. Aquí tienes la historia técnica detrás de la decisión y lo que aprendimos en el proceso.

Red Sift
Published: September 25, 2018·Updated: March 18, 2024·11 min read

Hoy lanzamos RedBPF y ingraind, nuestro toolkit de eBPF que se integra con StatsD y S3, para recibir comentarios y ver hasta dónde puede llevar este framework la comunidad Rust. Si buscas mejorar el monitoreo de tu empresa, obtener más información sobre tu clúster de Raspberry Pi en casa, o simplemente tienes un profundo interés académico en Rust y la gestión de bits a bajo nivel, sigue leyendo.RedBPF y ingraind, nuestro toolkit de eBPF que se integra con StatsD y S3, para recibir comentarios y ver hasta dónde puede llevar este framework la comunidad Rust. Si buscas mejorar el monitoreo de tu empresa, obtener más información sobre tu clúster de Raspberry Pi en casa, o simplemente tienes un profundo interés académico en Rust y la gestión de bits a bajo nivel, sigue leyendo.

Aparatos oxidados sobre un cocheAparatos oxidados sobre un coche

En Red Sift gestionamos nuestro propio clúster de Kubernetes y nuestra propia pipeline de procesamiento de datos sobre él. Procesamos una gran cantidad de correos electrónicos y queríamos comprender mejor los patrones de comunicación de nuestros sistemas. Este tipo de datos nos ayuda a desarrollar una base y a detectar cualquier irregularidad que pudiera indicar un incidente de seguridad o un mal funcionamiento.

RedBPF es una librería Rust para eBPF, una máquina virtual flexible dentro del kernel de Linux. Utilizando RedBPF, creamos ingraind, un agente recolector de métricas que podemos distribuir como un único binario y un archivo de configuración específico del sistema.

Las soluciones existentes de eBPF suelen centrarse principalmente en el rendimiento y la depuración, pero nosotros queríamos comprobar si eBPF podía ayudarnos a entender mejor nuestro perfil de seguridad.

Comenzamos con cuatro aspectos principales que queríamos conocer: actividad DNS y detalles de conexión TLS sin filtrar por puertos, volumen de tráfico UDP/TCP por proceso y patrones de acceso a archivos.

Como ya usamos Datadog para el monitoreo de rendimiento, resultaba lógico ampliar nuestros paneles con métricas relacionadas con la seguridad mediante un agente ligero que se integre fácilmente a un backend StatsD.

¿Qué procesos accedieron a /etc/passwd en el último día y cuándo?¿Qué procesos accedieron a /etc/passwd en el último día y cuándo?
¿Qué procesos accedieron a /etc/passwd en el último día y cuándo?

eBPF

eBPF es un subsistema relativamente nuevo en el kernel de Linux que está revolucionando la industria, y por buenas razones. Está en ese curioso punto de la curva del hype donde es lo suficientemente novedoso y también maduro como para tomárselo en serio.

eBPF es una máquina virtual dentro del kernel que permite engancharse a interfaces de red, syscalls e incluso funciones específicas internas del kernel detrás del acceso mediante syscall. Al momento de escribir esto, hay unas 200 llamadas que se pueden monitorear usando pequeños programas para obtener información profunda del kernel de Linux.

Por su flexibilidad, la gente usa módulos eBPF para diversas aplicaciones: filtrado de paquetes, redes definidas por software, monitoreo de rendimiento o, en general, extracción de diagnósticos en tiempo real para entender mejor un sistema.

Sin embargo, como el espacio y la atención en un blog técnico son limitados, para una inmersión técnica en eBPF consulta la página wiki que acabamos de abrir o el excelente artículo de Cilium.

Entra Rust

La mayoría del ecosistema actual de eBPF se basa en BCC, que tiene cómodos enlaces para Python y Go. Sin embargo, queríamos algo que no necesitara una toolchain completa de compilador ni fuentes del kernel en la máquina de destino, por lo que el flujo tradicional de trabajo con BCC quedaba descartado, así como sus prácticos bindings.

Nuestra propia limitación nos dejó con una opción: GoBPF soporta usar tu propio bytecode, y ya hay buenos programas que lo utilizan.

Sin embargo, debido al modelo de threading de Go, no puede igualar el rendimiento de Rust usando FFI de C. La interacción con módulos eBPF suele implicar mucha gestión de memoria a bajo nivel, así que esto era importante. Queríamos ejecutar ingraind en producción, así que no debía consumir más recursos de los absolutamente necesarios. Parecía lógico crear nuestro propio framework BPF en Rust.

RedBPF

Al trabajar con módulos eBPF en el kernel, hay muchas complejidades a tener en cuenta y hace falta revisar mucho código para entender cómo funciona todo. BCC sigue siendo el conjunto más completo para trabajar con eBPF. GoBPF y otras librerías han empezado a divergir, aunque manteniendo cierto parecido y añadiendo a la vez funciones inexistentes en otros lados.

Considero esto un antipatrón: al no ser un simple fork sino un semi-port, integrar correcciones y nuevas funciones desde la base original de código se vuelve cada vez más difícil. Mientras que la regla #1 del desarrollo del kernel de Linux es “No rompas userland”, en la práctica significa que se mantienen sistemas legacy mientras se añaden nuevas maneras preferidas de lograr lo mismo.

Para obtener lo mejor de ambos mundos, escogimos una estrategia mixta: reutilizar lo que sea posible del componente libbpflibbpf de BCC, una biblioteca increíble que se vincula estáticamente y gestiona la ejecución de módulos eBPF, e implementar los bindings de alto nivel, el análisis de ELF y otra lógica específica del flujo de trabajo en Rust.

El resultado es RedBPF, una librería enfocada en proveer bindings idiomáticos y de bajo esfuerzo para libbpf, consumir eventos perf y que ofrece un flujo build-load-run para eBPF, manteniendo el flujo poco invasivo. ¡Vamos a ver los detalles!

Construyendo módulos

Si la característica build está activa, RedBPF ofrece un conjunto de herramientas para compilar código C a módulos eBPF, generar enlaces de Rust a partir de archivos de cabecera C usando Bindgen, y usar una caché de compilación para acelerar el proceso cuando todo esto se utiliza a través de build.rs.

fn main() -> Result<(), Error> { let out_dir = PathBuf::from(env::var(“OUT_DIR”)?);

// aquí se generan los flags de build y bindgen. omitido por brevedad.

let mut cache = BuildCache::new(&out_dir); for file in source_files(“./bpf”, “c”)? { if cache.file_changed(&file) { build(&flags[..], &out_dir, &file).expect(“¡Error al compilar el plugin BPF!”); } } for file in source_files(“./bpf”, “h”)? { if cache.file_changed(&file) { generate_bindings(&bindgen_flags[..], &out_dir, &file) .expect(“¡Error al generar los data bindings!”); } }

cache.save(); Ok(()) } [/rust] La generación de enlaces Rust para estructuras de datos internas del kernel depende actualmente de que los structs tengan una definición que coincida con la expresión regular struct _data_[^{}]*, lo cual no es muy sofisticado, pero hasta ahora ha sido suficiente. Una ventaja de este enfoque es que las estructuras de datos que se envían a userland quedan claramente marcadas en el código C BPF en cada uso.

struct _data_connect { u64 id; u64 ts; char comm[TASK_COMM_LEN]; u32 saddr; u32 daddr; u16 dport; u16 sport; }; [/c] En ingraind leemos los datos brutos de un stream de eventos perf y los convertimos a una estructura segura y de alto nivel. Técnicamente, todavía hay bastante repetición en este proceso, algo que quizás optimicemos más adelante con algunos macros procedurales ingeniosos.

impl From<_data_connect> for Connection { fn from(data: _data_connect) -> Connection { Connection { task_id: data.id, name: to_string(unsafe { &*(&data.comm as *const [i8] as *const [u8]) }), source_ip: to_ipv4(data.saddr), destination_ip: to_ipv4(data.daddr), destination_port: to_le(data.dport), source_port: to_le(data.sport), proto: “”.to_string(), } } } [/rust] Aunque existen varias maneras de distribuir el bytecode eBPF resultante, optamos por incluirlo en el binario principal para hacer más sencilla la distribución.

Algo importante a resaltar es que el binario resultante puede depender del kernel. Aunque el ABI del kernel no es estable, ciertas partes rara vez cambian. Otro aspecto a considerar es RANDSTRUCT, o la aleatorización de layouts de estructuras. Esta es una función de robustecimiento que desordena el diseño de las structs marcadas en el kernel para dificultar la explotación. Los kernels de distribución suelen tenerlo desactivado, pero si compilas tu propio kernel, asegúrate de no llevarte una sorpresa.

Carga y mapas

Sin la característica build, RedBPF funciona sólo en tiempo de ejecución. Lee y analiza el archivo ELF que contiene el bytecode eBPF, devuelve la lista de componentes del objeto y proporciona enlaces para administrar programas, usar mapas BPF, consumir eventos perf y otras utilidades relacionadas donde no quisimos añadir más dependencias, como uname, o el análisis de los IDs de procesadores en línea.

Tras cargar un módulo, todos los mapas BPF se inicializan automáticamente, así que sólo es necesario ocuparse de los programas.

Como un programa BPF puede ser de muchos tipos, es responsabilidad del usuario adjuntarlos correctamente. Una forma de adjuntar todos los filtros XDP sería como sigue. Observa el unwrap: el programa se detendrá si la carga falla por cualquier motivo.

self.bind_perf(backends) [/rust]

Eventos perf

La transferencia de grandes cantidades de datos de traza desde el kernel puede hacerse principalmente de dos maneras: agregarlos en mapas del kernel y luego volcarlos periódicamente en userland, o usar eventos perf para transmitirlos en tiempo real desde el kernel mediante un buffer circular. Los eventos perf no requieren un syscall, así que, por ejemplo, si queremos agregar datos en función de propiedades arbitrarias, como hacemos nosotros, resulta más fácil enviar todo el stream bruto de eventos a userland y realizar allí la agregación. Esto da como resultado un uso de CPU aparentemente alto en top, pero en realidad es con muy bajo conteo de instrucciones por ciclo, lo que significa que nuestro programa la mayor parte del tiempo espera a la memoria, como explica en el blog de Brendan Gregg.

La interfaz de eventos perf en RedBPF fue diseñada para que podamos usar un solo bucle epoll para escuchar todas las fuentes de eventos perf. Esto también significa que, para mejorar el rendimiento, podríamos incluso iniciar un hilo epoll por cada CPU.

output } [/rust]

El ejemplo de código anterior generará un objeto PerfMap por cada CPU disponible, ya que en el kernel también se organizan los datos por CPU. De hecho, PerfHandler se encuentra en ingraind, no en RedBPF, y es un simple wrapper para leer el buffer circular. El fragmento interesante se ve así:

while let Some(ev) = self.perfmap.read() { match ev { Event::Lost(lost) => { println!(“Quizás se perdieron {} muestras para {}”, lost.count, &self.name); } Event::Sample(sample) => { let msg = unsafe { (self.callback)(slice::from_raw_parts( sample.data.as_ptr(), sample.size as usize, )) }; msg.and_then(|m| Some(send_to(&self.backends, m))); } } } [/rust] En conjunto, RedBPF busca un equilibrio entre ser lo suficientemente bajo nivel como para que comprendas lo que sucede, pero lo suficientemente alto como para no interponerse y permitir que el usuario se enfoque en lo que realmente le interesa.

ingraind

Junto con la librería RedBPF, hoy lanzamos ingraind, el agente que construimos alrededor de la librería. Y como nos encantan las pipelines de datos (¿a quién no?), ingraind se basa en procesar los datos en bruto que llegan desde los probes BPF.

La columna vertebral de esta pipeline es la biblioteca actix. Los datos brutos que provienen de los probes BPF los recibe un event handler, que los transforma en una estructura segura para pasarla al bucle de eventos de actores. Viaja por una tubería de lo que llamamos “agregadores” y termina en un “backend”.

Todos estos se implementan como actores, y el encadenamiento puede definirse en el archivo de configuración. El siguiente ejemplo rastrea los handshakes TLS a través de todos los puertos IPv4, y el acceso a archivos en todo el sistema de archivos raíz. Se pone en whitelist las etiquetas process, s_ip, d_port y path, que pueden tener más o menos sentido según cada métrica, y luego se reemplazan los nombres de procesos dinámicos generados por el proxy userland de Docker por un nombre uniforme. El destino es un bucket S3 y las credenciales de AWS se pasan por línea de comandos.

Conclusión

Creemos que la arquitectura y características únicas de RedBPF e ingraind las convierten en una potente combinación para monitorización de seguridad. En el futuro, hay sin duda margen de mejora en la ergonomía para compartir estructuras de datos entre código BPF y Rust, y siempre hay espacio para optimizar.

Para hacer todavía más sencillo el monitoreo en entornos contenerizados, añadiremos próximamente reconocimiento de contenedores al agente.

Nos entusiasma contribuir a la increíble comunidad Rust y recibir comentarios, issues y contribuciones para ofrecer una solución definitiva de monitoreo de seguridad en producción. Así que no dudes en contactarnos abajo. ¡Feliz hacking!

[rust] ​​use redbpf::build::{build, generate_bindings, cache::BuildCache, headers::headers};

[c] // la estructura de datos está originalmente definida en `connection.h`

// la compilación genera `$OUT_DIR/connection.rs`

[rust] include!(concat!(env!(“OUT_DIR”), “/connection.rs”));

#[derive(Debug, Serialize, Deserialize)] pub struct Connection {

pub task_id: u64,

pub name: String,

pub destination_ip: Ipv4Addr,

pub destination_port: u16,

pub source_ip: Ipv4Addr,

pub source_port: u16,

pub proto: String,

}

[rust] impl EBPFGrain<‘static> for UDP {

fn code() -> &’static [u8] {

include_bytes!(concat!(env!(“OUT_DIR”), “/udp.elf”))

}

}

[/rust]

[rust] use redbpf::Module;

let mut module = Module::parse(Self::code())?;

for prog in module.programs.iter_mut() {

prog.load(module.version, module.license.clone()).unwrap();

}

[/rust]

[rust] use redbpf::ProgramKind::*;

for prog in self

.module

.programs

.iter_mut()

.filter(|p| p.kind == Kprobe || p.kind == Kretprobe)

{

println!(“Programa: {}, {:?}”, prog.name, prog.kind);

prog.attach_probe().unwrap();

}

[rust] fn bind_perf(&mut self, backends: &[BackendHandler]) -> Vec> {

let online_cpus = cpus::get_online().unwrap();

let mut output: Vec> = vec![];

for ref mut m in self.module.maps.iter_mut().filter(|m| m.kind == 4) {

for cpuid in online_cpus.iter() {

let pm = PerfMap::bind(m, -1, *cpuid, 16, -1, 0).unwrap();

output.push(Box::new(PerfHandler {

name: m.name.clone(),

perfmap: pm,

callback: T::get_handler(m.name.as_str()),

backends: backends.to_vec(),

}));

}

}

[rust] use redbpf::Event;

[ini] [[probe]] pipelines = [“s3”] [probe.config] type = “Files”

monitor_dirs = [“/”] [[probe]] pipelines = [“s3”] [probe.config] type = “TLS” [pipeline.s3.config] backend = “S3” [[pipeline.s3.steps]] type = “Whitelist”

allow = [“process”, “s_ip”, “d_port”, “path”] [[pipeline.s3.steps]] type = “Regex”

patterns = [

{ key = “process”, regex = “conn\\d+”, replace_with = “docker_conn_proxy”},

] [/ini]

Red Sift