Skip to content

Latest commit

 

History

History
100 lines (70 loc) · 3.7 KB

File metadata and controls

100 lines (70 loc) · 3.7 KB

Z003 — Missing Public-Input Binding (Proof Malleability)

Category: zk-proof-integrity
Severity: Critical
Rule name: missing_public_input_binding


What it detects

A ZK-verifier entry point where the public inputs passed to the verifier are not explicitly bound to the transaction context (caller, recipient, amount, contract ID) before or during verification, allowing an attacker to substitute their own public inputs and redirect the proof's effect.


Why it matters

A ZK proof is only as secure as what it proves. If the verifier accepts (proof, arbitrary_public_inputs) without checking that the inputs correspond to this transaction, an attacker can take a valid proof, swap the recipient address in the public inputs, and replay it to redirect a transfer to themselves. This is proof malleability in the application layer.


Vulnerable example

pub fn withdraw(env: Env, proof: Vec<u64>, recipient: Address, amount: i128) {
    // Z003: public inputs are constructed from caller-supplied values with no
    // binding check — attacker substitutes a different recipient.
    let public_inputs = vec![&env, amount as u64];
    verify_proof(&env, &proof, &public_inputs);
    token_client.transfer(&env.current_contract_address(), &recipient, &amount);
}

Safe example

pub fn withdraw(env: Env, proof: Vec<u64>, recipient: Address, amount: i128) {
    // Bind recipient and amount into public inputs so the circuit commits to them.
    let recipient_hash = hash_address(&env, &recipient);
    let public_inputs = vec![&env, recipient_hash, amount as u64];
    verify_proof(&env, &proof, &public_inputs);
    token_client.transfer(&env.current_contract_address(), &recipient, &amount);
}

How the check works

The rule is a contract-side heuristic, not a proof of circuit correctness. For each function that calls a verifier it:

  1. collects the expressions handed to the verifier as public inputs;
  2. expands those expressions transitively through local let bindings, so let h = hash_address(&env, &recipient); counts as binding recipient;
  3. collects the security-relevant parameters still used after the verifier call — any Address-typed parameter, plus names containing recipient, receiver, beneficiary, amount, value, caller, destination, payee; and
  4. reports the ones that never reached the public inputs.

Step 3 is what keeps the rule quiet: a value that is not used after verification cannot be redirected, so it is not reported.

What it will not catch

  • Whether the circuit actually constrains the input you passed. Binding the value on-chain and constraining it in-circuit are two separate obligations; this rule can only see the first.
  • Encoding mismatches — passing a truncated or differently-hashed address than the circuit expects still counts as bound here.

Remediation

Include every security-relevant value in the public-input vector. Values that are not field elements (an Address, a 32-byte hash) must be folded into one first, and with the same encoding the circuit uses:

let recipient_hash = hash_address(&env, &recipient);
let public_inputs = vec![&env, recipient_hash, amount as u64];

Fixture

contracts/fixtures/finding-codes/z003_missing_public_input_binding.rs — covers the triggering case plus three clean cases (hash-bound, directly bound, and a value never used after verification).


References