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
PrivateAirdropcontract used a PLONK verifier plus a Merkle root to authorize private token claims, and it tried to prevent replay withmapping(bytes32 => bool) nullifierSpent. The contract, however, treatednullifierHashas 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 nullifiernand a second raw valuen + q, whereqis 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 differentbytes32entries innullifierSpent. Without an onchain< SNARK_FIELDguard, one airdrop right could have been redeemed twice. - Trigger / entry point:
- The vulnerable entry point was
collectAirdrop(bytes calldata proof, bytes32 nullifierHash)incontracts/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), andpubSignals[2] = uint256(uint160(msg.sender)). - Before the fix, nothing required
uint256(nullifierHash) < SNARK_FIELDeven 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 withkeyHash = toHex(pedersenHash(key)), then constructedkeyHashTwo = keyHash + SNARK_FIELDand asserted the second call must revert.
- The vulnerable entry point was
- Root cause:
- ZkDrops treated the nullifier as two different objects at once: a finite-field public input for the proof system and a raw
bytes32replay-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,
nandn + qcould 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.
- ZkDrops treated the nullifier as two different objects at once: a finite-field public input for the proof system and a raw
- Failure path:
- A user with one valid Merkle airdrop claim generated a proof whose nullifier was some field-valid value
n. - The contract accepted
collectAirdrop(proof, n)becausenullifierSpent[n]was stillfalse, the verifier acceptednas the intended field element, and the contract transferred the redemption amount. - The same claimant then submitted the same proof material again — the fix PR’s regression test deliberately reused the same
callDatabytes — but replaced the raw onchain nullifier withn + q, whereqis the BN254 scalar-field modulus andn + qstill fit intobytes32/uint256. - Inside the proof system, that second public input reduced modulo
qand therefore represented the same nullifier asn. - Onchain,
nullifierSpent[n + q]was a fresh slot distinct fromnullifierSpent[n]. - The verifier could still accept the proof while the contract treated the second redemption as unused, allowing the claimant to collect the airdrop again.
- A user with one valid Merkle airdrop claim generated a proof whose nullifier was some field-valid value
- 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
bytes32key 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 bothnullifierSpentandverifyProof(...), 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 calledcollectAirdrop(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 wasreuse 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
- Vulnerable contract:
- Response / mitigation:
- The fix added
require(uint256(nullifierHash) < SNARK_FIELD, "Nullifier is not within the field")at the top ofcollectAirdrop(), before both thenullifierSpentlookup and theverifyProof(...)call. - The patch also added an integration test that first redeemed with
keyHash, then tried to redeem again withkeyHash + SNARK_FIELD, and asserted the second call reverted withNullifier 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.
- The fix added
Pattern tags
- nullifier-canonicalization-failure
- public-input-domain-mismatch
- proof-facing-field-domain-canonicalization-discipline
- One logical nullifier crossed the proof / storage boundary under two raw encodings because
collectAirdrop()admitted anybytes32while the PLONK verifier only interpreted the same public input modulo the BN254 scalar field.
Three nullifier-settlement promotions this incident collapsed
-
A nullifier-shaped
bytes32value existed in calldata at allcollectAirdrop()accepted an arbitrarybytes32 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.
-
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.
nandn + qwere still distinct raw calldata / storage objects, but the verifier collapsed them to the same field element.
- ZkDrops implicitly treated
-
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
nullifierSpentgate and transfer tokens tomsg.sender. - The exploit was therefore not just
domain mismatch in a verifier wrapper; it wasa non-canonical raw value still inherited live ERC-20 claim authority.
- Once the contract believed the submitted nullifier was canonical and unused, it let that belief close the
Why the exploit worked
- ZkDrops asked the verifier to authenticate one field element, then asked Solidity replay protection to spend one raw
bytes32key 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 asnwhilenullifierSpentsaw 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 thenullifierSpentmap 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, andthat accepted object deserves token-payout authoritywere 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 circuitbug so much as ado not let caller-facing API values widen beyond verifier semanticsbug.
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
bytes32values 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 encodingrather than onlysame raw input reused twice. The regression test added ina16z/zkdrops#2is 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 mismatchfamily, 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, andaccepted nullifier closes one live payout rightare different promotions, not one vaguethe proof had a nullifierstory. - 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
bytes32oruint256, prove that every accepted raw value denotes exactly one verifier-side element. - When a contract both checks a
spentmap 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 + modulusor 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 justsame 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 issueonce 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 proofpluscheck spent; it isverify proof and settlement against one canonical claim object.
Internal linkages
- Narrow recurring pattern: nullifier-canonicalization-failure.
- Broader field-boundary family: public-input-domain-mismatch.
- Positive-design companion: proof-facing-field-domain-canonicalization-discipline — the payout-side
same logical nullifier, two raw encodingsexemplar for why claim replay protection has to canonicalize field-facing identifiers before storage. - Close replay cousin: semaphore-nullifier-field-modulus-aliasing-double-signal-replay-near-miss-2019-07-25 — same
nversusn + qnullifier aliasing shape, but on anonymous signaling rather than token redemption. - Merkle-claim context: duplicate-claim-idempotency-failure — useful as the payout-side analogue once the claim right exists, though the dominant root cause here still sits at the proof / field-boundary layer.
- Adjacent proof-statement family: witness-public-input-binding-failure — that family proves the wrong relation; this issue kept the relation intact but let the onchain replay key drift away from the verifier’s field semantics.
Control surface
-
The leverage sat in
collectAirdrop(), the rawbytes32nullifierSpentmap, the missing< SNARK_FIELDguard, and the verifier’s modulo-field interpretation of public inputs. -
The sharper question is not only
did the contract check spent nullifiers?butwhen did a caller-supplied bytes32 stop being mere transport data, become canonical verifier-side nullifier truth, and then become money-good ERC-20 payout authority? -
The decisive failure was collapsing those promotions into one path without a caller-boundary field-domain check.
-
Sources:
-
Last reviewed: 2026-08-05 UTC