feat: add ja4ebpf service — eBPF-based TLS/TCP fingerprinting daemon

- TC ingress hook captures TCP SYN (L3/L4) and TLS ClientHello
- Uprobes on SSL_read/SSL_set_fd capture decrypted TLS data
- Kprobes on accept4 correlate socket FDs to client IP:port
- JA4 fingerprint computed from parsed TLS ClientHello
- HTTP/2 SETTINGS and WINDOW_UPDATE extracted from decrypted streams
- Session manager with sharded map (256 shards) and GC goroutine
- Slowloris detection: sessions with no requests after 10s threshold
- ClickHouse batch writer to ja4_logs.http_logs_raw (raw_json)
- All tests pass: 17 parser + 10 correlation tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-11 22:43:26 +02:00
parent 7eb3ad21fd
commit a1e4c1dad5
24 changed files with 3984 additions and 0 deletions

View File

@ -0,0 +1,497 @@
// Package loader initialise les programmes eBPF via cilium/ebpf,
// attache les hooks TC ingress et les uprobes SSL, et expose
// les readers RingBuffer aux consommateurs Go.
package loader
import (
"context"
"encoding/binary"
"fmt"
"net"
"os"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
)
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -target amd64 -cflags "-O2 -g -Wall -Werror -D__TARGET_ARCH_x86" Ja4eBPF ../../bpf/tc_capture.c ../../bpf/uprobe_ssl.c -- -I../../bpf/headers
// Loader encapsule les objets eBPF compilés, les liens vers les hooks,
// et les readers RingBuffer exposés au pipeline de traitement.
type Loader struct {
objs *Ja4eBPFObjects // généré par bpf2go
tcLink link.Link
uprobeLinks []link.Link
// SynReader lit les événements TCP SYN depuis rb_tcp_syn.
SynReader *ringbuf.Reader
// TLSReader lit les événements TLS ClientHello depuis rb_tls_hello.
TLSReader *ringbuf.Reader
// SSLReader lit les données SSL déchiffrées depuis rb_ssl_data.
SSLReader *ringbuf.Reader
// AcceptReader lit les événements accept4 depuis rb_accept.
AcceptReader *ringbuf.Reader
// HTTPPlainReader lit les payloads HTTP en clair depuis rb_http_plain.
HTTPPlainReader *ringbuf.Reader
}
// New charge le bytecode eBPF embarqué, supprime la limite mémoire
// RLIMIT_MEMLOCK (requise pour les ring buffers et les maps eBPF),
// et retourne un Loader prêt à être attaché aux hooks.
//
// Cible : CentOS 8 / RHEL 8 et supérieur (kernel ≥ 4.18 avec BTF backporté).
// Le BTF natif est détecté automatiquement par cilium/ebpf via
// /sys/kernel/btf/vmlinux — aucun fallback manuel n'est requis.
func New() (*Loader, error) {
// Supprimer la limite mémoire pour les opérations eBPF
if err := rlimit.RemoveMemlock(); err != nil {
return nil, fmt.Errorf("suppression RLIMIT_MEMLOCK: %w", err)
}
objs := &Ja4eBPFObjects{}
// Charger le bytecode eBPF compilé (embarqué par bpf2go).
// nil = cilium/ebpf résout le BTF kernel natif automatiquement.
if err := LoadJa4eBPFObjects(objs, nil); err != nil {
return nil, fmt.Errorf("chargement objets eBPF: %w", err)
}
// Initialiser les readers pour chaque ring buffer
synReader, err := ringbuf.NewReader(objs.RbTcpSyn)
if err != nil {
objs.Close()
return nil, fmt.Errorf("création reader rb_tcp_syn: %w", err)
}
tlsReader, err := ringbuf.NewReader(objs.RbTlsHello)
if err != nil {
synReader.Close()
objs.Close()
return nil, fmt.Errorf("création reader rb_tls_hello: %w", err)
}
sslReader, err := ringbuf.NewReader(objs.RbSslData)
if err != nil {
tlsReader.Close()
synReader.Close()
objs.Close()
return nil, fmt.Errorf("création reader rb_ssl_data: %w", err)
}
acceptReader, err := ringbuf.NewReader(objs.RbAccept)
if err != nil {
sslReader.Close()
tlsReader.Close()
synReader.Close()
objs.Close()
return nil, fmt.Errorf("création reader rb_accept: %w", err)
}
httpPlainReader, err := ringbuf.NewReader(objs.RbHttpPlain)
if err != nil {
acceptReader.Close()
sslReader.Close()
tlsReader.Close()
synReader.Close()
objs.Close()
return nil, fmt.Errorf("création reader rb_http_plain: %w", err)
}
return &Loader{
objs: objs,
SynReader: synReader,
TLSReader: tlsReader,
SSLReader: sslReader,
AcceptReader: acceptReader,
HTTPPlainReader: httpPlainReader,
}, nil
}
// AttachTC attache le programme TC ingress sur l'interface réseau spécifiée.
// Utilise TCX (TC eXpress) disponible depuis le noyau 6.6+.
func (l *Loader) AttachTC(iface string) error {
// Résoudre l'interface réseau par son nom
netIface, err := net.InterfaceByName(iface)
if err != nil {
return fmt.Errorf("interface réseau %q introuvable: %w", iface, err)
}
// Attacher le programme TC en ingress via TCX
lnk, err := link.AttachTCX(link.TCXOptions{
Interface: netIface.Index,
Program: l.objs.CaptureTcIngress,
Attach: ebpf.AttachTCXIngress,
})
if err != nil {
return fmt.Errorf("attachement TC ingress sur %q: %w", iface, err)
}
l.tcLink = lnk
return nil
}
// AttachUprobes attache les uprobes SSL_read et SSL_set_fd
// sur le binaire libssl spécifié (ex: "/usr/lib64/libssl.so.3").
func (l *Loader) AttachUprobes(sslLibPath string) error {
// Vérifier que le fichier existe
if _, err := os.Stat(sslLibPath); err != nil {
return fmt.Errorf("bibliothèque SSL %q: %w", sslLibPath, err)
}
// Ouvrir le binaire exécutable pour les uprobes
ex, err := link.OpenExecutable(sslLibPath)
if err != nil {
return fmt.Errorf("ouverture exécutable %q pour uprobe: %w", sslLibPath, err)
}
// Uprobe sur SSL_set_fd (entry)
setFdLink, err := ex.Uprobe("SSL_set_fd", l.objs.UprobeSSLSetFd, nil)
if err != nil {
return fmt.Errorf("attachement uprobe SSL_set_fd: %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, setFdLink)
// Uprobe sur SSL_read (entry)
readEntryLink, err := ex.Uprobe("SSL_read", l.objs.UprobeSSLReadEntry, nil)
if err != nil {
return fmt.Errorf("attachement uprobe SSL_read (entry): %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, readEntryLink)
// Uretprobe sur SSL_read (exit)
readExitLink, err := ex.Uretprobe("SSL_read", l.objs.UretprobeSSLReadExit, nil)
if err != nil {
return fmt.Errorf("attachement uretprobe SSL_read (exit): %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, readExitLink)
return nil
}
// AttachAcceptProbe attache les kprobes sur l'appel système accept4.
func (l *Loader) AttachAcceptProbe() error {
// Kprobe à l'entrée d'accept4
kpEntry, err := link.Kprobe("accept4", l.objs.KprobeAccept4Entry, nil)
if err != nil {
return fmt.Errorf("attachement kprobe accept4 (entry): %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, kpEntry)
// Kretprobe à la sortie d'accept4
kpExit, err := link.Kretprobe("accept4", l.objs.KretprobeAccept4Exit, nil)
if err != nil {
return fmt.Errorf("attachement kretprobe accept4 (exit): %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, kpExit)
return nil
}
// Close détache tous les hooks eBPF et libère toutes les ressources associées.
func (l *Loader) Close() error {
// Fermer les readers RingBuffer
if l.HTTPPlainReader != nil {
l.HTTPPlainReader.Close()
}
if l.AcceptReader != nil {
l.AcceptReader.Close()
}
if l.SSLReader != nil {
l.SSLReader.Close()
}
if l.TLSReader != nil {
l.TLSReader.Close()
}
if l.SynReader != nil {
l.SynReader.Close()
}
// Détacher les uprobes et kprobes
for _, lnk := range l.uprobeLinks {
if lnk != nil {
lnk.Close()
}
}
// Détacher le hook TC
if l.tcLink != nil {
l.tcLink.Close()
}
// Libérer les objets eBPF (maps, programmes)
if l.objs != nil {
l.objs.Close()
}
return nil
}
// =============================================================================
// Types d'événements : représentations Go des structures C eBPF
// =============================================================================
// TCPSynEvent représente un événement TCP SYN capturé par TC ingress.
type TCPSynEvent struct {
SrcIP uint32
DstIP uint32
SrcPort uint16
DstPort uint16
TTL uint8
DFBit uint8
IPID uint16
WindowSize uint16
WindowScale uint8
MSS uint16
TCPOptions [40]byte
TCPOptionsLen uint8
Timestamp uint64
}
// TLSHelloEvent représente un événement TLS ClientHello.
type TLSHelloEvent struct {
SrcIP uint32
SrcPort uint16
Payload []byte
PayloadLen uint16
Timestamp uint64
}
// SSLDataEvent représente un bloc de données SSL déchiffré par uprobe.
type SSLDataEvent struct {
PID uint32
TGID uint32
FD uint32
SrcIP uint32
SrcPort uint16
Data []byte
DataLen uint32
Timestamp uint64
Direction uint8
EOF bool
}
// HTTPPlainEvent représente un payload TCP HTTP en clair capturé par TC ingress.
type HTTPPlainEvent struct {
SrcIP uint32
DstIP uint32
SrcPort uint16
DstPort uint16
Payload []byte
PayloadLen uint16
Timestamp uint64
}
// AcceptEvent représente une acceptation de connexion TCP (accept4).
type AcceptEvent struct {
PID uint32
TGID uint32
FD uint32
SrcIP uint32
SrcPort uint16
Timestamp uint64
}
// =============================================================================
// Méthodes de lecture des RingBuffers
// =============================================================================
// ReadTCPSynEvent lit un événement TCP SYN depuis le RingBuffer.
// Bloque jusqu'à ce qu'un événement soit disponible ou que ctx soit annulé.
func (l *Loader) ReadTCPSynEvent(ctx context.Context) (*TCPSynEvent, error) {
rec, err := readRecord(ctx, l.SynReader)
if err != nil {
return nil, err
}
data := rec.RawSample
// struct tcp_syn_event packed: src_ip(4)+dst_ip(4)+src_port(2)+dst_port(2)+
// ttl(1)+df(1)+ip_id(2)+window(2)+wscale(1)+mss(2)+opts(40)+opts_len(1)+_pad(1)+ts(8) = 71
if len(data) < 64 {
return nil, fmt.Errorf("tcp_syn_event trop court: %d octets", len(data))
}
ev := &TCPSynEvent{
SrcIP: binary.LittleEndian.Uint32(data[0:4]),
DstIP: binary.LittleEndian.Uint32(data[4:8]),
SrcPort: binary.LittleEndian.Uint16(data[8:10]),
DstPort: binary.LittleEndian.Uint16(data[10:12]),
TTL: data[12],
DFBit: data[13],
IPID: binary.LittleEndian.Uint16(data[14:16]),
WindowSize: binary.LittleEndian.Uint16(data[16:18]),
WindowScale: data[18],
MSS: binary.LittleEndian.Uint16(data[19:21]),
}
copy(ev.TCPOptions[:], data[21:61])
ev.TCPOptionsLen = data[61]
if len(data) >= 70 {
ev.Timestamp = binary.LittleEndian.Uint64(data[62:70])
}
return ev, nil
}
// ReadTLSHelloEvent lit un événement TLS ClientHello depuis le RingBuffer.
func (l *Loader) ReadTLSHelloEvent(ctx context.Context) (*TLSHelloEvent, error) {
rec, err := readRecord(ctx, l.TLSReader)
if err != nil {
return nil, err
}
data := rec.RawSample
// struct tls_hello_event: src_ip(4)+src_port(2)+payload(512)+payload_len(2)+ts(8) = 528
if len(data) < 8 {
return nil, fmt.Errorf("tls_hello_event trop court: %d octets", len(data))
}
plen := uint16(0)
if len(data) >= 520 {
plen = binary.LittleEndian.Uint16(data[518:520])
}
payload := make([]byte, plen)
if int(plen) <= 512 && len(data) >= 6+int(plen) {
copy(payload, data[6:6+plen])
}
ts := uint64(0)
if len(data) >= 528 {
ts = binary.LittleEndian.Uint64(data[520:528])
}
return &TLSHelloEvent{
SrcIP: binary.LittleEndian.Uint32(data[0:4]),
SrcPort: binary.LittleEndian.Uint16(data[4:6]),
Payload: payload,
PayloadLen: plen,
Timestamp: ts,
}, nil
}
// ReadSSLDataEvent lit un bloc de données SSL déchiffrées depuis le RingBuffer.
func (l *Loader) ReadSSLDataEvent(ctx context.Context) (*SSLDataEvent, error) {
rec, err := readRecord(ctx, l.SSLReader)
if err != nil {
return nil, err
}
data := rec.RawSample
// struct ssl_data_event: pid_tgid(8)+fd(4)+src_ip(4)+src_port(2)+data(4096)+data_len(4)+ts(8)+direction(1)
if len(data) < 27 {
return nil, fmt.Errorf("ssl_data_event trop court: %d octets", len(data))
}
pidTGID := binary.LittleEndian.Uint64(data[0:8])
dlen := uint32(0)
if len(data) >= 4118 {
dlen = binary.LittleEndian.Uint32(data[4114:4118])
}
payload := make([]byte, dlen)
if int(dlen) <= 4096 && len(data) >= 18+int(dlen) {
copy(payload, data[18:18+dlen])
}
ts := uint64(0)
if len(data) >= 4126 {
ts = binary.LittleEndian.Uint64(data[4118:4126])
}
dir := uint8(0)
if len(data) >= 4127 {
dir = data[4126]
}
return &SSLDataEvent{
PID: uint32(pidTGID & 0xFFFFFFFF),
TGID: uint32(pidTGID >> 32),
FD: binary.LittleEndian.Uint32(data[8:12]),
SrcIP: binary.LittleEndian.Uint32(data[12:16]),
SrcPort: binary.LittleEndian.Uint16(data[16:18]),
Data: payload,
DataLen: dlen,
Timestamp: ts,
Direction: dir,
}, nil
}
// ReadHTTPPlainEvent lit un événement HTTP en clair depuis le RingBuffer TC.
// struct http_plain_event: src_ip(4)+dst_ip(4)+src_port(2)+dst_port(2)+
//
// payload(4096)+payload_len(2)+ts(8) = 4118
func (l *Loader) ReadHTTPPlainEvent(ctx context.Context) (*HTTPPlainEvent, error) {
rec, err := readRecord(ctx, l.HTTPPlainReader)
if err != nil {
return nil, err
}
data := rec.RawSample
if len(data) < 12 {
return nil, fmt.Errorf("http_plain_event trop court: %d octets", len(data))
}
plen := uint16(0)
if len(data) >= 4110 {
plen = binary.LittleEndian.Uint16(data[4108:4110])
}
payload := make([]byte, plen)
if int(plen) <= 4096 && len(data) >= 12+int(plen) {
copy(payload, data[12:12+plen])
}
ts := uint64(0)
if len(data) >= 4118 {
ts = binary.LittleEndian.Uint64(data[4110:4118])
}
return &HTTPPlainEvent{
SrcIP: binary.LittleEndian.Uint32(data[0:4]),
DstIP: binary.LittleEndian.Uint32(data[4:8]),
SrcPort: binary.LittleEndian.Uint16(data[8:10]),
DstPort: binary.LittleEndian.Uint16(data[10:12]),
Payload: payload,
PayloadLen: plen,
Timestamp: ts,
}, nil
}
// ReadAcceptEvent lit un événement accept4 depuis le RingBuffer.
func (l *Loader) ReadAcceptEvent(ctx context.Context) (*AcceptEvent, error) {
rec, err := readRecord(ctx, l.AcceptReader)
if err != nil {
return nil, err
}
data := rec.RawSample
// struct accept_event: pid_tgid(8)+fd(4)+src_ip(4)+src_port(2)+ts(8) = 26
if len(data) < 26 {
return nil, fmt.Errorf("accept_event trop court: %d octets", len(data))
}
pidTGID := binary.LittleEndian.Uint64(data[0:8])
return &AcceptEvent{
PID: uint32(pidTGID & 0xFFFFFFFF),
TGID: uint32(pidTGID >> 32),
FD: binary.LittleEndian.Uint32(data[8:12]),
SrcIP: binary.LittleEndian.Uint32(data[12:16]),
SrcPort: binary.LittleEndian.Uint16(data[16:18]),
Timestamp: binary.LittleEndian.Uint64(data[18:26]),
}, nil
}
// readRecord lit un record brut depuis un RingBuffer avec annulation via context.
func readRecord(ctx context.Context, rd *ringbuf.Reader) (ringbuf.Record, error) {
type result struct {
rec ringbuf.Record
err error
}
ch := make(chan result, 1)
go func() {
rec, err := rd.Read()
ch <- result{rec, err}
}()
select {
case <-ctx.Done():
rd.Close() // débloque le Read() bloquant
return ringbuf.Record{}, ctx.Err()
case r := <-ch:
return r.rec, r.err
}
}