CRITICAL - Enables fund duplication and double-claiming
Detects ZK-verifier contract entry points that accept a proof and perform state-changing actions (transfers, mints, claims) without recording and checking a nullifier. This allows the same valid proof to be replayed for double-spend or double-claim attacks.
// ❌ BAD: No nullifier check before transfer
pub fn claim_reward(env: Env, proof: BytesN<32>, public_inputs: Vec<u64>) {
// Verify proof is valid
verify_zk_proof(&env, proof, public_inputs);
// Extract amount from public inputs
let amount = public_inputs.get(0).unwrap();
// ❌ Transfer without checking if this proof was already used!
transfer(&env, &get_claimer(&public_inputs), amount);
}Attack scenario:
- Attacker generates valid proof for 100 tokens once
- Calls
claim_reward()with same proof 10 times - Receives 1,000 tokens instead of 100
- Contract funds drained
// ✅ GOOD: Nullifier check prevents replay
pub fn claim_reward(
env: Env,
proof: BytesN<32>,
nullifier: BytesN<32>, // Unique identifier for this proof
public_inputs: Vec<u64>
) {
verify_zk_proof(&env, proof, public_inputs);
// Check nullifier hasn't been used
let used_nullifiers: Set<BytesN<32>> = env.storage()
.instance()
.get(&symbol_short!("nulls"))
.unwrap_or(Set::new(&env));
if used_nullifiers.contains(&nullifier) {
panic!("Nullifier already used - proof replay detected");
}
// Mark nullifier as used BEFORE transfer
let mut updated = used_nullifiers.clone();
updated.insert(nullifier);
env.storage().instance().set(&symbol_short!("nulls"), &updated);
// Now safe to transfer
let amount = public_inputs.get(0).unwrap();
transfer(&env, &get_claimer(&public_inputs), amount);
}// ✅ GOOD: Derive nullifier from proof itself
pub fn claim_reward(env: Env, proof: BytesN<32>, public_inputs: Vec<u64>) {
verify_zk_proof(&env, proof, public_inputs);
// Derive nullifier from proof + public inputs
let nullifier = keccak256(&env, &[proof, serialize_inputs(&public_inputs)]);
// Check and record nullifier
let used_nullifiers: Set<BytesN<32>> = env.storage()
.instance()
.get(&symbol_short!("nulls"))
.unwrap_or(Set::new(&env));
if used_nullifiers.contains(&nullifier) {
panic!("Proof already used");
}
let mut updated = used_nullifiers.clone();
updated.insert(nullifier);
env.storage().instance().set(&symbol_short!("nulls"), &updated);
// Transfer
let amount = public_inputs.get(0).unwrap();
transfer(&env, &get_claimer(&public_inputs), amount);
}// ✅ GOOD: Track nonces per user
pub fn claim_reward(
env: Env,
proof: BytesN<32>,
public_inputs: Vec<u64>,
nonce: u64
) {
verify_zk_proof(&env, proof, public_inputs);
let user = get_claimer(&public_inputs);
// Check nonce is next expected nonce for this user
let expected_nonce: u64 = env.storage()
.instance()
.get(&user)
.unwrap_or(0);
if nonce != expected_nonce {
panic!("Invalid nonce - must use sequential nonces");
}
// Increment user's nonce
env.storage().instance().set(&user, &(expected_nonce + 1));
// Transfer
let amount = public_inputs.get(0).unwrap();
transfer(&env, &user, amount);
}Without nullifier checks, ZK privacy systems completely break:
- Fund duplication: Attacker drains contract by replaying valid proofs
- Double-voting: Same proof used multiple times in governance
- Sybil attacks: One proof grants unlimited identities/credentials
- Privacy failure: If proof can be reused, the ZK system provides no security
This is the #1 ZK exploit pattern in real-world incidents.
Scenario: Anonymous airdrop system
- User proves eligibility once:
H(secret, merkle_proof) - System verifies proof, sends 1000 tokens
- User replays same proof 100 times
- User receives 100,000 tokens
- Airdrop budget exhausted, legitimate users get nothing
This rule analyzes function control flow:
- Find proof verifications: Identify
verify_proof,verify_zk_proofcalls - Track data flow: Follow execution paths after verification
- Detect state mutations: Find transfers, mints, storage updates
- Check nullifier operations: Look for:
- Nullifier storage lookups
- Nullifier existence checks
- Nullifier insertion after check
- Flag if missing: Report functions with mutations but no nullifier
- Token transfers (
transfer,transfer_from) - Token minting (
mint) - Reward claims (
claim,redeem) - Storage updates modifying balances/ownership
- Permission grants
// ❌ BAD: Checks nullifier but doesn't record it!
pub fn claim(env: Env, proof: BytesN<32>, nullifier: BytesN<32>) {
verify_zk_proof(&env, proof, public_inputs);
let used = env.storage().get(&nullifier);
if used.is_some() {
panic!("Already used");
}
// ❌ Forgot to record nullifier!
transfer(&env, &user, amount);
}// ❌ BAD: Records nullifier AFTER transfer
pub fn claim(env: Env, proof: BytesN<32>, nullifier: BytesN<32>) {
verify_zk_proof(&env, proof, public_inputs);
// ❌ Transfer happens first
transfer(&env, &user, amount);
// Nullifier recorded after - reentrancy possible!
env.storage().set(&nullifier, &true);
}// ❌ BAD: Weak nullifier (user-controlled, predictable)
pub fn claim(env: Env, proof: BytesN<32>, user_chosen_id: u64) {
verify_zk_proof(&env, proof, public_inputs);
// ❌ User can choose different IDs for same proof!
check_and_record_nullifier(&env, &user_chosen_id);
transfer(&env, &user, amount);
}- Z002: Insecure randomness (nullifier generation)
- Z006: Missing proof nonce (similar but for non-transfers)
- S015: Reentrancy (nullifier must be set before external call)
- #1192: ZK analysis infrastructure
- #1194: Proof verification pattern detection
- #1217: Paired test fixtures
See test fixtures:
contracts/fixtures/finding-codes/z001_missing_nullifier.rs(trigger)contracts/fixtures/finding-codes/z001_missing_nullifier.rs(clean)