mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-14 23:11:39 -04:00
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>
232 lines
8.2 KiB
Go
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"))
|
|
})
|
|
}
|