Environnement : Docker (image champgoblem/iovisor-bcc:latest-src5), Arch Linux.

Le but de ce TP est d’explorer les capacités d’eBPF (extended Berkeley Packet Filter) pour monitorer un système Linux en temps réel sans modifier le code du noyau. Nous nous concentrons sur trois types d’événements : la création de répertoires, les flux réseau TCP et l’exécution de processus.

Mise en place de l’environnement

Pour éviter de polluer l’hôte et disposer de tous les outils de compilation (bcc-tools), j’ai utilisé un conteneur Docker privilégié. Cette configuration est nécessaire car eBPF doit charger du bytecode directement dans le noyau.

docker run --rm -it --privileged \
  -v /usr/lib/modules:/lib/modules:ro \
  -v /usr/src:/usr/src:ro \
  -v /etc/localtime:/etc/localtime:ro \
  -v /sys/kernel/tracing:/sys/kernel/tracing \
  -v /sys/kernel/debug:/sys/kernel/debug \
  -v "$PWD":/work \
  --pid=host --net=host \
  champgoblem/iovisor-bcc:latest-src5

TIP

Sur Arch, j’ai dû préalablement récupérer les headers : pacman -S linux-headers.

Travaux pratiques

Surveillance des appels système (mkdir)

Sur ce kernel, le tracepoint sys_enter_mkdir n’exposait pas le pathname de façon exploitable avec BCC. On a donc utilisé un kprobe sur do_mkdirat et lu la chaîne en mémoire noyau (struct filename), ce qui fonctionne de manière stable.

from bcc import BPF
 
BPF_SOURCE_CODE = r"""
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
 
struct data_t {
    u32 pid;
    char comm[TASK_COMM_LEN];
    char pathname[256];
};
 
struct filename {
    const char *name;
};
 
BPF_PERF_OUTPUT(events);
 
int kprobe__do_mkdirat(struct pt_regs *ctx, int dfd, struct filename *name, umode_t mode) {
    const char *fname = NULL;
    struct data_t data = {};
    data.pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(&data.comm, sizeof(data.comm));
    bpf_probe_read_kernel(&fname, sizeof(fname), &name->name);
    bpf_probe_read_kernel_str(&data.pathname, sizeof(data.pathname), fname);
    events.perf_submit(ctx, &data, sizeof(data));
    return 0;
}
"""
 
bpf = BPF(text=BPF_SOURCE_CODE)
 
def print_event(cpu, data, size):
    event = bpf["events"].event(data)
    print(
        f"{event.pid} {event.comm.decode(errors='replace')} mkdir {event.pathname.decode(errors='replace')}",
        flush=True,
    )
 
bpf["events"].open_perf_buffer(print_event)
 
print("To test, create a directory (e.g. mkdir /work/frodo_ebpf)", flush=True)
print("CTRL-C to exit", flush=True)
 
while True:
    try:
        bpf.perf_buffer_poll()
    except KeyboardInterrupt:
        break

Résultat du test : Extrait de trace (création du dossier frodo_ebpf) :

502290 mkdir mkdir /work
502290 mkdir mkdir frodo_ebpf

Analyse des connexions TCP

Les outils BCC tcpconnect / tcpstates n’ont pas compilé sur ce kernel. Pour garder une preuve exploitable, on a utilisé le tracepoint sock:inet_sock_set_state (script local tcp_state_trace.py) et capturé les transitions d’état TCP.

Exemple de capture lors d’une connexion locale :

TCP_ESTABLISHED 127.0.0.1:43219 -> 127.0.0.1:16415
TCP_ESTABLISHED 127.0.0.1:16415 -> 127.0.0.1:43219
TCP_CLOSE 127.0.0.1:43219 -> 127.0.0.1:16415
TCP_CLOSE 127.0.0.1:16415 -> 127.0.0.1:43219

TIP

Un petit serveur web local a été lancé pour déclencher la connexion.

Supervision de l’exécution (execve)

L’observation des nouveaux processus est cruciale pour la sécurité. Ici, la trace a été prise via un tracepoint sys_enter_execve (script local exec_tracepoint.py).

Trace collectée :

509091 exec: /usr/bin/ls

(Action déclenchée par un ls dans le shell.)

Analyse théorique et sécurité

Comparaison : eBPF vs auditd (approche noyau classique)

D’après mes recherches sur la comparaison des mécanismes de tracking, voici les différences majeures :

CaractéristiqueAuditd (standard)eBPF (moderne)
PerformancePeut ralentir le système si trop de règles.Très performant (JIT compilation).
FlexibilitéLimité aux événements prévus.Programmable (on peut filtrer finement en C).
ComplexitéSimple à configurer.Demande des connaissances en développement noyau.
IntégritéLogs plus difficiles à altérer par l’user.Très discret, difficile à détecter pour un malware.

Modèle de menace et limites

L’utilisation d’eBPF s’inscrit dans une stratégie de détection proactive.

  1. Surface couverte : visibilité sur les syscalls, le réseau (événements TCP bas niveau) et les manipulations de fichiers.

  2. Surface non couverte :

    • Données chiffrées : eBPF voit passer les paquets, mais si TLS est utilisé, le contenu reste illisible sans instrumentation spécifique (e.g. interception des bibliothèques SSL).
    • Attaques rootkit : si un attaquant obtient des privilèges kernel avant le chargement de nos sondes, il peut bloquer ou tromper eBPF.
  3. La « Rule of 2 » : en lien avec les concepts de Google, eBPF permet de respecter cette règle en traitant des données potentiellement malveillantes (inputs noyau) dans une sandbox sécurisée (la VM eBPF) plutôt que de modifier directement le code critique du kernel.

Conclusion

Ce TP montre qu’eBPF est un outil puissant pour la sécurité moderne. Sa capacité à observer sans perturber est un atout majeur par rapport aux solutions classiques comme auditd. Cependant, la complexité de mise en œuvre et la nécessité d’un noyau récent restent des freins à son déploiement systématique.


Annexe

NOTE

Piège classique : les images BCC anciennes ne compilent pas sur des kernels récents (erreurs clang/headers). Utiliser une image à jour et monter /usr/lib/modules + /usr/src.

WARNING

Si /sys/kernel/tracing ou /sys/kernel/debug n’est pas monté dans le conteneur, BCC ne peut pas attacher les probes (trace_pipe / kprobe_events introuvables).

TIP

Certains outils BCC (tcpconnect, tcpstates) peuvent ne pas compiler selon la version du kernel.

NOTE

mkdir passe souvent par mkdirat côté libc. Le tracepoint sys_enter_mkdir n’est pas toujours suffisant, et il faut lire les chaînes userspace avec bpf_probe_read_*.

1) mkdir (kprobe do_mkdirat)

docker run --rm --privileged \
  -v /usr/lib/modules:/lib/modules:ro \
  -v /usr/src:/usr/src:ro \
  -v /etc/localtime:/etc/localtime:ro \
  -v /sys/kernel/tracing:/sys/kernel/tracing \
  -v /sys/kernel/debug:/sys/kernel/debug \
  -v "$PWD":/work \
  --pid=host --net=host \
  champgoblem/iovisor-bcc:latest-src5 \
  bash -lc "python3 /work/mkdir_kprobe.py & pid=\$!; sleep 1; rm -rf /work/frodo_ebpf; mkdir -p /work/frodo_ebpf; sleep 2; kill \$pid"

2) TCP (tracepoint sock:inet_sock_set_state)

python3 -m http.server 8000 >/tmp/http_server.log 2>&1 & srv=$!
cid=$(docker run -d --privileged \
  -v /usr/lib/modules:/lib/modules:ro \
  -v /usr/src:/usr/src:ro \
  -v /etc/localtime:/etc/localtime:ro \
  -v /sys/kernel/tracing:/sys/kernel/tracing \
  -v /sys/kernel/debug:/sys/kernel/debug \
  -v "$PWD":/work \
  --pid=host --net=host \
  champgoblem/iovisor-bcc:latest-src5 \
  bash -lc "python3 /work/tcp_state_trace.py")
sleep 1
python3 - <<'PY'
import socket
s = socket.socket()
s.connect(("127.0.0.1", 8000))
s.close()
PY
sleep 2
docker logs "$cid"
docker stop "$cid" >/dev/null
kill "$srv"