net/packet: cleanup IPv4 fragment guards

The first packet fragment guard had an additional guard clause that was
incorrectly comparing a length in bytes to a length in octets, and was
also comparing what should have been an entire IPv4 through transport
header length to a subprotocol payload length. The subprotocol header
size guards were otherwise protecting against short transport headers,
as is the conservative non-first fragment minimum offset size. Add an
explicit disallowing of fragmentation for TSMP for the avoidance of
doubt.

Updates #cleanup
Updates #5727

Signed-off-by: James Tucker <james@tailscale.com>
This commit is contained in:
James Tucker 2025-06-03 15:24:31 -07:00 committed by James Tucker
parent b0f7b23efe
commit 9206e766ed
4 changed files with 149 additions and 10 deletions

View File

@ -8,6 +8,7 @@ import (
"math"
)
const igmpHeaderLength = 8
const tcpHeaderLength = 20
const sctpHeaderLength = 12

View File

@ -161,14 +161,8 @@ func (q *Parsed) decode4(b []byte) {
if fragOfs == 0 {
// This is the first fragment
if moreFrags && len(sub) < minFragBlks {
// Suspiciously short first fragment, dump it.
q.IPProto = unknown
return
}
// otherwise, this is either non-fragmented (the usual case)
// or a big enough initial fragment that we can read the
// whole subprotocol header.
// Every protocol below MUST check that it has at least one entire
// transport header in order to protect against fragment confusion.
switch q.IPProto {
case ipproto.ICMPv4:
if len(sub) < icmp4HeaderLength {
@ -180,6 +174,10 @@ func (q *Parsed) decode4(b []byte) {
q.dataofs = q.subofs + icmp4HeaderLength
return
case ipproto.IGMP:
if len(sub) < igmpHeaderLength {
q.IPProto = unknown
return
}
// Keep IPProto, but don't parse anything else
// out.
return
@ -212,6 +210,15 @@ func (q *Parsed) decode4(b []byte) {
q.Dst = withPort(q.Dst, binary.BigEndian.Uint16(sub[2:4]))
return
case ipproto.TSMP:
// Strictly disallow fragmented TSMP
if moreFrags {
q.IPProto = unknown
return
}
if len(sub) < minTSMPSize {
q.IPProto = unknown
return
}
// Inter-tailscale messages.
q.dataofs = q.subofs
return
@ -224,8 +231,11 @@ func (q *Parsed) decode4(b []byte) {
} else {
// This is a fragment other than the first one.
if fragOfs < minFragBlks {
// First frag was suspiciously short, so we can't
// trust the followup either.
// disallow fragment offsets that are potentially inside of a
// transport header. This is notably asymmetric with the
// first-packet limit, that may allow a first-packet that requires a
// shorter offset than this limit, but without state to tie this
// to the first fragment we can not allow shorter packets.
q.IPProto = unknown
return
}
@ -315,6 +325,10 @@ func (q *Parsed) decode6(b []byte) {
q.Dst = withPort(q.Dst, binary.BigEndian.Uint16(sub[2:4]))
return
case ipproto.TSMP:
if len(sub) < minTSMPSize {
q.IPProto = unknown
return
}
// Inter-tailscale messages.
q.dataofs = q.subofs
return

View File

@ -385,6 +385,124 @@ var sctpDecode = Parsed{
Dst: mustIPPort("100.74.70.3:456"),
}
var ipv4ShortFirstFragmentBuffer = []byte{
// IP header (20 bytes)
0x45, 0x00, 0x00, 0x4f, // Total length 79 bytes
0x00, 0x01, 0x20, 0x00, // ID, Flags (MoreFragments set, offset 0)
0x40, 0x06, 0x00, 0x00, // TTL, Protocol (TCP), Checksum
0x01, 0x02, 0x03, 0x04, // Source IP
0x05, 0x06, 0x07, 0x08, // Destination IP
// TCP header (20 bytes), but packet is truncated to 59 bytes of TCP data
// (total 79 bytes, 20 for IP)
0x00, 0x7b, 0x02, 0x37, 0x00, 0x00, 0x12, 0x34, 0x00, 0x00, 0x00, 0x00,
0x50, 0x12, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
// Payload (39 bytes)
0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61,
0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61,
0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61,
}
var ipv4ShortFirstFragmentDecode = Parsed{
b: ipv4ShortFirstFragmentBuffer,
subofs: 20,
dataofs: 40,
length: len(ipv4ShortFirstFragmentBuffer),
IPVersion: 4,
IPProto: ipproto.TCP,
Src: mustIPPort("1.2.3.4:123"),
Dst: mustIPPort("5.6.7.8:567"),
TCPFlags: 0x12, // SYN + ACK
}
var ipv4SmallOffsetFragmentBuffer = []byte{
// IP header (20 bytes)
0x45, 0x00, 0x00, 0x28, // Total length 40 bytes
0x00, 0x01, 0x20, 0x08, // ID, Flags (MoreFragments set, offset 8 bytes (0x08 / 8 = 1))
0x40, 0x06, 0x00, 0x00, // TTL, Protocol (TCP), Checksum
0x01, 0x02, 0x03, 0x04, // Source IP
0x05, 0x06, 0x07, 0x08, // Destination IP
// Payload (20 bytes) - this would be part of the TCP header in a real scenario
0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61,
0x61, 0x61, 0x61, 0x61,
}
var ipv4SmallOffsetFragmentDecode = Parsed{
b: ipv4SmallOffsetFragmentBuffer,
subofs: 20, // subofs will still be set based on IHL
dataofs: 0, // It's unknown, so dataofs should be 0
length: len(ipv4SmallOffsetFragmentBuffer),
IPVersion: 4,
IPProto: ipproto.Unknown, // Expected to be Unknown
Src: mustIPPort("1.2.3.4:0"),
Dst: mustIPPort("5.6.7.8:0"),
}
// First fragment packet missing exactly one byte of the TCP header
var ipv4OneByteShortTCPHeaderBuffer = []byte{
// IP header (20 bytes)
0x45, 0x00, 0x00, 0x27, // Total length 51 bytes (20 IP + 19 TCP)
0x00, 0x01, 0x20, 0x00, // ID, Flags (MoreFragments set, offset 0)
0x40, 0x06, 0x00, 0x00, // TTL, Protocol (TCP), Checksum
0x01, 0x02, 0x03, 0x04, // Source IP
0x05, 0x06, 0x07, 0x08, // Destination IP
// TCP header - only 19 bytes (one byte short of the required 20)
0x00, 0x7b, 0x02, 0x37, // Source port, Destination port
0x00, 0x00, 0x12, 0x34, // Sequence number
0x00, 0x00, 0x00, 0x00, // Acknowledgment number
0x50, 0x12, 0x01, 0x00, // Data offset, flags, window size
0x00, 0x00, 0x00, // Checksum (missing the last byte of urgent pointer)
}
// IPv4 packet with maximum header length (60 bytes = 15 words) and a TCP header that's
// one byte short of being complete
var ipv4MaxHeaderShortTCPBuffer = []byte{
// IP header with max options (60 bytes)
0x4F, 0x00, 0x00, 0x4F, // Version (4) + IHL (15), ToS, Total length 79 bytes (60 IP + 19 TCP)
0x00, 0x01, 0x20, 0x00, // ID, Flags (MoreFragments set, offset 0)
0x40, 0x06, 0x00, 0x00, // TTL, Protocol (TCP), Checksum
0x01, 0x02, 0x03, 0x04, // Source IP
0x05, 0x06, 0x07, 0x08, // Destination IP
// IPv4 options (40 bytes)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
0x01, 0x01, 0x01, 0x01, // 4 NOP options (padding)
// TCP header - only 19 bytes (one byte short of the required 20)
0x00, 0x7b, 0x02, 0x37, // Source port, Destination port
0x00, 0x00, 0x12, 0x34, // Sequence number
0x00, 0x00, 0x00, 0x00, // Acknowledgment number
0x50, 0x12, 0x01, 0x00, // Data offset, flags, window size
0x00, 0x00, 0x00, // Checksum (missing the last byte of urgent pointer)
}
var ipv4MaxHeaderShortTCPDecode = Parsed{
b: ipv4MaxHeaderShortTCPBuffer,
subofs: 60, // 60 bytes for full IPv4 header with max options
dataofs: 0, // It's unknown, so dataofs should be 0
length: len(ipv4MaxHeaderShortTCPBuffer),
IPVersion: 4,
IPProto: ipproto.Unknown, // Expected to be Unknown
Src: mustIPPort("1.2.3.4:0"),
Dst: mustIPPort("5.6.7.8:0"),
}
var ipv4OneByteShortTCPHeaderDecode = Parsed{
b: ipv4OneByteShortTCPHeaderBuffer,
subofs: 20,
dataofs: 0, // It's unknown, so dataofs should be 0
length: len(ipv4OneByteShortTCPHeaderBuffer),
IPVersion: 4,
IPProto: ipproto.Unknown, // Expected to be Unknown
Src: mustIPPort("1.2.3.4:0"),
Dst: mustIPPort("5.6.7.8:0"),
}
func TestParsedString(t *testing.T) {
tests := []struct {
name string
@ -450,6 +568,10 @@ func TestDecode(t *testing.T) {
{"ipv4_sctp", sctpBuffer, sctpDecode},
{"ipv4_frag", tcp4MediumFragmentBuffer, tcp4MediumFragmentDecode},
{"ipv4_fragtooshort", tcp4ShortFragmentBuffer, tcp4ShortFragmentDecode},
{"ipv4_short_first_fragment", ipv4ShortFirstFragmentBuffer, ipv4ShortFirstFragmentDecode},
{"ipv4_small_offset_fragment", ipv4SmallOffsetFragmentBuffer, ipv4SmallOffsetFragmentDecode},
{"ipv4_one_byte_short_tcp_header", ipv4OneByteShortTCPHeaderBuffer, ipv4OneByteShortTCPHeaderDecode},
{"ipv4_max_header_short_tcp", ipv4MaxHeaderShortTCPBuffer, ipv4MaxHeaderShortTCPDecode},
{"ip97", mustHexDecode("4500 0019 d186 4000 4061 751d 644a 4603 6449 e549 6865 6c6c 6f"), Parsed{
IPVersion: 4,

View File

@ -19,6 +19,8 @@ import (
"tailscale.com/types/ipproto"
)
const minTSMPSize = 7 // the rejected body is 7 bytes
// TailscaleRejectedHeader is a TSMP message that says that one
// Tailscale node has rejected the connection from another. Unlike a
// TCP RST, this includes a reason.