Oggi rilasciamo RedBPF e ingraind, il nostro toolkit eBPF che si integra con StatsD e S3, per raccogliere feedback e vedere dove la community Rust potrebbe portare questo framework. Se vuoi migliorare il monitoraggio della tua azienda, raccogliere più dati sul tuo cluster Raspberry Pi a casa, o semplicemente hai un forte interesse accademico per Rust e la gestione di bit a basso livello, potresti voler continuare a leggere.RedBPF e ingraind, il nostro toolkit eBPF che si integra con StatsD e S3, per raccogliere feedback e vedere dove la community Rust potrebbe portare questo framework. Se vuoi migliorare il monitoraggio della tua azienda, raccogliere più dati sul tuo cluster Raspberry Pi a casa, o semplicemente hai un forte interesse accademico per Rust e la gestione di bit a basso livello, potresti voler continuare a leggere.


In Red Sift gestiamo il nostro cluster Kubernetes e la nostra pipeline di elaborazione dati sopra di esso. Gestiamo un gran numero di email e volevamo una comprensione più approfondita dei pattern di comunicazione dei nostri sistemi. Questo tipo di dati ci aiuta a sviluppare una baseline e rilevare eventuali anomalie che potrebbero indicare incidenti di sicurezza o malfunzionamenti.
RedBPF è una libreria Rust per eBPF, un sottosistema VM flessibile all’interno del kernel Linux. Usando RedBPF, abbiamo sviluppato ingraind, un agent per la raccolta di metriche che possiamo distribuire come singolo file binario e file di configurazione specifico del sistema.
Le soluzioni eBPF esistenti tendono a concentrarsi principalmente su prestazioni e debug, ma volevamo vedere se eBPF poteva aiutarci a comprendere meglio il nostro profilo di sicurezza.
Abbiamo iniziato con 4 principali aspetti che volevamo tracciare: attività DNS e dettagli delle connessioni TLS senza dover ricorrere al filtraggio delle porte, volume del traffico UDP/TCP per processo, e pattern di accesso ai file.
Dato che già utilizziamo Datadog per il monitoraggio delle prestazioni, sembrava logico espandere le nostre dashboard con metriche legate alla sicurezza usando un agent relativamente leggero che si integra facilmente con un backend StatsD.


eBPF
Un sottosistema relativamente nuovo nel kernel Linux, eBPF sta suscitando molto interesse nel settore, e a ragione. Si trova in una posizione particolare della curva di hype: è abbastanza nuovo e al tempo stesso abbastanza maturo da essere preso sul serio.
eBPF è una macchina virtuale all’interno del kernel che permette di agganciarsi alle interfacce di rete, ai syscall, o anche a funzioni specifiche del kernel dietro il gate dei syscall. Al momento della scrittura, ci sono circa 200 chiamate che possiamo monitorare usando piccoli programmi, per ottenere informazioni approfondite dal kernel di Linux.
Grazie alla sua flessibilità, le persone usano moduli eBPF per molte cose: filtraggio di pacchetti, reti definite dal software, monitoraggio delle prestazioni, o in generale per estrarre diagnostica in tempo reale così da comprendere meglio un sistema.
Tuttavia, siccome lo spazio e la capacità di attenzione in un post tecnico sono finiti, per un approfondimento tecnico su eBPF puoi consultare la pagina wiki che abbiamo appena aperto, oppure l’ottima trattazione di Cilium.
Entra in gioco Rust
La maggior parte dell’ecosistema eBPF esistente si basa su BCC, che offre comodissimi binding per Python e Go. Tuttavia, volevamo qualcosa che non richiedesse una toolchain completa da compilatore e i sorgenti del kernel sulla macchina di destinazione, perciò la tradizionale workflow BCC era da escludere, insieme ai suoi utili binding.
Questa limitazione autoimposta ci lasciava una sola opzione: GoBPF supporta il bytecode personalizzato, e già diversi ottimi software lo utilizzano.
Tuttavia, a causa del modello di threading di Go, non riesce a eguagliare le prestazioni di Rust in FFI con C. Interagire con moduli eBPF coinvolge molta gestione di memoria low level, aspetto importante. Volevamo eseguire ingraind in produzione, quindi non doveva consumare più risorse del necessario. Sembrava che costruire un nostro framework BPF in Rust fosse una scelta solida.
RedBPF
Gestire moduli eBPF nel kernel implica molte complessità a cui prestare attenzione, e bisogna scavare parecchio nel codice per capire come fare. BCC resta la suite più completa che si occupa di eBPF. GoBPF e alcune altre librerie hanno cominciato a divergere, pur mantenendo qualche somiglianza, aggiungendo intanto funzionalità non disponibili altrove.
Considero fortemente questo un anti-pattern: non essendo un vero e proprio fork, ma una sorta di porting parziale, integrare le fix e le nuove funzionalità dal codice originale diventa sempre più difficile. Anche se la regola #1 dello sviluppo del kernel Linux è “Non si rompe l'userland”, in pratica significa solo che i sistemi legacy vengono mantenuti mentre vengono aggiunti nuovi metodi preferiti per raggiungere gli stessi risultati.
Per ottenere il meglio di entrambi i mondi, abbiamo scelto un approccio misto: riutilizzare dove possibile il componente di BCC libbpflibbpf, una libreria eccezionale linkabile staticamente che si occupa esclusivamente della gestione runtime dei moduli eBPF, e sviluppare binding di alto livello, il parsing ELF, e altro codice specifico per il workflow, in Rust.
Il risultato finale è RedBPF, una libreria altamente focalizzata nel fornire binding idiomatici e di basso impatto per libbpf, consumare eventi perf, e offrire una workflow build-load-run per eBPF, senza imporre troppe opinioni su di essa. Vediamo i dettagli!
Costruire i moduli
Se la feature build è abilitata, RedBPF offre un toolkit per compilare codice C in moduli eBPF, generare binding Rust dai file header C tramite Bindgen, e usare una cache di compilazione per velocizzare tutto ciò quando viene usato tramite build.rs.
fn main() -> Result<(), Error> { let out_dir = PathBuf::from(env::var(“OUT_DIR”)?);
// qui generiamo i flag di build & bindgen. Omissi per brevità.
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(“Compilazione del plugin BPF fallita!”); } } for file in source_files(“./bpf”, “h”)? { if cache.file_changed(&file) { generate_bindings(&bindgen_flags[..], &out_dir, &file) .expect(“Generazione dei binding dati fallita!”); } }
cache.save();
Ok(())
}
[/rust]
La generazione dei binding Rust per le strutture dati nel kernel attualmente dipende dal fatto che le struct abbiano una definizione che risponde alla regex struct _data_[^{}]*, non molto sofisticata, ma sufficiente finora. Un vantaggio di questo approccio è che le strutture dati inviate in userland sono chiaramente marcate nel codice C BPF a ogni utilizzo.
struct _data_connect { u64 id; u64 ts; char comm[TASK_COMM_LEN]; u32 saddr; u32 daddr; u16 dport; u16 sport; }; [/c] In ingraind leggiamo i dati grezzi da uno stream di eventi perf e li convertiamo in una struttura sicura e di alto livello. Tecnicamente, c’è ancora una certa ripetizione in questo processo; si potrebbe ottimizzare con alcune macro procedurali ben congegnate.
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] Anche se ci sarebbero più modi per distribuire il bytecode eBPF risultante, abbiamo scelto di includerlo nel binario principale per semplificarne la distribuzione.
Una nota importante è che il binario risultante può dipendere dal kernel. Anche se l’ABI del kernel è instabile, alcune parti cambiano raramente. Un altro aspetto è RANDSTRUCT, o randomizzazione del layout delle struct. Si tratta di una funzione di hardening che modifica la disposizione interna delle struct marcate nel kernel per rendere più difficile l’exploit. I kernel distribuiti generalmente hanno questa opzione disabilitata, ma se compili il tuo kernel, fai attenzione a non trovarti sorpreso.
Caricamento e mappe
Senza la feature build, RedBPF è solo runtime. Legge e interpreta ELF che contiene bytecode eBPF, restituisce la lista dei componenti nell’oggetto, e offre binding per gestire programmi, utilizzare mappe BPF, consumare eventi perf e alcune altre utility per cui non volevamo introdurre altre dipendenze, come uname o il parsing degli ID dei processori online.
Dopo aver caricato un modulo, tutte le BPF map vengono inizializzate automaticamente, quindi dobbiamo solo gestire i programmi.
Dato che un programma BPF può essere di vari tipi, allegarli correttamente è compito dell’utente. Un modo per allegare tutti i filtri XDP potrebbe essere il seguente. Nota l’unwrap: in questo modo il programma va in crash deliberato se il caricamento fallisce per qualche motivo.
self.bind_perf(backends) [/rust]
Eventi perf
Trasferire una grossa quantità di dati di tracing dal kernel può avvenire principalmente in due modi: aggregandoli nelle mappe kernel, da scaricare poi periodicamente in userspace, oppure usando i perf event per trasmetterli in tempo reale dal kernel, tramite un ring buffer. I perf event non richiedono syscall, quindi, se ad esempio, vogliamo aggregare dati basandoci su proprietà arbitrarie – cosa che facciamo – ci conviene inviare lo stream degli eventi grezzi in userland e fare il processing lì. Questo comporta un apparente uso elevato di CPU in top, ma con un basso numero di istruzioni per ciclo, ossia il programma attende principalmente l’accesso alla memoria, come spiega il blog post di Brendan Gregg.
L’interfaccia dei perf event in RedBPF è stata pensata in modo da poter usare un unico loop epoll per ascoltare tutte le sorgenti di perf event. Questo significa anche che, per throughput migliori, potremmo persino avviare un thread epoll per ogni CPU.
output } [/rust]
L’esempio sopra genererà un oggetto PerfMap per ogni CPU online, poiché anche nel kernel i dati sono organizzati per CPU. In effetti, PerfHandler si trova in ingraind, non in RedBPF, ed è un semplice wrapper per la lettura del ring buffer. La parte interessante appare così:
while let Some(ev) = self.perfmap.read() { match ev { Event::Lost(lost) => { println!(“Potrebbero essere stati persi {} campioni per {}”, 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] Nel complesso, RedBPF cerca di bilanciare la vicinanza al livello basso – così da permettere comprendere cosa succede – con un livello sufficientemente alto da non essere d’intralcio e lasciare che l’utente si concentri su ciò che gli serve.
ingraind
Oltre alla libreria RedBPF, oggi rilasciamo ingraind, l’agent che abbiamo costruito sulla sua base. E poiché amiamo le pipeline di dati (chi non le ama?!), ingraind si basa sull’elaborazione dei dati grezzi provenienti dai probe BPF.
L’ossatura di questa pipeline è l’ottima libreria actix. I dati grezzi provenienti dai BPF probe sono raccolti da un event handler, trasformati in una struttura sicura, e quindi inviati nell’event loop degli actor. Viaggiano attraverso una pipeline di elementi che chiamiamo “aggregatori” e arrivano infine su un “backend”.
Tutti questi sono implementati come actor, e il chaining può essere definito nel file di configurazione. L’esempio seguente traccia le handshake TLS su tutte le porte IPv4 e l’accesso ai file ovunque sul filesystem root. Mette in whitelist le tag process, s_ip, d_port e path, che potrebbero avere senso o meno a seconda delle misurazioni, poi sostituisce i nomi dinamici di processo generati dal proxy userland di Docker con uno uniforme. La destinazione è un bucket S3, e le credenziali AWS vengono passate da riga di comando.
Conclusione
Crediamo che l’architettura e le funzionalità uniche di RedBPF e ingraind rendano questi strumenti una combinazione potente per il monitoraggio della sicurezza. In futuro, c’è senz’altro margine di miglioramento nella condivisione delle strutture dati tra codice BPF e Rust, e ci sarà sempre spazio per ulteriori ottimizzazioni.
Per rendere il monitoraggio degli ambienti containerizzati ancora più facile, aggiungeremo a breve la container awareness all’agent.
Siamo entusiasti di restituire qualcosa all’incredibile community Rust e di ricevere feedback, segnalazioni e contributi per offrire una soluzione chiavi in mano per il monitoraggio della sicurezza in produzione. Sentiti libero di contattarci qui sotto! Buon hacking!
[rust] use redbpf::build::{build, generate_bindings, cache::BuildCache, headers::headers};
[c] // la struttura dati è originariamente definita in `connection.h`
// lo step di build 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!(“Programma: {}, {:?}”, 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]




