mirror of
https://github.com/luxfi/lattice.git
synced 2026-09-24 00:25:44 +00:00
ReadUint{16,32,64}Slice recurses on c[buffered:] without making progress
when the underlying Reader returns a partial Peek (buffered == 0) but no
error. An attacker-supplied wire prefix combined with a long destination
slice exhausts the goroutine stack on the destination chain.
Discovered by github.com/luxfi/warp/pulsar's FuzzPulseDeserialize
harness during the Mar-3 2026 PQ Consensus Architecture Freeze
(regression seed: testdata/fuzz/FuzzPulseDeserialize/ccdb090e0ca0007b).
Fix:
- Replace recursion with an iterative loop (O(1) stack)
- Detect zero-progress reads and return ErrZeroProgress
- Add Read{Uint16,Uint32,Uint64}SliceBounded length-prefix-aware
wrappers gated by MaxSliceLen() (default 1 MiB, configurable via
LUX_LATTICE_MAX_UINT64_SLICE_LEN env var)
- Regression tests for all three slice readers + happy path + bounded
rejection
174 lines
5.3 KiB
Go
174 lines
5.3 KiB
Go
// Copyright (c) Lux Industries Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package buffer
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
)
|
|
|
|
// truncReader is a Reader implementation that exposes a payload shorter
|
|
// than what its Size() advertises. This mirrors the malicious wire format
|
|
// discovered by luxfi/warp/pulsar's FuzzPulseDeserialize: a giant outer
|
|
// length prefix combined with a few bytes of payload. Without the
|
|
// zero-progress guard, ReadUint{16,32,64}Slice recurses on c[buffered:]
|
|
// where buffered == 0, exhausting the goroutine stack.
|
|
type truncReader struct {
|
|
buf []byte
|
|
advertise int // value returned by Size(); intentionally != len(buf)
|
|
off int
|
|
}
|
|
|
|
func newTruncReader(payload []byte, advertise int) *truncReader {
|
|
return &truncReader{buf: payload, advertise: advertise}
|
|
}
|
|
|
|
func (r *truncReader) Read(p []byte) (int, error) {
|
|
if r.off >= len(r.buf) {
|
|
return 0, io.EOF
|
|
}
|
|
n := copy(p, r.buf[r.off:])
|
|
r.off += n
|
|
return n, nil
|
|
}
|
|
|
|
// Size reports the *advertised* size, NOT the actual remaining bytes.
|
|
// This is what triggered the original CVE: the lattigo path trusts
|
|
// r.Size() for capping its Peek length.
|
|
func (r *truncReader) Size() int {
|
|
remain := r.advertise - r.off
|
|
if remain < 0 {
|
|
return 0
|
|
}
|
|
return remain
|
|
}
|
|
|
|
// Peek returns up to n bytes of the actual buffer, but does NOT return
|
|
// io.EOF when the request exceeds the actual buffer — instead it returns
|
|
// (short, nil). This is the exact misbehavior produced by the warp/pulsar
|
|
// regression seed; bufio.Reader can also reach this state when the
|
|
// underlying io.Reader returns (0, nil).
|
|
func (r *truncReader) Peek(n int) ([]byte, error) {
|
|
avail := len(r.buf) - r.off
|
|
if avail >= n {
|
|
return r.buf[r.off : r.off+n], nil
|
|
}
|
|
// Short, non-erroring peek: the bug surface.
|
|
return r.buf[r.off:], nil
|
|
}
|
|
|
|
func (r *truncReader) Discard(n int) (int, error) {
|
|
avail := len(r.buf) - r.off
|
|
if n > avail {
|
|
r.off = len(r.buf)
|
|
return avail, io.EOF
|
|
}
|
|
r.off += n
|
|
return n, nil
|
|
}
|
|
|
|
// TestReadUint64Slice_ZeroProgress is the regression test for the DoS
|
|
// vulnerability discovered by github.com/luxfi/warp/pulsar's
|
|
// FuzzPulseDeserialize harness, regression seed ccdb090e0ca0007b. Prior
|
|
// to the fix in cap.go + the iterative rewrite of ReadUint{16,32,64}Slice,
|
|
// this test would crash with `runtime: goroutine stack exceeds limit`.
|
|
func TestReadUint64Slice_ZeroProgress(t *testing.T) {
|
|
// Two bytes of payload, advertise 1 GiB. After decoding `buffered=0`
|
|
// elements the buggy code recurses on c[0:] which equals c.
|
|
r := newTruncReader([]byte{0x41, 0x42}, 1<<30)
|
|
|
|
// Destination slice large enough to make the fault observable.
|
|
c := make([]uint64, 1<<20)
|
|
|
|
_, err := ReadUint64Slice(r, c)
|
|
if !errors.Is(err, ErrZeroProgress) {
|
|
t.Fatalf("expected ErrZeroProgress, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestReadUint32Slice_ZeroProgress verifies the same guard for uint32.
|
|
func TestReadUint32Slice_ZeroProgress(t *testing.T) {
|
|
r := newTruncReader([]byte{0x01}, 1<<30)
|
|
c := make([]uint32, 1<<20)
|
|
|
|
_, err := ReadUint32Slice(r, c)
|
|
if !errors.Is(err, ErrZeroProgress) {
|
|
t.Fatalf("expected ErrZeroProgress, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestReadUint16Slice_ZeroProgress verifies the same guard for uint16.
|
|
func TestReadUint16Slice_ZeroProgress(t *testing.T) {
|
|
r := newTruncReader([]byte{}, 1<<30)
|
|
c := make([]uint16, 1<<20)
|
|
|
|
_, err := ReadUint16Slice(r, c)
|
|
if !errors.Is(err, ErrZeroProgress) {
|
|
t.Fatalf("expected ErrZeroProgress, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestReadUint64Slice_HappyPath ensures the iterative rewrite still
|
|
// decodes correctly when the Peek returns a partial slice that DOES
|
|
// permit progress (>= 8 bytes). This guards against regressing the
|
|
// original lattigo callers, which depend on multi-step decoding of
|
|
// large polynomials over a chunked Reader.
|
|
func TestReadUint64Slice_HappyPath(t *testing.T) {
|
|
const N = 16
|
|
payload := make([]byte, N*8)
|
|
for i := 0; i < N; i++ {
|
|
// Little-endian uint64 with i+1 in the low byte.
|
|
payload[i*8] = byte(i + 1)
|
|
}
|
|
|
|
// Buffer-backed reader exercises the in-tree happy path.
|
|
b := NewBuffer(payload)
|
|
c := make([]uint64, N)
|
|
|
|
n, err := ReadUint64Slice(b, c)
|
|
if err != nil {
|
|
t.Fatalf("ReadUint64Slice: %v", err)
|
|
}
|
|
if n != int64(N*8) {
|
|
t.Fatalf("read %d bytes, want %d", n, N*8)
|
|
}
|
|
for i := 0; i < N; i++ {
|
|
if c[i] != uint64(i+1) {
|
|
t.Fatalf("c[%d] = %d, want %d", i, c[i], i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReadUint64SliceBounded_RejectsHugeLength asserts that the
|
|
// length-bounded wrapper rejects attacker-controlled slice lengths
|
|
// before any Peek is issued.
|
|
func TestReadUint64SliceBounded_RejectsHugeLength(t *testing.T) {
|
|
r := NewBuffer([]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08})
|
|
_, _, err := ReadUint64SliceBounded(r, MaxSliceLen()+1)
|
|
if !errors.Is(err, ErrSliceTooLarge) {
|
|
t.Fatalf("expected ErrSliceTooLarge, got %v", err)
|
|
}
|
|
_, _, err = ReadUint64SliceBounded(r, -1)
|
|
if !errors.Is(err, ErrSliceTooLarge) {
|
|
t.Fatalf("expected ErrSliceTooLarge for negative, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSetMaxSliceLen confirms the cap can be tightened at runtime and
|
|
// reset to default with n <= 0.
|
|
func TestSetMaxSliceLen(t *testing.T) {
|
|
t.Cleanup(func() { SetMaxSliceLen(0) })
|
|
|
|
SetMaxSliceLen(64)
|
|
if got := MaxSliceLen(); got != 64 {
|
|
t.Fatalf("MaxSliceLen() = %d, want 64", got)
|
|
}
|
|
|
|
SetMaxSliceLen(0) // reset
|
|
if got := MaxSliceLen(); got != DefaultMaxSliceLen {
|
|
t.Fatalf("MaxSliceLen() = %d, want %d after reset", got, DefaultMaxSliceLen)
|
|
}
|
|
}
|