
Use eBPF to Trace VPN Traffic Across Encryption
The hook you pick decides whether you see anything at all. To monitor VPN connections with eBPF, attach a TC (traffic control) program to the tunnel interface (wg0, tun0) for the plaintext flows, add a probe on the physical NIC or a kprobe on the kernel's UDP send path for the encrypted side, then correlate the two by socket or process ID. Prove each hook fires with a bpftrace one-liner first, and only then build a BCC or libbpf CO-RE program around it. Attach to the wrong layer and your monitor reports near-zero while gigabytes move.
Last updated: 2026-07-27
Why VPN traffic needs kernel-level visibility
A userspace sniffer like tcpdump sees the wire. For a VPN, the wire is the problem. On the physical interface you get one thing: a stream of encrypted UDP or ESP packets between two endpoints. The flows inside the tunnel are ciphertext by the time they leave the box.
The cleartext exists too, just at a different spot. It lives on the virtual tunnel interface (tun0, wg0) before the kernel encrypts it, or after it decrypts inbound. So the kernel handles the same connection in both forms: plaintext on the tunnel device, ciphertext on the physical NIC. A tool that watches only one of those is blind to the other half.
That split is why eBPF earns its place here. Because your program runs inside the kernel, you can attach one probe on the tunnel device and another on the egress path, then correlate them by socket or by process. No packet copy to userspace, no context switch per packet, and you see both sides of the encryption boundary at once. External capture cannot do that at any price.
How does eBPF actually see VPN traffic?

It sees whatever the hook you picked sees, and each hook sits at a different depth in the stack. Get the depth wrong and you either read ciphertext when you wanted plaintext, or you miss the packet entirely because something dropped it earlier. So pick the hook by the layer your answer lives at.
Here is where the common hooks sit, from the wire inward:
| Hook | Where it runs | What it sees for a VPN |
|---|---|---|
| XDP | NIC driver, before the stack | Raw encrypted frames on the physical link, earliest possible point |
| TC (traffic control) | after XDP, both ingress and egress | Full skb on physical or tunnel device, plaintext if attached to tun0/wg0 |
| kprobe | any kernel function | Whatever that function touches, e.g. plaintext in udp_sendmsg on the tunnel path |
| socket filter / cgroup-BPF | per-socket or per-cgroup | One application's traffic, scoped to a process or container |
The mistake I see most is reaching for XDP because a blog called it the fastest hook. XDP runs before the kernel has decrypted anything. On a VPN box it hands you encrypted bytes and packet sizes and nothing about the flows inside. That works for counting tunnel bandwidth, and it is useless for seeing which internal service is talking.
Attach to the tunnel device with TC when you want the cleartext, and to the physical NIC when you want the ciphertext accounting. Same tool, opposite answers, depending on where you clip it in.
WireGuard and eBPF at the kernel level
WireGuard lives in the kernel, which changes where you attach. Its first public release was December 9th, 2016, and it later merged into the mainline kernel. There is no userspace daemon shuffling packets on a normal WireGuard setup, so the plaintext-to-ciphertext handoff happens entirely inside kernel code on the UDP tunnel path.
That is different from OpenVPN. OpenVPN in its classic form runs a userspace process that reads plaintext off a tun device, encrypts it, and sends it out through a normal socket. So for OpenVPN you can watch the tun device for cleartext and the process's socket for ciphertext. A read or write in that daemon is a real event you can kprobe.
WireGuard gives you no such process to hook. The work is a kernel function, not a syscall in some daemon. Your plaintext observation point is the wg0 interface itself, and your ciphertext point is the UDP send path in the kernel.
So a kprobe on the kernel's UDP transmit function catches the encrypted side, and a TC program on wg0 catches the plaintext side. Attaching a socket filter to "the WireGuard process" gets you nothing, because there is no such socket in userspace to filter. That detail trips up people porting an OpenVPN monitor over and wondering why every probe fires zero times.
The right hooks for connection tracking
Track connections at the layer that already holds the state you need, not the one that is easiest to attach. The real options, with honest tradeoffs:
- kprobes on kernel functions like
udp_sendmsg,udp_recvmsg, orip_output. These give you the socket and the packet at a precise point in the path, so you can read source and destination and tie the flow to a socket. The cost: they are tied to kernel internals. A function that exists in one kernel version can be renamed or inlined in the next, and your probe silently stops matching. No error, just zeros. - TC classifier programs on a specific device. These are stable across kernels because the hook is an interface, not a symbol. On the tunnel device you get plaintext flows; on the NIC you get the tunnel aggregate. This is the option I trust for long-lived monitors, because it does not break on a kernel bump.
- Socket-level BPF attached per-socket or per-cgroup. Best when you want one application or one container's VPN traffic and nothing else. Weakest for a host-wide view, because you have to attach to every relevant socket.
For steady connection tracking on a VPN box, I default to a TC program on the tunnel device plus a per-CPU hash map keyed on the flow. It survives kernel updates and it reads the plaintext, which is what you actually asked for. Reach for kprobes when you need a signal only a specific kernel function exposes, and accept that you own the maintenance when that function changes.
Build the monitor from a one-liner up
Run bpftrace before you write a line of BCC or libbpf. A lot of the eBPF monitors people struggle with started as a full build toolchain thrown at a question a one-liner would have answered. The point of the one-liner is to confirm the hook fires and carries the data you think it does.
Start by proving the packet path. Watch the tunnel send path and confirm it fires when traffic moves:
sudo bpftrace -e 'kprobe:udp_sendmsg { printf("%s %d\n", comm, pid); }'
Send traffic through the tunnel and watch. If nothing prints, your assumption about the hook is wrong, and no amount of BCC scaffolding on top will save you. That tells you to go read the source, not to add more code.
Why read the kernel source before you compile anything
Find how the function's arguments are laid out for your kernel, because those struct offsets are what your program dereferences. Guessing here produces a program that loads, runs, and prints garbage that looks plausible.
When the one-liner reliably shows the flows you expect, and only then, move to a BCC or CO-RE libbpf program to aggregate into maps and export to userspace. BCC has been around since its first stable release in April 2015, and it is still the fastest way to iterate before you commit to a compiled program. The rule is the same one from any eBPF work with BCC tools: validate the hook cheap, build the heavy thing once you know it works.
What do the common failures look like?
eBPF VPN monitors fail in a handful of predictable ways, and each one has a tell. Learn the tell and you stop guessing.
- Wrong attach point. The program loads, attaches, and reports zero. No error anywhere. This is almost always a hook that never sees your traffic, like XDP expecting plaintext or a kprobe on a function your kernel inlined. Diagnose it with a
bpftraceone-liner on the same hook; if that shows nothing either, the hook is wrong, not your program. - Missing BTF. The program refuses to load with a complaint about type information. CO-RE programs need BTF (BPF Type Format) in the kernel to relocate struct offsets. Check for
/sys/kernel/btf/vmlinux. If it is absent, your kernel was built without it, so you need a kernel that has it or a BCC build that reads headers at runtime. - Verifier rejection. The load fails and prints a verifier log. Read it, do not scroll past it. The verifier names the exact instruction and register it could not prove safe, usually an unbounded loop or an unchecked pointer. The fix is in that message.
- Missed packets from an early drop. Your count is low and you cannot see why. Something ahead of your hook dropped the packet, so it never reached you. Move your probe earlier in the path, or add a probe at the drop point to confirm.
Check the kernel floor before you debug anything else
You need a kernel new enough for the map types and helpers this work depends on. The ready-made tools publish their own floors, and Tetragon's installation notes, for one, ask for Linux kernel version 4.19 or greater. Run uname -r before you spend an evening on a machine that was never going to load the program.
Per-tunnel bandwidth and latency without lying numbers

Account for bytes at the layer that matches the number you are reporting. Encrypted tunnels add overhead: headers, padding, the encryption envelope. Count on the physical NIC and you are measuring ciphertext plus overhead. Count on the tunnel device and you are measuring the plaintext payload. Those are different numbers for the same traffic, and mixing them is how a bandwidth graph ends up lying.
Use eBPF maps to aggregate, not per-packet userspace reads. Key a per-CPU hash map on the tunnel or the flow, add the byte count in the kernel, and let userspace read the totals on an interval. Per-CPU maps avoid lock contention on a busy box, which matters more than it sounds when a tunnel is saturated.
For latency, sample round-trip time at the socket layer where the kernel already tracks it. Timing a plaintext packet out and matching a decrypted packet back means correlating across separate interfaces with different packet shapes, and the arithmetic drifts. Pull the RTT the kernel already computed for the tunnel's transport socket and you skip the whole mess.
Decide up front whether you are reporting payload throughput or wire throughput, label it, and account at that one layer.
When to use an existing tool instead of writing your own
Use the smallest thing that answers your question. Most people reach for a custom program when a one-liner or an existing tool would have settled it in a minute.
- bpftrace script, when you are exploring or answering a one-off question. Which process is sending on the tunnel? Is this hook even firing? A script runs now with no compile step and throws away cleanly.
- BCC tools, when you want a repeatable monitor and can accept some runtime overhead and a compiler on the box. Good for something you run by hand during an incident.
- Custom libbpf CO-RE program, when you need something that ships to production, starts fast, and runs across kernel versions without a toolchain on every host. This is the heavy option. Build it once the cheap ones proved the approach works.
The thing off-the-shelf network tools quietly get wrong for VPNs is the layer. A generic eBPF flow monitor attaches to the physical interface and reports the tunnel as one big encrypted flow, because that is all it can see there. It is not broken. It just never knew to look at the tunnel device for the cleartext.
If your goal is per-tunnel or per-service visibility inside the VPN, a general tool will hand you an aggregate and call it done. That gap is the reason to write your own, and the only good one. Once you can see inside the tunnel, the same map-and-correlate pattern carries over to an eBPF intrusion detection setup.
For the broader kernel mechanics behind all of this, the eBPF project's overview of program types and hooks lays out where each one attaches.
FAQ
Does running eBPF probes slow down my VPN throughput?
A well-scoped program adds little, because it runs in the kernel path and skips per-packet copies to userspace. The cost comes from what you do inside the program. Keep the kernel side to reading a few fields and updating a map, push parsing and enrichment to userspace, and sample on a saturated tunnel rather than touching every packet. A probe doing heavy work per packet will show up in your latency; one that increments a counter will not.
Can I monitor an IPsec tunnel the same way as WireGuard?
Mostly, but the plaintext point differs. IPsec transforms packets through the kernel's xfrm framework, so the cleartext-to-ciphertext handoff happens at the xfrm layer, not on a simple tun device. You watch xfrm policy and state events for the transform, and the physical NIC for the encrypted ESP packets. The correlate-both-layers approach is identical; the hook names change.
Why does my probe count fewer packets than tcpdump on the same interface?
Your probe likely sits after a point where packets get dropped, or on a different device than tcpdump. tcpdump taps the interface broadly, while a TC or kprobe hook sees only what reaches that exact spot. Confirm both are on the same device, then check whether a drop, a firewall rule, or an offload path removes packets before your hook runs.
Do I need to disable NIC offloads to see individual packets?
Sometimes, for XDP and physical-NIC work. Generic receive offload and similar features hand the stack merged super-packets, so byte counts stay right while packet counts and boundaries do not match the wire. If you are counting packets rather than bytes and the numbers look off, check your offload settings before you blame the program. On the tunnel device this rarely bites, since that traffic is already past most offload handling.
This is an informed, researched piece rather than a report from one specific production deployment. Validate every hook against your own kernel, because struct layouts and function names shift between versions.
Related on this blog
Related: WireGuard Linux Server Setup: A Reliable Step-by-Step
