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 <hello@stevenavery.com>
This commit is contained in:
Steve Avery
2026-06-15 11:18:00 -07:00
committed by James Tucker
parent f002f6bb3a
commit 4c4ec3d468
3 changed files with 257 additions and 11 deletions
+70 -11
View File
@@ -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")
+129
View File
@@ -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},
+58
View File
@@ -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