PROTOCOLS-FUN . eBPF

eBPF in the Network Stack

Using eBPF programs on the kernel packet path to read and rewrite the TCP-layer JA4+ fingerprints.

JA4 and JA4H come from data your process can see: the TLS ClientHello and the HTTP request. The transport fingerprints (JA4T, JA4L) cannot be read that way. By the time accept() returns a socket, the kernel has completed the handshake and discarded the client's SYN, and the SYN's window size, MSS and TCP option order are the JA4T fingerprint. No syscall returns them. They only exist on the kernel's packet path, which is where eBPF runs.

 wire ─▶ [NIC] ─▶ XDP ─▶ tc clsact ─▶ netfilter ─▶ TCP handshake ─▶ [socket recv-q] ─▶ accept()
          driver   ingress   in + egress      nft          (kernel)          buffered           your app
          └────────────── the SYN is visible HERE ──────────────┘                               │
                                                        kernel completes + discards it ──────────┘
                                                        so by accept() the JA4T signal is GONE
eBPF is the clever way, not the standard way. A passive sensor on a tap (Zeek, Suricata) already sees the raw SYN; a TLS-terminating edge (a CDN) already holds the ClientHello. Both compute JA4 without eBPF. eBPF is what lets a normal endpoint recover the transport fingerprint the kernel discarded - see the fingerprinting tab of Attack & Mitigation.

An eBPF program is a small function attached to a kernel hook. Before it loads, the verifier proves it terminates and only touches memory it's allowed to - so it can't panic the kernel. It shares state with userland through maps (hash, array, LRU, ring buffer). Different hooks see the packet at different stages:

hookwherecan it
XDPNIC driver, before an skb existsobserve / drop / redirect at line rate (DDoS scrubbing); ingress only
tc (clsact)after skb, ingress and egressread + rewrite packets; needed for anything on the way out
socket / sockopsat the socket layertune connections, steer SO_REUSEPORT, sample data
kprobe / tracepointarbitrary kernel functionsobservability + tracing (not the packet path)
the verifierstatic-analyzes the program (bounded loops, no wild pointers) before load - the reason eBPF is safe to run in production kernels
mapsthe userland bridge; an LRU_HASH keyed by flow is the classic "kernel writes, userland reads" pattern
bpf2gocodegen that keeps the C struct and the Go struct byte-identical, so map layouts never drift
$ bpftool prog loadall ja4t.bpf.o /sys/fs/bpf/ja4t type xdp # load + verify $ bpftool net attach xdpgeneric pinned /sys/fs/bpf/ja4t/xdp_ja4t dev eth0 $ bpftool map dump name ja4t_map # captured SYN fingerprints

The sensor attaches at tc/XDP and inspects every SYN and SYN-ACK before the kernel digests them: it parses the TCP options in wire order into JA4T, timestamps the packet with bpf_ktime_get_ns and reads the TTL for JA4L, then writes the result into a map keyed by the client (ip, port). Userland looks that map up by conn.RemoteAddr() when the request arrives - and there's no race, because the SYN always precedes the handshake.

a JA4T fingerprint
64240_2-4-8-1-3_1460_8
64240TCP window size from the SYN
2-4-8-1-3option kinds in wire order (2 MSS, 4 SACK-OK, 8 timestamps, 1 NOP, 3 window-scale) - the most OS-discriminating part
1460MSS value
8window scale shift
observe, never drop. The sensor returns XDP_PASS / passes the skb - it only reads, so it's safe to attach in front of a live listener. The same program timestamps the SYN-ACK's egress to derive one-way latency (JA4L), which is why it lives at tc (both directions), not XDP (ingress only).

JA4T is often assumed to be unforgeable, because the kernel builds SYN options from system-wide sysctls and there is no per-connection setting for option order. eBPF can forge it by rewriting the packet on the tc egress path without changing the TCP stack.

Phase A (values)rewrite window / MSS / window-scale in place, recompute the TCP checksum. A SYN has no payload, so the sum covers only the ≤60-byte header - small and verifier-friendly
Phase B (order)reordering options changes the length + data-offset. Since the SYN's options are its tail, bpf_skb_change_tail resizes it; then re-validate every pointer, rewrite doff + IP length, drop in the target OS template, recompute both checksums
so the kernel TCP fingerprint isn't unspoofable ground truth - it just costs an eBPF egress program. Real-NIC catch: locally-generated segments hit the egress hook as CHECKSUM_PARTIAL (the NIC finishes the sum via TX offload), so on a physical interface you disable offload first: ethtool -K eth0 tx off, or the hardware clobbers the checksum you computed.

A JA4+ toolkit that fingerprints who connects from raw wire data, using eBPF for the transport members an endpoint cannot otherwise read. Fingerprints get harder to forge lower in the stack, as control moves app → library → kernel.

memberlayerforge difficulty
JA4HHTTPtrivial - it's request text
JA4 (TLS)librarymoderate - needs uTLS to shape the ClientHello
JA4T (TCP)kernelhard - needs the eBPF egress rewriter above
JA4L (latency)physicscan't - eBPF can rewrite TTL, not the speed of light
ja4-loko - our JA4+ research toolkit does exactly this: an eBPF tc sensor for JA4T/JA4L off the wire, a raw-ClientHello peek for JA4, and an eBPF egress rewriter that forges JA4T (option order and all, tcpdump-verified). It's the reference implementation of everything on this page. github.com/hed0rah/ja4-loko.
the real defense is coherence. Any one layer is forgeable, so the bar is faking every layer at once, live - and the one signal no field can lie about is timing (JA4L). Same "obscurity is a layer, not the control" lesson as address scanning and fingerprint spoofing everywhere else in this family.

eBPF is used well beyond networking. The same model, verified programs on hook points sharing maps with userland, applies across the kernel:

observabilitybpftrace one-liners over kprobes/tracepoints; the "print any kernel event" superpower
networkingCilium (k8s CNI), XDP load-balancing and line-rate DDoS drop before the stack ever sees the flood
securityFalco, LSM hooks, seccomp-style syscall filtering - policy enforced in-kernel
TLS visibilityuprobes on SSL_read/SSL_write read plaintext at the library boundary - observability without a MITM proxy
same lineage. Classic BPF - the capture-filter language on the Capture & Filter page - is the ancestor: a tiny in-kernel program that matches packets. eBPF is its grown-up descendant: from "match packets" to "run verified programs almost anywhere in the kernel."