Category: zk-proof-integrity
Severity: Critical
Rule name: missing_public_input_binding
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.
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.
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);
}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);
}The rule is a contract-side heuristic, not a proof of circuit correctness. For each function that calls a verifier it:
- collects the expressions handed to the verifier as public inputs;
- expands those expressions transitively through local
letbindings, solet h = hash_address(&env, &recipient);counts as bindingrecipient; - collects the security-relevant parameters still used after the verifier call —
any
Address-typed parameter, plus names containingrecipient,receiver,beneficiary,amount,value,caller,destination,payee; and - 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.
- 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.
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];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).
- Proof malleability — ZKP security considerations
- Z003 implementation issue #1199
- Related: Z001 (nullifier replay), Z007 (input range checks)