Public-Input Domain Mismatch / Field-Modulus Aliasing

  • Name: public-input domain mismatch / field-modulus aliasing
  • Category: exploit pattern / zk-proof integration failure / finite-field boundary mismatch / raw-uint256 versus field-element drift
  • Summary: A zk verifier only reasons about public inputs inside its scalar field, but surrounding contracts often store or compare those same values as unrestricted uint256s. If the application does not enforce the same < q boundary that the proof system assumes, the verifier and the contract stop talking about one canonical object. Two different 256-bit values such as x and x + q can collapse to the same field element inside proof verification while still looking distinct to a Solidity uniqueness map, a user-facing identifier like groupId can be accepted by contract code even though the verifier will always reject it, or a Merkle tree can be initialized with zero values that later become unusable proof siblings. The result can be replayable nullifiers, double-signaling, duplicate reward claims, permanently unverifiable groups or polls, or Merkle / commitment objects that can never participate soundly in the proof flow they were created for.
  • What it is:
    • A zk application feeds security-critical public inputs from Solidity into a verifier generated for a finite field of size q.
    • The verifier expects each public input to live in that field, whether by explicit input[i] < q checks or by arithmetic that is only meaningful modulo q.
    • Application contracts, storage maps, or helper modules treat the same values as unrestricted uint256s.
    • The protocol implicitly assumes field element seen by the verifier and raw integer consumed by the contract are the same object.
    • In the vulnerable case, no end-to-end range check or canonicalization forces that equality of meaning.
    • The exploit cashes out wherever the raw-value surface matters: nullifier uniqueness, scope selection, external nullifier / group identifiers, Merkle-tree initialization, or any other proof-facing state object.
  • Typical preconditions:
    • Proof-facing values such as nullifiers, group ids, external nullifiers, commitments, roots, or zero values enter the system through Solidity uint256 parameters.
    • The verifier or circuit only makes sense for values in the SNARK scalar field.
    • The surrounding contract stores, compares, or indexes those values without first range-checking or canonicalizing them.
    • The same value matters both to proof validity and to an onchain uniqueness / authorization / availability rule.
    • Successful proof verification or successful object creation directly unlocks some economically or operationally meaningful path.
  • Common vulnerable surfaces:
    • nullifier maps where the verifier sees values modulo q but Solidity stores raw uint256 keys
    • external-nullifier, scope, or group-id fields reused as public inputs in voting, signaling, or anonymous-action systems
    • Merkle-tree initialization paths that accept leaf or zero values larger than the proving field even though later inclusion proofs, removal proofs, or sibling checks assume field-safe inputs
    • precomputed default-path state such as zero-leaf ladders, empty-subtree caches, or constructor-seeded commitment tables that quietly become proof siblings later even if callers think of them as harmless setup data
    • helper contracts and extensions that inherit zk primitives but omit the same scalar-field checks as the base verifier
    • legacy proof integrations where developers remember the verifier’s field constraint abstractly but do not push that invariant out to every caller-facing contract boundary
  • Exploit sequence:
    1. The attacker identifies a proof-facing value that the application accepts as an unrestricted uint256.
    2. They find where the proof system interprets that value inside the SNARK field, either by explicit < q checks or by arithmetic that effectively reduces modulo q.
    3. They choose two raw values that the contract distinguishes but the proof system does not, or one raw value that the contract accepts even though the verifier will always reject it.
    4. In the replay variant, they use the aliased value to satisfy proof verification while bypassing raw-value uniqueness checks such as usedNullifier[x].
    5. In the availability variant, they create a group, poll, tree, or scope that looks valid to contract code but can never verify later proofs.
    6. The protocol either pays out, accepts a duplicate anonymous action, or silently bricks the proof-backed object.
  • Variants:
    • Nullifier alias replay — n and n + q verify as the same field element, but onchain uniqueness maps treat them as different raw values, enabling double-use of one proof right.
    • Scope / group-id denial of service — a poll, group, or external nullifier is created above the scalar-field bound, so future proofs always revert even though the object was accepted onchain.
    • Commitment / leaf initialization mismatch — Merkle or commitment values outside the field are admitted into state, so later proof generation or verification breaks or becomes unsound.
    • Extension-level omission — the base protocol understands the field bound, but downstream voting, signaling, or wrapper contracts forget to enforce it at their own caller boundary.

Three proof-facing input promotions healthy systems should not collapse

  1. A raw proof-facing value exists at all

    • A caller supplied a uint256, a constructor accepted a groupId, a tree was initialized with a zero value, or a wrapper forwarded a nullifier-like identifier.
    • This is only the transport-shape question: some raw value reached the proof-facing boundary.
  2. That raw value counts as the canonical field-safe object the proving system means to reason about

    • This is the promotion vulnerable systems skip over. They quietly treat the contract accepted this integer as if it already meant the verifier, storage layer, and later proofs all agree on one canonical field element.
    • Semaphore’s groupId / external-nullifier bug is the clean availability-side example: a raw identifier existed and was accepted onchain, but that never made it a field-safe object the verifier could honor later.
  3. That accepted field-safe object deserves downstream uniqueness, availability, or payout authority

    • Even once an input is properly canonicalized into one field-safe object, the protocol still has to decide what that object may authorize: one-time claims, anonymous signaling, poll participation, tree usability, or reward payout.
    • Healthy systems do not let this integer arrived or even this integer is field-safe stand in for this object now deserves replay-prevention, scope-selection, or economically meaningful authority without making those later promotions explicit.
  • Detection heuristics:
    • Trace every public input from user-controlled Solidity parameter to verifier boundary and ask where < q is first enforced.
    • Fuzz with x, x + q, and x + 2q for nullifiers, scopes, group ids, leaves, and commitments; require the system to treat them consistently.
    • Audit storage keys and uniqueness maps for proof-facing values that are keyed by raw uint256 rather than canonical field-safe representations.
    • Review extension contracts, not just the base verifier, because many incidents come from wrappers that reuse proof-facing identifiers without reusing scalar-field guards.
    • For availability-sensitive flows, try creating groups, polls, or trees at the largest possible uint256 values and confirm proofs remain generatable and verifiable.
    • Treat verifier rejects out-of-range inputs as only half a defense; the rest of the system must reject or canonicalize those same inputs before they become state.
  • Defenses:
    • Enforce input < q at the earliest caller-facing contract boundary for every proof-facing value, not only inside the verifier.
    • Canonicalize public inputs once and use that canonical representation for storage, uniqueness maps, events, and downstream comparisons.
    • Make nullifier-consumption logic and proof verification talk about the same domain object: one field-safe nullifier, not one verifier-side field element plus one raw storage key.
    • Reuse shared scalar-field guard libraries across base contracts and application extensions so wrappers do not silently drop the bound.
    • Add invariant tests that prove availability too: any created group, poll, or tree must admit at least one syntactically valid proof path if other preconditions are met.
    • Document the proving field as part of the protocol’s external API, not as an internal verifier detail.

What sound design looks like

  • Healthy systems keep three facts separate on purpose: a raw proof-facing value exists, that value is the canonical field-safe object the proving system actually reasons about, and that accepted object is now allowed to drive replay-prevention, proof-backed object creation, or payout authority.
  • A proof-facing identifier should have one machine-checked domain all the way from user input to verifier logic. If a nullifier, scope, group id, zero value, or commitment is meaningful only as an element of the BN254 scalar field, then the contract that first accepts it should enforce that fact before it ever becomes storage, event data, or reward-consumption state.
  • Uniqueness and proof validity should share the same canonical object. A healthy design does not let the verifier consume n mod q while the replay-protection map stores raw n and n + q as different keys.
  • Availability-sensitive objects like groups, polls, and Merkle trees should be validated at creation time against the proving system’s own domain assumptions. Object was created should already imply this object can later participate in the proof flow it was created for.
  • Library defaults deserve the same treatment as explicit user inputs. If zero leaves, empty-subtree hashes, or seeded commitment tables are ever going to reappear inside Merkle proofs or public-input vectors, they are already proof-facing objects and should be forced into the field before initialization succeeds.
  • The clean reviewer question is: where exactly do we prove that the Solidity value we stored, compared, or keyed is the same field element the verifier later reasons about, and where exactly do we prove what downstream authority that accepted field-safe object is allowed to carry?
  • High-signal incidents:
    • double-signal replay near miss (2019-07-25) — the replay variant, where raw nullifier keys could diverge from verifier-side field semantics and allow the same anonymous action right to be consumed twice.
    • [[../../../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 claim-payout replay variant, where one Merkle airdrop entitlement could be redeemed twice by presenting n and n + q as distinct onchain nullifiers.
    • [[../../../incidents/proofs-verifiers/semaphore-groupid-external-nullifier-range-check-omission-inoperable-poll-near-miss-2022-03-07|Semaphore groupId / external-nullifier range-check omission / inoperable poll near miss (2022-03-07)]] — the availability variant, where contracts accepted identifiers the verifier would never honor.
    • [[../../../incidents/proofs-verifiers/zk-kit-incremental-binary-quin-tree-zero-leaf-snark-scalar-range-check-omission-near-miss-2022-03-09|ZK-Kit incremental binary/quin tree zero-leaf SNARK_SCALAR_FIELD range-check omission near miss (2022-03-09)]] — the Merkle-initialization variant, where the impossible object was not a replayable nullifier or oversized poll id but a proof-backed tree seeded with out-of-field zero values that later reappeared as invalid siblings.

Failure-prevention checklist

  • Enforce the proving-field bound at the first caller-facing boundary for every proof-facing value: nullifiers, scopes, group ids, external nullifiers, roots, zero values, leaves, and commitments. Do not let raw transport-shape acceptance masquerade as proof-facing validity.
  • Canonicalize proof-facing identifiers once and reuse that canonical form everywhere: verifier inputs, storage keys, replay-protection maps, emitted events, and downstream authorization logic.
  • Keep object-creation rules aligned with proof-verification rules. If a group, poll, tree, or commitment object would later be unverifiable because a value is out of field, reject it before state is created.
  • Centralize scalar-field guards in shared libraries or modifiers so wrappers, extensions, and helper contracts cannot silently accept a wider domain than the base verifier.
  • Add adversarial tests for x, x + q, and x + 2q across every replay-sensitive or availability-sensitive input surface. Require the system to reject or treat them identically, never inconsistently.
  • Make uniqueness maps key the same semantic object the verifier consumes. A nullifier-spent map should not index raw uint256 values if the proof system only recognizes field-canonicalized elements.
  • Validate Merkle tree initialization parameters, zero values, empty-subtree ladders, and helper defaults against the proving field at creation time, not only when proofs later fail.
  • Document the proving field and canonicalization rules as part of the external contract API so integrators understand that uint256 is only the transport type, not the semantic domain.
  • Include availability invariants in testing and monitoring: any created group, poll, or tree should still admit valid proof generation under ordinary conditions.
  • Review downstream wrappers and governance-added extensions with the same paranoia as the original verifier integration; range-check omissions often reappear one layer out from the audited core.

Audit prompts

  • Where is < q first enforced for each proof-facing value, and does every caller path hit that guard before state, events, or replay-protection maps are updated?
  • Do storage keys, uniqueness maps, and emitted identifiers use the same canonical field-safe representation the verifier reasons about, or raw uint256 values that can alias modulo q?
  • Can an attacker supply x + q to consume the same proof right, nullifier, or anonymous action while bypassing an onchain uniqueness check keyed by raw integer value?
  • Can the contract create a group, poll, tree, or other proof-backed object using identifiers or zero values that the verifier will later reject, permanently bricking the object?
  • Do extension contracts, wrappers, or helper modules reuse proof-facing ids without reusing the same scalar-field guardrails as the base system?
  • Are there any places where a verifier-side rejection is relied upon as the only protection, even though state changes or object creation happen earlier?
  • What exact canonicalization rule would an offchain prover, indexer, or relayer need to reproduce so that it talks about the same identifier the contract stores?
  • If one identifier is emitted in logs, stored in mappings, and passed into proofs, are those three representations provably the same semantic object?
  • During upgrades, where is the assertion that the new circuit field, verifier wrapper, and application-layer storage rules still agree about input domain?
  • If a user asks why a created poll, group, or tree can never verify a proof, what creation-time invariant failed to stop that impossible object from existing?

Lessons that should already be standard

  • uint256 is not a semantic domain. In zk integrations it is often just a transport envelope for a much smaller field element, and treating raw integer arrived as if it already meant canonical proof-facing object exists is how replay and bricking bugs happen.
  • Replay protection only works if the replay-protection map and the verifier consume the same canonical identifier. Raw-value uniqueness is not enough when the proof system aliases modulo q.
  • Availability is part of correctness. Accepting proof-backed objects that can never later verify is a real protocol failure, not a mere UX bug.
  • Verifier-side range checks are necessary but not sufficient. By the time the verifier rejects an out-of-field value, the application may already have stored unusable state or emitted misleading external truth.
  • Wrapper contracts are where sound invariants often die. Teams remember the field bound in the audited core and then forget to restate it in extensions, voting modules, airdrop claimers, or helper factories.
  • The proving field belongs in external docs, code comments, tests, and API assumptions. If integrators have to rediscover SNARK_SCALAR_FIELD from generated verifier code, the boundary is too implicit.
  • The clean mental model is one proof-facing identifier, one canonical field-safe representation, everywhere. Any second representation that matters operationally is a likely exploit surface.

Where this pattern stops

  • Use creation-time-proof-usability-failure when the sharper recurring story is no longer broad field-domain drift or replay, but that the application published a proof-backed object that was dead on arrival for its intended lifecycle. Semaphore oversized groupId polls and ZK-Kit poisoned zero-leaf trees still belong to this broader family, but that specialized note carries the heavier analytical load when created successfully, unusable later is the main lesson.
  • Use witness-public-input-binding-failure when the proof statement is wrong because public-input truth and witness-side truth were split into two unconstrained claims. That family is about an underbound statement inside the proof relation itself. This family is about the contract and verifier disagreeing on the domain of one public input.
  • Use proof-to-effects-binding-failure when the proof may be sound and the public-input domain may be sound, but a downstream settlement consumer still executes a different economically relevant effect set than the proof authenticated.
  • Use verification-key-independence-collapse when the verifier key itself destroys soundness for all inputs. Here the proving system can remain mathematically fine while the application mis-handles the boundary between field elements and raw contract integers.
  • In short: this pattern begins when the verifier's field element and the contract's stored integer stop being one canonical object, and it stops once the sharper story is instead a split statement, a bad proof consumer, or a broken verifier key.

Internal linkages

Control surface