a73x

internal/agent/netsnoop/netsnoop_test.go

Ref:   Size: 7.3 KiB   History

package netsnoop

import (
	"bytes"
	"net"
	"testing"

	"github.com/insomniacslk/dhcp/dhcpv4"
)

// frame wraps a DHCP payload in Ethernet/IPv4/UDP headers the way it crosses
// a guest tap: server 67 -> client 68.
func frame(t *testing.T, payload []byte) []byte {
	t.Helper()
	return framePorts(t, payload, 67, 68)
}

// framePorts is frame with the UDP ports under the test's control, so the
// port guard can be exercised without hand-rolling a second builder.
func framePorts(t *testing.T, payload []byte, src, dst int) []byte {
	t.Helper()
	udp := make([]byte, 8+len(payload))
	udp[0], udp[1] = byte(src>>8), byte(src)
	udp[2], udp[3] = byte(dst>>8), byte(dst)
	udp[4], udp[5] = byte(len(udp)>>8), byte(len(udp))
	copy(udp[8:], payload)
	ip := make([]byte, 20+len(udp))
	ip[0] = 0x45 // v4, ihl 5
	ip[2], ip[3] = byte(len(ip)>>8), byte(len(ip))
	ip[8] = 64
	ip[9] = 17 // UDP
	copy(ip[20:], udp)
	eth := make([]byte, 14+len(ip))
	eth[12], eth[13] = 0x08, 0x00 // IPv4
	copy(eth[14:], ip)
	return eth
}

// newReply builds a server->client DHCP message. dhcpv4.New defaults the
// opcode to BootRequest (the client direction), so replies set it explicitly.
func newReply(t *testing.T, mods ...dhcpv4.Modifier) *dhcpv4.DHCPv4 {
	t.Helper()
	msg, err := dhcpv4.New(mods...)
	if err != nil {
		t.Fatal(err)
	}
	msg.OpCode = dhcpv4.OpcodeBootReply
	return msg
}

// reply builds a server->client DHCP message of the given type for mac,
// granting yiaddr ("" leaves yiaddr unset).
func reply(t *testing.T, mac net.HardwareAddr, typ dhcpv4.MessageType, yiaddr string) []byte {
	t.Helper()
	mods := []dhcpv4.Modifier{
		dhcpv4.WithMessageType(typ),
		dhcpv4.WithHwAddr(mac),
	}
	if yiaddr != "" {
		mods = append(mods, dhcpv4.WithYourIP(net.ParseIP(yiaddr)))
	}
	return newReply(t, mods...).ToBytes()
}

// withIPOptions splices four bytes of IPv4 option (NOP, NOP, NOP, EOL) into a
// frame's IP header and fixes up IHL and total length, so that the UDP header
// starts at 4*IHL rather than at a fixed 20. It catches anyone reading the
// ports at a constant offset — in the parser or in the kernel filter.
func withIPOptions(t *testing.T, plain []byte) []byte {
	t.Helper()
	out := make([]byte, 0, len(plain)+4)
	out = append(out, plain[:14+20]...)
	out = append(out, 1, 1, 1, 0)
	out = append(out, plain[14+20:]...)
	out[14] = 0x46 // v4, ihl 6
	total := len(out) - 14
	out[16], out[17] = byte(total>>8), byte(total)
	return out
}

// granted is one frame that carries a real lease, with what must be read out
// of it.
type granted struct {
	frame []byte
	mac   net.HardwareAddr
	ip    string
}

// grantedFrames is the corpus of real leases: every shape a site's server
// grants one in that has to survive the receive path intact. Both halves of
// that path answer to it — ParseACK must report each of these, and the kernel
// filter must pass each of them — because the filter runs first, so a frame it
// drops is one the parser never sees.
func grantedFrames(t *testing.T) map[string]granted {
	t.Helper()
	first, _ := net.ParseMAC("52:54:00:aa:bb:cc")
	renewing, _ := net.ParseMAC("52:54:00:de:ad:01")
	plain := frame(t, reply(t, first, dhcpv4.MessageTypeAck, "192.168.0.42"))

	// A renewal is unicast to the guest and carries the options a real server
	// sends a lease with, after the fixed header. Renewals are the only chance
	// to notice a site server moving a guest, so they have to be read exactly
	// like the broadcast first lease.
	renewal := newReply(t,
		dhcpv4.WithMessageType(dhcpv4.MessageTypeAck),
		dhcpv4.WithHwAddr(renewing),
		dhcpv4.WithYourIP(net.ParseIP("10.4.5.6")),
		dhcpv4.WithNetmask(net.CIDRMask(24, 32)),
		dhcpv4.WithRouter(net.ParseIP("10.4.5.1")),
		dhcpv4.WithDNS(net.ParseIP("10.4.5.1")),
	)

	return map[string]granted{
		"first lease":            {plain, first, "192.168.0.42"},
		"renewal with options":   {frame(t, renewal.ToBytes()), renewing, "10.4.5.6"},
		"IP header with options": {withIPOptions(t, plain), first, "192.168.0.42"},
	}
}

func TestParseACKAcceptsEveryGrantedLease(t *testing.T) {
	for name, want := range grantedFrames(t) {
		gotMAC, gotIP, ok := ParseACK(want.frame)
		if !ok || !bytes.Equal(gotMAC, want.mac) || gotIP.String() != want.ip {
			t.Errorf("%s: ParseACK = %v %v %v, want %v %s true",
				name, gotMAC, gotIP, ok, want.mac, want.ip)
		}
	}
}

func TestParseACKRejects(t *testing.T) {
	mac, _ := net.ParseMAC("52:54:00:aa:bb:cc")
	ack := reply(t, mac, dhcpv4.MessageTypeAck, "192.168.0.42")

	// A request the guest sends: client -> server, and a BootRequest (New's
	// default opcode).
	req, err := dhcpv4.New(
		dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest),
		dhcpv4.WithHwAddr(mac),
	)
	if err != nil {
		t.Fatal(err)
	}

	truncatedIP := frame(t, ack)[:14+12]
	badIHL := frame(t, ack)
	badIHL[14] = 0x44 // v4, ihl 4 — shorter than the fixed header allows

	notUDP := frame(t, ack)
	notUDP[14+9] = 6 // TCP

	// A guest that forges an ACK on the right ports still fails the opcode
	// check: only a boot reply grants an address.
	forged := newReply(t,
		dhcpv4.WithMessageType(dhcpv4.MessageTypeAck),
		dhcpv4.WithHwAddr(mac),
		dhcpv4.WithYourIP(net.ParseIP("192.168.0.42")),
	)
	forged.OpCode = dhcpv4.OpcodeBootRequest

	for name, b := range map[string][]byte{
		"offer-not-ack":  frame(t, reply(t, mac, dhcpv4.MessageTypeOffer, "192.168.0.42")),
		"nak-not-ack":    frame(t, reply(t, mac, dhcpv4.MessageTypeNak, "")),
		"guest-request":  framePorts(t, req.ToBytes(), 68, 67),
		"opcode-request": frame(t, forged.ToBytes()),
		"ack-wrong-dst":  framePorts(t, ack, 67, 12345),
		"ack-wrong-src":  framePorts(t, ack, 12345, 68),
		"zero-yiaddr":    frame(t, reply(t, mac, dhcpv4.MessageTypeAck, "")),
		"truncated-ip":   truncatedIP,
		"bad-ihl":        badIHL,
		"not-udp":        notUDP,
		"garbage-dhcp":   frame(t, []byte{1, 2, 3, 4}),
		"short":          {0, 1, 2},
		"not-ipv4":       append(make([]byte, 14), 0xde),
		"arp-ethertype":  append([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x08, 0x06}, make([]byte, 60)...),
		"ipv6-in-v4-eth": func() []byte { f := frame(t, ack); f[14] = 0x65; return f }(),
		"empty":          {},
		"nil":            nil,
	} {
		if _, _, ok := ParseACK(b); ok {
			t.Errorf("%s: ParseACK accepted, want reject", name)
		}
	}
}

// A DHCPv4 yiaddr is four bytes on the wire, so anything ParseACK decodes
// has a non-nil To4 and the To4==nil arm of the address guard is unreachable
// from a real frame. This pins that invariant: if the decoder ever started
// handing back a wider IP, the guard would become live and this test would be
// the record of why it exists.
func TestDecodedYourIPIsAlwaysV4(t *testing.T) {
	mac, _ := net.ParseMAC("52:54:00:aa:bb:cc")
	msg, err := dhcpv4.FromBytes(reply(t, mac, dhcpv4.MessageTypeAck, "192.168.0.42"))
	if err != nil {
		t.Fatal(err)
	}
	if msg.YourIPAddr.To4() == nil {
		t.Fatalf("decoded yiaddr %v is not IPv4-shaped", msg.YourIPAddr)
	}
}

// The snoop runs on every frame a busy tap carries, so it must survive
// arbitrary bytes without panicking — the only failure mode allowed is ok=false.
func TestParseACKFuzzResistant(t *testing.T) {
	mac, _ := net.ParseMAC("52:54:00:aa:bb:cc")
	full := frame(t, reply(t, mac, dhcpv4.MessageTypeAck, "192.168.0.42"))
	for i := range full {
		ParseACK(full[:i]) // every truncation of a real frame
	}
	for i := range full {
		mutated := append([]byte(nil), full...)
		mutated[i] ^= 0xff
		ParseACK(mutated)
	}
}