Semaphore Nullifier Field-Modulus Aliasing / Double-Signal Replay Near Miss (2019-07-25)
- Name: Semaphore nullifier field-modulus aliasing / double-signal replay near miss
- Date: 2019-07-25
- Protocol: Semaphore
- Chain: Ethereum
- Category: public-input domain mismatch / field-modulus aliasing / nullifier replay / double-signal near miss
- Loss: no public realized theft was documented, but the issue author showed that one anonymous signaling right could be reused and the same funded contract path could therefore emit duplicate signals and duplicate broadcaster reimbursements or rewards if the contract held ETH
- Summary: Early Semaphore’s
broadcastSignal()path mixed two different meanings ofnullifiers_hash. Inside the zk verifier, public inputs lived in the BN254 scalar fieldq = 21888242871839275222246405745257275088548364400416034343698204186575808495617. Outside the verifier, the contract tracked spent nullifiers in raw Solidity storage withmapping (uint => bool) nullifiers_set. The contract never requiredinput[1] < qbefore callingverifyProof(...)or before keyingnullifiers_set[input[1]]. As reported on 2019-07-25, that letnullifier = n + qverify as the same field element asnwhile still hitting a different raw storage slot. In practice, one already-used anonymous action could be replayed under an aliased nullifier key, defeating Semaphore’s one-signal-per-nullifier guarantee. - Trigger / entry point:
- The vulnerable entry point was
broadcastSignal(bytes signal, uint[2] a, uint[2][2] b, uint[2] c, uint[5] input)in the 2019Semaphore.solcontract. - The function expected
input = (root, nullifiers_hash, signal_hash, external_nullifier, broadcaster_address). - It verified the proof first and only then checked
require(nullifiers_set[input[1]] == false)before markingnullifiers_set[input[1]] = true. - No caller-facing range check forced
input[1]into the SNARK scalar field even though proof verification only made sense inside that field.
- The vulnerable entry point was
- Root cause:
- Semaphore treated the nullifier as one object in the proof system and a different object in contract storage.
- The verifier reasoned about field elements modulo
q, while Solidity replay protection keyed rawuint256values. - Because the contract did not canonicalize or reject out-of-range nullifiers,
nandn + qcould represent the same verifier-side nullifier but different onchain storage keys. - The replay-prevention invariant therefore failed at the contract / verifier boundary rather than inside the Merkle proof or membership relation.
- Exploit path:
- A legitimate Semaphore member generates a valid proof and broadcasts a signal with nullifier
n. - The contract accepts the proof, inserts the signal hash into the signal tree, and records
nullifiers_set[n] = true. - The attacker or same user reuses the same anonymous action right with an aliased raw nullifier value
n + q, whereqis the SNARK scalar field modulus andn + qstill fits intouint256. - Inside proof verification, that aliased value is accepted as the same field element the proof already authenticated.
- Outside the verifier, the contract checks a different storage slot,
nullifiers_set[n + q], which is stillfalse. broadcastSignal()therefore accepts the second action as fresh, emits another signal, and marks the aliased raw key as spent only after the duplicate action has already succeeded.- If the Semaphore instance held ETH for broadcaster reimbursement or rewards, the replay could also duplicate that payout path.
- A legitimate Semaphore member generates a valid proof and broadcasts a signal with nullifier
- What the attacker needed to be true:
- They needed one valid Semaphore proof for a live root and external nullifier.
- The aliased nullifier
n + qhad to remain withinuint256range. - The contract had to store used nullifiers by raw integer key rather than by one canonical field-safe representation.
- No earlier boundary check could reject
input[1] >= q. - The instance needed to still honor repeated
broadcastSignal()calls economically or semantically for the duplicate action to matter.
- Technical anatomy worth remembering:
- The bug was not
proof verified when it should have failedin the usual cryptographic sense. The verifier and the replay-protection map were simply reasoning about different domains. - Semaphore’s signaling surface is a clean example of why nullifiers are not just circuit objects; they are also settlement and uniqueness objects, so their numeric domain has to stay identical end to end.
- The old contract also paid the broadcaster after successful signal submission, using
(21000 + gas_used + reward) * gas_price, which means the replay was not only a semanticdouble signalproblem but also a potential duplicate-payout path. - This is the replay variant of a broader zk integration family: later Semaphore issue
#90showed the same field-boundary drift ongroupId/ external-nullifier handling, but that cousin bricked polls rather than replaying them.
- The bug was not
- Representative contracts and artifacts:
- Vulnerable contract source commit:
602dd57abb43e48f490e92d7091695d717a63915 - Vulnerable function:
broadcastSignal(...)insemaphorejs/contracts/Semaphore.sol - Raw replay-protection state:
mapping (uint => bool) nullifiers_set - Issue-reported alias modulus:
21888242871839275222246405745257275088548364400416034343698204186575808495617 - Public reproduction artifacts cited by the reporter:
- Kovan tx
0x5e8bf35ad76a086b98698f9d20bd7b6397ccc90aa6f85c1c5debc0262be5458a - Kovan tx
0x9a47cc8daec9d0a5e9a860ada77730190124f9864a5917dcb8f41773d94cfc1a
- Kovan tx
- Vulnerable contract source commit:
- Response / mitigation:
- The core fix was to treat proof-facing nullifiers as field-bounded API inputs rather than raw integers and reject values
>= qbefore verification or storage. - More broadly, later Semaphore and PSE work made the scalar-field boundary explicit in related contracts and issues, including caller-facing checks for
groupIdand other proof-facing values. - The durable lesson was not merely
add one require. It was that replay protection must store the same canonical object the verifier authenticates.
- The core fix was to treat proof-facing nullifiers as field-bounded API inputs rather than raw integers and reject values
Three nullifier-authority promotions this incident collapsed
-
A nullifier-shaped raw value existed at all
broadcastSignal()accepted some caller-suppliedinput[1]and passed it into the proof / settlement path.- That is only the transport fact: a raw
uint256arrived that looked nullifier-shaped.
-
That raw value still counted as the one canonical verifier-side nullifier object
- This was the decisive broken promotion. Inside the verifier,
nandn + qwere the same field element; outside the verifier,nullifiers_setstill treated them as different storage keys. - Semaphore therefore promoted
raw caller-supplied integerintocanonical nullifier truthwithout first proving that verifier math, storage keys, and later settlement logic were still talking about one field-safe object.
- This was the decisive broken promotion. Inside the verifier,
-
That accepted nullifier still deserved one-time signaling and reimbursement authority
- Once the aliased raw key passed as fresh, the contract still let it emit another signal and traverse the broadcaster-payment path as though a new anonymous action right had appeared.
- The cash-out question was not just
could the proof verify again?butwhy did that accepted raw encoding still retain live one-time authority over signal publication and any ETH reimbursement budget?
This is what makes the incident more useful than a generic forgot < q story: the bug was really a promotion ladder failure from transport value to canonical nullifier truth to one-time settlement authority.
Pattern tags
- nullifier-canonicalization-failure
- public-input-domain-mismatch
- proof-facing-field-domain-canonicalization-discipline
- One logical nullifier crossed the verifier / storage boundary under two raw encodings because Solidity tracked
uint256keys while the proof only constrained field elements moduloq.
Why the exploit worked
- Semaphore’s proof relation and Semaphore’s replay map were not consuming the same object. Inside the verifier,
nandn + qwere the same field element; outside the verifier, they were different storage keys. - The contract therefore spent
raw uint256 calldatarather thanthe canonical field-safe nullifierthe proof had actually authenticated. - That mismatch let one already-consumed anonymous signaling right reappear as fresh simply by choosing another representative of the same verifier-side nullifier.
- The bug is especially reusable because nothing exotic had to fail inside the circuit: the cryptography could stay sound while the contract still reopened replay via an uncanonicalized caller boundary.
Detection and audit pivots
- Trace every proof-facing public input across the full boundary: calldata type, pre-verifier validation, verifier field semantics, storage keying, emitted events, and any later settlement or payout logic.
- Fuzz the exact alias family
x,x + q,x + 2q, and boundary-adjacent values around the field modulus while holding the semantic proof-side action constant; replay protection should see one canonical object or reject the alias. - Ask the boundary question explicitly: does the contract store the same normalized field object the verifier authenticates, or does it store a raw host-language integer that only coincides with the verifier domain when callers behave?
- Review any
mapping(uint256 => ...)or raw ABI integer plumbing attached to zk public inputs with suspicion. If the proof lives in a finite field, the host-side API must either enforce that field or canonicalize into it before uniqueness logic runs.
Why this incident matters
- It is a crisp near miss where the proving system and the settlement contract disagreed on the domain of one public input, and that alone was enough to re-open a supposedly consumed nullifier.
- It preserves a useful zk-integration lesson that is easy to miss in circuit-focused reviews: even if the proof relation is sound, contract storage can still mis-handle the public inputs it is supposed to trust.
- It also broadens the proofs/verifiers corpus beyond forged-withdrawal stories into a more operational failure mode: anonymous-action uniqueness drifted because the contract and verifier stopped sharing one canonical nullifier object.
- The 2019 issue report is especially worth preserving because the bug report was tiny and still complete: it named the exact missing
< qboundary, showed then + qalias shape, and linked two Kovan transactions demonstrating that the same logical signal right could be spent twice.
Failure-prevention checklist
- Reject every proof-facing public input that falls outside the verifier’s field or bounded domain before proof verification, replay-map lookup, event emission, or payout logic can observe it as fresh.
- Treat replay protection as settlement logic, not as an afterthought to the verifier. If one successful proof can emit signals, mint rewards, or reimburse broadcasters, the uniqueness object must already be canonical at the first caller boundary.
- Keep one named modulus / domain constant and reuse it everywhere contracts, tests, relayers, or SDKs touch proof-facing integers; drift in constants is usually drift in security assumptions.
- Prove that every
spent,nullifier,used root, orconsumed notemap stores the same canonical object the verifier authenticates, not a wider host-language representation that only coincides when callers behave. - Add alias-family regression tests for
x,x + q,x + 2q, and boundary-adjacent values under the same semantic action. - Review reimbursement or reward code with the same paranoia as the proof gate. A duplicate signal is already bad; a duplicate signal plus gas refund or reward path is a cash-out surface.
- When a protocol supports multiple proof-facing identifiers such as roots, nullifiers, group IDs, or external nullifiers, give each one its own explicit domain check instead of assuming verifier validity will cover caller-supplied raw integers.
- Make field-domain assumptions visible in docs and interface comments so downstream integrators do not widen ABI types and silently reopen alias families during refactors or ports.
- Prefer end-to-end invariants over local checks: one semantic anonymous action should map to exactly one admissible replay key and exactly one successful settlement path.
Audit prompts
- What exact object does the verifier authenticate here, and where does the contract prove that storage keys, emitted events, and payout logic refer to that same object rather than to a raw ABI integer?
- Can I hold the semantic proof-side action fixed and submit
nullifier,nullifier + q, or another modulus alias that still verifies while opening a fresh storage slot? - Which proof-facing values enter as unrestricted
uint256orbytes32, and where are their finite-field or bounded-domain assumptions enforced before state changes? - Does replay protection run before or after proof verification, and does either branch observe a wider numeric domain than the other?
- If the contract pays refunds, rewards, or relayer compensation after proof success, could a domain-mismatch bug turn a semantic replay into a direct treasury drain?
- Are domain constants copied across contracts, relayers, SDKs, or tests, and what review step catches divergence between those copies?
- Do tests cover
same semantic action, different raw encoding, or onlysame raw calldata repeated twice? - For adjacent protocol cousins, which other proof-facing values besides nullifiers could suffer the same verifier-domain versus storage-domain drift?
- If the verifier library changed tomorrow, what invariant would still prove that contract settlement consumes one canonical field-safe object per anonymous action?
Lessons that should already be standard
Verifier accepts itis not enough. Settlement code has to consume the same object the verifier accepted.- A nullifier is not merely a circuit public input; it is also a replay-prevention and sometimes a payout object, so its numeric domain must stay identical end to end.
- Raw Solidity integer types are wider than many proof systems. Treat that mismatch as hostile by default.
- The smallest zk bugs are often boundary bugs, not cryptographic breaks. One missing
< qcheck can reopen an otherwise sound proof system. - Anonymous signaling systems still have treasury logic when they reimburse broadcasters. Once ETH leaves the contract, uniqueness failures are economic failures.
- Exact-input replay tests are table stakes. High-signal reviews also try equivalent encodings that preserve verifier semantics while changing host-language representation.
- If a protocol stores
used[caller_supplied_value], reviewers should immediately ask whethercaller_supplied_valueis already canonical in the verifier’s domain.
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 clean
same logical nullifier, two raw encodingsexample for why replay protection must store the same field-safe object the verifier authenticates. - Close payout-side replay cousin: a16z-zkdrops-privateairdrop-nullifier-range-check-double-claim-near-miss-2022-01-26 — same
nversusn + qaliasing shape, but in an ERC-20 redemption path instead of anonymous signal emission plus broadcaster reimbursement. - Adjacent proof-statement family: witness-public-input-binding-failure — that family proves the wrong relation; this issue kept the relation intact but let verifier-side field semantics drift away from onchain uniqueness keys.
- Availability cousin in the same protocol family: semaphore-groupid-external-nullifier-range-check-omission-inoperable-poll-near-miss-2022-03-07.
Control surface
-
The leverage sat in the
broadcastSignal()caller boundary, the missing< qnullifier check, the rawnullifiers_setmapping key, and the verifier’s finite-field interpretation of public inputs. -
The decisive question was not
did Semaphore have nullifiers?butdid the contract store exactly the same nullifier object the verifier authenticated? -
Sources:
-
Last reviewed: 2026-08-04 UTC