Files
zeekay 414aa8fa29 precompiles: parse calldata through one cursor, answer with one verdict
Class 1. Every parser walked its wire format with hand-written slice
expressions, and the reason that is dangerous is not the one written in the
old fixtures' comments. A precompile's input is the two-index slice
Memory.GetPtr returns; nothing on the path to Run copies it. len is what the
caller declared and paid for, cap is the rest of EVM memory, and the caller
MSTOREd it. So s[a:b] past len does not panic and does not halt a validator --
it returns bytes the attacker chose and the verifier answers over them.
Reverting one cursor read in frost and declaring the input one byte short
returns verdict word 1 over a byte nobody sent: an accept, not a crash.

contract.Cursor bounds every read against len, caps what it returns so a field
cannot be re-sliced forward, and refuses a declared count before it sizes
anything -- Fields divides rather than multiplying, because n*w wraps and a
wrapped product compares as small. STARKVerifyPrecompile did make([]uint64, n)
for an attacker-chosen uint32, up to 34 GB, and only then checked.

Class 2. (bool, error) has four states and three meanings: (false, nil) is
both "verified and rejected" and "nothing ran". That collision is why one
verifier could fold its rounds, discard the result, return true, and read as
ordinary code. contract.Verdict carries the missing bit, so its zero value
denies and a refusal is distinguishable from a rejection. A positive can only
come from Checked, and Checked(true) fails the build -- reintroduced in
fflonkVerify, the compiler and go vet both pass it, the gate names the line.

Nine parse functions across eight packages carry zero hand-written bounds now.
The class rests on two checks in Cursor; deleting either fails every suite,
including with fixtures degraded back to plain append -- which is what the
class used to depend on.
2026-08-28 12:10:59 -07:00

209 lines
6.9 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package contract
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
)
// TestCursorRefusesPastTheDeclaredEnd is the property the whole type exists
// for. A hand-written s[a:b] over this input succeeds and returns poison; the
// cursor refuses.
func TestCursorRefusesPastTheDeclaredEnd(t *testing.T) {
in := Poisoned([]byte{1, 2, 3, 4}, 64)
// The bug, spelled out: a slice expression does not save you here.
require.NotPanics(t, func() {
over := in[:20]
require.Equal(t, byte(0xA5), over[10],
"a hand-written slice reads attacker bytes past the declared end")
})
// The cursor refuses the same read.
c := Read(in)
_, err := c.Bytes(20)
require.ErrorIs(t, err, ErrShort)
require.ErrorIs(t, err, ErrInvalidInput, "callers refusing on ErrInvalidInput keep refusing")
require.Equal(t, 4, c.Len(), "a refused read consumes nothing")
// And every field reader refuses at the boundary rather than reading on.
c = Read(in)
_, err = c.Bytes(4)
require.NoError(t, err)
_, err = c.Byte()
require.ErrorIs(t, err, ErrShort)
_, err = c.Uint16()
require.ErrorIs(t, err, ErrShort)
_, err = c.Uint32()
require.ErrorIs(t, err, ErrShort)
_, err = c.Uint64()
require.ErrorIs(t, err, ErrShort)
}
// TestCursorReturnsCappedSlices pins the tripwire: a field handed back cannot
// be re-sliced forward into the input's spare capacity.
func TestCursorReturnsCappedSlices(t *testing.T) {
in := Poisoned([]byte{1, 2, 3, 4, 5, 6, 7, 8}, 64)
c := Read(in)
got, err := c.Bytes(4)
require.NoError(t, err)
require.Equal(t, []byte{1, 2, 3, 4}, got)
require.Equal(t, len(got), cap(got), "a returned field has no spare capacity")
require.Panics(t, func() { _ = got[:8] },
"re-slicing a field past its length must fail loudly, not read the next field")
rest := c.Rest()
require.Equal(t, []byte{5, 6, 7, 8}, rest)
require.Equal(t, len(rest), cap(rest), "Rest is capped too")
require.Panics(t, func() { _ = rest[:len(rest)+1] })
}
// TestCursorReadsFieldsInOrder walks a whole wire format.
func TestCursorReadsFieldsInOrder(t *testing.T) {
in := Poisoned([]byte{
0x01, // byte
0x02, 0x03, // uint16
0x00, 0x00, 0x00, 0x04, // uint32
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, // uint64
0xAA, 0xBB, // rest
}, 32)
c := Read(in)
require.Equal(t, 17, c.Len())
b, err := c.Byte()
require.NoError(t, err)
require.Equal(t, byte(0x01), b)
u16, err := c.Uint16()
require.NoError(t, err)
require.Equal(t, uint16(0x0203), u16)
u32, err := c.Uint32()
require.NoError(t, err)
require.Equal(t, uint32(4), u32)
u64, err := c.Uint64()
require.NoError(t, err)
require.Equal(t, uint64(5), u64)
require.Equal(t, 2, c.Len())
require.Error(t, c.End())
require.ErrorIs(t, c.End(), ErrTrailing)
require.ErrorIs(t, c.End(), ErrInvalidInput)
require.Equal(t, []byte{0xAA, 0xBB}, c.Rest())
require.Equal(t, 0, c.Len())
require.NoError(t, c.End())
require.Empty(t, c.Rest(), "Rest at the end yields nothing")
}
// TestCursorRefusesADeclaredLengthLargerThanTheInput is the length-prefixed
// shape every parser here uses, and the one that reads attacker bytes when it
// is written by hand.
func TestCursorRefusesADeclaredLengthLargerThanTheInput(t *testing.T) {
for _, claim := range []uint32{5, 1 << 20, 1 << 30, ^uint32(0)} {
body := []byte{byte(claim >> 24), byte(claim >> 16), byte(claim >> 8), byte(claim), 1, 2, 3, 4}
c := Read(Poisoned(body, 1<<16))
n, err := c.Uint32()
require.NoError(t, err)
require.Equal(t, claim, n)
_, err = c.Bytes(uint64(n))
require.ErrorIs(t, err, ErrShort, "declared length %d exceeds the calldata", claim)
}
}
// TestCursorLengthArithmeticDoesNotWrap pins that the bound is a subtraction
// on the trusted length, never an addition against the declared one. A guard
// written as `off+n > len` wraps for n near 2^64 and admits the read.
func TestCursorLengthArithmeticDoesNotWrap(t *testing.T) {
c := Read(Poisoned([]byte{1, 2, 3, 4}, 1<<16))
_, err := c.Bytes(1)
require.NoError(t, err)
for _, n := range []uint64{^uint64(0), ^uint64(0) - 3, 1 << 63, 1 << 40} {
c2 := Read(Poisoned([]byte{1, 2, 3, 4}, 1<<16))
_, err := c2.Bytes(1)
require.NoError(t, err)
_, err = c2.Bytes(n)
require.ErrorIs(t, err, ErrShort, "n=%d must be refused, not wrapped", n)
}
}
// TestZeroCursorRefusesEverything pins the fail-secure default.
func TestZeroCursorRefusesEverything(t *testing.T) {
var c Cursor
require.Equal(t, 0, c.Len())
_, err := c.Byte()
require.ErrorIs(t, err, ErrShort)
_, err = c.Bytes(1)
require.ErrorIs(t, err, ErrShort)
require.NoError(t, c.End())
require.Empty(t, c.Rest())
// A zero-length read on an empty input is the one thing that succeeds,
// and it yields nothing.
got, err := c.Bytes(0)
require.NoError(t, err)
require.Empty(t, got)
}
// TestCursorSentinelsAreDistinct guards the error identities the converted
// parsers depend on.
func TestCursorSentinelsAreDistinct(t *testing.T) {
require.False(t, errors.Is(ErrShort, ErrTrailing))
require.False(t, errors.Is(ErrTrailing, ErrShort))
require.True(t, errors.Is(ErrShort, ErrInvalidInput))
require.True(t, errors.Is(ErrTrailing, ErrInvalidInput))
require.False(t, errors.Is(ErrInvalidInput, ErrShort))
}
// TestCursorFieldsBoundsTheCountBeforeAllocating is the property Fields
// exists for. A declared count must be refused against what the input can
// hold BEFORE it sizes anything — otherwise the refusal arrives after the
// allocation it was supposed to prevent.
func TestCursorFieldsBoundsTheCountBeforeAllocating(t *testing.T) {
in := Poisoned([]byte{
1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3,
}, 1<<16)
c := Read(in)
got, err := c.Fields(3, 4)
require.NoError(t, err)
require.Equal(t, [][]byte{{1, 1, 1, 1}, {2, 2, 2, 2}, {3, 3, 3, 3}}, got)
require.Equal(t, 0, c.Len())
for _, f := range got {
require.Equal(t, len(f), cap(f), "each field is capped")
}
// A count the input cannot back is refused, and nothing is consumed.
for _, n := range []uint64{4, 1 << 20, 1 << 32, ^uint64(0), ^uint64(0) / 2} {
c := Read(in)
_, err := c.Fields(n, 4)
require.ErrorIs(t, err, ErrShort, "n=%d fields of 4 do not fit in %d bytes", n, len(in))
require.Equal(t, len(in), c.Len(), "a refused read consumes nothing")
}
// The bound divides rather than multiplying: these counts times their
// width wrap uint64, and a wrapped product compares as small.
c = Read(in)
_, err = c.Fields(1<<63, 4)
require.ErrorIs(t, err, ErrShort, "n*w wrapping must not admit the read")
_, err = c.Fields((^uint64(0)/32)+1, 32)
require.ErrorIs(t, err, ErrShort)
// Degenerate shapes.
c = Read(in)
zero, err := c.Fields(0, 4)
require.NoError(t, err)
require.Empty(t, zero)
require.Equal(t, len(in), c.Len(), "zero fields consume nothing")
nothing, err := c.Fields(1<<40, 0)
require.NoError(t, err, "fields of zero width are the empty read")
require.Empty(t, nothing)
require.Equal(t, len(in), c.Len())
}