# ja4ebpf — Agent eBPF CO-RE ## Description `ja4ebpf` est l'agent de collecte de données de la plateforme ja4-platform. C'est un binaire Go unique qui utilise eBPF CO-RE (Compile Once — Run Everywhere) pour observer passivement le trafic réseau d'un serveur Linux, sans modifier ni interrompre le serveur web cible (Apache, Nginx, Varnish, HAProxy, ou tout processus utilisant OpenSSL/BoringSSL). Il capture simultanément les métadonnées réseau L3/L4 (TCP SYN), les paramètres TLS L5 (ClientHello), et le contenu applicatif L7 (requêtes HTTP déchiffrées), corrèle le tout en mémoire par la clé `src_ip:src_port`, et envoie les données vers ClickHouse en batch. ## Architecture interne ``` +-----------------+ TC ingress +--------------------+ | réseau entrant |---------------------------->| bpf/tc_capture.c | | | | | | SYN packet | --> pb_tcp_syn (perf) | Programme eBPF | | TLS ClientHello| --> pb_tls_hello (perf) | CO-RE | | HTTP port 80 | --> pb_http_plain (perf) | | +-----------------+ +--------------------+ | +-----------------+ uprobe SSL_read/SSL_write +--------------------+ | serveur web |---------------------------> | bpf/uprobe_ssl.c | | (OpenSSL) | | | | flux déchiffré | --> pb_ssl_data (perf) | Programme eBPF | | accept4 events | --> pb_accept (perf) | CO-RE | +-----------------+ +--------------------+ | +-----------v-----------+ | Go userspace | | | | internal/loader/ | | - PerfEvent readers | | - 5 goroutines | | | | internal/parser/ | | - JA4/JA3 calculator | | - H2ConnState (HPACK) | | - HTTP/1.1 parser | | | | internal/dispatcher/ | | - Magic Bytes router | | | | internal/correlation/ | | - 256-shard manager | | - AcceptCache | | - GC 100ms | | - timeout 500ms | | | | internal/writer/ | | - ClickHouse batch | +----------+-------------+ | INSERT batch TCP :9000 | v +----------------------+ | ja4_logs. | | http_logs_raw | +----------------------+ ``` ## Hooks eBPF ### TC ingress — Couches L3/L4/L5 Le programme `bpf/tc_capture.c` est attaché aux interfaces réseau via **TC (Traffic Control)** en ingress (clsact qdisc). Par défaut, il s'attache sur **toutes les interfaces UP** (sauf loopback). Il s'exécute pour chaque paquet entrant : **Filtrage kernel-side** : - **Ports autorisés** : la map `allowed_ports` (HASH) filtre par port destination/source. Seuls les ports configurés (défaut : 80, 443) sont traités. - **IP/CIDR ignorés** : la map `ignored_src` (LPM_TRIE) ignore le trafic provenant des CIDR configurés. Le lookup utilise le format `{prefixlen, data[4]}` en network byte order. **Paquets TCP SYN** : extraction des options TCP et métadonnées IP depuis les headers du paquet. - `bpf_skb_load_bytes()` pour lire les options TCP depuis le skb - Émis via `pb_tcp_syn` (PerfEventArray) **ClientHello TLS** : détection du type 0x16 (Handshake) et sous-type 0x01 (ClientHello). - `bpf_skb_load_bytes()` avec tailles en cascade (1024 → 512 → 256) pour capturer SNI et extensions - Le struct `tls_hello_event` place le payload à l'offset 0 (compatible kernel 4.18) - Émis via `pb_tls_hello` (PerfEventArray) **HTTP en clair (port 80/8080)** : pour les connexions non chiffrées. - SYN/FIN/RST exclus (uniquement les segments porteurs de données) - `bpf_skb_load_bytes()` avec tailles en cascade (512 → 256 → 128 → 64) - Le struct `http_plain_event` place le payload à l'offset 0 - Émis via `pb_http_plain` (PerfEventArray) ### Uprobes SSL_read/SSL_write — Couche L7 Les uprobes s'attachent dynamiquement aux fonctions OpenSSL dans `libssl.so` : | Hook | Type | Rôle | |------|------|------| | `SSL_set_fd` | uprobe | Associe `SSL*` → `fd`, peuple `ssl_conn_map` | | `SSL_read` | uprobe + uretprobe | Capture les requêtes du client (direction=0) | | `SSL_write` | uprobe + uretprobe | Capture les réponses du serveur (direction=1) | ### Tracepoints/Kretprobe recvfrom (Nginx HTTP en clair) Les hooks `sys_enter_recvfrom` / `sys_exit_recvfrom` capturent les appels système `recvfrom()` du serveur Nginx pour capturer le trafic HTTP en clair complet : | Hook | Type | État | Rôle | |------|------|------|------| | `tp_syscalls_sys_enter_recvfrom` | tracepoint | ✅ Fonctionnel | Sauvegarde les arguments recvfrom (sockfd, buf_ptr, len) | | `tp_sys_exit_recvfrom` | kretprobe | ✅ Fonctionnel | Capture les données lues + émet vers pb_ginx_http | **Note** : Le kretprobe sur `__x64_sys_recvfrom` remplace le tracepoint `sys_exit_recvfrom` qui échouait avec "permission denied" sur Rocky Linux 9+. **Filtrage par PID nginx** : La map `nginx_pid_map` ne permet que les processus nginx identifiés via `/proc//cmdline`. **Corrélation `fd → src_ip:src_port`** (3 niveaux de priorité) : 1. `ssl_conn_map[ssl_ptr]` — si `SSL_set_fd` a été appelé et que `fd_conn_map[fd]` contient l'IP (via accept4) 2. `accept_map[{pid_tgid, fd}]` — cache accept4 (tracepoint kernel) 3. Fallback `/proc//net/tcp` — lecture depuis l'espace utilisateur (moins fiable) Les données sont émises via `pb_ssl_data` (PerfEventArray) avec un flag `direction` (0=client→serveur, 1=serveur→client). ### Tracepoints accept4 Les tracepoints `sys_enter_accept4` / `sys_exit_accept4` (plus stables que les kprobes) capturent le tuple `(src_ip, src_port, fd, pid_tgid)` pour chaque nouvelle connexion acceptée. Ils peuplent : - `accept_map[{pid_tgid, fd}]` — pour corrélation SSL côté BPF - `fd_conn_map[fd]` — pour SSL_set_fd - `pb_accept` (PerfEventArray) — pour corrélation SSL côté Go (AcceptCache) ### Maps eBPF résumé | Map | Type | Rôle | |-----|------|------| | `allowed_ports` | HASH (key=u16, val=u8) | Ports TCP autorisés (peuplée depuis Go) | | `ignored_src` | LPM_TRIE (key={prefixlen, data[4]}, val=u8) | CIDR/IP sources à ignorer (peuplée depuis Go) | | `tc_stats` | PERCPU_ARRAY (7 compteurs) | Statistiques de debug BPF | | `ssl_conn_map` | HASH (key=ssl_ptr, val=ssl_conn_info) | Association SSL* → fd + IP | | `fd_conn_map` | HASH (key=fd, val=ssl_conn_info) | Association fd → IP (depuis accept4) | | `accept_map` | HASH (key={pid_tgid,fd}, val=accept_event) | Cache accept4 côté BPF | | `ssl_args_map` | HASH (key=pid_tgid, val=ssl_read_args) | Sauvegarde arguments SSL_read/Write entry | | `__tls_buf` | PERCPU_ARRAY (1 entrée) | Buffer temp > 512o (stack eBPF limit) | | `__http_buf` | PERCPU_ARRAY (1 entrée) | Buffer temp HTTP plain | | `__ssl_buf` | PERCPU_ARRAY (1 entrée) | Buffer temp SSL data | ## Corrélation in-memory Le gestionnaire `internal/correlation` maintient un état par connexion : | Mécanisme | Valeur | |-----------|--------| | Sharding | 256 buckets (hash XOR src_ip ^ src_port) | | Timeout orphelin | 500 ms (→ session exportée vers ClickHouse) | | Détection Slowloris | 10 s (export partiel) | | GC interval | 100 ms | | Keep-Alive | `len(Requests)` incrémenté par requête | ### AcceptCache Le `AcceptCache` map `{tgid, fd} → SessionKey + dstIP + dstPort` avec TTL 10s. Il est peuplé par les événements `accept4` et consulté par le consommateur SSL quand `ssl_conn_map` a `src_ip=0`. Purge automatique toutes les 30s. ### FDCache Le `FDCache` résout `fd → IP:port` via `/proc//net/tcp` (fallback quand accept4 n'est pas disponible). TTL 5s. Utilise `parseHexIPv4` / `parseHexIPv6` pour décoder le format little-endian du kernel. ### Filtrage ignore_src (userspace) En plus du filtrage BPF (LPM_TRIE côté kernel), les 5 goroutines de consommation appliquent un filtrage userspace via `isIgnoredIP()` + `net.IPNet.Contains()`. Cela couvre le chemin SSL (SSL_read/SSL_write/accept4) où le BPF LPM_TRIE ne peut pas filtrer (l'IP vient de `ssl_conn_map` ou `/proc`, pas du paquet réseau). ### Routeur Magic Bytes (dispatcher) ``` Buffer reçu (SSL data ou HTTP plain) | +-- starts with "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" --> ProtoHTTP2 | +-- starts with "GET " / "POST " / "PUT " / ... --> ProtoHTTP1 | +-- partial H2 preface prefix --> ProtoHTTP2 (buffer) | +-- autre --> ProtoUnknown (ignoré) ``` ### Parsing HTTP/2 — H2ConnState Le `H2ConnState` (package `internal/parser`) maintient un décodeur HPACK par-connexion avec table dynamique. Il traite les frames via `golang.org/x/net/http2.Framer` : | Frame | Action | |-------|--------| | SETTINGS | Extraction des 7 paramètres (+ paramètre 0x7 JA4H2). Mise à jour de la taille de table HPACK. | | WINDOW_UPDATE (stream 0) | Capture de l'incrément de fenêtre connexion | | HEADERS + CONTINUATION | Assemblage des fragments, décodage HPACK, extraction pseudo-headers ordre | | DATA | Comptage des octets par stream | | PRIORITY | Capture des paramètres de priorité (StreamDep, Exclusive, Weight) | | RST_STREAM | Transition du stream vers "closed" | | GOAWAY | Capture LastStreamID + ErrCode | | PING | Détection ACK | **Fingerprinting Akamai** : - `h2_fingerprint` : `SETTINGS[pairs]|WINDOW_UPDATE[value]|PRIORITY[0/1]|PSEUDO_ORDER[order]` - `h2_settings_fp` : liste brute des paramètres SETTINGS - `h2_pseudo_order` : notation abrégée (ex: `m,a,s,p` pour `:method, :authority, :scheme, :path`) ## Données collectées ### L3/L4 (TCP SYN) | Champ | Description | |-------|-------------| | `src_ip`, `src_port` | Clé de corrélation | | `dst_ip`, `dst_port` | Destination IP et port | | `ttl` | Time To Live initial | | `df_bit` | Don't Fragment bit | | `ip_id` | IP Identification (0 = Linux/VPN/spoofé) | | `ip_total_length` | Longueur totale IP (octets) | | `window_size` | Taille fenêtre TCP SYN | | `window_scale` | Option Window Scale (RFC 1323), 0xFF = absent | | `mss` | Maximum Segment Size, 0 = absent | | `tcp_options` | Options TCP brutes (40 octets max), noms abrégés (MSS, WS, SACK, TS, NOP) | ### L5 (TLS ClientHello) | Champ | Description | |-------|-------------| | `tls_version` | Version TLS la plus haute annoncée (extrait des SupportedVersions) | | `cipher_suites` | Liste suites cryptographiques (hex séparées par tirets) | | `extensions` | Liste IDs extensions TLS (hex séparés par tirets) | | `supported_groups` | Groupes Diffie-Hellman supportés | | `ec_point_formats` | Formats de points elliptiques | | `alpn` | ALPN list (h2, http/1.1, ...) | | `sni` | Server Name Indication | | `ja4` | Empreinte JA4 (FoxIO) | | `ja3_raw` | Empreinte JA3 brute (version,ciphers,exts,groups,ecfmts) | | `ja3_hash` | JA3 hash MD5 | ### L7 HTTP/1.1 | Champ | Description | |-------|-------------| | `method` | Méthode HTTP | | `path` | Chemin (sans query string) | | `query_string` | Paramètres query (sans le '?') | | `host` | En-tête Host ou TLS SNI | | `http_version` | HTTP/1.0 ou HTTP/1.1 | | `headers_raw` | Noms d'en-têtes joints par ";" | | `header_order_signature` | Signature de l'ordre | | `status_code` | Code de statut de la réponse | | `response_size` | Taille réponse (octets) | | `duration_ms` | Durée requête | | `client_headers` | JSON des en-têtes capturés | | `header_User-Agent`, etc. | 16 en-têtes individuels capturés | ### L7 HTTP/2 (preface client) | Champ | Description | |-------|-------------| | `h2_header_table_size` | SETTINGS ID 1 (-1 si absent du preface) | | `h2_enable_push` | SETTINGS ID 2 | | `h2_max_concurrent_streams` | SETTINGS ID 3 | | `h2_initial_window_size` | SETTINGS ID 4 | | `h2_max_frame_size` | SETTINGS ID 5 | | `h2_max_header_list_size` | SETTINGS ID 6 | | `h2_enable_connect_protocol` | SETTINGS ID 8 (RFC 8441) | | `h2_window_update` | Incrément WINDOW_UPDATE connexion | | `h2_has_priority` | 1 si une frame PRIORITY a été reçue | | `h2_settings_ack` | 1 si SETTINGS ACK reçu | | `h2_pseudo_order` | Ordre pseudo-headers (ex: `m,a,s,p`) | | `h2_fingerprint` | Fingerprint composite Akamai | | `h2_settings_fp` | Chaîne brute des SETTINGS | ## eBPF CO-RE | Aspect | Détail | |--------|--------| | Compilateur | `clang` + `llvm` via `bpf2go` | | Target | `bpf` (amd64, kernel 4.18+) | | BTF source | `/sys/kernel/btf/vmlinux` (disponible RHEL 8+) | | Relocations | `cilium/ebpf` résout automatiquement les offsets struct | | Embed | `go:generate` génère `ja4tc_x86_bpfel.go` + `ja4ssl_x86_bpfel.go` | | Compatibilité | Rocky/RHEL Linux 8, 9, 10 (kernel 4.18+) | | IPC | `BPF_MAP_TYPE_PERF_EVENT_ARRAY` (kernel 4.4+) | | Lecture paquets | `bpf_skb_load_bytes()` (kernel 4.5+) avec tailles constantes en cascade | **Contrainte kernel 4.18** : le vérificateur BPF rejette les tailles variables vers map values. Toutes les copies de payload utilisent des tailles constantes (1024 → 512 → 256 pour TLS, 512 → 256 → 128 → 64 pour HTTP). Les structs > 512o utilisent un `PERCPU_ARRAY` temporaire (limite stack eBPF). ## Configuration ```yaml # /etc/ja4ebpf/config.yml # Interfaces réseau à surveiller (TC ingress). # "any" = toutes les interfaces UP (sauf loopback). # Ou liste explicite : ["eth0", "eth1"] interfaces: - any # Chemin vers libssl pour les uprobes SSL_read/SSL_write/SSL_set_fd ssl_lib_path: "/usr/lib64/libssl.so.3" # Ports TCP à surveiller (filtrage BPF côté kernel) listen_ports: - 80 - 443 # CIDR/IP sources à ignorer (filtrage BPF LPM_TRIE + filtrage userspace SSL) # ignore_src: # - 10.0.0.0/8 # - 172.16.0.0/12 # - 192.168.0.0/16 # - 127.0.0.1 # Mode debug debug: false clickhouse: dsn: "clickhouse://default:@127.0.0.1:9000/ja4_logs?async_insert=0" batch_size: 500 flush_secs: 1 correlation: timeout_ms: 500 slowloris_ms: 10000 log: level: "info" format: "json" ``` ### Variables d'environnement | Variable | Défaut | Description | |----------|--------|-------------| | `JA4EBPF_CONFIG` | `/etc/ja4ebpf/config.yml` | Chemin du fichier config | | `JA4EBPF_INTERFACES` | `any` | Interfaces (séparées par virgule) | | `JA4EBPF_INTERFACE` | — | Rétrocompatibilité : une seule interface | | `JA4EBPF_SSL_LIB_PATH` | `/usr/lib64/libssl.so.3` | Chemin libssl | | `JA4EBPF_CLICKHOUSE_DSN` | voir config | DSN ClickHouse | | `JA4EBPF_DEBUG` | `false` | Mode debug (`true`, `1`, `yes`) | | `JA4EBPF_LISTEN_PORTS` | `80,443` | Ports surveillés (séparés par virgule) | | `JA4EBPF_IGNORE_SRC` | — | CIDR ignorés (séparés par virgule) | ## Build ```bash # Compilation eBPF → Go (nécessite clang sur la machine cible) go generate ./internal/loader/ # Build du binaire go build ./cmd/ja4ebpf/ # Build complet (bytecode eBPF + binaire Go) — Docker Rocky Linux make build # Tests unitaires go test ./... # Build RPMs make rpm-ja4ebpf ``` ## Structure du code ``` services/ja4ebpf/ ├── bpf/ │ ├── bpf_types.h # Structs C partagées + déclarations maps PerfEventArray │ ├── headers/vmlinux.h # Types kernel BTF (auto-généré) │ ├── tc_capture.c # Programme TC ingress (L3/L4/L5 + HTTP plain) │ └── uprobe_ssl.c # Programme uprobes SSL + tracepoints accept4 ├── cmd/ja4ebpf/ │ ├── main.go # Point d'entrée : 5 goroutines consumer + config │ └── main_test.go # Tests parseCIDRs, parseIgnoreNets, isIgnoredIP, parseTCPOptions ├── internal/ │ ├── loader/ │ │ ├── loader.go # Chargement eBPF + PerfEvent readers + attachement TC/uprobes │ │ ├── ja4tc_x86_bpfel.go # Bytecode TC embarqué (généré par bpf2go) │ │ └── ja4ssl_x86_bpfel.go# Bytecode SSL embarqué (généré par bpf2go) │ ├── parser/ │ │ ├── tls.go # ParseClientHello + ComputeJA4 + ComputeJA3 │ │ ├── http1.go # Parser HTTP/1.1 (requêtes + réponses) │ │ ├── http2.go # Constantes HTTP/2 + filtres en-têtes capturés │ │ ├── h2conn.go # H2ConnState : framer + HPACK + fingerprinting │ │ ├── tls_test.go # Tests JA4, JA3, SNI, extensions │ │ ├── http1_test.go # Tests HTTP/1.1 │ │ └── http2_test.go # Tests H2ConnState complet │ ├── dispatcher/ │ │ ├── dispatcher.go # Routeur Magic Bytes (ProtoHTTP1/2/Unknown) │ │ └── dispatcher_test.go # Tests Classify │ ├── correlation/ │ │ ├── session.go # Structs L3L4, TLSInfo, HTTPRequest, SessionState │ │ ├── manager.go # Gestionnaire sessions 256-shard + GC │ │ ├── accept_cache.go # AcceptCache {tgid,fd} → SessionKey (TTL 10s) │ │ ├── correlation_test.go# Tests Manager + SessionState │ │ └── accept_cache_test.go# Tests AcceptCache │ ├── procutil/ │ │ ├── proc_lookup.go # FDCache : résolution fd → IP via /proc/net/tcp │ │ └── proc_lookup_test.go# Tests parseHexIPv4/IPv6, isIPv4MappedIPv6 │ └── writer/ │ ├── clickhouse.go # Writer ClickHouse (batch + JSON → http_logs_raw) │ └── clickhouse_test.go # Tests formatTCPOptions, H2 fingerprints, etc. ├── packaging/ │ ├── rpm/ja4ebpf.spec # Spec RPM (el8/el9/el10) │ └── systemd/ja4ebpf.service # Unit systemd ├── config.yml.example # Exemple de configuration ├── Dockerfile # Image de production ├── Dockerfile.tests # Image de tests ├── Dockerfile.package # Build RPM multi-distro └── Makefile ``` ## Problèmes connus ### ✅ HTTP Nginx via recvfrom — RÉSOLU (2026-04-20) **Solution implémentée** : Remplacement du tracepoint `sys_exit_recvfrom` par un kretprobe sur `__x64_sys_recvfrom`. **Détails** : Le tracepoint exit échouait avec "permission denied" sur Rocky Linux 9+. Le kretprobe contourne cette limitation en s'attachant directement à la fonction kernel. **Validation** : - ✅ Toutes les données HTTP capturées sans troncature (path jusqu'à 39 chars, query jusqu'à 244 chars) - ✅ Headers custom (X-Request-ID, X-Custom-Header) complets - ✅ Tests unitaires Go ajoutés et validés - ✅ Rapport de validation : `services/ja4ebpf/docs/CLICKHOUSE_VALIDATION_REPORT.md` ### Maps eBPF résumé | Map | Type | Rôle | |-----|------|------| | `allowed_ports` | HASH (key=u16, val=u8) | Ports TCP autorisés (peuplée depuis Go) | | `ignored_src` | LPM_TRIE (key={prefixlen, data[4]}, val=u8) | CIDR/IP sources à ignorer (peuplée depuis Go) | | `tc_stats` | PERCPU_ARRAY (7 compteurs) | Statistiques de debug BPF | | `ssl_conn_map` | HASH (key=ssl_ptr, val=ssl_conn_info) | Association SSL* → fd + IP | | `fd_conn_map` | HASH (key=fd, val=ssl_conn_info) | Association fd → IP (depuis accept4) | | `accept_map` | HASH (key={pid_tgid,fd}, val=accept_event) | Cache accept4 côté BPF | | `ssl_args_map` | HASH (key=pid_tgid, val=ssl_read_args) | Sauvegarde arguments SSL_read/Write entry | | `nginx_pid_map` | HASH (key=u32, val=u8) | Filtrage recvfrom par PID nginx | | `nginx_read_args_map` | HASH (key=pid_tgid, val=nginx_read_args) | Sauvegarde arguments recvfrom entry | | `__tls_buf` | PERCPU_ARRAY (1 entrée) | Buffer temp > 512o (stack eBPF limit) | | `__http_buf` | PERCPU_ARRAY (1 entrée) | Buffer temp HTTP plain | | `__ssl_buf` | PERCPU_ARRAY (1 entrée) | Buffer temp SSL data | | `__nginx_buf` | PERCPU_ARRAY (1 entrée) | Buffer temp nginx HTTP | L'agent tourne sous l'utilisateur `ja4ebpf` (UID/GID 490 fixe). Les capabilities Linux accordées via `AmbientCapabilities` : | Capability | Raison | |------------|--------| | `CAP_BPF` | Chargement des programmes eBPF (kernel 5.8+) | | `CAP_SYS_ADMIN` | Uprobes + RHEL 8 (kernel 4.18, pré-CAP_BPF) | | `CAP_NET_ADMIN` | Attachement hooks TC ingress | | `CAP_NET_RAW` | Accès raw socket (fallback) | | `CAP_PERFMON` | Accès perf events pour les uprobes | | `CAP_SYS_PTRACE` | Résolution des offsets de fonctions pour les uprobes | | `CAP_DAC_READ_SEARCH` | Lecture `/proc//maps` pour localiser libssl.so | `LimitMEMLOCK=infinity` est requis pour le `mlock()` des maps eBPF.