Skip to main content
Sigvex

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.

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.

References