Skip to content

Latest commit

 

History

History
200 lines (156 loc) · 5.89 KB

File metadata and controls

200 lines (156 loc) · 5.89 KB

Z006: Missing Proof Nonce/Uniqueness Enforcement

Severity

HIGH - Enables replay attacks on privileged actions

Description

Detects proof-gated privileged actions (governance votes, identity attestation, access control) that lack nonce or context-binding checks, allowing the same proof to be replayed across different transactions or contexts.

Vulnerable Pattern

// ❌ BAD: Vote without nonce - can be replayed
pub fn vote(env: Env, proposal_id: u64, proof: BytesN<32>, inputs: Vec<u64>) {
    verify_zk_proof(&env, proof, inputs);
    
    // No nonce check - attacker can resubmit same proof!
    let votes = env.storage().get(&proposal_id).unwrap_or(0);
    env.storage().set(&proposal_id, votes + 1);
}

Attack scenario:

  1. Attacker obtains valid voting proof once
  2. Resubmits the same proof multiple times
  3. Casts unlimited votes, manipulating governance

Secure Patterns

Pattern 1: Nonce Tracking

// ✅ GOOD: Nonce enforcement
pub fn vote_with_nonce(
    env: Env, 
    proposal_id: u64, 
    proof: BytesN<32>, 
    inputs: Vec<u64>,
    nonce: u64
) {
    verify_zk_proof(&env, proof, inputs);
    
    // Check nonce hasn't been used
    let used_nonces: Set<u64> = env.storage()
        .instance()
        .get(&symbol_short!("nonces"))
        .unwrap_or(Set::new(&env));
    
    if used_nonces.contains(&nonce) {
        panic!("Nonce already used");
    }
    
    // Mark nonce as used
    let mut updated_nonces = used_nonces.clone();
    updated_nonces.insert(nonce);
    env.storage()
        .instance()
        .set(&symbol_short!("nonces"), &updated_nonces);
    
    // Process vote
    let votes = env.storage().get(&proposal_id).unwrap_or(0);
    env.storage().set(&proposal_id, votes + 1);
}

Pattern 2: Context Binding

// ✅ GOOD: Context binding
pub fn vote_with_context(
    env: Env, 
    proposal_id: u64, 
    proof: BytesN<32>, 
    inputs: Vec<u64>,
    context: BytesN<32>  // H(contract_address || proposal_id || "vote")
) {
    // Verify proof includes context commitment
    let expected_context = compute_context(&env, proposal_id);
    if context != expected_context {
        panic!("Invalid context");
    }
    
    verify_proof_with_context(&env, proof, inputs, context);
    
    // Proof is bound to this specific context, can't be replayed elsewhere
    let votes = env.storage().get(&proposal_id).unwrap_or(0);
    env.storage().set(&proposal_id, votes + 1);
}

fn compute_context(env: &Env, proposal_id: u64) -> BytesN<32> {
    let mut data = Bytes::new(env);
    data.append(&env.current_contract_address().to_bytes());
    data.append(&Bytes::from_array(env, &proposal_id.to_be_bytes()));
    data.append(&Bytes::from_slice(env, b"vote"));
    keccak256(env, &data)
}

Pattern 3: Timestamp Window

// ✅ GOOD: Time-bound proof validity
pub fn vote_with_expiry(
    env: Env, 
    proposal_id: u64, 
    proof: BytesN<32>, 
    inputs: Vec<u64>,
    proof_timestamp: u64,
    signature: BytesN<64>  // Signs proof + timestamp
) {
    // Verify timestamp is recent
    let current_time = env.ledger().timestamp();
    let max_age = 3600; // 1 hour validity
    
    if current_time > proof_timestamp + max_age {
        panic!("Proof expired");
    }
    
    if current_time < proof_timestamp {
        panic!("Proof timestamp in future");
    }
    
    // Verify signature over (proof, timestamp) to prevent timestamp manipulation
    verify_signature(&env, proof, proof_timestamp, signature);
    
    verify_zk_proof(&env, proof, inputs);
    
    // Process vote (still need per-user tracking)
    let votes = env.storage().get(&proposal_id).unwrap_or(0);
    env.storage().set(&proposal_id, votes + 1);
}

Why This Matters

Without replay protection:

  • Governance manipulation: Vote multiple times with one proof, swing elections
  • Identity fraud: Reuse attestation across platforms/contracts
  • Access abuse: Replay authorization indefinitely after revocation
  • Delegation attacks: Resubmit delegation proof after intended expiry

Distinction from Z001

  • Z001: Value transfer replay (double-spend via missing nullifiers)
  • Z006: Non-transfer replay (general privilege escalation)

Both are replay attacks but in different contexts:

  • Z001 focuses on preventing double-spending of value
  • Z006 focuses on preventing replay of authorizations/actions

Detection Method

This rule analyzes proof-verification call sites:

  1. Find proof verifications: Identify all verify_proof/verify_zk_proof calls
  2. Check if privileged: Determine if action:
    • Modifies storage
    • Grants permissions
    • Affects governance
    • Changes access control
  3. Exclude transfers: Skip value-transfer patterns (covered by Z001)
  4. Check nonce: Look for:
    • Nonce parameter in function signature
    • Nonce validation against used-nonce storage
    • Nonce marked as used after verification
  5. Check context binding: Look for:
    • Context parameter (hash of contract+action)
    • Context verification in proof
  6. Flag if missing: Report if neither protection mechanism present

Common Vulnerable Actions

  • Governance voting
  • Delegation/authorization
  • Identity attestation
  • Role assignment
  • Permission grants
  • Access control updates

Related Rules

  • Z001: Missing nullifier checks (value-transfer specific)
  • Z003: Public input leakage
  • S012: Missing access control

Implementation Dependencies

  • #1192: ZK infrastructure foundation
  • #1194: Proof verification pattern detection
  • #1197: Z001 implementation (to avoid duplicate findings)

References

Examples

See test fixtures in contracts/fixtures/finding-codes/z006_replay_missing_nonce.rs for comprehensive examples.