Skip to content

Latest commit

 

History

History
240 lines (180 loc) · 6.98 KB

File metadata and controls

240 lines (180 loc) · 6.98 KB

Z001: Missing Nullifier/Double-Spend Check

Severity

CRITICAL - Enables fund duplication and double-claiming

Description

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.

Vulnerable Pattern

// ❌ 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:

  1. Attacker generates valid proof for 100 tokens once
  2. Calls claim_reward() with same proof 10 times
  3. Receives 1,000 tokens instead of 100
  4. Contract funds drained

Secure Patterns

Pattern 1: Nullifier with Storage Set

// ✅ 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);
}

Pattern 2: Nullifier Derived from Proof

// ✅ 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);
}

Pattern 3: Per-User Nonce Tracking

// ✅ 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);
}

Why This Matters

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.

Real-World Impact

Scenario: Anonymous airdrop system

  1. User proves eligibility once: H(secret, merkle_proof)
  2. System verifies proof, sends 1000 tokens
  3. User replays same proof 100 times
  4. User receives 100,000 tokens
  5. Airdrop budget exhausted, legitimate users get nothing

Detection Method

This rule analyzes function control flow:

  1. Find proof verifications: Identify verify_proof, verify_zk_proof calls
  2. Track data flow: Follow execution paths after verification
  3. Detect state mutations: Find transfers, mints, storage updates
  4. Check nullifier operations: Look for:
    • Nullifier storage lookups
    • Nullifier existence checks
    • Nullifier insertion after check
  5. Flag if missing: Report functions with mutations but no nullifier

State-Changing Operations Tracked

  • Token transfers (transfer, transfer_from)
  • Token minting (mint)
  • Reward claims (claim, redeem)
  • Storage updates modifying balances/ownership
  • Permission grants

Common Pitfalls

Checking but Not Recording

// ❌ 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);
}

Recording After Transfer

// ❌ 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);
}

Using Weak Nullifiers

// ❌ 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);
}

Related Rules

  • Z002: Insecure randomness (nullifier generation)
  • Z006: Missing proof nonce (similar but for non-transfers)
  • S015: Reentrancy (nullifier must be set before external call)

Implementation Dependencies

  • #1192: ZK analysis infrastructure
  • #1194: Proof verification pattern detection
  • #1217: Paired test fixtures

References

Examples

See test fixtures:

  • contracts/fixtures/finding-codes/z001_missing_nullifier.rs (trigger)
  • contracts/fixtures/finding-codes/z001_missing_nullifier.rs (clean)