Assessment reports>Brevis>Critical findings>Using the ,FromValues, method for the ,Int248, type can lead to incorrect cached ,SignBit
Category: Coding Mistakes

Using the FromValues method for the Int248 type can lead to incorrect cached SignBit

Critical Severity
Critical Impact
High Likelihood

Description

In brevis-sdk, the type Int248 is implemented in sdk/api_int248.go, intended as an in-circuit, signed 248-bit integer type. The value is stored in the field Val, which is a single circuit variable holding a value that is at most 248 bits wide, with the little-endian bits of Val corresponding to the two's complement representation of the represented signed integer. The Int248 type also has two additional fields, SignBit and signBitSet. The first, SignBit, is a circuit variable that may hold the value's sign bit, and signBitSet is a native Boolean that indicates whether the sign bit indicated by SignBit is reliable or not. The purpose of caching the sign bit this way is to reduce circuit variables and constraints needed by avoiding deconstructing Val into bits unnecessarily.

The method function ensureSignBit is used to update SignBit by extracting it from Val should signBitSet be false:

func (api *Int248API) ensureSignBit(v Int248) Int248 {
	if v.signBitSet {
		return v
	}
	bin := api.g.ToBinary(v.Val, 248)
	v.SignBit = bin[247]
	v.signBitSet = true
	return v
}

Whenever signBitSet is true, ensureSignBit returns immediately, thereby not requiring any additional circuit variables or constraints.

Functions operating on Int248 that require the sign bit can then call ensureSignBit first and then use SignBit rather than computing it themselves, as can be seen, for example, in the case of IsGreaterThan:

func (api *Int248API) IsGreaterThan(a, b Int248) Uint248 {
	a = api.ensureSignBit(a)
	b = api.ensureSignBit(b)

	cmp := api.g.Cmp(a.Val, b.Val)
	isGtAsUint := api.g.IsZero(api.g.Sub(cmp, 1))

	isLt := api.g.Lookup2(
		a.SignBit, b.SignBit,
		isGtAsUint, // a, b both pos
		0,          // a neg, b pos
		1,          // a pos, b neg
		isGtAsUint, // a, b both neg
	)

	return newU248(isLt)
}

The FromValues method function is used to update an Int248 by retrieving a new value from a raw circuit variable. It is implemented as follows:

func (v Int248) FromValues(vs ...frontend.Variable) CircuitVariable {
	if len(vs) != 1 {
		panic("Int248.FromValues only takes 1 param")
	}
	v.Val = vs[0]
	return v
}

This updates Val only, but it does not invalidate the cached sign in SignBit. Should signBitSet have been true before, then the cached sign will continue to be considered valid. However, there is no consistency check done to ensure that the cached sign bit is consistent with the sign bit of the new value.

Impact

Comparison functions such as IsLessThan and IsGreaterThan use SignBit to calculate the result, so an incorrect cached sign bit can lead to incorrect return values for those comparison functions.

The pattern of overwriting a previous value of an Int248 using FromValues in a way that can cause this problem occurs in practice, for example, in the Reduce method function in sdk/datastream.go. Therefore, Reduce can return Int248 with an incorrectly cached sign, leading to incorrect results when then applying IsLessThan or IsGreaterThan.

The Reduce function is in turn used for MaxGeneric, which, with a maximum, can be computed over a data stream.

The following proof of concept demonstrates how the MaxGeneric function using Int248 types behaves incorrectly:

package sdk

import (
	"fmt"
	"math/big"
	"testing"

	"github.com/consensys/gnark-crypto/ecc"
	"github.com/consensys/gnark/frontend"
	"github.com/consensys/gnark/test"
)

type TestZellicMaxInt248FromValuesSignCircuit struct {
	g           frontend.API
	i248        *Int248API
	Values      [3]Int248
	ExpectedMax Int248
}

func TestZellicMaxInt248FromValuesSign(t *testing.T) {
	c := &TestZellicMaxInt248FromValuesSignCircuit{
		Values: [3]Int248{
			ConstInt248(big.NewInt(1)),
			ConstInt248(big.NewInt(47)),
			ConstInt248(big.NewInt(42)),
		},
		ExpectedMax: ConstInt248(big.NewInt(47)),
	}
	err := test.IsSolved(c, c, ecc.BN254.ScalarField())
	check(err)
}

func (c *TestZellicMaxInt248FromValuesSignCircuit) Define(g frontend.API) error {
	api := NewCircuitAPI(g)
	c.g = g
	c.i248 = newInt248API(g)

	listForDataStream := List[Int248](c.Values[:])
	datastream := NewDataStream(api,
		DataPoints[Int248]{
			Raw:     listForDataStream,
			Toggles: []frontend.Variable{1, 1, 1},
		},
	)

	max := MaxGeneric(
		// We take maximum over the values set in the circuit struct.
		datastream,
		// The initial maximum is the smallest possible Int248, -(1<<247).
		ConstInt248(new(big.Int).Sub(big.NewInt(0), new(big.Int).Lsh(big.NewInt(1), 247))),
		c.i248.IsGreaterThan,
	)

	fmt.Println("Calculated max:")
	c.g.Println(max.SignBit, max.Val)
	fmt.Println("Expected max:")
	c.g.Println(c.ExpectedMax.SignBit, c.ExpectedMax.Val)
	c.i248.AssertIsEqual(max, c.ExpectedMax)

	return nil
}

In this test, it is attempted to calculate the maximum of the Int248 values 1, 47, and 42 using MaxGeneric. As the initial maximum (so the maximum that would be returned if the list we take the maximum over were empty), we take the lowest possible Int248, which is the negative value . Because this value is negative, the FromValues bug will cause the current maximum value in the reduction performed by MaxGeneric to always have a cached sign bit indicating a negative value. As our list contains only positive values, each comparison will indicate that the new value is larger than the current maximum (being positive, while the current maximum is negative). Thus, MaxGeneric will incorrectly return the last value as the maximum. Additionally, the returned maximum will have a cached sign bit incorrectly indicating that this value is negative.

Running the above test outputs the following:

=== RUN   TestZellicMaxInt248FromValuesSign
Calculated max:
(test.engine) zellic_max_int248_test.go:55 1 42
Expected max:
(test.engine) zellic_max_int248_test.go:57 0 47
--- FAIL: TestZellicMaxInt248FromValuesSign (0.01s)
panic: [assertIsEqual] 42 == 47
sdk.(*Int248API).AssertIsEqual
        api_int248.go:285
sdk.(*TestZellicMaxInt248FromValuesSignCircuit).Define
        zellic_max_int248_test.go:58
 [recovered]
        panic: [assertIsEqual] 42 == 47
sdk.(*Int248API).AssertIsEqual
        api_int248.go:285
sdk.(*TestZellicMaxInt248FromValuesSignCircuit).Define
        zellic_max_int248_test.go:58

The upshot is that various computations that users may do with Int248 types can result in incorrect results.

Note that the above example also demonstrates how the incorrect result of MaxGeneric breaks the assumption that the result of MaxGeneric does not depend on the order the list is given in. If a specific order of the list is not otherwise enforced by the app circuit, this could thus allow an attacker to use the order of the data points to manipulate the return value.

Recommendations

The FromValues method function should set signBitSet to false, thereby invalidating the cached sign bit and ensuring that ensureSignBit will recompute the correct sign bit:

func (v Int248) FromValues(vs ...frontend.Variable) CircuitVariable {
	if len(vs) != 1 {
		panic("Int248.FromValues only takes 1 param")
	}
	v.Val = vs[0]
+    v.signBitSet = false
	return v
}

Remediation

This issue has been acknowledged by Brevis, and a fix was implemented in commit 3191f301↗.

Zellic Ā© 2025Back to top ↑