feat(ebpf): add nginx uprobes skeleton for HTTP L7 capture

Add initial implementation of nginx uprobes to capture complete HTTP
headers at application layer. This addresses the limitation of TC-based
capture which truncates headers spanning multiple packets.

Changes:
- Add uprobe_nginx.c with read() syscall interception
- Add nginx_read_args map for uretprobe correlation
- Add AttachUprobesNginx() method with retry support
- Config via uprobes.enabled in YAML or JA4EBPF_UPROBES_ENABLED env var

Current status:
-  HTTPS (TLS) capture works perfectly - complete headers via SSL_read
-  HTTP plain nginx uprobes don't fire - nginx uses recv() not read()
- ⚠️  HTTP plain TC capture truncates headers (fundamental limitation)

Note: The nginx uprobes approach has limitations:
1. nginx uses recv()/recvmsg() syscalls, not read()
2. PLT attachment to glibc recv() doesn't trigger properly
3. Consider kprobes on sys_recvfrom or packet reassembly for future

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-04-20 01:29:53 +02:00
parent b6735b3081
commit 9e4bfe8289
3 changed files with 221 additions and 0 deletions

View File

@ -19,6 +19,7 @@ import (
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -target amd64 -cflags "-O2 -g -Wall -D__TARGET_ARCH_x86 -Wno-pass-failed" Ja4Tc ../../bpf/tc_capture.c -- -I../../bpf/headers
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -target amd64 -cflags "-O2 -g -Wall -D__TARGET_ARCH_x86 -Wno-pass-failed" Ja4Ssl ../../bpf/uprobe_ssl.c -- -I../../bpf/headers
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -target amd64 -cflags "-O2 -g -Wall -D__TARGET_ARCH_x86 -Wno-pass-failed" Ja4Nginx ../../bpf/uprobe_nginx.c -- -I../../bpf/headers
// perCPUBufferSize est la taille du buffer perf per-CPU en octets (256 KB).
const perCPUBufferSize = 256 * 1024
@ -28,6 +29,7 @@ const perCPUBufferSize = 256 * 1024
type Loader struct {
tcObjs *Ja4TcObjects // généré par bpf2go (tc_capture.c)
sslObjs *Ja4SslObjects // généré par bpf2go (uprobe_ssl.c)
nginxObjs *Ja4NginxObjects // généré par bpf2go (uprobe_nginx.c)
tcLinks []netlink.Link // interfaces netlink pour cleanup TC
uprobeLinks []link.Link
statsMap *ebpf.Map // map tc_stats pour lecture des compteurs BPF (mode debug)
@ -44,6 +46,8 @@ type Loader struct {
AcceptReader *perf.Reader
// HTTPPlainReader lit les payloads HTTP en clair depuis pb_http_plain.
HTTPPlainReader *perf.Reader
// NginxHTTPReader lit les requêtes HTTP complètes depuis nginx via uprobes.
NginxHTTPReader *perf.Reader
}
// StatNames associe chaque index de compteur BPF à un nom lisible.
@ -147,6 +151,14 @@ func New() (*Loader, error) {
return nil, fmt.Errorf("chargement objets SSL eBPF: %w", err)
}
// Charger les objets nginx/uprobe (uprobe_nginx.c)
nginxObjs := &Ja4NginxObjects{}
if err := LoadJa4NginxObjects(nginxObjs, nil); err != nil {
sslObjs.Close()
tcObjs.Close()
return nil, fmt.Errorf("chargement objets nginx eBPF: %w", err)
}
// Initialiser les readers pour chaque perf event array
synReader, err := perf.NewReader(tcObjs.PbTcpSyn, perCPUBufferSize)
if err != nil {
@ -193,9 +205,22 @@ func New() (*Loader, error) {
return nil, fmt.Errorf("création reader pb_accept: %w", err)
}
nginxHTTPReader, err := perf.NewReader(nginxObjs.PbGinxHttp, perCPUBufferSize)
if err != nil {
nginxHTTPReader.Close()
acceptReader.Close()
sslReader.Close()
httpPlainReader.Close()
tlsReader.Close()
synReader.Close()
sslObjs.Close()
return nil, fmt.Errorf("création reader pb_ginx_http: %w", err)
}
return &Loader{
tcObjs: tcObjs,
sslObjs: sslObjs,
nginxObjs: nginxObjs,
statsMap: tcObjs.TcStats,
allowedPorts: tcObjs.AllowedPorts,
ignoredSrc: tcObjs.IgnoredSrc,
@ -204,6 +229,7 @@ func New() (*Loader, error) {
SSLReader: sslReader,
AcceptReader: acceptReader,
HTTPPlainReader: httpPlainReader,
NginxHTTPReader: nginxHTTPReader,
}, nil
}
@ -343,6 +369,36 @@ func (l *Loader) AttachAcceptProbe() error {
return nil
}
// AttachUprobesNginx attache les uprobes read() dans nginx pour capturer
// le trafic HTTP complet. Cette approche utilise read() syscall qui est
// appelé par nginx pour lire les requêtes depuis les clients.
func (l *Loader) AttachUprobesNginx(nginxBinPath string) error {
if _, err := os.Stat(nginxBinPath); err != nil {
return fmt.Errorf("binaire nginx %q: %w", nginxBinPath, err)
}
ex, err := link.OpenExecutable(nginxBinPath)
if err != nil {
return fmt.Errorf("ouverture exécutable %q pour uprobe: %w", nginxBinPath, err)
}
// Attacher uprobe sur read() (entrée)
readEntryLink, err := ex.Uprobe("read", l.nginxObjs.UprobeReadEntry, nil)
if err != nil {
return fmt.Errorf("attachement uprobe read (entry): %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, readEntryLink)
// Attacher uretprobe sur read() (sortie) pour capturer les données lues
readExitLink, err := ex.Uretprobe("read", l.nginxObjs.UretprobeReadExit, nil)
if err != nil {
return fmt.Errorf("attachement uretprobe read (exit): %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, readExitLink)
return nil
}
// attachSSLWrite attache les uprobes SSL_write pour capturer
// les réponses HTTP du serveur (direction=1).
func (l *Loader) attachSSLWrite(ex *link.Executable) error {
@ -378,6 +434,9 @@ func (l *Loader) Close() error {
if l.SynReader != nil {
l.SynReader.Close()
}
if l.NginxHTTPReader != nil {
l.NginxHTTPReader.Close()
}
// Détacher les filtres TC ingress sur toutes les interfaces
for _, nlLink := range l.tcLinks {