
How eBPF Intercepts Packets Before Kernel Routing
eBPF does not replace the kernel routing table. It intercepts the packet before that table is consulted. That single fact explains why so many "my routes are gone" sessions go nowhere: the packet got redirected in the TC or XDP hook, long before the kernel ran its FIB lookup, and no amount of ip route inspection will show it. To reason about a routing table and eBPF on one box, you need to know which layer made the decision. Get that wrong and you chase ghost routes for an hour.
Last updated: 2026-07-21
What actually happens to a packet in an eBPF router?
Trace the path once and most confusion disappears. A frame arrives at the NIC. If you attached an XDP program, it runs first, at the driver, before the kernel builds an sk_buff. Your program reads the headers, checks a BPF map, and returns a verdict: XDP_PASS, XDP_DROP, or XDP_TX/XDP_REDIRECT.
If the verdict is redirect, the packet leaves on another interface right there. It never enters the normal receive path. It never reaches netfilter. It never triggers a FIB lookup. As far as ip route is concerned, that packet did not exist.
TC works the same way, just later. A program on the clsact qdisc runs after the sk_buff exists but before netfilter ingress. So you get more context at a small cost in cycles. The mental model is simple: eBPF hooks are gates in front of the stack. The routing table sits behind them.
Does an eBPF router still use the kernel routing table?
Yes, but only for the packets your program hands back to the kernel. This is the core of every routing table eBPF confusion I see. The FIB and your eBPF program are two separate decision-makers running side by side.
Return XDP_PASS or TC_ACT_OK and the packet continues into the normal path, where the kernel consults its routing table like always. Call bpf_redirect() and you have overruled the table for that packet. Both can be true on one box at once for different flows.
Why does this matter for troubleshooting? Because the tool you reach for depends on who decided. If your program passed the packet, ip route get 10.0.0.5 tells you the truth. If your program redirected it, that command tells you nothing, because the kernel was never asked. Know which layer owns the decision before you pick a tool.
How forwarding actually works under the hood
Two helpers do the real work, and reading their kernel definitions beats any tutorial. The first is bpf_redirect() (and bpf_redirect_map()), which sets the target interface index and returns the redirect action. That is raw forwarding: you decided the egress, no table involved.
The second is bpf_fib_lookup(). This one is the bridge. You fill in a struct bpf_fib_lookup with source and destination addresses, and the helper runs an actual routing table lookup inside the program. It returns the next hop and the output interface. So you consult the kernel's routes and then redirect, all in the data plane, without dropping the packet to the slow path.
Map-based forwarding tables sit on top of that. You keep your own next-hop table in a BPF_MAP_TYPE_HASH or an LPM trie, populate it from userspace, and look it up per packet. Build one only when the kernel FIB cannot express what you need; otherwise you have signed up to maintain a second routing table by hand. The three approaches compare like this:
| Approach | What it does | Uses the kernel routing table? | Reach for it when |
|---|---|---|---|
bpf_redirect() | Sets the egress interface directly | No | You already know the output port |
bpf_fib_lookup() | Runs a real FIB lookup in-program | Yes, live state | You want kernel routes without the slow path |
| Own BPF map (hash or LPM) | Private next-hop table | No, a snapshot | You need logic the FIB cannot express |
On OpenWRT you need CONFIG_BPF, CONFIG_BPF_SYSCALL, CONFIG_NET_CLS_BPF, and CONFIG_NET_ACT_BPF compiled in before any of this loads. Miss one and the program fails to attach with a message you will misread as a code bug. If you are wiring the toolchain from scratch, my BPF toolchain setup on OpenWRT walks the build.
Why eBPF forwarding breaks routing table compatibility
The usual break is a sync gap. Your map-based forwarding table is a snapshot. The kernel routing table is live. When a route changes and nothing updates your map, your program keeps forwarding to a next hop that no longer exists. The kernel that could have corrected it never sees the packet.
Here is what happens in practice. You add a route, ip route shows it, you test with ping from another host, and it black-holes. Everything looks correct because the kernel side is correct. The stale copy lives in your BPF map, and only bpftool map dump will show it to you.
Network namespaces add a second trap. A program attached in one netns and a route added in another are different worlds. If your userspace controller runs in the host namespace and your veth lives in a container namespace, the FIB you think you are reading is not the FIB the packet hits. Check the namespace before you blame the code.
The fix is not a bigger map. It is a controller that subscribes to route and link changes over rtnetlink and updates the map on every event. If you use bpf_fib_lookup() instead of a private table, this whole class of bug goes away, because you read the live routing table each time.
eBPF redirects packets, it cannot insert kernel routes
Be blunt about the limit. An eBPF program decides a packet's fate. It cannot add, change, or delete an entry in the kernel routing table. There is no helper for "insert this route," and there should not be.
People conflate the two and write bugs from it. They expect that redirecting all traffic for a subnet somehow teaches the kernel a route. It does not. The kernel routing table is unchanged, so any packet your program passes back still gets routed by the old table, and now you have two policies fighting.
If you actually need a route in the kernel table, that is a userspace job: ip route add, or a routing daemon, or rtnetlink from your controller. Keep the roles clean. eBPF chooses fate at the hook; userspace owns the table. Wire the controller to do the table edits and let the data plane make the fast decisions.
Is eBPF forwarding faster than iptables?
For the hot path, yes, and it comes down to mechanism rather than any vendor claim. An XDP redirect skips the sk_buff allocation, the full netfilter traversal, and the trip up into userspace that a proxy would need. You cut work per packet, so on a CPU-bound router you free real cycles.
TC is a smaller win because the sk_buff already exists by the time it runs, but it still short-circuits ahead of netfilter. Compared to a long iptables ruleset, both hooks avoid walking chains for every packet.
The tradeoff is what people skip. You gave up conntrack, you gave up the netfilter ecosystem, and you took on writing and verifying your own code. XDP native needs driver support, and on cheap router silicon that support is thin. Without it you fall back to generic XDP. That runs after the sk_buff is built and can force a copy, wiping out the gain you came for. My notes on low-latency processing with XDP go deeper on where the cycles actually go.
SNAT and masquerading without conntrack
This is where silent failures hide. Kernel masquerade leans on nf_conntrack to remember every flow and reverse the translation on the way back. When you redirect in XDP or TC, you skipped conntrack, so nothing is tracking that flow for you.
So you have two honest choices:
- Keep the return path in the kernel and let conntrack do its job. You redirect one direction only and accept the cost.
- Build your own connection table in a BPF map, keyed on the five-tuple, and rewrite addresses and ports in both directions yourself.
The second path is more code than it looks. You handle timeouts, port allocation, and the reverse lookup, and you update checksums after every rewrite. Forget the checksum and the packet leaves looking fine and gets dropped downstream with no local error. That one costs people a whole evening, because tcpdump on the router shows a packet that "looks correct." Verify the checksum, not the address.
How the kernel talks to userspace in an eBPF setup
Maps are the channel, and knowing three shapes covers most work:
- Plain map (
BPF_MAP_TYPE_HASHor array): shared memory. Your program writes counters or state, your userspace controller reads them withbpftool map dumpor the map API. This is your forwarding table and your statistics. - Ring buffer (
BPF_MAP_TYPE_RINGBUF): the current, ordered, low-overhead way to stream records from the data plane up to userspace. - Per-CPU perf array (
BPF_MAP_TYPE_PERF_EVENT_ARRAY): the older event channel. You still see it in old code, but the ring buffer is what I reach for now.
When forwarding misbehaves, read these channels before you touch the program. Dump the forwarding map to see the actual next hops in play. Watch the ring buffer to see whether your program even ran on the packet you sent. Half the "the eBPF router is broken" reports are a map that was never populated, which the dump shows in one command.
How you get real-time notifications from the data plane
Push events with bpf_ringbuf_output() from inside the program, then read them from a userspace poll loop. That is the whole pattern. Your program reserves a record, fills it with the fields you care about, and submits it. Userspace wakes up and processes it.
Do not trust that it is firing just because the code compiled and loaded. Verify. Confirm the program is attached, confirm the map exists, and watch the reader receive records while you replay traffic. If nothing arrives, the common cause is a program that returned early on your test packet. The ring buffer is rarely the culprit.
A cheap check is to increment a map counter on every code path and dump the map after your test. If the counter moved but no events came out, your notification code is the bug. If the counter did not move, your packet never reached the hook, so the bug is attachment and the notification code is fine. For live inspection during this, capturing packets on OpenWRT with eBPF pairs well with the counter trick.
Cilium as a reference implementation
If you want to see all of this done seriously, read Cilium. It runs eBPF datapath forwarding for Kubernetes at scale, and its source is public, so you can watch how a real project handles the exact problems above instead of guessing. The Cilium documentation lays out the datapath design.
Two things are worth studying there. First, how it keeps its BPF maps in sync with the live network state through a userspace agent, which is the answer to the stale-map problem. Second, how it decides between passing to the kernel stack and handling forwarding entirely in eBPF, which is the pass-versus-redirect split made production-grade.
You do not need Kubernetes to learn from it. Read how the agent reacts to endpoint and route changes, and copy the discipline, not the scale. It shows that eBPF routing works when a controller owns the state and the data plane stays dumb and fast.
Debugging silent route failures in the data plane
Run bpftool net show first. It lists every XDP and TC program actually attached, per interface. Most "my eBPF router dropped everything" cases are a program that loaded but never attached, or attached to the wrong interface, and this one command tells you in a line. Do not assume attachment from a successful load.
Then work down the layers. Dump your forwarding map to check the next hops are current. Watch your ring buffer while you send one known packet. That ties a log line to a moment, the same way journalctl --since does. Check dmesg for verifier or driver complaints if the program refuses to attach at all.
What I never do is reapply a Stack Overflow snippet that "fixed it for someone." An eBPF forwarding bug is reproducible by nature: same packet, same map state, same verdict. So reproduce it with one crafted packet, read what the program did to it, and fix the branch that was wrong. If you want the firewall angle on attachment, my XDP firewall setup on OpenWRT covers the same verification path. Understand the failure or it comes back at 2 a.m.
FAQ
Can I run eBPF forwarding and normal iptables rules on the same router?
You can, and people do, but you have to know the order. XDP runs before netfilter entirely, so anything you redirect there never hits your iptables rules. TC ingress also runs ahead of netfilter. Whatever you pass back with XDP_PASS or TC_ACT_OK still goes through the full firewall as usual, so scope your program tightly and let the rest fall through.
Which is easier to get right, bpf_fib_lookup() or my own map?
bpf_fib_lookup(), without much contest, if the kernel routing table already holds the routes you want. It reads live state, so you dodge the whole stale-sync problem. Roll your own map only when you need forwarding logic the kernel FIB cannot express, and accept that you now own keeping it current.
Why does my redirected traffic show up fine in tcpdump but still fail?
Because tcpdump on the router shows the packet as your program built it. It says nothing about whether the receiver will accept it. The usual culprit after an eBPF rewrite is a wrong IP or transport checksum. Recompute the checksum after every address or port change, then capture on the far side to confirm the packet is accepted.
Do I need a specific kernel version for eBPF routing?
You need the config options built in more than a specific number. On OpenWRT, CONFIG_BPF, CONFIG_BPF_SYSCALL, CONFIG_NET_CLS_BPF, and CONFIG_NET_ACT_BPF must be present, and bpf_fib_lookup() needs a reasonably modern kernel. Check what your build actually shipped rather than trusting the version string, since vendors trim configs.
How do I tell whether a packet was routed by the kernel or by eBPF?
Increment a distinct map counter in each program branch, then dump the map after a test. If your redirect counter moved, eBPF owned that packet. If it did not and the flow still worked, the kernel routing table handled it. That split saves you from staring at ip route for a decision your program already made.
