Aujourd’hui, nous publions RedBPF et ingraind, notre boîte à outils eBPF qui s’intègre à StatsD et S3, afin de recueillir des retours et de voir où la communauté Rust pourrait mener ce framework. Que vous cherchiez à améliorer la surveillance de votre entreprise, à collecter plus de données sur votre cluster Raspberry Pi à la maison, ou que vous ayez tout simplement un intérêt académique fort pour Rust et la gestion bas niveau des bits, il se pourrait que la suite vous intéresse.RedBPF et ingraind, notre boîte à outils eBPF qui s’intègre à StatsD et S3, afin de recueillir des retours et de voir où la communauté Rust pourrait mener ce framework. Que vous cherchiez à améliorer la surveillance de votre entreprise, à collecter plus de données sur votre cluster Raspberry Pi à la maison, ou que vous ayez tout simplement un intérêt académique fort pour Rust et la gestion bas niveau des bits, il se pourrait que la suite vous intéresse.


Chez Red Sift, nous exploitons notre propre cluster Kubernetes et notre propre pipeline de traitement des données dessus. Nous traitons énormément de courriels et souhaitions mieux comprendre les schémas de communication de nos systèmes. Ce type de données nous aide à établir une base de référence et à détecter toute irrégularité susceptible d’indiquer un incident de sécurité ou une anomalie.
RedBPF est une bibliothèque Rust pour eBPF, une machine virtuelle flexible intégrée au noyau Linux. Grâce à RedBPF, nous avons construit ingraind, un agent de collecte de métriques que nous pouvons distribuer sous la forme d’un binaire unique et d’un fichier de configuration spécifique à chaque système.
Les solutions eBPF existantes se concentrent généralement sur la performance et le débogage, mais nous voulions voir si eBPF pouvait aussi nous aider à mieux comprendre notre posture de sécurité.
Nous sommes partis avec quatre objectifs principaux : suivre l’activité DNS et les détails de connexion TLS sans avoir à filtrer les ports, mesurer le volume de trafic UDP/TCP par processus, et observer les schémas d’accès aux fichiers.
Puisque nous utilisons déjà Datadog pour la surveillance des performances, il nous a semblé logique d’étendre nos tableaux de bord avec des métriques sécurité à l’aide d’un agent relativement léger qui s’intègre facilement à un backend StatsD.


eBPF
Relativement récent dans le noyau Linux, eBPF fait beaucoup parler de lui dans l'industrie, et à juste titre. Il se situe à cette étape particulière de la courbe du hype où il est à la fois suffisamment nouveau et mature pour être pris au sérieux.
eBPF est une machine virtuelle dans le noyau qui permet d’accrocher des interfaces réseaux, des appels systèmes, ou même des fonctions internes précises derrière la porte des syscalls. Au moment de l’écriture, il existe environ 200 appels qu’on peut surveiller via de petits programmes pour obtenir des informations approfondies du noyau Linux.
Grâce à sa flexibilité, les modules eBPF sont utilisés pour divers usages : filtrage de paquets, réseaux définis par logiciel, surveillance de performance ou extraction de diagnostics en temps réel pour mieux comprendre un système.
Cependant, l’espace et la capacité d’attention étant limités dans un article technique, pour une plongée technique dans eBPF, jetez un œil à la page wiki que nous venons d’ouvrir, ou à l’excellente synthèse de Cilium.
L’arrivée de Rust
La plupart de l’écosystème eBPF repose sur BCC, avec des bindings Python et Go pratiques. Mais nous voulions éviter l’obligation d’installer tout un ensemble d’outils de compilation et les sources du noyau sur la machine cible, ce qui éliminait le flux de travail traditionnel BCC, et donc ses bindings pratiques.
Cette contrainte ne nous laissait qu’une solution : GoBPF permet d’apporter son propre bytecode, et certains logiciels majeurs l’utilisent déjà.
Cependant, à cause du modèle de threading de Go, il ne peut pas égaler les performances de Rust lorsqu’il s’agit d’appels FFI en C. L’interaction avec les modules eBPF implique souvent de la gestion mémoire très bas niveau, ce point était donc capital. Comme nous souhaitions faire tourner ingraind en production, l’empreinte mémoire devait être minimale. Il nous a donc semblé judicieux de construire notre propre framework BPF en Rust.
RedBPF
Gérer des modules eBPF dans le noyau requiert de faire attention à de nombreux détails et il faut fouiller pas mal de code pour comprendre le fonctionnement. BCC reste la suite la plus complète sur le sujet. GoBPF et d’autres bibliothèques ont commencé à diverger, tout en gardant quelques similitudes, et en ajoutant des fonctionnalités qu’on ne retrouve nulle part ailleurs.
Je considère fermement que c’est un anti-pattern : comme il ne s’agit pas d’un fork pur, mais d’un portage partiel, intégrer les correctifs et nouvelles fonctionnalités de la base de code originale devient de plus en plus compliqué. Même si la règle #1 du développement du noyau Linux est “On ne casse pas l’espace utilisateur”, en pratique, cela signifie juste que les systèmes hérités sont conservés tandis que de nouvelles méthodes préférées sont ajoutées pour obtenir le même résultat.
Pour profiter du meilleur des deux mondes, nous avons opté pour une approche mixte : réutiliser tout ce que l’on peut du composant libbpflibbpf de BCC, une bibliothèque remarquable que l’on peut lier statiquement et qui s’occupe exclusivement de la gestion à l’exécution des modules eBPF, puis implémenter les bindings haut niveau, le parsing ELF et d’autres éléments de workflow en Rust.
Le résultat final est RedBPF, une bibliothèque axée sur la fourniture de bindings idiomatiques, peu contraignants pour libbpf, la consommation d’événements perf, et qui propose un workflow build-load-run pour eBPF, tout en restant très souple. Regardons cela en détail !
Construire des modules
Si la fonctionnalité build est activée, RedBPF propose une boîte à outils pour compiler du code C en modules eBPF, générer des bindings Rust à partir des headers C via Bindgen, et utiliser un cache de compilation pour accélérer le tout lorsqu’on passe par build.rs.
fn main() -> Result<(), Error> { let out_dir = PathBuf::from(env::var(« OUT_DIR »)?);
// générez ici les options build & bindgen. détail omis par souci de clarté.
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(« Echec compilation plugin BPF !»); } } for file in source_files(« ./bpf », « h »)? { if cache.file_changed(&file) { generate_bindings(&bindgen_flags[..], &out_dir, &file) .expect(« Échec de la génération des bindings !»); } }
cache.save();
Ok(())
}
[/rust]
La génération de bindings Rust pour les structures de données in-kernel dépend actuellement du fait que les structs aient une définition correspondant à l’expression régulière struct _data_[^{}]*, ce qui est assez rudimentaire mais suffisant jusqu’ici. Un avantage de cette approche est que toutes les structures envoyées à l’espace utilisateur sont clairement marquées dans le code C BPF à chaque utilisation.
struct _data_connect { u64 id; u64 ts; char comm[TASK_COMM_LEN]; u32 saddr; u32 daddr; u16 dport; u16 sport; }; [/c] Dans ingraind, nous lisons les données brutes depuis un flux d’événements perf, puis nous les convertissons en une structure haut niveau et sûre. Techniquement, ce processus reste un peu répétitif, mais quelques macros procédurales astucieuses pourraient l’optimiser davantage.
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] Bien qu’il existe plusieurs manières de distribuer le bytecode eBPF généré, nous avons choisi de l’inclure dans le binaire principal pour simplifier la distribution.
Il est important de noter ici que le binaire résultant peut dépendre du noyau. Bien que l’ABI du noyau soit instable, certaines parties changent rarement. Une autre considération est RANDSTRUCT, c’est-à-dire la randomisation de la disposition des structs. Cette mesure de sécurité modifie l’agencement des structs marquées dans le noyau pour compliquer l’exploitation. Les kernels de distribution ont souvent cette option désactivée, mais si vous compilez votre propre noyau, ne soyez pas surpris.
Chargement et maps
Sans la fonctionnalité build, RedBPF est limité à l’exécution. Il lit et analyse l’ELF contenant le bytecode eBPF, retourne la liste des composants de l’objet et fournit des bindings pour gérer les programmes, utiliser les maps BPF, consommer des événements perf, et quelques utilitaires liés pour ne pas ajouter de dépendances inutiles, comme uname ou l’analyse des IDs des CPU en ligne.
Une fois le module chargé, toutes les maps BPF sont initialisées automatiquement, il ne reste donc qu'à gérer les programmes.
Comme un programme BPF peut être de plusieurs types, il revient à l’utilisateur de les attacher correctement. Voici une façon d’attacher tous les filtres XDP. Notez le unwrap : il provoque délibérément un plantage du programme si le chargement échoue pour une quelconque raison.
self.bind_perf(backends) [/rust]
Événements perf
Pour transférer un gros volume de données de trace depuis le noyau, deux méthodes principales : faire l’agrégation dans des maps kernel puis les vider périodiquement vers l’espace utilisateur, ou utiliser les événements perf pour les streamer en temps réel via un buffer circulaire. Les perf events ne nécessitent pas de syscall, donc, si on souhaite par exemple agréger selon des propriétés arbitraires (ce que nous faisons), il est plus simple d’extraire le flux d’événements brut et de l’agréger côté utilisateur. Cela donne apparemment une haute utilisation CPU dans top, mais avec très peu d’instructions par cycle, ce qui veut dire que le programme attend surtout l’accès en mémoire, comme expliqué dans le blog de Brendan Gregg.
L’interface d’événements perf dans RedBPF a été conçue pour permettre l’écoute de toutes les sources d’événements via une seule boucle epoll. Cela nous permet, pour améliorer encore le débit, de lancer une boucle epoll par CPU si besoin.
output } [/rust]
L’exemple ci-dessus génère un objet PerfMap pour chaque CPU actif, car le noyau organise aussi les données par CPU. Au fait, PerfHandler se trouve dans ingraind, pas dans RedBPF, et c’est un wrapper simple autour de la lecture du buffer. La partie intéressante ressemble à ça :
while let Some(ev) = self.perfmap.read() { match ev { Event::Lost(lost) => { println!(« Possiblement perdu {} échantillons pour {} », 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] Dans l’ensemble, RedBPF cherche un équilibre : assez bas niveau pour comprendre ce qui se passe, mais assez haut niveau pour se faire oublier et permettre à l’utilisateur de se concentrer sur ses besoins.
ingraind
En plus de la bibliothèque RedBPF, nous publions aujourd’hui ingraind, l’agent construit autour. Et parce qu’on adore les pipelines de données (qui n’aime pas ?!), ingraind a été conçu autour du traitement des données métriques brutes provenant des sondes BPF.
L’ossature du pipeline repose sur l’excellente bibliothèque actix. Les données brutes issues des sondes BPF sont récupérées par un gestionnaire d’événements, transformées en structure sûre, puis passées dans la boucle d’événements « actor ». Elles parcourent une chaîne de ce que nous appelons des « aggrégateurs », et terminent dans un « backend ».
Tous sont implémentés comme des « acteurs » et leur enchaînement se définit dans le fichier de configuration. L’exemple suivant suit les handshakes TLS sur tous les ports IPv4, et l’accès aux fichiers sur tout le filesystem racine. Il autorise les tags process, s_ip, d_port et path, qui ne sont pas toujours pertinents selon la mesure, puis remplace les noms de processus dynamiques générés par le proxy userland de Docker par un nom uniforme. La destination de collecte est un bucket S3, et les identifiants AWS sont passés par la ligne de commande.
Conclusion
Nous pensons que l’architecture et les caractéristiques uniques de RedBPF et ingraind en font une combinaison puissante pour la surveillance de sécurité. À l’avenir, il y a largement de quoi améliorer l’ergonomie du partage de structures de données entre code BPF et Rust, sans parler d’optimisations possibles.
Pour simplifier la surveillance d’environnements containerisés, nous ajouterons bientôt la gestion native des conteneurs à l’agent.
Nous sommes ravis d’offrir quelque chose à la communauté Rust et d’entendre vos retours, bugs et contributions, afin de proposer une solution tout-en-un pour la sécurité en production. N’hésitez pas à nous contacter ci-dessous ! Bon hack !
[rust] use redbpf::build::{build, generate_bindings, cache::BuildCache, headers::headers};
[c] // la structure de données est initialement définie dans `connection.h`
// la phase de build génère `$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!(« Programme: {}, {:?} », 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]




