January 12, 20226 min read

How a Local IP Address Broke IPv6 Tunnels in the Linux Kernel

Some bugs announce themselves with a crash. This one announced itself with silence — a packet that simply never left the machine. No panic, no error, no log line. Just a ping that timed out while its twin sailed through.

It was early 2022, and I was at Cloudflare with a very specific goal: take the traffic of one particular application — and only that application — and push it through a FOU tunnel. FOU, “Foo-over-UDP,” wraps tunneled packets inside an ordinary UDP datagram so they pass cleanly through middleboxes that would otherwise choke on exotic protocol numbers.

Linux has a beautiful tool for the “only that application” part: policy-based routing, where you match packets by the UID of the process that generated them and send them to their own routing table. So the plan was to match the app by its UID, point that table at a sit tunnel (IPv6-in-IPv4) with FOU encapsulation, and let the tunnel carry it.

# steer just this app's traffic (matched by its UID) into table 100
$ sudo ip rule add uidrange 1000-1000 lookup 100

# table 100 routes everything out through the FOU tunnel
$ sudo ip link add name fou_test type sit \
    remote 127.0.0.1 encap fou encap-sport auto encap-dport 1111
$ sudo ip link set fou_test up
$ sudo ip route add default dev fou_test table 100

To sanity-check the tunnel itself, I sent a little of each kind of traffic through it:

$ ping  -I fou_test -c 1 1.1.1.1
$ ping6 -I fou_test -c 1 fe80::d0b0:dfff:fe4c:fcbc

…and watched the wire:

$ tcpdump -i any udp dst port 1111

The IPv4 ping appeared, neatly wrapped in UDP on port 1111. The IPv6 ping didn’t. No encapsulated packet, no error thrown back at me — it just evaporated. Same tunnel, same config, two protocols: one worked, one vanished.

The asymmetry

That asymmetry is what nagged at me. If the tunnel were simply broken, IPv4 would have failed too. If IPv6 were unsupported, the interface would have rejected the route outright. Instead the kernel happily encapsulated IPv4 to the peer and quietly refused to do the same for IPv6.

The clue was hiding in plain sight in my own setup: remote 127.0.0.1. The tunnel’s far end was an address configured on the very machine doing the sending. That looks like a contrived test — until you remember it’s exactly what happens in a cluster. An orchestrator schedules a “peer” service onto some node, and once in a while that node is the same one trying to reach it. Most of the fleet would be fine. The one unlucky server that happened to co-host the peer would silently fail to send.

Into sit.c

Encapsulated IPv4 to a local address was allowed; IPv6 wasn’t. So I went reading net/ipv6/sit.c. In ipip6_tunnel_xmit() — the function that wraps an IPv6 packet in its IPv4 outer header — there’s a route lookup, and right after it, a guard:

if (rt->rt_type != RTN_UNICAST) {
    ip_rt_put(rt);
    dev->stats.tx_carrier_errors++;
    goto tx_error_icmp;
}

There it was. When the tunnel’s remote is an address that lives on the local box, the route lookup doesn’t return a RTN_UNICAST route — it returns RTN_LOCAL. This check saw “not unicast,” threw the packet away, bumped tx_carrier_errors, and bailed to the error path. The IPv4 transmit path had no equivalent restriction, which is precisely why only IPv6 disappeared.

A line older than git

The strangest part: nobody could say why the restriction was there. It predates the observable git history — older than the kernel’s move to git in 2005 — so there was no commit, no message, no rationale to point to. Just an over-cautious guard that had quietly outlawed a perfectly reasonable case for over fifteen years, until a cloud scheduler happened to place a service on the wrong node.

The fix is one line. Allow RTN_LOCAL too:

- if (rt->rt_type != RTN_UNICAST) {
+ if (rt->rt_type != RTN_UNICAST && rt->rt_type != RTN_LOCAL) {

One line, several signatures

My colleague Ignat Korchagin turned the finding into a patch and sent it to netdev. David Ahern reviewed it; Jakub Kicinski, the networking maintainer, replied “Applied, thanks!” and it landed as commit ed6ae5ca437d, later backported across the stable kernels. My name rode along on the Reported-by: line — which is its own small thrill: a tag in a tree that runs on a meaningful fraction of the planet’s servers.

What stuck with me isn’t the patch — it’s the shape of the thing. The change was a single &&. Getting there meant noticing a silence, trusting an asymmetry, and reading code old enough to have no author left to ask. Most of the work in a one-line fix is everything that isn’t the line.

Patch thread on lore.kernel.org · upstream commit ed6ae5ca437d.

← All posts