A critical heap corruption vulnerability lurks in the Noir compiler’s Brillig backend. When Noir programs call external functions that return nested arrays containing tuples or other composites, the compiler under-allocates heap memory. The virtual machine then overwrites adjacent memory during result writes, risking crashes, incorrect zk-proofs, or worse—exploits in proof generation.
This affects any Noir circuit using foreign calls with results like [(u32, u32); 3]. Noir, the domain-specific language from Aztec for writing zero-knowledge proofs, relies on Brillig as a high-level VM to execute circuits efficiently without full circuit compilation. Foreign calls let Noir invoke Rust or other code, bridging provable computation with off-circuit logic. But this bug, in the SSA-to-Brillig bytecode pipeline, exposes the heap.
How the Bug Triggers
The compiler processes SSA instructions block-by-block in BrilligBlock::compile_block(). On hitting Instruction::Call with a foreign function, it calls codegen_call(), which routes to convert_ssa_foreign_call(). Before emitting the call opcode, allocate_external_call_results() pre-allocates heap space for array results.
For outer arrays, it works: define_variable() invokes allocate_value_with_type(), computing the correct “semi-flattened” size—total memory slots, where composites like tuples span multiple slots. The formula in compute_array_length() nails it:
pub(crate) fn compute_array_length(item_typ: &CompositeType, elem_count: usize) -> usize {
item_typ.len() * elem_count
}
Tuples count their fields; a (u32, u32) tuple takes 2 slots.
Nested arrays break in allocate_foreign_call_result_array(). For Type::Array(types, nested_size), it ignores types and allocates using just nested_size—the logical element count, not semi-flattened size:
Type::Array(_, nested_size) => {
let inner_array = self.brillig_context.allocate_brillig_array(*nested_size as usize);
// ...
}
Example: [(u32, u32); 3]. Semantic length: 3. Element size: 2 slots. Needs 6 data slots + 1 metadata = 7 total. Code allocates for 3: 3 data + 1 metadata = 4 slots. VM writes 7 slots anyway. Boom—heap smash.
BrilligArray tracks this semi-flattened size field. Under-allocation misfires codegen_initialize_array(), leaving the heap vulnerable.
Impact: Why Developers Must Act Now
Heap corruption in Brillig means unreliable execution. ZK proofs from affected circuits become invalid—either rejected by verifiers or, scarier, maliciously crafted to pass false claims. Aztec’s ecosystem, building private DeFi and payments on Ethereum L2, depends on Noir for confidentiality. A single buggy foreign call in a transaction circuit could drain funds or expose secrets.
No public exploits yet, but the pattern screams RCE potential. Attackers could craft inputs controlling foreign call results, steering the overwrite to hijack control flow. Brillig runs in-memory during proof gen; no sandboxing mentioned. Testnets like Aztec’s Sepolia might already run vulnerable code.
Scale: Noir 0.14+ uses Brillig by default for non-circuit code. Foreign calls appear in oracle integrations, signature verification, or custom crypto primitives. If your circuit returns nested composites from Rust (e.g., packed Merkle proofs), audit now. Silent corruption beats loud crashes—proofs might “succeed” with garbage.
Fix and Workarounds
Patch: In allocate_foreign_call_result_array(), compute semi-flattened size properly. Destructure Type::Array(inner_types, nested_size), then pass inner_types.len() * nested_size to allocate_brillig_array(). Matches outer array logic. Aztec’s team likely patched internally; check Noir repo for 0.28.0 or later.
Workaround: Flatten nested arrays in foreign functions. Return [u32; 6] instead of [(u32,u32);3]. Or avoid composites in nests. Regenerate proofs post-fix; re-audit circuits with noirup latest.
This exposes Noir’s youth—powerful for zk devs, but rough edges remain. Brillig trades circuit bloat for speed, handling 10x more ops than R1CS. Bugs like this underscore: zk tooling lags production needs. Teams building on Aztec or similar should isolate foreign calls, fuzz inputs, and monitor heap in VM traces. Security starts with allocation sanity.