Skip to content

Commit 47fa73c

Browse files
committed
nclient4: add a fuzz target for BroadcastRawUDPConn.ReadFrom
Fuzzes the raw IPv4/UDP receive path with arbitrary frames and a fuzzed buffer size. It keeps a small self-contained mock and frame builder so the target also builds standalone under OSS-Fuzz. Rather than a few invariants, the target checks ReadFrom against an independent reference model: for the bytes the socket delivered it recomputes, from the header fields, whether the frame is accepted and the exact payload, length, and source, then requires ReadFrom to match. A rejected frame must surface as an error; an accepted one must return that exact payload and source, which locks the length and payload semantics (for example dropping bytes past IP Total Length) and not just the frame type. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
1 parent c308df0 commit 47fa73c

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

dhcpv4/nclient4/fuzz_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright 2018 the u-root Authors. All rights reserved
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build go1.18 && (darwin || freebsd || linux || netbsd || openbsd || dragonfly)
6+
7+
package nclient4
8+
9+
import (
10+
"bytes"
11+
"encoding/binary"
12+
"io"
13+
"net"
14+
"testing"
15+
"time"
16+
)
17+
18+
// fuzzFrame builds an IPv4+UDP frame with the given IPv4 total-length field and
19+
// dhcp payload. It is kept here, not shared with the other tests, so the target
20+
// builds standalone under OSS-Fuzz's go-118-fuzz-build (which sees only this file).
21+
func fuzzFrame(ipTotalLen int, dhcp []byte) []byte {
22+
pkt := make([]byte, 28+len(dhcp))
23+
pkt[0] = 0x45 // IPv4, IHL 5
24+
binary.BigEndian.PutUint16(pkt[2:], uint16(ipTotalLen))
25+
pkt[9] = byte(udpProtocolNumber)
26+
binary.BigEndian.PutUint16(pkt[22:], 68) // UDP destination port
27+
binary.BigEndian.PutUint16(pkt[24:], uint16(8+len(dhcp)))
28+
copy(pkt[28:], dhcp)
29+
return pkt
30+
}
31+
32+
// fuzzConn hands ReadFrom the fuzz frame once, then reports EOF so the receive
33+
// loop stops.
34+
type fuzzConn struct {
35+
frame []byte
36+
done bool
37+
}
38+
39+
func (c *fuzzConn) ReadFrom(p []byte) (int, net.Addr, error) {
40+
if c.done {
41+
return 0, nil, io.EOF
42+
}
43+
c.done = true
44+
return copy(p, c.frame), &net.UDPAddr{}, nil
45+
}
46+
func (c *fuzzConn) WriteTo([]byte, net.Addr) (int, error) { return 0, nil }
47+
func (c *fuzzConn) Close() error { return nil }
48+
func (c *fuzzConn) LocalAddr() net.Addr { return &net.UDPAddr{} }
49+
func (c *fuzzConn) SetDeadline(time.Time) error { return nil }
50+
func (c *fuzzConn) SetReadDeadline(time.Time) error { return nil }
51+
func (c *fuzzConn) SetWriteDeadline(time.Time) error { return nil }
52+
53+
// expectedReadFrom independently recomputes what ReadFrom must return for the
54+
// bytes the socket actually delivered, so the fuzz target pins the exact length,
55+
// payload, and source rather than only the frame type. It encodes the base
56+
// receive-path contract (#583, #292): the payload is the IPv4 payload after the
57+
// UDP header bounded by IP Total Length, so bytes past Total Length are dropped.
58+
//
59+
// It does not yet model the #589 UDP Length bound or the #591 fragment rejection,
60+
// which live on separate branches. When this target is rebased onto them, also
61+
// require 8 <= UDP Length <= IP payload, take the payload as
62+
// delivered[hlen+8 : hlen+udpLen], and reject MF or a non-zero fragment offset.
63+
func expectedReadFrom(delivered []byte, bufLen int) (accept bool, wantN int, payload []byte, srcIP net.IP, srcPort int) {
64+
if len(delivered) < ipv4MinimumSize {
65+
return false, 0, nil, nil, 0
66+
}
67+
hlen := int(delivered[0]&0x0f) * 4
68+
tlen := int(binary.BigEndian.Uint16(delivered[2:4]))
69+
// isValid: header within [20, total length], total length within the frame.
70+
if hlen < ipv4MinimumSize || hlen > tlen || tlen > len(delivered) {
71+
return false, 0, nil, nil, 0
72+
}
73+
if delivered[0]>>4 != 4 { // IPv4 version
74+
return false, 0, nil, nil, 0
75+
}
76+
if delivered[9] != byte(udpProtocolNumber) {
77+
return false, 0, nil, nil, 0
78+
}
79+
ipPayloadLen := tlen - hlen
80+
if ipPayloadLen < udpMinimumSize { // #583: room for a UDP header
81+
return false, 0, nil, nil, 0
82+
}
83+
// tlen <= len(delivered) and ipPayloadLen >= 8 together put the UDP header
84+
// and the payload up to Total Length within the delivered bytes.
85+
if binary.BigEndian.Uint16(delivered[hlen+2:hlen+4]) != 68 { // destination port
86+
return false, 0, nil, nil, 0
87+
}
88+
payload = delivered[hlen+udpMinimumSize : tlen] // bytes past Total Length dropped (#292)
89+
wantN = ipPayloadLen - udpMinimumSize
90+
if wantN > bufLen {
91+
wantN = bufLen
92+
}
93+
srcIP = net.IP(delivered[srcAddr : srcAddr+ipv4AddressSize])
94+
srcPort = int(binary.BigEndian.Uint16(delivered[hlen : hlen+2]))
95+
return true, wantN, payload, srcIP, srcPort
96+
}
97+
98+
// FuzzBroadcastRawUDPConnReadFrom feeds arbitrary bytes as one raw frame into
99+
// ReadFrom with a fuzzed buffer size and checks the result against an
100+
// independent model: a rejected frame must surface as an error, and an accepted
101+
// frame must return exactly the payload, length, and source the model derives.
102+
func FuzzBroadcastRawUDPConnReadFrom(f *testing.F) {
103+
dhcp := []byte("hello world dhcp payload")
104+
seeds := [][]byte{
105+
fuzzFrame(20, nil), // IPv4 payload 0, below the UDP header (#583)
106+
fuzzFrame(27, nil), // IPv4 payload 7, below the UDP header (#583)
107+
fuzzFrame(28, []byte{1, 2, 3}), // IPv4 payload 8, empty datagram
108+
fuzzFrame(28+len(dhcp), dhcp), // a valid reply
109+
fuzzFrame(28, []byte("trailing data past total")), // total length stops at the header (#292)
110+
{0x45, 0, 0, 40}, // header claims 40 bytes, frame truncated (#455)
111+
{0x4f, 0, 0, 60}, // IHL 15 header with nothing after it (#507)
112+
{0x45}, // truncated IPv4 header
113+
{}, // empty frame
114+
}
115+
for _, s := range seeds {
116+
f.Add(s, uint16(512))
117+
}
118+
f.Add(fuzzFrame(28+len(dhcp), dhcp), uint16(0)) // a valid reply into an empty buffer
119+
f.Add(fuzzFrame(28+len(dhcp), dhcp), uint16(4)) // ... and one too small to hold it
120+
121+
f.Fuzz(func(t *testing.T, raw []byte, bufLen uint16) {
122+
b := make([]byte, int(bufLen%2048))
123+
upc := &BroadcastRawUDPConn{
124+
PacketConn: &fuzzConn{frame: raw},
125+
boundAddr: &net.UDPAddr{Port: 68},
126+
}
127+
n, addr, err := upc.ReadFrom(b)
128+
129+
// The socket copies raw into a fixed-size receive buffer, so ReadFrom only
130+
// ever parses this prefix.
131+
maxRecv := ipv4MaximumHeaderSize + udpMinimumSize + len(b)
132+
delivered := raw
133+
if len(delivered) > maxRecv {
134+
delivered = delivered[:maxRecv]
135+
}
136+
accept, wantN, payload, srcIP, srcPort := expectedReadFrom(delivered, len(b))
137+
138+
if !accept {
139+
if err == nil {
140+
t.Fatalf("accepted a frame the model rejects: n=%d delivered=%x", n, delivered)
141+
}
142+
return
143+
}
144+
if err != nil {
145+
t.Fatalf("rejected a frame the model accepts: err=%v delivered=%x", err, delivered)
146+
}
147+
if n != wantN || n < 0 || n > len(b) {
148+
t.Fatalf("ReadFrom n=%d, want %d (buffer %d)", n, wantN, len(b))
149+
}
150+
if !bytes.Equal(b[:n], payload[:wantN]) {
151+
t.Fatalf("ReadFrom payload=%x, want %x", b[:n], payload[:wantN])
152+
}
153+
ua, ok := addr.(*net.UDPAddr)
154+
if !ok || ua == nil {
155+
t.Fatalf("ReadFrom addr=%v, want *net.UDPAddr", addr)
156+
}
157+
if !ua.IP.Equal(srcIP) {
158+
t.Fatalf("ReadFrom source IP=%v, want %v", ua.IP, srcIP)
159+
}
160+
if ua.Port != srcPort {
161+
t.Fatalf("ReadFrom source port=%d, want %d", ua.Port, srcPort)
162+
}
163+
})
164+
}

0 commit comments

Comments
 (0)