Heute veröffentlichen wir RedBPF und ingraind, unsere eBPF-Toolkit-Lösung, die sich mit StatsD und S3 integriert. Wir möchten Feedback sammeln und beobachten, wie andere aus der Rust-Community dieses Framework weiterentwickeln. Wenn Sie das Monitoring in Ihrem Unternehmen auf das nächste Level heben möchten, mehr Daten zu Ihrem Raspberry Pi-Cluster zu Hause erheben wollen oder einfach ein stark akademisches Interesse an Rust und low-level Speicherverwaltung haben, lesen Sie gerne weiter!RedBPF und ingraind, unsere eBPF-Toolkit-Lösung, die sich mit StatsD und S3 integriert. Wir möchten Feedback sammeln und beobachten, wie andere aus der Rust-Community dieses Framework weiterentwickeln. Wenn Sie das Monitoring in Ihrem Unternehmen auf das nächste Level heben möchten, mehr Daten zu Ihrem Raspberry Pi-Cluster zu Hause erheben wollen oder einfach ein stark akademisches Interesse an Rust und low-level Speicherverwaltung haben, lesen Sie gerne weiter!


Bei Red Sift betreiben wir unser eigenes Kubernetes-Cluster sowie eine eigene Datenverarbeitungspipeline darauf. Wir haben es mit sehr vielen E-Mails zu tun und wollten die Kommunikationsmuster unserer eigenen Systeme besser verstehen. Diese Art von Daten hilft uns letztlich, einen Standard zu entwickeln und Unregelmäßigkeiten zu erkennen, die auf Sicherheitsvorfälle oder Fehlfunktionen hinweisen könnten.
RedBPF ist eine Rust-Bibliothek für eBPF, ein flexibles VM-Subsystem im Linux-Kernel. Mithilfe von RedBPF haben wir ingraind gebaut – einen Metrik-Collector-Agent, den wir als einzelnes Binary zusammen mit einer systemspezifischen Konfigurationsdatei ausliefern können.
Bestehende eBPF-Lösungen konzentrieren sich meist auf Performance und Debugging. Wir wollten jedoch herausfinden, ob eBPF uns ein besseres Verständnis unseres eigenen Sicherheitsprofils geben kann.
Wir sind mit vier Hauptpunkten gestartet, über die wir mehr wissen wollten: DNS-Aktivitäten und TLS-Verbindungsdetails ohne Portfilterung, UDP-/TCP-Trafficvolumen je Prozess sowie Dateizugriffsmuster.
Da wir bereits Datadog zur Performanceüberwachung nutzen, lag es nahe, unsere Dashboards mit sicherheitsrelevanten Metriken zu erweitern – und zwar mit einem relativ leichten Agent, der sich problemlos an ein StatsD-Backend andocken lässt.


eBPF
Ein relativ neues Subsystem im Linux-Kernel – eBPF – sorgt aus guten Gründen in der Industrie für Aufsehen. Es befindet sich gerade an dem Punkt des Hype-Cycles, wo es sowohl jung als auch gereift genug ist, um ernst genommen zu werden.
eBPF ist eine virtuelle Maschine im Kernel, mit der Netzwerk-Interfaces, Syscalls oder bestimmte Kernel-Funktionen direkt hinter dem Syscall-Gate abgefangen werden können. Zum Zeitpunkt des Schreibens gibt es etwa 200 verschiedene Aufrufe, die mit kleinen Programmen beobachtet werden können, um tiefgehende Einblicke aus dem Linux-Kernel zu erhalten.
Wegen seiner Flexibilität wird eBPF für vielfältige Zwecke genutzt: Paketfilterung, Software Defined Networks, Performance-Monitoring oder im Allgemeinen, um in Echtzeit Diagnosedaten zu extrahieren und Systeme besser zu verstehen.
Da ein technischer Blogbeitrag aber nicht unbegrenzt Platz (und Aufmerksamkeitsspanne) bietet: Für einen tieferen technischen Einblick in eBPF lohnt sich entweder die gerade von uns geöffnete Wiki-Seite oder die großartige Einführung von Cilium.
Rust kommt ins Spiel
Der Großteil des bestehenden eBPF-Ökosystems basiert auf BCC, das bequeme Python- und Go-Bindings bietet. Wir wollten jedoch etwas, das auf der Zielmaschine keinen vollständigen Compiler-Toolchain und Kernelquellen benötigt. Die herkömmliche BCC-Lösung kam damit nicht infrage – ebenso wenig wie deren komfortable Bindings.
Unsere selbstgewählte Einschränkung ließ uns eine Option: GoBPF, das eigene Bytecode-Unterstützung bietet und bereits in einigen großartigen Projekten verwendet wird.
Allerdings kann Go wegen seines Threadings nicht die gleiche Performance wie Rust beim C-FFI erreichen. Da beim Umgang mit eBPF-Modulen viel low-level Speicherverwaltung nötig ist, war das entscheidend. ingraind sollte produktiv eingesetzt werden und möglichst wenig Ressourcen verbrauchen. Für uns schien es daher die beste Lösung, unser eigenes Rust-BPF-Framework zu bauen.
RedBPF
Bei der Arbeit mit eBPF-Modulen im Kernel gibt es viele Feinheiten; man muss viel Code wälzen, um herauszufinden, wie es geht. BCC ist weiterhin die umfassendste Suite für eBPF, während GoBPF und einige andere Bibliotheken begonnen haben, sich in eine andere Richtung zu entwickeln. Oft werden dabei Features ergänzt, die woanders nicht zu finden sind.
Ich halte das für einen Anti-Pattern: Da es sich nicht um einen klaren Fork, sondern um einen Halve-Port handelt, ist es immer schwieriger, Fehlerbehebungen oder neue Funktionen aus dem Original zu übernehmen. Regel Nr. 1 der Linux-Kernel-Entwicklung lautet zwar „Du brichst das Userland nicht“, in der Praxis bedeutet das aber oft, dass alte Systeme erhalten bleiben, während neue, bevorzugte Methoden zum Erreichen desselben Ziels ergänzt werden.
Um das Beste aus beiden Welten zu vereinen, haben wir uns für einen gemischten Ansatz entschieden: Wir verwenden, was möglich ist, aus BCC‘s libbpflibbpf-Komponente – eine herausragende, statisch linkbare Bibliothek, die ausschließlich das Laufzeitmanagement von eBPF-Modulen übernimmt – und implementieren High-Level-Bindings, ELF-Parsing und weitere workflowspezifische Logik in Rust.
Das Ergebnis ist RedBPF, eine Bibliothek, die sich darauf konzentriert, einfache, idiomatische Bindings für libbpf bereitzustellen, Perf-Events zu nutzen und einen Build-Load-Run-Workflow für eBPF zu ermöglichen, dabei aber flexibel genug bleibt. Gehen wir ins Detail!
Module bauen
Ist das build-Feature aktiviert, stellt RedBPF ein Toolkit bereit, um C-Code in eBPF-Module zu kompilieren, Rust-Bindings aus C-Headers über Bindgen zu generieren und einen Build-Cache zu nutzen, um das Ganze zu beschleunigen, wenn man es via build.rs einbindet.
fn main() -> Result<(), Error> { let out_dir = PathBuf::from(env::var(“OUT_DIR”)?);
// Die Build- und Bindgen-Flags werden hier generiert. Aus Platzgründen ausgelassen.
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(“Failed building BPF plugin!”); } } for file in source_files(“./bpf”, “h”)? { if cache.file_changed(&file) { generate_bindings(&bindgen_flags[..], &out_dir, &file) .expect(“Failed generating data bindings!”); } }
cache.save();
Ok(())
}
[/rust]
Die Generierung der Rust-Bindings für in-kernel Datenstrukturen basiert aktuell darauf, dass structs ein zur Regex struct _data_[^{}]* passendes Muster haben – nicht besonders ausgefeilt, aber bislang ausreichend. Der Vorteil daran: Datenstrukturen, die ins Userland gereicht werden, sind im C-BPF-Code immer eindeutig markiert.
struct _data_connect { u64 id; u64 ts; char comm[TASK_COMM_LEN]; u32 saddr; u32 daddr; u16 dport; u16 sport; }; [/c] In ingraind lesen wir die Rohdaten aus einem Perf-Event-Strom und wandeln sie in eine sichere High-Level-Struktur um. Technisch gesehen gibt es dabei noch einige Wiederholungen, die sich mit etwas eleganteren Makros weiter optimieren ließen.
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] Das resultierende eBPF-Bytecode kann auf verschiedene Weise verteilt werden – wir haben uns für die Einbindung im Haupt-Binary entschieden, um den Rollout zu vereinfachen.
Beachtenswert ist, dass das End-Binary unter Umständen kernelabhängig ist. Auch wenn das Kernel-ABI unstabil ist, verändern sich manche Teile kaum. Interessant ist auch RANDSTRUCT, die Randomisierung von Struct-Layouts. Das ist ein Härtungs-Feature, das die Struktur interner Kernel-Structs ändert, um Exploits zu erschweren. Distribution-Kernel haben das meist deaktiviert, doch falls Sie Ihren eigenen Kernel bauen: Lassen Sie sich davon nicht überraschen.
Laden und Maps
Ohne das build-Feature ist RedBPF rein zur Laufzeit nutzbar. Es liest und parsed ELF-Dateien mit eBPF-Bytecode, gibt die enthaltenen Komponenten zurück und stellt Bindings zum Managen von Programmen, zur Nutzung von BPF-Maps, für Perf-Events und einige weitere Hilfsfunktionen bereit (z. B. für uname oder das Parsen von Online-Prozessor-IDs), für die wir keine zusätzliche Abhängigkeit einbauen wollten.
Nach dem Laden eines Moduls werden alle BPF-Maps automatisch initialisiert – wir müssen uns also nur noch um die Programme kümmern.
Da ein BPF-Programm viele Typen annehmen kann, ist das richtige Anhängen dem Nutzer überlassen. Alle XDP-Filter könnten so angebunden werden; das unwrap ist bewusst so gewählt, damit das Programm bei jedem Ladefehler sofort abstürzt.
self.bind_perf(backends) [/rust]
Perf-Events
Um große Mengen an Trace-Daten aus dem Kernel zu übertragen, gibt es im Wesentlichen zwei Wege: entweder in Kernel-Maps aggregieren und regelmäßig in Userspace auslesen – oder Perf-Events nutzen, um die Daten per Ringbuffer in Echtzeit zu streamen. Perf-Events benötigen keinen Syscall; das ist von Vorteil, wenn wie in unserem Fall die Aggregation erst im Userspace nach beliebigen Kriterien erfolgen soll. Dadurch erscheint unser Programm zwar kurzzeitig mit hoher CPU-Auslastung im top, hat aber eine sehr geringe Instruktionen-pro-Takt-Rate, d. h. unser Programm wartet hauptsächlich auf Speicherzugriffe, wie Brendan Gregg in seinem Blog post erklärt.
Das Perf-Event-Interface in RedBPF ist so gestaltet, dass wir mit einem einzigen epoll-Loop alle Perf-Event-Quellen abhören können. Für maximalen Durchsatz könnten sogar eigene epoll-Threads je CPU gestartet werden.
output } [/rust]
Der obige Code erzeugt für jede Online-CPU ein PerfMap-Objekt, da die Daten im Kernel pro CPU verwaltet werden. PerfHandler ist in ingraind implementiert und wickelt nur das Auslesen des Ringbuffers ab. Interessant ist dieser Teil:
while let Some(ev) = self.perfmap.read() { match ev { Event::Lost(lost) => { println!(“Possibly lost {} samples for {}”, 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] RedBPF möchte eine Balance finden: ausreichend Low-Level, um zu verstehen, was läuft, aber High-Level genug, um dem Nutzer die Fokussierung auf das Wesentliche zu ermöglichen.
ingraind
Mit der RedBPF-Bibliothek veröffentlichen wir auch ingraind – den Agent, den wir darum herum gebaut haben. Und weil wir Datenpipelines lieben (wer nicht?!), ist ingraind darauf ausgelegt, die rohen Metrikdaten der BPF-Probes weiterzuverarbeiten.
Die Grundlage dieser Pipeline ist die großartige actix-Bibliothek. Rohdaten der BPF-Probes werden von einem Event-Handler eingesammelt, in eine sichere Datenstruktur überführt, in die Actor-Eventschleife gegeben und wandern über eine Kette von „Aggregatoren“ zum „Backend“.
Alle diese Komponenten sind als Actor implementiert und ihre Verkettung kann in der Konfigurationsdatei beschrieben werden. Das folgende Beispiel verfolgt TLS-Handshakes über alle IPv4-Ports und Dateizugriffe überall auf dem Root-Dateisystem, whitelisted die Tags process, s_ip, d_port und path (was je nach Messung sinnvoll ist oder nicht), ersetzt von Docker‘s Userland Proxy generierte dynamische Prozessnamen durch einen einheitlichen. Ziel ist ein S3-Bucket, die AWS-Zugangsdaten werden via Kommandozeile übergeben.
Fazit
Wir sind überzeugt, dass die Architektur und die einzigartigen Features von RedBPF und ingraind sehr stark für Sicherheitsmonitoring sind. In Zukunft gibt es Optimierungspotential bei der Nutzung gemeinsamer Datenstrukturen zwischen BPF-Code und Rust sowie immer Möglichkeiten für weitere Performance-Verbesserungen.
Um das Monitoring containerisierter Umgebungen zu erleichtern, werden wir dem Agent bald Container-Awareness hinzufügen.
Wir freuen uns, der großartigen Rust-Community etwas zurückzugeben – und sind gespannt auf Feedback, Issues und Beiträge, damit daraus die Komplettlösung für Security Monitoring im Produktivbetrieb entsteht. Kontaktieren Sie uns gern unten! Viel Spaß beim Hacken!
[rust] use redbpf::build::{build, generate_bindings, cache::BuildCache, headers::headers};
[c] // Die Datenstruktur ist ursprünglich in `connection.h` definiert
// Der Buildschritt erzeugt `$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!(„Program: {}, {:?}“, 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]




