The Problem with Userspace Capture
Traditional packet capture stacks — libpcap, DPDK, even PF_RING — impose architectural tradeoffs that make them unsuitable for sustained, high-throughput monitoring at Tor exit relays. Libpcap copies every packet from kernel to userspace. DPDK bypasses the kernel entirely, sacrificing the operating system's networking stack, security primitives, and management tooling. PF_RING offers a middle ground but requires proprietary kernel modules.
None of these approaches were designed for what we needed: real-time, zero-drop ingestion of every packet traversing a Tor exit relay at 10Gbps line rate, running on commodity hardware, without taking the NIC away from the kernel.
Enter XDP: eXpress Data Path
XDP (eXpress Data Path) allows eBPF programs to execute at the earliest possible point in the Linux networking stack — directly in the NIC driver, before the kernel even allocates a socket buffer (sk_buff). This means:
- No memory allocation overhead — packets are processed in-place in the driver's DMA ring buffer
- No context switches — the eBPF program runs in softirq context
- No copies — the program sees the raw frame and can make forwarding decisions immediately
Our XDP program performs three operations in under 200 nanoseconds per packet:
SEC("xdp")
int nodesight_capture(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct flow_key key;
if (extract_flow_key(&key, data, data_end) < 0)
return XDP_PASS;
if (!is_tor_candidate(&key, data, data_end))
return XDP_PASS;
bpf_ringbuf_output(&events, &key, sizeof(key), 0);
return XDP_PASS;
}
AF_XDP: Zero-Copy Userspace Delivery
For flows that require deep analysis, we use AF_XDP sockets — a mechanism that redirects packets from the NIC's DMA ring buffer directly into a userspace memory region, bypassing the entire kernel networking stack while still allowing the kernel to manage the NIC.
The key innovation is the UMEM shared memory region: both the kernel and our userspace application see the same memory. No copies. The XDP program redirects selected packets to an AF_XDP socket, and our Rust-based analysis engine picks them up from the completion queue.
Performance benchmarks (Intel X710, single core):
BPF CO-RE: Deploy Once, Run Everywhere
Our XDP programs are compiled using BPF CO-RE (Compile Once — Run Everywhere), which means a single binary runs across kernel versions 5.10 through 6.8 without recompilation. This is critical for enterprise deployment where sensor hosts may run different kernel versions.
The libbpf loader handles BTF (BPF Type Format) relocations at load time, mapping our program's field accesses to the running kernel's actual struct layouts. This eliminates the need for per-kernel-version builds and reduces our deployment surface from hundreds of binaries to one.
What This Means for Detection
By operating at the XDP layer, our sensors see every packet — including those that would be dropped by iptables, nftables, or connection tracking. This is particularly important for Tor exit monitoring, where we need visibility into:
- Connection setup and teardown (SYN/FIN patterns)
- Payload entropy measurements (detecting encrypted exfiltration)
- Inter-arrival timing signatures (used for circuit correlation)
- Fragmented packets (often used to evade IDS)
The result is a capture pipeline that delivers full-fidelity packet metadata to our inference engine in under a microsecond, at line rate, with zero drops.