fix(ebpf): replace tracepoint with kretprobe for sys_exit_recvfrom

Fixes "permission denied" error when attaching tracepoint sys_exit_recvfrom
on Rocky Linux 9 (kernel 5.14+). The tracepoint exit has stricter permissions
than entry tracepoints.

Changes:
- BPF: SEC("tp/syscalls/sys_exit_recvfrom") → SEC("kretprobe/__x64_sys_recvfrom")
- BPF: Extract retval using PT_REGS_RC(ctx) instead of ctx->ret
- Loader: link.Tracepoint() → link.Kretprobe()
- Add nginxPidMap for filtering recvfrom calls by nginx PID

Validation:
- All HTTP fields captured without truncation (path up to 39 chars, query up to 244 chars)
- Custom headers (X-Request-ID, X-Custom-Header) fully captured
- Unit tests added and passing (TestKretprobeRecvfromAttachment, TestKretprobeVsTracepoint)
- ClickHouse validation complete: http_logs and http_logs_raw tables verified

Tested on:
- Rocky Linux 9 (kernel 5.14+)
- bpftool shows: kprobe name tp_sys_exit_recvfrom (kretprobe active)

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

View File

@ -8,6 +8,8 @@ import (
"fmt"
"log"
"os"
"strconv"
"strings"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
@ -35,6 +37,7 @@ type Loader struct {
statsMap *ebpf.Map // map tc_stats pour lecture des compteurs BPF (mode debug)
allowedPorts *ebpf.Map // map allowed_ports pour filtrage par port
ignoredSrc *ebpf.Map // map ignored_src (LPM_TRIE) pour filtrage IP/CIDR
nginxPidMap *ebpf.Map // map nginx_pid_map pour filtrage recvfrom par PID
// SynReader lit les événements TCP SYN depuis pb_tcp_syn.
SynReader *perf.Reader
@ -123,6 +126,30 @@ func (l *Loader) PopulateIgnoredSrc(cidrs []LPMKey) error {
return nil
}
// AddNginxPid ajoute un PID nginx à la map nginx_pid_map pour le filtrage recvfrom.
// Un PID nginx activé permettra la capture de ses appels recvfrom() via tracepoints.
func (l *Loader) AddNginxPid(pid uint32) error {
if l.nginxPidMap == nil {
return fmt.Errorf("map nginx_pid_map non disponible")
}
var val uint8 = 1
if err := l.nginxPidMap.Put(pid, val); err != nil {
return fmt.Errorf("ajout PID %d dans nginx_pid_map: %w", pid, err)
}
return nil
}
// RemoveNginxPid supprime un PID nginx de la map nginx_pid_map.
func (l *Loader) RemoveNginxPid(pid uint32) error {
if l.nginxPidMap == nil {
return fmt.Errorf("map nginx_pid_map non disponible")
}
if err := l.nginxPidMap.Delete(pid); err != nil {
return fmt.Errorf("suppression PID %d de nginx_pid_map: %w", pid, err)
}
return nil
}
// New charge le bytecode eBPF embarqué, supprime la limite mémoire
// RLIMIT_MEMLOCK (requise pour les maps eBPF),
// et retourne un Loader prêt à être attaché aux hooks.
@ -224,6 +251,7 @@ func New() (*Loader, error) {
statsMap: tcObjs.TcStats,
allowedPorts: tcObjs.AllowedPorts,
ignoredSrc: tcObjs.IgnoredSrc,
nginxPidMap: nginxObjs.NginxPidMap,
SynReader: synReader,
TLSReader: tlsReader,
SSLReader: sslReader,
@ -369,36 +397,86 @@ 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.
// AttachUprobesNginx configure les tracepoints recvfrom pour capturer
// le trafic HTTP complet depuis nginx. Cette approche utilise les tracepoints
// kernel sys_enter/exit_recvfrom.
// Le PID nginx est ajouté à la map nginx_pid_map pour filtrer les appels recvfrom().
func (l *Loader) AttachUprobesNginx(nginxBinPath string) error {
if _, err := os.Stat(nginxBinPath); err != nil {
return fmt.Errorf("binaire nginx %q: %w", nginxBinPath, err)
// Attacher les tracepoints recvfrom
kpEntry, err := link.Tracepoint("syscalls", "sys_enter_recvfrom",
l.nginxObjs.TpSysEnterRecvfrom, nil)
if err != nil {
return fmt.Errorf("attachement tracepoint sys_enter_recvfrom: %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, kpEntry)
// NOTE: Utilisation de Kretprobe pour sys_exit_recvfrom pour contourner
// le bug "permission denied" des tracepoints sur certains kernels (Rocky Linux 9, kernel 5.14+).
// Les kretprobes ciblent directement la fonction kernel __x64_sys_recvfrom.
kpExit, err := link.Kretprobe("__x64_sys_recvfrom",
l.nginxObjs.TpSysExitRecvfrom, &link.KprobeOptions{})
if err != nil {
return fmt.Errorf("attachement kretprobe sys_exit_recvfrom: %w", err)
}
l.uprobeLinks = append(l.uprobeLinks, kpExit)
// Trouver le PID nginx en cherchant dans /proc ou via pgrep
pids, err := findNginxPIDs()
if err != nil {
return fmt.Errorf("recherche PID nginx: %w", err)
}
if len(pids) == 0 {
return fmt.Errorf("aucun processus nginx trouvé")
}
ex, err := link.OpenExecutable(nginxBinPath)
if err != nil {
return fmt.Errorf("ouverture exécutable %q pour uprobe: %w", nginxBinPath, err)
// Ajouter tous les PIDs nginx trouvés à la map de filtrage
for _, pid := range pids {
if err := l.AddNginxPid(pid); err != nil {
log.Printf("[ja4ebpf] avertissement: ajout PID nginx %d: %v", pid, err)
} else {
log.Printf("[ja4ebpf] tracepoints recvfrom activés pour PID nginx %d", pid)
}
}
// 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
}
// findNginxPIDs trouve tous les PIDs des processus nginx en cours d'exécution.
func findNginxPIDs() ([]uint32, error) {
// Lire /proc pour trouver les processus nginx
entries, err := os.ReadDir("/proc")
if err != nil {
return nil, fmt.Errorf("lecture /proc: %w", err)
}
var pids []uint32
for _, entry := range entries {
// Vérifier que le nom est un nombre (PID)
if !entry.IsDir() {
continue
}
pid, err := strconv.ParseUint(entry.Name(), 10, 32)
if err != nil {
continue
}
// Vérifier si c'est un processus nginx en lisant /proc/[pid]/cmdline
cmdlinePath := fmt.Sprintf("/proc/%d/cmdline", pid)
cmdlineData, err := os.ReadFile(cmdlinePath)
if err != nil {
continue
}
// La cmdline contient le chemin du binaire, ex: "nginx: master process" ou "nginx: worker process"
cmdline := string(cmdlineData)
if strings.Contains(cmdline, "nginx") {
pids = append(pids, uint32(pid))
}
}
return pids, 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 {