[BUG] vrf: attaching a packet tap to a VRF device corrupts skb->csum for packets from L3 ingress devices (Udp6InCsumErrors, packets dropped)

From: Stefano Sasso <hidden>
Date: 2026-09-05 12:59:25
Also in: lkml

Hi all,

Up front, so you know what you are dealing with: **I am not a kernel
programmer — I am a networking guy.** Please bear with me if I get the
terminology or the conventions wrong, and feel free to tell me bluntly if this
is not the right place or the right format. I also used an AI assistant to help
me navigate the kernel sources and narrow this down, so please treat my source
analysis below as a hypothesis rather than as an authoritative statement.

What I do have is a **small, deterministic, self-contained reproducer** that
triggers the problem 100% of the time on a stock kernel, plus a control test
that isolates the trigger condition. Those parts I am confident about, because I
ran them.

## Summary

Attaching a packet tap (tcpdump/dumpcap, i.e. anything that populates
`vrf_dev->ptype_all`) to a VRF device causes incoming UDP datagrams to be
dropped with `Udp6InCsumErrors`, when:

- the packet arrives on an ingress device with no `header_ops`
  (ARPHRD_NONE: tun, ip6tnl, gre, wireguard...) that is enslaved to
that VRF, and
- the skb is in `CHECKSUM_COMPLETE` state by the time it reaches
  `vrf_ip6_rcv()` (in my case because conntrack software-verified the checksum
  in PREROUTING).

Stop the capture and the packets are delivered again. Start it and they are
dropped again. Capturing on the enslaved ingress device instead of on the VRF
device is harmless.

IPv4 should be affected identically, since `vrf_ip_rcv()` uses the same helper.
I only tested IPv6.

## Real-world impact

I hit this in production: a SIP proxy bound into a VRF stopped receiving large
(fragmented) REGISTER messages over IPv6/UDP, but only while we were running a
packet capture on the VRF device to debug an unrelated issue. The capture we
took to diagnose the problem was the cause of the problem.

In the capture, the reassembled datagram as seen on the VRF device has
**source MAC == destination MAC == the VRF device MAC**, which is what put me
on the trail of `vrf_prepare_mac_header()`.

## Suspected cause

`drivers/net/vrf.c`, `vrf_prepare_mac_header()`:
__skb_push(skb, ETH_HLEN);
eth = (struct ethhdr *)skb->data;

skb_reset_mac_header(skb);
skb_reset_mac_len(skb);

ether_addr_copy(eth->h_dest, vrf_dev->dev_addr);
ether_addr_copy(eth->h_source, vrf_dev->dev_addr);
eth->h_proto = htons(proto);

skb->protocol = eth->h_proto;
skb->pkt_type = PACKET_HOST;

skb_postpush_rcsum(skb, skb->data, ETH_HLEN);

skb_pull_inline(skb, ETH_HLEN);
The synthetic 14-byte Ethernet header is folded into `skb->csum` by
`skb_postpush_rcsum()`, then removed from the packet by `skb_pull_inline()` —
but there is no matching `skb_postpull_rcsum()`. For a `CHECKSUM_COMPLETE` skb,
`skb->csum` is left permanently off by `csum_partial(fake_eth, ETH_HLEN)`, and
`udpv6_rcv()` subsequently rejects the datagram.

This code is only reached from `vrf_ip6_rcv()` / `vrf_ip_rcv()` inside
if (!list_empty(&vrf_dev->ptype_all)) {
which is exactly why the behaviour depends on whether a sniffer is attached.

`vrf_add_mac_header_if_unset()` short-circuits when the ingress device has a
real link layer (`dev_has_header(orig_dev)`), so a veth-enslaved port never
shows the problem — only L3 devices such as tun do.

Naively, the fix looks like it should be to make the accounting symmetric:
either drop the `skb_postpush_rcsum()` call, since the push/pull pair leaves the
packet unchanged, or add the matching `skb_postpull_rcsum()` before
`skb_pull_inline()`. I do not feel qualified to say which is correct, or whether
there are other callers/paths that rely on the current behaviour, so I am not
sending a patch.

## Why the packet is already reassembled when the VRF taps it

`l3mdev_ip6_rcv()` is called from `ip6_rcv_finish()`, i.e. after
`NF_INET_PRE_ROUTING`, so `nf_defrag_ipv6` (PREROUTING, prio -400) has already
reassembled the fragments. That is why the ingress device shows two fragments
while the VRF device shows one full datagram.

## Why the skb is CHECKSUM_COMPLETE

A tun device hands the stack a `CHECKSUM_NONE` skb, for which
`skb_postpush_rcsum()` is a no-op. In my setup the promotion is done by
conntrack: `nf_conntrack_udp_packet()` -> `nf_checksum()` -> `nf_ip6_checksum()`
-> `__skb_checksum_complete()`, which sets `CHECKSUM_COMPLETE` and stores
`skb->csum`. This also runs at PREROUTING, before the VRF rx handler, and only
when `net.netfilter.nf_conntrack_checksum=1` (the default).

Test 4 of the reproducer sets `net.netfilter.nf_conntrack_checksum=0` and the
problem disappears while the capture stays attached — which I believe confirms
that the corruption is in `skb->csum` and not in the packet data.

## Reproducer

Tested on `6.12.0-211.49.1.el10_2.x86_64` (Red Hat Enterprise Linux 10). It
needs root, `iproute2`, `nftables`, `tcpdump` and python3. No external network,
no second host — everything happens in one network namespace.

Topology:
[netns kbug]
  vrf0                 (table 100)
    └── tun0           IFF_TUN, ARPHRD_NONE, 2001:db8:2::1/64
`inject.py` opens `/dev/net/tun`, attaches to `tun0`, and writes two hand-built
IPv6 fragments of a single 1640-byte UDP datagram (correct UDP checksum) into
the fd. `receiver.py` is a UDP socket bound to `[2001:db8:2::1]:5060` with
`SO_BINDTODEVICE=vrf0`.

An nftables `ct state` rule in PREROUTING activates `nf_defrag_ipv6` and
conntrack:
add table inet ctstate
add chain inet ctstate prerouting { type filter hook prerouting
priority -150; policy accept; }
add rule inet ctstate prerouting ct state new,established,related,invalid accept
Results:
== Test 1  no tap                                  PASS  delivered
(Udp6InCsumErrors +0)
== Test 2  tcpdump on vrf0                         FAIL  dropped
(Udp6InCsumErrors +1)
== Test 3  tcpdump on tun0 (ingress)               PASS  delivered
(Udp6InCsumErrors +0)
== Test 4  tcpdump on vrf0, nf_conntrack_checksum=0 PASS  delivered
(Udp6InCsumErrors +0)
The full set of scripts (`setup.sh`, `inject.py`, `receiver.py`,
`reproduce.sh`, `teardown.sh`) is available here:

  https://gist.github.com/ssasso/c089227b116b0fa6a954a18d85639d67

They are short and have no dependencies beyond the above. I can inline them in a
follow-up mail if you prefer not to follow links.

## Workarounds I am using meanwhile

- Capture on the enslaved interface instead of on the VRF device.
- `sysctl -w net.netfilter.nf_conntrack_checksum=0` in the affected netns.

Happy to run any test, apply any debug patch, or collect any additional data you
need — just tell me exactly what to run.

Thanks for your time,
Stefano
Keyboard shortcuts
hback out one level
jnext message in thread
kprevious message in thread
ldrill in
Escclose help / fold thread tree
?toggle this help