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
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:
| hook | where | can it |
|---|---|---|
| XDP | NIC driver, before an skb exists | observe / drop / redirect at line rate (DDoS scrubbing); ingress only |
| tc (clsact) | after skb, ingress and egress | read + rewrite packets; needed for anything on the way out |
| socket / sockops | at the socket layer | tune connections, steer SO_REUSEPORT, sample data |
| kprobe / tracepoint | arbitrary kernel functions | observability + tracing (not the packet path) |
LRU_HASH keyed by flow is the classic "kernel writes, userland reads" patternThe 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.
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.
bpf_skb_change_tail resizes it; then re-validate every pointer, rewrite doff + IP length, drop in the target OS template, recompute both checksumsCHECKSUM_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.
| member | layer | forge difficulty |
|---|---|---|
| JA4H | HTTP | trivial - it's request text |
| JA4 (TLS) | library | moderate - needs uTLS to shape the ClientHello |
| JA4T (TCP) | kernel | hard - needs the eBPF egress rewriter above |
| JA4L (latency) | physics | can't - eBPF can rewrite TTL, not the speed of light |
eBPF is used well beyond networking. The same model, verified programs on hook points sharing maps with userland, applies across the kernel:
bpftrace one-liners over kprobes/tracepoints; the "print any kernel event" superpowerSSL_read/SSL_write read plaintext at the library boundary - observability without a MITM proxy