From 4c4ec3d468a78fe9ba8576232b54f3f406192e79 Mon Sep 17 00:00:00 2001 From: Steve Avery Date: Sun, 14 Jun 2026 16:37:08 -0700 Subject: [PATCH] net/packet,wgengine/filter: handle IPv6 fragment extension header decode6 didn't parse the IPv6 Fragment extension header (Next Header 44), so any source-fragmented IPv6 packet was classified as an unknown protocol and matched no ACL rule. The filter then silently dropped it and counted it as an "acl" drop, even on allow-all tailnets, blackholing large UDP (DNS, WebRTC, etc.) over a tailnet's IPv6 addresses. IPv4 fragments were already handled by decode4. Parse the fragment header the same way: read the first fragment's transport ports so the filter matches it like an unfragmented packet, pass later fragments through as ipproto.Fragment, and reject overlapping-fragment offsets (RFC 1858) and first fragments too short to hold the transport header as unknown. Fixes #20083 Signed-off-by: Steve Avery --- net/packet/packet.go | 81 ++++++++++++++++++--- net/packet/packet_test.go | 129 +++++++++++++++++++++++++++++++++ wgengine/filter/filter_test.go | 58 +++++++++++++++ 3 files changed, 257 insertions(+), 11 deletions(-) diff --git a/net/packet/packet.go b/net/packet/packet.go index b41e0dcd9..c5ce2a8ae 100644 --- a/net/packet/packet.go +++ b/net/packet/packet.go @@ -19,6 +19,16 @@ const unknown = ipproto.Unknown // RFC1858: prevent overlapping fragment attacks. const minFragBlks = (60 + 20) / 8 // max IPv4 header + basic TCP header in fragment blocks (8 bytes each) +// ip6FragHeader is the IANA protocol number for the IPv6 Fragment extension +// header ("IPv6-Frag"). It appears as the base header's Next Header value on a +// fragmented packet; decode6 steps over it to reach the real sub-protocol. +// This is distinct from ipproto.Fragment (0xFF), our internal sentinel for a +// non-first fragment whose sub-protocol header is not present. +const ip6FragHeader ipproto.Proto = 44 + +// ip6FragHeaderLength is the length of the IPv6 Fragment extension header. +const ip6FragHeaderLength = 8 + type TCPFlag uint8 const ( @@ -272,19 +282,25 @@ func (q *Parsed) decode6(b []byte) { q.Src = withIP(q.Src, srcIP) q.Dst = withIP(q.Dst, dstIP) - // We don't support any IPv6 extension headers. Don't try to - // be clever. Therefore, the IP subprotocol always starts at - // byte 40. + // The IP subprotocol normally begins right after the 40-byte IPv6 + // header. The one extension header we parse is the Fragment header + // (Next Header 44): a host source-fragmenting a datagram larger than + // the tun MTU emits these, and RFC 8200 section 4.5 requires the + // receiver to reassemble them, so we must let them through. For the + // first fragment we step over the fragment header and read the real + // sub-protocol's ports exactly as decode4 does; later fragments are + // marked ipproto.Fragment and passed through by the filter. // - // Note that this means we don't support fragmentation in - // IPv6. This is fine, because IPv6 strongly mandates that you - // should not fragment, which makes fragmentation on the open - // internet extremely uncommon. - // - // This also means we don't support IPSec headers (AH/ESP), or - // IPv6 jumbo frames. Those will get marked Unknown and - // dropped. + // We still don't parse any other extension headers (hop-by-hop, + // routing, destination options) or IPSec headers (AH/ESP), nor a + // Fragment header that isn't the base header's immediate Next Header. + // Those get marked Unknown and dropped. q.subofs = 40 + if q.IPProto == ip6FragHeader { + if !q.decode6Fragment(b) { + return + } + } sub := b[q.subofs:] sub = sub[:len(sub):len(sub)] // help the compiler do bounds check elimination @@ -341,6 +357,49 @@ func (q *Parsed) decode6(b []byte) { } } +// decode6Fragment parses the IPv6 Fragment extension header at q.subofs in b +// (q.subofs is the 40-byte base header length when called). It reports whether +// decode6 should continue into the sub-protocol switch: true only for the +// first fragment, where q.subofs and q.IPProto have been advanced to the real +// transport header so its ports get parsed like an unfragmented packet. For +// later or malformed fragments it sets q.IPProto itself (ipproto.Fragment to +// pass through, or unknown to drop) and returns false. +func (q *Parsed) decode6Fragment(b []byte) (continueDecode bool) { + // The fragment header is 8 bytes: Next Header, Reserved, a 13-bit + // Fragment Offset (in 8-byte blocks) plus a More-Fragments flag, and a + // 32-bit Identification. + if len(b) < q.subofs+ip6FragHeaderLength { + q.IPProto = unknown + return false + } + frag := b[q.subofs:] + nextHdr := ipproto.Proto(frag[0]) + fragOfs := binary.BigEndian.Uint16(frag[2:4]) >> 3 + + // Step over the fragment header. The real sub-protocol (first fragment) + // or the continued payload (later fragments) begins here. + q.subofs += ip6FragHeaderLength + + if fragOfs == 0 { + // First fragment: decode the real sub-protocol's header so the + // filter can match on its ports. The switch in decode6 performs + // the per-protocol bounds checks, including rejecting a first + // fragment too short to hold the transport header. + q.IPProto = nextHdr + return true + } + + // Later fragment: there's no sub-protocol header to read. Reject offsets + // small enough to overlap the transport header (RFC 1858, same guard as + // decode4); otherwise pass it through as a fragment. + if fragOfs < minFragBlks { + q.IPProto = unknown + return false + } + q.IPProto = ipproto.Fragment + return false +} + func (q *Parsed) IP4Header() IP4Header { if q.IPVersion != 4 { panic("IP4Header called on non-IPv4 Parsed") diff --git a/net/packet/packet_test.go b/net/packet/packet_test.go index 4dbf88009..a865b9150 100644 --- a/net/packet/packet_test.go +++ b/net/packet/packet_test.go @@ -232,6 +232,131 @@ var udp6RequestDecode = Parsed{ Dst: mustIPPort("[2607:f8b0:400a:809::200e]:443"), } +// First fragment of a source-fragmented UDP datagram over IPv6. +// +// The base header's next-header is 44 (IPv6 Fragment extension header), +// which carries the real upper-layer protocol (UDP) and the fragment +// offset (0 here, with the More-Fragments flag set). Like decode4's +// first-fragment handling, decode6 should reach past the 8-byte fragment +// header and parse the transport ports. +var udp6FirstFragmentBuffer = []byte{ + // IPv6 header up to hop limit. Next header = 44 (Fragment), payload len = 24. + 0x60, 0x00, 0x00, 0x00, 0x00, 0x18, 0x2c, 0x40, + // Src addr + 0x20, 0x01, 0x05, 0x59, 0xbc, 0x13, 0x54, 0x00, 0x17, 0x49, 0x46, 0x28, 0x39, 0x34, 0x0e, 0x1b, + // Dst addr + 0x26, 0x07, 0xf8, 0xb0, 0x40, 0x0a, 0x08, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0e, + // Fragment extension header (8 bytes): + // NextHeader=UDP(0x11), Reserved, FragmentOffset=0 + M flag, Identification. + 0x11, 0x00, 0x00, 0x01, 0xde, 0xad, 0xbe, 0xef, + // UDP header + 0xd4, 0x04, 0x01, 0xbb, 0x00, 0x29, 0x96, 0x84, + // Payload (start of the datagram, carried by the first fragment) + 0x5c, 0x06, 0xae, 0x85, 0x02, 0xf5, 0xdb, 0x90, +} + +var udp6FirstFragmentDecode = Parsed{ + b: udp6FirstFragmentBuffer, + subofs: 48, // 40-byte IPv6 header + 8-byte fragment extension header + dataofs: 56, // subofs + 8-byte UDP header + length: len(udp6FirstFragmentBuffer), + + IPVersion: 6, + IPProto: UDP, + Src: mustIPPort("[2001:559:bc13:5400:1749:4628:3934:e1b]:54276"), + Dst: mustIPPort("[2607:f8b0:400a:809::200e]:443"), +} + +// A non-first fragment over IPv6: it carries a fragment header at a +// nonzero offset and no transport header, so its ports are unknown. Like +// decode4, decode6 should classify it as ipproto.Fragment so the filter's +// pre() pass-through path accepts it. +var udp6NonFirstFragmentBuffer = []byte{ + // IPv6 header up to hop limit. Next header = 44 (Fragment), payload len = 16. + 0x60, 0x00, 0x00, 0x00, 0x00, 0x10, 0x2c, 0x40, + // Src addr + 0x20, 0x01, 0x05, 0x59, 0xbc, 0x13, 0x54, 0x00, 0x17, 0x49, 0x46, 0x28, 0x39, 0x34, 0x0e, 0x1b, + // Dst addr + 0x26, 0x07, 0xf8, 0xb0, 0x40, 0x0a, 0x08, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0e, + // Fragment extension header (8 bytes): + // NextHeader=UDP(0x11), Reserved, FragmentOffset=185 (0x05c8>>3) + M=0, Identification. + 0x11, 0x00, 0x05, 0xc8, 0xde, 0xad, 0xbe, 0xef, + // Payload continuation (no transport header in a non-first fragment) + 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, +} + +var udp6NonFirstFragmentDecode = Parsed{ + b: udp6NonFirstFragmentBuffer, + subofs: 48, // past the fragment extension header, at the continued payload + dataofs: 0, // no sub-protocol header present + length: len(udp6NonFirstFragmentBuffer), + + IPVersion: 6, + IPProto: Fragment, + Src: mustIPPort("[2001:559:bc13:5400:1749:4628:3934:e1b]:0"), + Dst: mustIPPort("[2607:f8b0:400a:809::200e]:0"), +} + +// A first fragment (offset 0) truncated before the full transport header, +// so the ports can't be read. Like decode4's tcp4ShortFragment case, this +// must be rejected as Unknown rather than guessed at: a stateless filter +// that trusted such a fragment could be bypassed by a follow-up fragment +// that supplies the rest of the header (RFC 1858). +var udp6ShortFirstFragmentBuffer = []byte{ + // IPv6 header. Next header = 44 (Fragment), payload len = 12. + 0x60, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x2c, 0x40, + // Src addr + 0x20, 0x01, 0x05, 0x59, 0xbc, 0x13, 0x54, 0x00, 0x17, 0x49, 0x46, 0x28, 0x39, 0x34, 0x0e, 0x1b, + // Dst addr + 0x26, 0x07, 0xf8, 0xb0, 0x40, 0x0a, 0x08, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0e, + // Fragment extension header: NextHeader=UDP, Reserved, Offset=0 + M=1, Identification. + 0x11, 0x00, 0x00, 0x01, 0xde, 0xad, 0xbe, 0xef, + // Truncated UDP header: only 4 of the required 8 bytes. + 0xd4, 0x04, 0x01, 0xbb, +} + +var udp6ShortFirstFragmentDecode = Parsed{ + b: udp6ShortFirstFragmentBuffer, + subofs: 48, // header walk reaches past the fragment header... + dataofs: 0, // ...but the transport header is incomplete, so it's unknown + length: len(udp6ShortFirstFragmentBuffer), + + IPVersion: 6, + IPProto: Unknown, + Src: mustIPPort("[2001:559:bc13:5400:1749:4628:3934:e1b]:0"), + Dst: mustIPPort("[2607:f8b0:400a:809::200e]:0"), +} + +// A non-first fragment whose offset is small enough that its bytes could +// overlap the transport header on reassembly. Mirrors decode4's +// ipv4SmallOffsetFragment: reject as Unknown to prevent overlapping-fragment +// firewall bypass (RFC 1858), rather than passing it through as Fragment. +var udp6SmallOffsetFragmentBuffer = []byte{ + // IPv6 header. Next header = 44 (Fragment), payload len = 16. + 0x60, 0x00, 0x00, 0x00, 0x00, 0x10, 0x2c, 0x40, + // Src addr + 0x20, 0x01, 0x05, 0x59, 0xbc, 0x13, 0x54, 0x00, 0x17, 0x49, 0x46, 0x28, 0x39, 0x34, 0x0e, 0x1b, + // Dst addr + 0x26, 0x07, 0xf8, 0xb0, 0x40, 0x0a, 0x08, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0e, + // Fragment extension header: NextHeader=UDP, Reserved, + // Offset=1 block (8 bytes, below the safe minimum) + M=0, Identification. + 0x11, 0x00, 0x00, 0x08, 0xde, 0xad, 0xbe, 0xef, + // Payload continuation that could overlap the transport header. + 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, +} + +var udp6SmallOffsetFragmentDecode = Parsed{ + b: udp6SmallOffsetFragmentBuffer, + subofs: 48, + dataofs: 0, + length: len(udp6SmallOffsetFragmentBuffer), + + IPVersion: 6, + IPProto: Unknown, + Src: mustIPPort("[2001:559:bc13:5400:1749:4628:3934:e1b]:0"), + Dst: mustIPPort("[2607:f8b0:400a:809::200e]:0"), +} + var udp4ReplyBuffer = []byte{ // IP header up to checksum 0x45, 0x00, 0x00, 0x29, 0x21, 0x52, 0x00, 0x00, 0x40, 0x11, 0x49, 0x5f, @@ -567,6 +692,10 @@ func TestDecode(t *testing.T) { {"ipv4_tsmp", ipv4TSMPBuffer, ipv4TSMPDecode}, {"ipv4_sctp", sctpBuffer, sctpDecode}, {"ipv4_frag", tcp4MediumFragmentBuffer, tcp4MediumFragmentDecode}, + {"ipv6_frag_first", udp6FirstFragmentBuffer, udp6FirstFragmentDecode}, + {"ipv6_frag_nonfirst", udp6NonFirstFragmentBuffer, udp6NonFirstFragmentDecode}, + {"ipv6_frag_short_first", udp6ShortFirstFragmentBuffer, udp6ShortFirstFragmentDecode}, + {"ipv6_frag_small_offset", udp6SmallOffsetFragmentBuffer, udp6SmallOffsetFragmentDecode}, {"ipv4_fragtooshort", tcp4ShortFragmentBuffer, tcp4ShortFragmentDecode}, {"ipv4_short_first_fragment", ipv4ShortFirstFragmentBuffer, ipv4ShortFirstFragmentDecode}, {"ipv4_small_offset_fragment", ipv4SmallOffsetFragmentBuffer, ipv4SmallOffsetFragmentDecode}, diff --git a/wgengine/filter/filter_test.go b/wgengine/filter/filter_test.go index a3b9a8e00..56e35b88c 100644 --- a/wgengine/filter/filter_test.go +++ b/wgengine/filter/filter_test.go @@ -254,6 +254,8 @@ func TestNoAllocs(t *testing.T) { {"udp6_in", in, udp6Packet}, {"udp4_out", out, udp4Packet}, {"udp6_out", out, udp6Packet}, + {"frag6_first_in", in, udp6FirstFragment}, + {"frag6_nonfirst_in", in, udp6NonFirstFragment}, } for _, test := range tests { @@ -377,6 +379,41 @@ func BenchmarkFilter(b *testing.B) { } } +// udp6FirstFragment is the first fragment (offset 0) of a source-fragmented +// IPv6 UDP datagram from 2001::5 to [2001::1]:443. decode6 reads its ports past +// the 8-byte Fragment extension header so the filter can match it like an +// unfragmented packet. +var udp6FirstFragment = []byte{ + // IPv6 header. Next header = 44 (Fragment), payload len = 24. + 0x60, 0x00, 0x00, 0x00, 0x00, 0x18, 0x2c, 0x40, + // Src: 2001::5 + 0x20, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, + // Dst: 2001::1 + 0x20, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + // Fragment header: NextHeader=UDP, Reserved, Offset=0 + M=1, Identification. + 0x11, 0x00, 0x00, 0x01, 0xde, 0xad, 0xbe, 0xef, + // UDP header: sport 1234, dport 443. + 0x04, 0xd2, 0x01, 0xbb, 0x00, 0x10, 0x00, 0x00, + // Payload. + 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, +} + +// udp6NonFirstFragment is a later fragment (nonzero offset, no transport +// header) of an IPv6 datagram. decode6 classifies it as ipproto.Fragment, +// which pre() passes through regardless of ACL, exactly as for IPv4. +var udp6NonFirstFragment = []byte{ + // IPv6 header. Next header = 44 (Fragment), payload len = 16. + 0x60, 0x00, 0x00, 0x00, 0x00, 0x10, 0x2c, 0x40, + // Src: 2001::5 + 0x20, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, + // Dst: 2001::1 + 0x20, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + // Fragment header: NextHeader=UDP, Reserved, Offset=185 blocks + M=0, Identification. + 0x11, 0x00, 0x05, 0xc8, 0xde, 0xad, 0xbe, 0xef, + // Payload continuation (no transport header). + 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, +} + func TestPreFilter(t *testing.T) { packets := []struct { desc string @@ -389,6 +426,7 @@ func TestPreFilter(t *testing.T) { {"short-junk", Drop, usermetric.ReasonTooShort, raw4default(ipproto.Unknown, 10)}, {"long-junk", Drop, usermetric.ReasonUnknownProtocol, raw4default(ipproto.Unknown, 21)}, {"fragment", Accept, "", raw4default(ipproto.Fragment, 40)}, + {"fragment6", Accept, "", udp6NonFirstFragment}, {"tcp", noVerdict, "", raw4default(ipproto.TCP, 0)}, {"udp", noVerdict, "", raw4default(ipproto.UDP, 0)}, {"icmp", noVerdict, "", raw4default(ipproto.ICMPv4, 0)}, @@ -404,6 +442,26 @@ func TestPreFilter(t *testing.T) { } } +// TestRunInIPv6FirstFragment checks that the first fragment of a +// source-fragmented IPv6 datagram is matched on its ports and accepted like an +// unfragmented packet, rather than being dropped as an unknown protocol (the +// bug where v6 fragments were silently counted as "acl" drops). +func TestRunInIPv6FirstFragment(t *testing.T) { + f := newFilter(t.Logf) + + var p packet.Parsed + p.Decode(udp6FirstFragment) + // The fragment header must be parsed through to the real sub-protocol; + // otherwise no ACL rule can match it. + if p.IPProto != ipproto.UDP { + t.Fatalf("decoded IPProto = %v, want UDP (fragment header not parsed)", p.IPProto) + } + // 2001::5 => [2001::1]:443 is permitted by the "::/0 => ::/0:443" rule. + if got := f.RunIn(&p, 0); got != Accept { + t.Errorf("RunIn(first fragment) = %v, want Accept", got) + } +} + func TestOmitDropLogging(t *testing.T) { tests := []struct { name string