Deduplicating Cached Multiplications Leaves Outputs Unconstrained
Description
The Ultra circuit builder uses a vector of cached_partial_non_native_field_multiplication structs to store bigfields , along with three outputs of a partial multiplication:
Note that and are the low and high contributions to , while is an intermediate value.
Partial multiplications are used by unsafe_evaluate_multiple_multiply_add() as an intermediate step to constraining a sum of products in the non-native field . Instead of constraining them immediately, it calls queue_partial_non_native_field_multiplication(), which puts , and into fresh witnesses and adds a new cache entry.
When finalizing the circuit, Ultra circuit builder calls process_non_native_field_multiplications() to add the deferred constraints:
void UltraCircuitBuilder_<Arithmetization>::process_non_native_field_multiplications()
{
// [ZKSECURITY] replace every "input wire" with the "canonical wire" in the equivalence class
for (size_t i = 0; i < cached_partial_non_native_field_multiplications.size(); ++i) {
auto& c = cached_partial_non_native_field_multiplications[i];
for (size_t j = 0; j < 5; ++j) {
c.a[j] = this->real_variable_index[c.a[j]];
c.b[j] = this->real_variable_index[c.b[j]];
}
}
// [ZKSECURITY] deduplicate the cached partial nnf multiplications based on the input wires
cached_partial_non_native_field_multiplication
::deduplicate(cached_partial_non_native_field_multiplications);
// iterate over the cached items and create constraints
for (const auto& input : cached_partial_non_native_field_multiplications) {
// [ZKSECURITY] elided: check deduplicated multiplications
}
}
The problem lies in deduplicate(): it removes entries where , point to the same variables. However, deduplicating ignores , and . These will in practice be different variables (with the same values) across deduplicated entries, because they are created in queue_partial_non_native_field_multiplication() independently each time that function is called.
To see why deduplicate() ignores , and , observe that it is based on a std::unordered_set with the hash function and equality operator defined in cached_partial_non_native_field_multiplication. Both Hash and operator== ignore struct contents besides and .
struct cached_partial_non_native_field_multiplication {
std::array<uint32_t, 5> a;
std::array<uint32_t, 5> b;
FF lo_0;
FF hi_0;
FF hi_1;
bool operator==(const cached_partial_non_native_field_multiplication& other) const
{
// [ZKSECURITY] we only compare a and b
bool valid = true;
for (size_t i = 0; i < 5; ++i) {
valid = valid && (a[i] == other.a[i]);
valid = valid && (b[i] == other.b[i]);
}
return valid;
}
static void deduplicate(std::vector<cached_partial_non_native_field_multiplication>& vec)
{
std::unordered_set<cached_partial_non_native_field_multiplication,
Hash, std::equal_to<>> seen;
// [ZKSECURITY] elided ...
};
Since NNF constraints are only added on the deduplicated cache entries, the , and in discarded cache entries end up being completely unconstrained. This means a prover can freely assign any value to them.
Nevertheless, unconstrained and are used as outputs of the partial multiplication in unsafe_evaluate_multiple_multiply_add() and are accumulated into the remainder_terms of the final call to evaluate_non_native_field_multiplication().
Impact
Controlling the remainder term means the prover can freely adjust the overall bigfield multiplication result – for example, remainder in mult_madd –, whenever a call to unsafe_evaluate_multiple_multiply_add() features a pair of factors , that is repeated across the same circuit.
Code comments indicate that such repeated pairs of factors occur in practice in the biggroup gadgets (out of scope of this audit), which could open the door to real-world attacks that break the protocol.
One caveat that makes attacks harder to pull off is that the prime limb of the remainder term is still constrained correctly, and would be inconsistent with the binary limbs when manipulating a multiplication result.
Recommendation
Before discarding , and , add equality constraints ensuring they are equal to the multiplication results that end up being checked. This should be a small local change in the deduplication step, with no impact on the number of gates.
Side remark: As can be seen in the code excerpt above, cached_partial_non_native_field_multiplication struct defines lo_0, hi_0 and hi_1 as FF (field elements). This is likely a mistake, because they represent witness indices (uint32_t), and are implicitly cast back to uint32_t when used in gates. The type should be changed to uint32_t to avoid confusion.
Developer Response
The issue was fixed as recommended.