Noir Circuit Vulnerabilities Remediation
How to fix Noir-specific vulnerabilities including unconstrained function misuse, oracle manipulation, and Brillig escapes.
Noir Circuit Vulnerabilities Remediation
Overview
Related Detector: Noir Circuit Detectors
Noir circuits face five categories of vulnerability: unconstrained function returns used without assertion, oracle data consumed without verification, assertions depending on mutable state from unconstrained code, Brillig escapes that bypass ACIR constraint generation, and oracle responses used without validation. The common thread is that unconstrained code and oracles execute outside the constraint system — their outputs must always be verified.
Recommended Fix
1. Unconstrained Function Return (Before / After)
// Before: Unconstrained return used directly
unconstrained fn compute_sqrt(x: Field) -> Field {
// witness-only computation
}
fn main(x: pub Field) -> pub Field {
let root = compute_sqrt(x);
root // BUG: root is not verified
}
// After: Assert constrains the result
fn main(x: pub Field) -> pub Field {
let root = compute_sqrt(x);
assert(root * root == x); // Constrains the unconstrained result
root
}
2. Missing Assert After Oracle (Before / After)
// Before: Oracle data used without verification
#[oracle(get_price)]
unconstrained fn get_price(token: Field) -> Field {}
fn main(token: pub Field, expected_hash: pub Field) {
let price = get_price(token);
// BUG: price is prover-controlled, no verification
}
// After: Verify oracle data against a commitment
fn main(token: pub Field, price_commitment: pub Field) {
let price = get_price(token);
let computed = std::hash::pedersen_hash([token, price]);
assert(computed == price_commitment); // Verify against public commitment
}
3. Brillig Escape (Before / After)
// Before: Unconstrained block bypasses ACIR
unconstrained fn risky_transfer(amount: Field, balance: Field) -> bool {
amount as u64 <= balance as u64 // Only runs in Brillig, no constraint
}
fn main(amount: pub Field, balance: pub Field) {
let ok = risky_transfer(amount, balance);
// BUG: ok is not constrained
}
// After: Use constrained comparison
fn main(amount: pub Field, balance: pub Field) {
let ok = risky_transfer(amount, balance);
// Re-verify in constrained context
assert(amount as u64 <= balance as u64);
}
Alternative Mitigations
Wrapper Functions for Oracle Verification
Create a constrained wrapper that always verifies oracle output:
fn verified_price(token: Field, commitment: pub Field) -> Field {
let price = get_price(token);
let hash = std::hash::pedersen_hash([token, price]);
assert(hash == commitment);
price
}
Range Validation for Unconstrained Returns
When an unconstrained function returns a value expected to be within a range, assert the range in constrained code:
let value = unconstrained_compute(input);
assert(value as u64 < MAX_VALUE);
Common Mistakes
Assuming unconstrained is safe for non-critical code: Any value that flows from unconstrained code into constrained logic must be verified. The prover can substitute any value for unconstrained outputs.
Verifying oracle data with another oracle: Checking one oracle result against another oracle provides no security. Verification must use public inputs or on-chain commitments.
Forgetting that Brillig and ACIR have different semantics: Integer overflow, comparison, and division behave differently in Brillig (unconstrained) versus ACIR (constrained). Always re-verify in constrained context.