Files
tailscale/control/controlbase/fuzz_test.go
T
Mike Jensen 4b2cd69d7c fuzz: expand fuzzing test coverage (#21123)
This change expands our fuzzing coverage in protocol and parsing logic. No issues discovered from this fuzzing. Wiring into oss-fuzz for continual coverage.

Updates https://github.com/tailscale/corp/issues/46608

Change-Id: I6b5218cb1103ccc5b957c512a10d87f637c4b6e5

Signed-off-by: Mike Jensen <mikej@tailscale.com>
2026-09-14 09:07:29 -06:00

232 lines
8.2 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlbase
import (
"bytes"
"crypto/cipher"
"encoding/binary"
"io"
"net"
"slices"
"testing"
"time"
chp "golang.org/x/crypto/chacha20poly1305"
)
func FuzzWholeMessageLocked(f *testing.F) {
f.Add([]byte{4, 0x00, 0x02})
// One complete record frame (3-byte header + 2-byte payload)
f.Add([]byte{byte(msgTypeRecord), 0x00, 0x02, 0xaa, 0xbb})
// Two consecutive frames
f.Add([]byte{byte(msgTypeRecord), 0x00, 0x02, 0xaa, 0xbb, byte(msgTypeRecord), 0x00, 0x01, 0xcc})
// Empty buffer and zero-length frame
f.Add([]byte{})
f.Add([]byte{byte(msgTypeRecord), 0x00, 0x00})
// A length field of 0xffff, far beyond any possible buffer
f.Add([]byte{byte(msgTypeRecord), 0xff, 0xff})
// rx.next (from last byte) beyond rx.n, making available negative
f.Add([]byte{byte(msgTypeRecord), 0x00, 0x00, 0xff})
// A frame that exactly fills maxMessageSize (3-byte header + 0x0ffd payload), and one byte over
f.Add(append([]byte{byte(msgTypeRecord), 0x0f, 0xfd}, make([]byte, maxMessageSize-headerLen)...))
f.Add(append([]byte{byte(msgTypeRecord), 0x0f, 0xfe}, make([]byte, maxMessageSize-headerLen-1)...))
f.Fuzz(func(t *testing.T, data []byte) {
var c Conn
c.rx.buf = new(maxMsgBuffer)
n := min(len(data), maxMessageSize)
copy(c.rx.buf[:], data[:n])
c.rx.n = n
if len(data) > 0 {
c.rx.next = int(data[len(data)-1]) % (maxMessageSize + 1)
}
_ = c.wholeMessageLocked()
})
}
// newTestAEAD returns an AEAD keyed with all-zeros for use in fuzz
// tests. The cipher is stateless, so it can be shared across iterations
// as long as each iteration uses a fresh Conn (fresh nonce).
func newTestAEAD(tb testing.TB) cipher.AEAD {
key := make([]byte, chp.KeySize)
aead, err := chp.New(key)
if err != nil {
tb.Fatal(err)
}
return aead
}
// sealFrame produces one valid Noise record frame containing plaintext,
// encrypted with nonce. The returned slice is freshly allocated so it
// can be used as fuzz seed data.
func sealFrame(aead cipher.AEAD, nonce *nonce, plaintext []byte) []byte {
buf := new(maxMsgBuffer)
buf[0] = msgTypeRecord
binary.BigEndian.PutUint16(buf[1:headerLen], uint16(len(plaintext)+chp.Overhead))
ret := aead.Seal(buf[:headerLen], nonce[:], plaintext, nil)
return slices.Clone(ret)
}
func FuzzDecryptLocked(f *testing.F) {
aead := newTestAEAD(f)
var nonce nonce
f.Add([]byte{4, 2, 0, 3, 6})
// Frames with a wrong message type byte, hitting the type-check error path
f.Add([]byte{0x00, 0x00, 0x10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0})
f.Add([]byte{0x01, 0x00, 0x10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0})
f.Add([]byte{0x03, 0x00, 0x10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0})
f.Add([]byte{0xff, 0x00, 0x10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0})
// Ciphertext shorter than the AEAD overhead, all reaching Open failure
f.Add([]byte{byte(msgTypeRecord), 0x00, 0x00})
f.Add([]byte{byte(msgTypeRecord), 0x00, 0x01, 0xaa})
f.Add(append([]byte{byte(msgTypeRecord), 0x00, 0x0f}, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15))
// Exactly 16 bytes of ciphertext with a bogus tag
f.Add(append([]byte{byte(msgTypeRecord), 0x00, 0x10}, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16))
// A length field of 0xffff with only 100 bytes of ciphertext behind it
f.Add(append([]byte{byte(msgTypeRecord), 0xff, 0xff}, make([]byte, 100)...))
// Seed with a validly-encrypted frame so the successful Open path,
// nonce increment and plaintext consumption are reached.
f.Add(sealFrame(aead, &nonce, []byte("hello")))
// A valid empty-plaintext frame at nonce 0, for the zero-length success path
f.Add(sealFrame(aead, &nonce, nil))
nonce.Increment()
f.Add(sealFrame(aead, &nonce, nil))
f.Fuzz(func(t *testing.T, msg []byte) {
if len(msg) < headerLen {
return
}
var c Conn
c.rx.cipher = aead
_ = c.decryptLocked(msg)
})
}
// fuzzConn is a net.Conn that yields the fuzzed bytes and then EOF.
type fuzzConn struct {
r *bytes.Reader
}
func (fuzzConn) Write([]byte) (int, error) { return 0, nil }
func (c fuzzConn) Close() error { return nil }
func (c fuzzConn) Read(p []byte) (int, error) {
return c.r.Read(p)
}
func (fuzzConn) LocalAddr() net.Addr { return dummyAddr{} }
func (fuzzConn) RemoteAddr() net.Addr { return dummyAddr{} }
func (fuzzConn) SetDeadline(time.Time) error { return nil }
func (fuzzConn) SetReadDeadline(time.Time) error { return nil }
func (fuzzConn) SetWriteDeadline(time.Time) error { return nil }
type dummyAddr struct{}
func (dummyAddr) Network() string { return "tcp" }
func (dummyAddr) String() string { return "" }
// recordConn captures everything written to it, and optionally fails a
// chosen write. Used to drive Conn.Write without real I/O.
type recordConn struct {
bs bytes.Buffer
writes int
failAt int // index of the write that should fail; -1 for never
}
func (c *recordConn) Write(p []byte) (int, error) {
if c.failAt == c.writes {
c.writes++
return 0, errWriteFailure{}
}
c.writes++
return c.bs.Write(p)
}
func (*recordConn) Read([]byte) (int, error) { return 0, io.EOF }
func (*recordConn) Close() error { return nil }
func (*recordConn) LocalAddr() net.Addr { return dummyAddr{} }
func (*recordConn) RemoteAddr() net.Addr { return dummyAddr{} }
func (*recordConn) SetDeadline(time.Time) error { return nil }
func (*recordConn) SetReadDeadline(time.Time) error { return nil }
func (*recordConn) SetWriteDeadline(time.Time) error {
return nil
}
type errWriteFailure struct{}
func (errWriteFailure) Error() string { return "write failure" }
func FuzzConnRead(f *testing.F) {
aead := newTestAEAD(f)
var nonce nonce
frame1 := sealFrame(aead, &nonce, []byte("hello"))
nonce.Increment()
frame2 := sealFrame(aead, &nonce, nil)
twoFrames := append(frame1, frame2...)
// Fuzz data follows the two prepended frames, so it is consumed at nonce 2
nonce.Increment()
// A third valid frame so the success path is reached again (the fuzzer can't forge AEAD)
f.Add(sealFrame(aead, &nonce, []byte("x")))
// In-size frames with a wrong type byte
f.Add(append([]byte{byte(msgTypeError), 0x00, 0x13}, make([]byte, 19)...))
f.Add(append([]byte{byte(msgTypeInitiation), 0x00, 0x13}, make([]byte, 19)...))
// Short and full-size garbage ciphertexts, hitting Open failure
f.Add([]byte{byte(msgTypeRecord), 0x00, 0x00})
f.Add([]byte{byte(msgTypeRecord), 0x00, 0x01, 0xaa})
f.Add(append([]byte{byte(msgTypeRecord), 0x00, 0x10}, make([]byte, 16)...))
f.Add(append([]byte{byte(msgTypeRecord), 0x00, 0x10}, bytes.Repeat([]byte{0xab}, 16)...))
// Length-field boundaries of readNLocked: exactly maxMessageSize, and just over
f.Add(append([]byte{byte(msgTypeRecord), 0x0f, 0xfd}, make([]byte, maxMessageSize-headerLen)...))
f.Add(append([]byte{byte(msgTypeRecord), 0x0f, 0xfd}, make([]byte, maxMessageSize-headerLen-1)...))
f.Add([]byte{byte(msgTypeRecord), 0x0f, 0xfe})
f.Add([]byte{byte(msgTypeRecord), 0x10, 0x00})
f.Add([]byte{byte(msgTypeRecord), 0xff, 0xff})
f.Add(twoFrames)
f.Add(bytes.Repeat([]byte{'a'}, maxMessageSize+10))
f.Fuzz(func(t *testing.T, data []byte) {
var c Conn
c.rx.cipher = aead
stream := append(slices.Clone(twoFrames), data...)
c.conn = &fuzzConn{r: bytes.NewReader(stream)}
var buf [maxPlaintextSize]byte
for range 3 {
if _, err := c.Read(buf[:]); err != nil {
break
}
}
})
}
func FuzzConnWrite(f *testing.F) {
f.Add([]byte("hello"))
f.Add(bytes.Repeat([]byte{'a'}, maxPlaintextSize+1)) // force frame splitting
// Even final byte: no forced write failure, reaching the success path
f.Add([]byte("hell"))
// Successful multi-frame chunking of an over-length payload
f.Add(append(bytes.Repeat([]byte{'b'}, maxPlaintextSize), 'c', 'd'))
// An empty write, skipping the frame loop entirely
f.Add([]byte{})
aead := newTestAEAD(f)
f.Fuzz(func(t *testing.T, data []byte) {
var c Conn
c.tx.cipher = aead
rec := &recordConn{failAt: -1}
if len(data) > 0 && data[len(data)-1]%2 == 1 {
// Force the first frame write to fail, exercising the errPartialWrite fatal-error path
rec.failAt = 0
}
c.conn = rec
_, _ = c.Write(data)
// A second write must observe the recorded error (or succeed)
_, _ = c.Write([]byte("x"))
})
}