Nullifier Canonicalization Failure / Field-Modulus Alias Replay
- Name: nullifier canonicalization failure / field-modulus alias replay
- Category: exploit pattern / zk-proof integration failure / nullifier uniqueness drift / replay-protection boundary failure / field-domain aliasing
- Summary: This pattern appears when a zk system authenticates a nullifier as a field element inside the proof, but the surrounding contract stores or compares that same nullifier as an unrestricted raw integer or
bytes32. Once the verifier and the replay-protection map stop talking about one canonical nullifier object, one consumed right can come back as a second raw encoding. The canonical shape isnversusn + q, whereqis the SNARK scalar field modulus: both values collapse to the same verifier-side nullifier, but Solidity can still treat them as different map keys. The result is duplicate signaling, duplicate claims, or repeated payout rights even though the protocol appears to have nullifier-based replay protection. - What it is:
- A protocol uses a zk proof to authorize a one-time action such as a withdrawal, claim, vote, or anonymous signal.
- The proof system reasons about the nullifier inside a finite field of size
q. - Contract code stores used nullifiers in a raw
uint256orbytes32map without forcing a canonical field-safe encoding first. - The design assumes
the nullifier the verifier authenticatedandthe nullifier the contract marked spentare the same object. - In the vulnerable case, they are not: different raw encodings can represent the same field element to the verifier while still reaching different storage keys onchain.
- The attacker reuses one proof right under an aliased raw nullifier and bypasses replay protection without needing a second real entitlement.
- Typical preconditions:
- Nullifiers are user-supplied or otherwise caller-influenced public inputs.
- The proving system works over a scalar field such as BN254, but the contract accepts nullifiers as unrestricted 256-bit values.
- Replay protection keys storage by the raw submitted value rather than by one canonical field-bounded representation.
- The proof relation remains otherwise valid, so verifier success still authorizes a meaningful action.
- The protocol has some action worth replaying: reward claim, anonymous signal, broadcaster reimbursement, withdrawal, or equivalent one-time right.
- Common vulnerable surfaces:
- anonymous signaling systems that mark
nullifierHashornullifiers_set[input[1]]after proof verification - private claim systems where one Merkle entitlement is supposed to be redeemable exactly once
- mixer or privacy-pool withdrawals that trust a spent-nullifier map for replay prevention
- wrapper or extension contracts that reuse a base zk primitive but forget the scalar-field guard on nullifiers
- any zk integration where nullifier uniqueness is enforced in Solidity while nullifier semantics live in the proof system
- anonymous signaling systems that mark
- Exploit sequence:
- The attacker obtains one genuine proof right tied to nullifier
n, or otherwise one valid proof template where the nullifier is part of the public inputs. - They identify that the verifier reasons about the nullifier modulo the SNARK field
q, while the contract stores the raw submitted value. - They use the original nullifier
nfor the first successful action, which marks the raw keynas spent. - They then submit the same logical nullifier under an aliased raw encoding such as
n + q. - Inside proof verification, the aliased value is interpreted as the same field element and still satisfies the proof relation.
- Outside the verifier, the contract checks a fresh storage slot such as
spent[n + q], sees it as unused, and accepts the action again. - The protocol emits a duplicate signal, pays a second reward, or otherwise reopens a supposedly consumed right.
- The attacker obtains one genuine proof right tied to nullifier
- Variants:
- Nullifier replay alias — the direct
nversusn + qcase seen in early Semaphore and a16z ZkDrops. - Wrapper-surface omission — a base contract or library knows the field bound, but an application wrapper accepts nullifiers on a looser surface and keys replay protection by the raw value.
- Payout replay — the duplicate action is economically meaningful, such as claiming ERC-20 rewards or mixer withdrawals rather than just duplicating a signal.
- Semantic replay without theft — even when no direct payout exists, the same anonymous-action right can be reused, corrupting vote, signal, or rate-limit semantics.
- Nullifier replay alias — the direct
Three nullifier-authority promotions reviewers should not collapse
-
A nullifier-shaped value exists at all
- Some
uint256orbytes32arrived from calldata, got passed into the verifier, or was emitted into logs and spent maps. - This is only the transport question:
there is some value here the system might later treat as a nullifier.
- Some
-
That value counts as the one canonical verifier-side nullifier object
- The real question is whether this submitted value is already field-safe and whether verifier math, storage keys, emitted events, wrappers, and relayers are all still talking about the same semantic nullifier rather than raw aliases of one field element.
- This is the canonical-object question:
the thing the contract is storing and the thing the verifier authenticated are exactly the same nullifier object.
-
That accepted canonical nullifier still deserves one-time settlement authority here
- Even once a value is canonical, the protocol still has to decide whether consuming it here should close the right economic or governance action exactly once across every wrapper, reward path, reimbursement path, or withdrawal rail.
- This is the one-time-authority question: why does this accepted nullifier close exactly one real action here and now, with no alternate raw or wrapper-mediated route left open?
Healthy replay protection keeps those promotions separate instead of flattening them into one vague the proof had a nullifier story.
- Detection heuristics:
- Trace every nullifier from user input to verifier boundary and ask where
< qis first enforced. - Try
n,n + q, andn + 2qagainst replay-protected flows and require them to be rejected or canonicalized consistently. - Review whether spent-nullifier maps use raw
uint256/bytes32keys or a canonical field-bounded representation. - Compare proof-generation semantics with onchain replay semantics, not just proof validity.
- Treat extension contracts and claim wrappers as first-class audit targets because they often inherit the proof system but not the caller-boundary guards.
- Trace every nullifier from user input to verifier boundary and ask where
- Defenses:
- Enforce
nullifier < qat the earliest caller-facing boundary, before verification, storage lookup, or event emission. - Canonicalize nullifiers once and use that canonical representation everywhere: verifier inputs, spent maps, logs, and downstream comparisons.
- Make replay-protection state consume exactly the same object the verifier authenticates.
- Add regression tests that redeem or signal once with
n, then try again withn + qand require failure. - Document nullifier domain assumptions explicitly in the external API so integrators do not treat them as arbitrary integers.
- Enforce
What sound design looks like
- Healthy nullifier handling stays staged rather than collapsing into one
nullifier existsfact. Protocols should keepa nullifier-shaped transport value exists,that value is the one canonical verifier-side nullifier object, andthat accepted object closes one real action exactly onceas separate promotions with explicit answers at each step. - A healthy design has only one nullifier object. The verifier, the spent map, and any downstream payout logic should all reason about the same field-safe value rather than letting the proof system consume
n mod qwhile storage separately tracks rawnandn + q. - Replay protection should fail closed at the first boundary. If a submitted nullifier is not already a valid field element, the contract should reject it before asking the verifier anything and before touching uniqueness state.
- Canonicalization should be visible and testable. Reviewers should be able to point to one line or helper that proves
the nullifier the verifier sawis exactlythe nullifier this contract marked spent. - The clean reviewer question is:
can one logical nullifier ever appear under two raw encodings across the verifier / storage boundary? - Just as important, this pattern should not be overclaimed: proving that storage and verifier semantics share one canonical nullifier encoding still does not prove the protocol chose the right underlying object to nullify in the first place.
- High-signal incidents:
- double-signal replay near miss (2019-07-25) — the canonical anonymous-action replay exemplar.
- [[../../../incidents/proofs-verifiers/a16z-zkdrops-privateairdrop-nullifier-range-check-double-claim-near-miss-2022-01-26|a16z ZkDrops
PrivateAirdrop.collectAirdrop()nullifier range-check omission / double-claim near miss (2022-01-26)]] — the payout-side version where one Merkle airdrop right could be redeemed twice.
Failure-prevention checklist
- Review three promotions separately every time:
nullifier-shaped value exists,value is the one canonical verifier-side nullifier object, andthat accepted object closes one real action exactly once through this exact path. Healthy systems prove all three instead of assuming one implies the next. - Reject out-of-field nullifiers at the first caller-facing boundary, before proof verification, replay checks, event emission, or payout-side accounting. Prefer fail-closed rejection to silent modulo reduction.
- Define one canonical nullifier representation and reuse it everywhere that matters: verifier inputs, spent-nullifier storage, emitted events, refund logic, relayer accounting, and offchain indexing.
- Encode the field bound into shared helper code or types so wrapper contracts, claim modules, and governance-added extensions cannot silently widen the nullifier domain back to raw
uint256. - Test the full replay surface, not just proof validity: spend with
n, then retry withn + qandn + 2q, and require every one-time path to stay closed. - Keep nullifier-consumption semantics adjacent to proof verification. The more wrappers or asynchronous settlement hops a nullifier crosses before it is marked spent, the easier it is for domain drift to reappear.
- Treat logs and indexers as part of the uniqueness boundary when downstream systems consume nullifier events. Offchain consumers should not be forced to guess which raw encoding was the real canonical one.
- Make payout, reimbursement, or reward logic consume the same canonical nullifier object as the replay-protection map. A duplicate economic action should be impossible even if a wrapper forgets one local check.
- Document the proving-field modulus and nullifier API contract explicitly for integrators, auditors, and indexer authors.
uint256should be understood as transport shape, not semantic domain. - Add invariants for upgrade and migration work: any new verifier, wrapper, or storage migration must preserve
one logical nullifier -> one canonical stored key. - Maintain a regression corpus of past aliasing cases so future refactors re-run the exact
nversusn + qfailures that broke earlier systems.
Audit prompts
- Which promotion is happening at each step here:
nullifier-shaped value exists,that value is the one canonical verifier-side nullifier object, orthat accepted object is allowed to close a one-time action through this exact path right now? - Where exactly is
nullifier < qfirst enforced, and can any caller path touch state, emit logs, or reach verifier code before that guard runs? - Does the spent-nullifier map key the same canonical object the verifier authenticates, or a raw
uint256/bytes32value that could alias moduloq? - Can the same anonymous-action right, claim right, or withdrawal right be replayed by presenting
n,n + q, or another aliased encoding through a wrapper or relayer path? - If a downstream system indexes nullifier events, which representation does it treat as authoritative, and could two raw encodings of one logical nullifier look distinct offchain even if the verifier sees them as the same?
- Do helper contracts, airdrop claimers, relayer reimbursements, governance modules, or proxy wrappers reuse the nullifier without reusing the same field-bound check?
- Is canonicalization performed once at the boundary and then reused, or does each call site make its own assumptions about whether raw values are already field-safe?
- During upgrades or circuit swaps, what proves the new verifier field, calldata parser, and stored nullifier representation still describe the same object?
- If the protocol intentionally canonicalizes rather than rejects out-of-range inputs, can reviewers point to one exact implementation that every caller path shares?
- Are there any places where replay protection is applied after a side effect such as event emission, reward accounting, or broadcaster reimbursement, allowing partial duplicate settlement even if the main action later fails?
- What property test proves
one valid proof right cannot produce two distinct onchain nullifier keysacross the whole application surface, not only the base verifier contract?
Lessons that should already be standard
A value looked like a nullifieris weaker thanthat value was the one canonical verifier-side nullifier object, which is weaker thanthat accepted object closed one real action exactly once. Replay bugs live in the gaps between those promotions.- Nullifiers are settlement objects, not just circuit variables. If the verifier and the contract disagree about their domain, replay protection is already broken no matter how elegant the circuit looks.
We store used nullifiersis not the same aswe have one-time semantics. One-time semantics only exist when storage, verifier math, events, and payout code all consume the same canonical nullifier object.- Wrapper contracts are the natural relapse point. Teams often fix the audited core and then quietly reintroduce raw-
uint256nullifier handling one layer out in claim modules or relayer helpers. - Offchain infrastructure is part of the trust boundary when it reacts to nullifier events or reimbursements. Canonicalization drift can create duplicate operational truth even before it becomes onchain theft.
- The proving field belongs in the public API. If integrators have to rediscover the modulus from generated verifier code, the protocol has left a critical security invariant implicit.
- Replays are often found by extremely simple adversarial inputs.
Try n + qshould be a permanent test fixture, not a clever one-off bug bounty trick. - The clean design mantra is simple: one logical nullifier, one accepted encoding, one stored key, everywhere.
Where this pattern stops
- Use public-input-domain-mismatch when the same field-boundary drift affects a broader proof-facing object such as
groupId, external nullifiers, Merkle zero values, commitments, or roots rather than specifically replayable nullifiers. - Use nullifier-right-object-canonicalization-discipline when the nullifier is already field-safe and canonically encoded, but the sharper failure is that the protocol derived replay-prevention state from the wrong semantic object — for example from a merely valid authorization witness rather than from the one-time right being consumed.
- Use witness-public-input-binding-failure when the circuit statement itself is underbound and proves the wrong relation even if the nullifier’s numeric domain is fine.
- Use proof-to-effects-binding-failure when the proof can be sound and the nullifier can be canonical, but a downstream settlement consumer still executes a different economically relevant effect set than the proof authenticated.
- Use duplicate-claim-idempotency-failure when the replay or duplicate claim bug does not depend on zk field aliasing at all and instead comes from ordinary application-level idempotency failures.
- In short: this pattern begins once one logical nullifier can cross the verifier / contract boundary under multiple raw encodings, and it stops once the sharper story is instead a broader public-input mismatch, a bad proof statement, or a non-zk duplicate-claim bug.
Internal linkages
- Parent family: public-input-domain-mismatch — the broader pattern covering the same field-boundary drift for nullifiers, group ids, external nullifiers, and Merkle initialization values.
- Positive-design companion: proof-facing-field-domain-canonicalization-discipline — use it when the sharper question is how caller-boundary checks and storage keys should ensure the verifier and spent-nullifier map can only ever talk about one canonical nullifier object.
- Adjacent nullifier-identity companion: nullifier-right-object-canonicalization-discipline — use it when field-domain correctness is already satisfied and the remaining question is whether the protocol chose a canonical semantic one-time right rather than a malleable witness artifact.
- Best current exemplars: semaphore-nullifier-field-modulus-aliasing-double-signal-replay-near-miss-2019-07-25, a16z-zkdrops-privateairdrop-nullifier-range-check-double-claim-near-miss-2022-01-26.
- Availability-side cousin in the same family: semaphore-groupid-external-nullifier-range-check-omission-inoperable-poll-near-miss-2022-03-07 — same field-boundary problem, but the failure mode is unusable poll state rather than replayable nullifiers.
- Adjacent payout-side family: duplicate-claim-idempotency-failure — useful once the question shifts from
was one nullifier object split in two domains?todid the application fail to consume claim rights exactly once?.
Control surface
-
The leverage sits where protocols promote a raw caller-facing value into canonical nullifier truth and then into one-time settlement authority: calldata parsing, scalar-field range checks, spent-nullifier maps, log formats, wrapper contracts, relayer reimbursements, and any path that turns verifier success into a one-time economic or governance action.
-
The recurring mistake is to think
we have nullifiersautomatically meanswe have one-time semantics; the hard part is ensuring the verifier, storage, wrappers, and payout paths all agree on exactly one canonical nullifier object and one exact consumed action. -
Sources:
-
Last reviewed: 2026-07-31 UTC