net/packet: fix Transport panic from a bad IPv4 IHL (#21331)

`decode4` assigned `q.subofs` before validating it against the declared IP total length. A packet with an IHL past the end of the buffer was rejected but left `subofs` dangling there, so a later Transport call would panic.

This change only store `subofs` once validated. As defense in depth an additional bounds check is added in Transport.

Fuzzing was expanded and improved to get better coverage in `packet.go`.

Credit to @Dev-next-gen for finding and reporting.

Fixes tailscale/corp#48322
Updates tailscale/corp#46608

Change-Id: I198d06b921add9b188daad79d4ca473aed1e3b66

Signed-off-by: Mike Jensen <mikej@tailscale.com>
This commit is contained in:
Mike Jensen authored and GitHub committed 2026-09-17 10:00:01 -06:00
1 parent b16bc957aa
commit 408504af89
4 files changed
+89 -59

No files matched your search

-1
View File
@@ -127,7 +127,6 @@ build_fuzzers() {
build_fuzzer tailscale.com/net/packet FuzzParsedPayload packet_parsed_payload
build_fuzzer tailscale.com/net/packet FuzzDecode4 packet_decode4
build_fuzzer tailscale.com/net/packet FuzzDecode6 packet_decode6
build_fuzzer tailscale.com/net/packet FuzzDecode6Fragment packet_decode6_fragment
build_fuzzer tailscale.com/net/packet FuzzIP4Checksum packet_ip4_checksum
build_fuzzer tailscale.com/net/packet FuzzChecksumBytes packet_checksum_bytes
build_fuzzer tailscale.com/net/packet FuzzICMP6Checksum packet_icmp6_checksum
+78 -54
View File
@@ -35,7 +35,7 @@ func FuzzParsedDecode(f *testing.F) {
}, make([]byte, 32)...), 0x80, 0x00, 0x00, 0x00))
// A TSMP packet (Tailscale's inter-node message protocol)
f.Add([]byte{
0x45, 0x00, 0x00, 0x2b, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.TSMP),
0x45, 0x00, 0x00, 0x1c, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.TSMP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x00,
})
@@ -45,8 +45,6 @@ func FuzzParsedDecode(f *testing.F) {
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
})
// Empty input
f.Add([]byte{})
// A bad IP version nibble, hitting the default (unknown) case
f.Add([]byte{0x50, 0x00, 0x00, 0x14, 0x12, 0x34, 0x00, 0x00, 0x40, 0x11,
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2})
@@ -57,7 +55,7 @@ func FuzzParsedDecode(f *testing.F) {
f.Add([]byte{0x4f, 0x00, 0x00, 0x14, 0x12, 0x34, 0x00, 0x00, 0x40, 0x11,
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2})
// IHL 6: 4 option bytes, UDP sub-header at offset 24
f.Add([]byte{0x46, 0x00, 0x00, 0x24, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.UDP),
f.Add([]byte{0x46, 0x00, 0x00, 0x20, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.UDP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2, 0x01, 0x02, 0x03, 0x04,
0x30, 0x39, 0x04, 0xd2, 0x00, 0x08, 0x00, 0x00})
// IHL 3: sub-header read from inside the IP addresses (IHL < 5 is not validated)
@@ -76,11 +74,11 @@ func FuzzParsedDecode(f *testing.F) {
f.Add([]byte{0x45, 0x00, 0x00, 0x1c, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.SCTP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2, 0x30, 0x39, 0x04, 0xd2, 0x00, 0x00, 0x00, 0x01})
// A fragmented TSMP, strictly disallowed
f.Add([]byte{0x45, 0x00, 0x00, 0x2b, 0x12, 0x34, 0x20, 0x00, 0x40, byte(ipproto.TSMP),
f.Add([]byte{0x45, 0x00, 0x00, 0x1c, 0x12, 0x34, 0x20, 0x00, 0x40, byte(ipproto.TSMP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x00})
// A TSMP sub-header shorter than the minimum 7 bytes
f.Add([]byte{0x45, 0x00, 0x00, 0x26, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.TSMP),
f.Add([]byte{0x45, 0x00, 0x00, 0x1a, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.TSMP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60})
// A later fragment with an offset below minFragBlks (8 < 10), rejected
f.Add([]byte{0x45, 0x00, 0x00, 0x14, 0x12, 0x34, 0x20, 0x08, 0x40, byte(ipproto.TCP),
@@ -104,14 +102,6 @@ func FuzzParsedDecode(f *testing.F) {
// IPv6 with the internal Fragment sentinel as the next-header byte
f.Add(append(append([]byte{0x60, 0x00, 0x00, 0x00, 0x00, 0x04, 0xff, 0x40},
make([]byte, 32)...), 0x00, 0x00, 0x00, 0x00))
f.Fuzz(func(t *testing.T, b []byte) {
var q Parsed
q.Decode(b)
})
}
func FuzzParsedPayload(f *testing.F) {
// A complete minimal IPv4 UDP packet with a 4-byte payload
f.Add([]byte{
0x45, 0x00, 0x00, 0x20, 0x12, 0x34, 0x40, 0x00, 0x40, byte(ipproto.UDP),
@@ -125,19 +115,11 @@ func FuzzParsedPayload(f *testing.F) {
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x30, 0x39, 0x04, 0xd2, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x50, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
})
// A complete IPv4 ICMP echo request
f.Add([]byte{
0x45, 0x00, 0x00, 0x1c, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.ICMPv4),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
})
// A complete minimal IPv6 UDP packet with a 4-byte payload
f.Add(append(append([]byte{
0x60, 0x00, 0x00, 0x00, 0x00, 0x0c, byte(ipproto.UDP), 0x40,
}, make([]byte, 32)...), 0x30, 0x39, 0x04, 0xd2, 0x00, 0x0c, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef))
// Empty input
f.Add([]byte{})
// IPv4 declared length (0x0100) larger than the buffer
// IPv4 declared length (0x0100) larger than the buffer, with sub-header
f.Add([]byte{0x45, 0x00, 0x01, 0x00, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.UDP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x30, 0x39, 0x04, 0xd2, 0x00, 0x08, 0x00, 0x00})
@@ -159,11 +141,35 @@ func FuzzParsedPayload(f *testing.F) {
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x30, 0x39, 0x04, 0xd2, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0xf0, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
})
// subofs = 28 past the end of a 20-byte packet
f.Add([]byte{0x47, 0x00, 0x00, 0x14, 0x12, 0x34, 0x00, 0x00, 0x40, 0x11,
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2})
f.Fuzz(func(t *testing.T, b []byte) {
var q Parsed
q.Decode(b)
if q.IPVersion == 4 {
_ = q.IP4Header()
if q.IPProto == ipproto.ICMPv4 {
_ = q.ICMP4Header()
} else if q.IPProto == ipproto.UDP {
_ = q.UDP4Header()
}
} else if q.IPVersion == 6 {
_ = q.IP6Header()
if q.IPProto == ipproto.ICMPv6 {
_ = q.ICMP6Header()
}
}
_ = q.Buffer()
_ = q.Transport()
_ = q.Payload()
_ = q.String()
_ = q.IsTCPSyn()
_ = q.IsError()
_ = q.IsEchoRequest()
_ = q.IsEchoResponse()
_ = q.EchoIDSeq()
})
}
@@ -190,13 +196,13 @@ func FuzzDecode4(f *testing.F) {
})
// A complete IPv4 IGMP packet (8-byte IGMP header)
f.Add([]byte{
0x45, 0x00, 0x00, 0x20, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.IGMP),
0x45, 0x00, 0x00, 0x1c, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.IGMP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x11, 0x64, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x01,
})
// A complete IPv4 SCTP packet (12-byte SCTP header)
f.Add([]byte{
0x45, 0x00, 0x00, 0x24, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.SCTP),
0x45, 0x00, 0x00, 0x20, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.SCTP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x30, 0x39, 0x04, 0xd2, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
})
@@ -210,7 +216,7 @@ func FuzzDecode4(f *testing.F) {
f.Add([]byte{0x4f, 0x00, 0x00, 0x14, 0x12, 0x34, 0x00, 0x00, 0x40, 0x11,
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2})
// IHL 6: 4 option bytes, UDP sub-header at offset 24
f.Add([]byte{0x46, 0x00, 0x00, 0x24, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.UDP),
f.Add([]byte{0x46, 0x00, 0x00, 0x20, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.UDP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2, 0x01, 0x02, 0x03, 0x04,
0x30, 0x39, 0x04, 0xd2, 0x00, 0x08, 0x00, 0x00})
// Truncated sub-headers: TCP, UDP, ICMPv4, IGMP, SCTP
@@ -225,11 +231,11 @@ func FuzzDecode4(f *testing.F) {
f.Add([]byte{0x45, 0x00, 0x00, 0x1c, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.SCTP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2, 0x30, 0x39, 0x04, 0xd2, 0x00, 0x00, 0x00, 0x01})
// A fragmented TSMP, strictly disallowed
f.Add([]byte{0x45, 0x00, 0x00, 0x2b, 0x12, 0x34, 0x20, 0x00, 0x40, byte(ipproto.TSMP),
f.Add([]byte{0x45, 0x00, 0x00, 0x1c, 0x12, 0x34, 0x20, 0x00, 0x40, byte(ipproto.TSMP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2,
0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x00})
// A TSMP sub-header shorter than the minimum 7 bytes
f.Add([]byte{0x45, 0x00, 0x00, 0x26, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.TSMP),
f.Add([]byte{0x45, 0x00, 0x00, 0x1a, 0x12, 0x34, 0x00, 0x00, 0x40, byte(ipproto.TSMP),
0x00, 0x00, 10, 0, 0, 1, 10, 0, 0, 2, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60})
// A later fragment with an offset below minFragBlks (8 < 10), rejected
f.Add([]byte{0x45, 0x00, 0x00, 0x14, 0x12, 0x34, 0x20, 0x08, 0x40, byte(ipproto.TCP),
@@ -240,13 +246,28 @@ func FuzzDecode4(f *testing.F) {
f.Fuzz(func(t *testing.T, b []byte) {
var q Parsed
q.b = b
q.decode4(b)
_ = q.Transport()
_ = q.Payload()
_ = q.IsError()
_ = q.IsEchoRequest()
_ = q.IsEchoResponse()
_ = q.EchoIDSeq()
})
}
func FuzzDecode6(f *testing.F) {
f.Add(make([]byte, ip6HeaderLength))
f.Add(append(make([]byte, 40), 0x2c)) // Next Header = Fragment (44)
// Base header Next Header = Fragment (44), with a fragment header claiming
// a first fragment (offset 0) but no sub-header bytes behind it
f.Add(append(append([]byte{
0x60, 0x00, 0x00, 0x00, 0x00, 0x08, byte(ip6FragHeader), 0x40,
}, make([]byte, 32)...), byte(ipproto.UDP), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
// A fragment header cut off mid-way (4 of 8 bytes)
f.Add(append(append([]byte{
0x60, 0x00, 0x00, 0x00, 0x00, 0x04, byte(ip6FragHeader), 0x40,
}, make([]byte, 32)...), byte(ipproto.UDP), 0x00, 0x00, 0x00))
// A complete minimal IPv6 UDP packet (40-byte header + 8-byte UDP header)
f.Add(append(append([]byte{
0x60, 0x00, 0x00, 0x00, 0x00, 0x08, byte(ipproto.UDP), 0x40,
@@ -258,6 +279,22 @@ func FuzzDecode6(f *testing.F) {
}, make([]byte, 32)...),
byte(ipproto.UDP), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0x30, 0x39, 0x04, 0xd2, 0x00, 0x08, 0x00, 0x00))
// A first fragment whose UDP sub-header is truncated past the fragment header
f.Add(append(append([]byte{
0x60, 0x00, 0x00, 0x00, 0x00, 0x0c, byte(ip6FragHeader), 0x40,
}, make([]byte, 32)...),
byte(ipproto.UDP), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0x30, 0x39, 0x04, 0xd2))
// A later fragment with a block offset below minFragBlks (1 < 10), rejected
f.Add(append(append([]byte{
0x60, 0x00, 0x00, 0x00, 0x00, 0x08, byte(ip6FragHeader), 0x40,
}, make([]byte, 32)...),
byte(ipproto.UDP), 0x00, 0x00, 0x08, 0x03, 0x04, 0x05, 0x06))
// A later fragment with a block offset of 32, passed through as ipproto.Fragment
f.Add(append(append([]byte{
0x60, 0x00, 0x00, 0x00, 0x00, 0x0a, byte(ip6FragHeader), 0x40,
}, make([]byte, 32)...),
byte(ipproto.UDP), 0x00, 0x01, 0x00, 0x03, 0x04, 0x05, 0x06, 0xaa, 0xbb))
// A complete minimal IPv6 TCP packet (40-byte header + 20-byte TCP header)
f.Add(append(append([]byte{
0x60, 0x00, 0x00, 0x00, 0x00, 0x14, byte(ipproto.TCP), 0x40,
@@ -279,27 +316,14 @@ func FuzzDecode6(f *testing.F) {
f.Fuzz(func(t *testing.T, b []byte) {
var q Parsed
q.b = b
q.decode6(b)
})
}
func FuzzDecode6Fragment(f *testing.F) {
// A fragment header: next-header(1), reserved(1), frag-ofs+flags(2), identification(4)
f.Add([]byte{0x11, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06})
f.Add(make([]byte, ip6FragHeaderLength))
// A later fragment with a non-zero offset (passed through as ipproto.Fragment)
f.Add([]byte{byte(ipproto.UDP), 0x00, 0x01, 0x00, 0x03, 0x04, 0x05, 0x06, 0xaa, 0xbb})
// 40 dummy bytes + an 8-byte frag header: first fragment (offset 0, next=UDP)
f.Add(append(make([]byte, 40), byte(ipproto.UDP), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01))
// Later fragment with offset 1 (< minFragBlks), rejected
f.Add(append(make([]byte, 40), byte(ipproto.UDP), 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01))
// Later fragment with offset 32, passed through as ipproto.Fragment
f.Add(append(make([]byte, 40), byte(ipproto.UDP), 0x00, 0x01, 0x00, 0x03, 0x04, 0x05, 0x06))
f.Fuzz(func(t *testing.T, b []byte) {
var q Parsed
q.subofs = 40
q.decode6Fragment(b)
_ = q.Transport()
_ = q.Payload()
_ = q.IsError()
_ = q.IsEchoRequest()
_ = q.IsEchoResponse()
_ = q.EchoIDSeq()
})
}
@@ -346,16 +370,16 @@ func FuzzICMP6Checksum(f *testing.F) {
}
func FuzzGeneveDecode(f *testing.F) {
f.Add([]byte{0x00, 0x40, 0x65, 0xb8, 0x00, 0x01, 0x02, 0x03})
f.Add([]byte{0x00, 0x40, 0x65, 0x58, 0x00, 0x01, 0x02, 0x03})
f.Add(make([]byte, GeneveFixedHeaderLength))
// A full valid Geneve header: v0, options-length 2 (8 option bytes)
f.Add(append([]byte{0x00, 0x40, 0x65, 0xb8, 0x12, 0x34, 0x56, 0x00},
f.Add(append([]byte{0x02, 0x40, 0x65, 0x58, 0x12, 0x34, 0x56, 0x00},
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08))
// One byte short of the fixed header
f.Add([]byte{0x00, 0x40, 0x65, 0xb8, 0x00, 0x01, 0x02})
f.Add([]byte{0x00, 0x40, 0x65, 0x58, 0x00, 0x01, 0x02})
// Control bit set, and a non-zero version nibble
f.Add([]byte{0x00, 0xc0, 0x65, 0xb8, 0x12, 0x34, 0x56, 0x00})
f.Add([]byte{0xc0, 0x40, 0x65, 0xb8, 0x12, 0x34, 0x56, 0x00})
f.Add([]byte{0x00, 0xc0, 0x65, 0x58, 0x12, 0x34, 0x56, 0x00})
f.Add([]byte{0xc0, 0x40, 0x65, 0x58, 0x12, 0x34, 0x56, 0x00})
f.Fuzz(func(t *testing.T, b []byte) {
var h GeneveHeader
+9 -2
View File
@@ -149,12 +149,15 @@ func (q *Parsed) decode4(b []byte) {
q.Src = withIP(q.Src, netaddr.IPv4(b[12], b[13], b[14], b[15]))
q.Dst = withIP(q.Dst, netaddr.IPv4(b[16], b[17], b[18], b[19]))
q.subofs = int((b[0] & 0x0F) << 2)
if q.subofs > q.length {
// subofs is only committed to q after validation, so a rejected
// packet can't leave it pointing past the end of the buffer.
subofs := int((b[0] & 0x0F) << 2)
if subofs > q.length {
// next-proto starts beyond end of packet.
q.IPProto = unknown
return
}
q.subofs = subofs
sub := b[q.subofs:]
sub = sub[:len(sub):len(sub)] // help the compiler do bounds check elimination
@@ -478,6 +481,10 @@ func (q *Parsed) Payload() []byte {
// Transport returns the transport header and payload (IP subprotocol, such as TCP or UDP).
// This is a read-only view; that is, p retains the ownership of the buffer.
func (p *Parsed) Transport() []byte {
if p.subofs > len(p.b) {
// defensive check, should not survive decode4 or decode6 (first line)
return nil
}
return p.b[p.subofs:]
}
+2 -2
View File
@@ -195,10 +195,10 @@ var invalid4RequestBuffer = []byte{
}
// Regression check for the IHL field pointing beyond the end of the
// packet.
// packet. The rejected offset must not be recorded in subofs.
var invalid4RequestDecode = Parsed{
b: invalid4RequestBuffer,
subofs: 40,
subofs: 0,
length: len(invalid4RequestBuffer),
IPVersion: 4,