a16z ZkDrops PrivateAirdrop.collectAirdrop() Nullifier Range-Check Omission / Double-Claim Near Miss (2022-01-26)

  • Name: a16z ZkDrops PrivateAirdrop.collectAirdrop() nullifier range-check omission / double-claim near miss
  • Date: 2022-01-26
  • Protocol: a16z ZkDrops
  • Chain: Ethereum
  • Category: public-input domain mismatch / nullifier range-check omission / double-claim near miss
  • Loss: no public live exploit was documented, but the merged fix and added regression test showed that one airdrop entitlement could otherwise have been redeemed twice by presenting two raw nullifier encodings that collapsed to the same SNARK-field element inside proof verification
  • Summary: a16z’s PrivateAirdrop contract used a PLONK verifier plus a Merkle root to authorize private token claims, and it tried to prevent replay with mapping(bytes32 => bool) nullifierSpent. The contract, however, treated nullifierHash as an unrestricted 256-bit value. The proof system did not: inside proof generation and verification, the same public input lived in the BN254 scalar field. That let a claimant use one field-valid nullifier n and a second raw value n + q, where q is the SNARK field modulus. Both values reduced to the same field element for the prover / verifier, so both could satisfy the proof relation, but Solidity keyed them as different bytes32 entries in nullifierSpent. Without an onchain < SNARK_FIELD guard, one airdrop right could have been redeemed twice.
  • Trigger / entry point:
    • The vulnerable entry point was collectAirdrop(bytes calldata proof, bytes32 nullifierHash) in contracts/PrivateAirdrop.sol.
    • Replay protection lived in mapping(bytes32 => bool) public nullifierSpent.
    • The contract built verifier public inputs as pubSignals[0] = root, pubSignals[1] = uint256(nullifierHash), and pubSignals[2] = uint256(uint160(msg.sender)).
    • Before the fix, nothing required uint256(nullifierHash) < SNARK_FIELD even though the proof system only reasoned about the value modulo the SNARK scalar field.
    • The merged regression test preserved the exploit shape very directly under the name cannot exploit using public inputs larger than the scalar field: it redeemed once with keyHash = toHex(pedersenHash(key)), then constructed keyHashTwo = keyHash + SNARK_FIELD and asserted the second call must revert.
  • Root cause:
    • ZkDrops treated the nullifier as two different objects at once: a finite-field public input for the proof system and a raw bytes32 replay-protection key for Solidity storage.
    • The protocol assumed those two meanings were the same without enforcing a canonical field-safe representation.
    • Because the contract never rejected out-of-field nullifiers, n and n + q could behave identically to the verifier while remaining distinct storage keys onchain.
    • The uniqueness guarantee therefore failed at the contract / verifier boundary, not inside the Merkle inclusion proof itself.
  • Failure path:
    1. A user with one valid Merkle airdrop claim generated a proof whose nullifier was some field-valid value n.
    2. The contract accepted collectAirdrop(proof, n) because nullifierSpent[n] was still false, the verifier accepted n as the intended field element, and the contract transferred the redemption amount.
    3. The same claimant then submitted the same proof material again — the fix PR’s regression test deliberately reused the same callData bytes — but replaced the raw onchain nullifier with n + q, where q is the BN254 scalar-field modulus and n + q still fit into bytes32 / uint256.
    4. Inside the proof system, that second public input reduced modulo q and therefore represented the same nullifier as n.
    5. Onchain, nullifierSpent[n + q] was a fresh slot distinct from nullifierSpent[n].
    6. The verifier could still accept the proof while the contract treated the second redemption as unused, allowing the claimant to collect the airdrop again.
  • What the attacker needed to be true:
    • They needed one genuine ZkDrops inclusion proof for a live Merkle root and their recipient address.
    • The circuit had to treat the nullifier as a field element modulo the SNARK scalar field.
    • The contract had to store spent nullifiers by raw bytes32 key rather than by one canonical field-bounded encoding.
    • No pre-verification guard could reject uint256(nullifierHash) >= SNARK_FIELD.
    • The airdrop contract had to still hold redeemable token balance for a second claim to monetize the replay.
  • Technical anatomy worth remembering:
    • This was the airdrop analogue of Semaphore’s nullifier-alias replay bug: one logical nullifier existed twice, once as a field element and once as a raw storage key.
    • The replay surface sat in a very small amount of code. collectAirdrop() looked locally reasonable because it checked both nullifierSpent and verifyProof(...), but those two checks were not talking about the same numeric object.
    • The primary-source regression test matters because it held the semantic claim right fixed and changed only the raw encoding. In the merged diff, the test redeemed once, computed keyHashTwo = toHex(BigInt(keyHash) + BigInt(SNARK_FIELD)), and then called collectAirdrop(callData, keyHashTwo) with the same proof bytes.
    • That makes the exploit fingerprint especially clean: the bug was not generate a different proof for a second claim; it was reuse the same proof relation under a second raw storage key.
    • The fix landed in the correct place: not by changing the verifier, but by pushing the field bound out to the first caller-facing contract boundary.
  • Representative contracts and artifacts:
    • Vulnerable contract: contracts/PrivateAirdrop.sol
    • Vulnerable replay-protection state: mapping(bytes32 => bool) public nullifierSpent
    • Vulnerable entry point: collectAirdrop(bytes calldata proof, bytes32 nullifierHash)
    • Verifier public-input wiring: pubSignals[0] = uint256(root), pubSignals[1] = uint256(nullifierHash), pubSignals[2] = uint256(uint160(msg.sender))
    • Fix PR: a16z/zkdrops#2 (fix: don't allow double-spending with a large nullifier)
    • Fix merge commit: fdd80c94fc164841c5acbc9d10c2b9a92138bf97
    • Added constant: SNARK_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617
    • Regression test name: cannot exploit using public inputs larger than the scalar field
    • Regression test exploit alias: keyHashTwo = keyHash + SNARK_FIELD
  • Response / mitigation:
    • The fix added require(uint256(nullifierHash) < SNARK_FIELD, "Nullifier is not within the field") at the top of collectAirdrop(), before both the nullifierSpent lookup and the verifyProof(...) call.
    • The patch also added an integration test that first redeemed with keyHash, then tried to redeem again with keyHash + SNARK_FIELD, and asserted the second call reverted with Nullifier is not within the field.
    • The durable mitigation lesson was to treat nullifiers as field-bounded API objects everywhere they cross the contract boundary, not just inside proof generation.

Pattern tags

Three nullifier-settlement promotions this incident collapsed

  1. A nullifier-shaped bytes32 value existed in calldata at all

    • collectAirdrop() accepted an arbitrary bytes32 nullifierHash, and the contract could then thread that value into replay checks and verifier public inputs.
    • This was only a transport fact: some caller-supplied bytes32 exists that the claim flow might treat like a nullifier.
  2. That raw value was promoted into canonical verifier-side nullifier truth

    • ZkDrops implicitly treated uint256(nullifierHash) as if it were already the same object the PLONK verifier authenticated, even though the verifier only reasoned about that input modulo the BN254 scalar field.
    • This is where the dangerous promotion happened. n and n + q were still distinct raw calldata / storage objects, but the verifier collapsed them to the same field element.
  3. That accepted nullifier object was promoted into one-time payout authority

    • Once the contract believed the submitted nullifier was canonical and unused, it let that belief close the nullifierSpent gate and transfer tokens to msg.sender.
    • The exploit was therefore not just domain mismatch in a verifier wrapper; it was a non-canonical raw value still inherited live ERC-20 claim authority.

Why the exploit worked

  • ZkDrops asked the verifier to authenticate one field element, then asked Solidity replay protection to spend one raw bytes32 key and quietly assumed those were the same object.
  • That assumption only held when callers volunteered already-canonical values. Once a claimant supplied n + q, the verifier still saw the same nullifier as n while nullifierSpent saw a fresh storage slot.
  • The exploit shape is useful precisely because almost everything else looked correct: the Merkle inclusion relation, the recipient binding to msg.sender, and the nullifierSpent map were all present, but the contract and verifier still disagreed on the numeric domain of the replay-prevention object.
  • The sharper lesson is that a nullifier-shaped value exists, that value is the canonical verifier-side nullifier, and that accepted object deserves token-payout authority were three separate promotions, but the vulnerable flow flattened them into one unchecked assumption.
  • The fix landed at the right seam. This was not a change the circuit bug so much as a do not let caller-facing API values widen beyond verifier semantics bug.

Detection and audit pivots

  • Fuzz every proof-facing identifier across the alias family x, x + q, and boundary-adjacent values around the scalar field while holding the semantic claim right fixed; replay protection should either reject the alias or collapse it to the same spent object.
  • Diff the object used for replay storage against the object the verifier actually authenticates. If one side stores raw ABI integers or bytes32 values while the proof reasons modulo a field, reviewers should assume alias risk until a canonicalization boundary is explicit.
  • Treat claim contracts as settlement code, not only verifier wrappers. If a zk airdrop, mixer, or voting contract can mint or transfer value after proof success, then nullifier range checks belong at the first caller boundary, not as an optional library concern.
  • Look for tests that exercise same proof relation, different raw encoding rather than only same raw input reused twice. The regression test added in a16z/zkdrops#2 is the high-signal shape to preserve.
  • Hold proof bytes constant in at least one regression. If changing only the raw public-input encoding can reopen payout, the system still has a verifier/storage canonicalization bug even when all cryptographic witnesses stay unchanged.

Why this incident matters

  • It is a clean economic near miss for the proofs/verifiers corpus: the bug did not just brick a poll or break semantics; it directly reopened a token payout path.
  • It adds an airdrop-claim exemplar to the public-input domain mismatch family, which otherwise skews toward anonymous signaling and library initialization cases.
  • It preserves a sharper normative split that is easy to forget in zk claim systems: nullifier-shaped input exists, input counts as canonical verifier-side nullifier truth, and accepted nullifier closes one live payout right are different promotions, not one vague the proof had a nullifier story.
  • It preserves a reusable design lesson for zk claims and mixers alike: replay protection must store the same canonical nullifier object the verifier authenticates.

Failure-prevention checklist

  • Put verifier-domain bounds at the first caller-facing contract boundary, not only inside circuits, proof generators, or prover-side helpers.
  • Treat every proof-facing identifier as an API object with one canonical encoding. If Solidity stores bytes32 or uint256, prove that every accepted raw value denotes exactly one verifier-side element.
  • When a contract both checks a spent map and calls a verifier, explicitly document that both operations consume the same semantic object.
  • Reject out-of-field public inputs before any replay-protection lookup, transfer, or proof verification path can observe them as fresh keys.
  • Add regression tests for alias families such as x, x + q, and other boundary-adjacent values rather than only exact input reuse.
  • Keep scalar-field constants centralized and named so reviewers can audit every proof-facing boundary for the same modulus and domain assumptions.
  • For payout flows, treat nullifier storage as settlement logic. If the contract will transfer ERC-20s, the nullifier representation cannot be a loose wrapper around verifier semantics.
  • Re-check all proof-facing ABI types after refactors, verifier swaps, or chain-porting work; a sound circuit can still become unsafe when a wider host-language type is reintroduced at the boundary.
  • Require invariant tests that hold the semantic claim right fixed while varying raw encodings; every alias should either revert or collapse to an already-spent object.

Audit prompts

  • What exact object does the verifier authenticate here, and does storage use that same object or only a raw host-language encoding of it?
  • Which caller-supplied proof-facing values live in a finite field, subgroup, or bounded domain, and where are those bounds enforced before state changes?
  • Can I submit x + modulus or another equivalent raw encoding and still satisfy the proof relation while opening a fresh storage slot?
  • Does the replay-protection map key by a canonicalized field element, or by an ABI value that may admit aliases outside verifier semantics?
  • If the verifier reduces inputs modulo a field, where does the contract prove that callers cannot exploit that reduction to separate proof truth from settlement truth?
  • Are there tests for same proof relation, different raw encoding, not just same calldata repeated twice?
  • Which constants define the verifier’s numeric domain, and are they reused consistently across contracts, tests, and proof-generation code?
  • If the payout asset or recipient binding is correct, could a domain-mismatch bug still reopen redemption solely because the nullifier representation drifted?
  • After a verifier-library or proving-system migration, what review step re-establishes that replay storage and proof semantics still share one canonical domain?

Lessons that should already be standard

  • A replay-protection map is only as sound as the representation it keys on. mapping(bytes32 => bool) does not create uniqueness by itself.
  • Contracts and verifiers must agree on the same object, not merely on values that look similar in source code.
  • Field alias bugs are payout bugs when settlement happens after proof success. This is not just a zk math issue once tokens can leave the contract.
  • The right place to kill domain-mismatch exploits is the first public boundary where wide caller input meets narrow verifier semantics.
  • Exact-input replay tests are necessary but insufficient. Auditors should expect alias-family tests whenever proof systems interpret public inputs inside a smaller numeric domain.
  • The best alias-family tests keep the proof bytes fixed and vary only the raw encoded public input. If that alone changes replay behavior, settlement and verifier semantics are already split.
  • If a proof system reasons modulo a field, every caller-facing contract that stores or spends those inputs needs an explicit canonicalization story.
  • A secure claim flow is not verify proof plus check spent; it is verify proof and settlement against one canonical claim object.

Internal linkages

Control surface