Semaphore groupId / External-Nullifier Range-Check Omission / Inoperable Poll Near Miss (2022-03-07)
- Name: Semaphore
groupId/ external-nullifier range-check omission / inoperable poll near miss - Date: 2022-03-07
- Protocol: Semaphore
- Chain: Ethereum
- Category: public-input domain mismatch / external-nullifier range-check omission / verifier-level denial of service / inoperable poll near miss
- Loss: no direct theft was reported, but any poll or whistleblowing group created with
groupId > SNARK_SCALAR_FIELDwould be permanently unable to verify proofs, leaving the onchain object created but unusable for its intended anonymous-action flow - Summary: In 2022, PSE’s security team reported that Semaphore extensions such as
SemaphoreVotingandSemaphoreWhistleblowingreusedgroupIdas the proof system’s external nullifier. That madegroupIda direct public input toVerifier.verifyProof(...), which explicitly rejected any public input>= SNARK_SCALAR_FIELD. ButSemaphoreGroupsstill let callers create groups, polls, and whistleblowing entities with arbitraryuint256identifiers. The result was a clean contract / verifier domain mismatch: the application could successfully create a poll that the proof system would never accept. No funds were stolen, but the protocol could author onchain objects that were dead on arrival from the proving system’s point of view. - Trigger / entry point:
- The reported issue centered on
SemaphoreGroup.soland downstream extension contracts such asSemaphoreVoting.createPoll. - Those extensions used
groupIdas the external nullifier public input for proof verification. - The verifier already enforced
require(input[i] < snark_scalar_field, "verifier-gte-snark-scalar-field")for every public input. - The creation path did not enforce the same bound on the user-supplied
pollId/groupIdbefore storing and exposing the new group.
- The reported issue centered on
- Root cause:
- Semaphore’s application-layer object creation and its verifier consumed the same identifier under different numeric assumptions.
- Solidity treated
groupIdas any validuint256. - The verifier treated the same value as an element of the SNARK scalar field.
- No caller-facing range check forced those meanings to match before the group became live state.
- The protocol could therefore create a group whose identifier was valid as contract state but invalid as proof input.
- Failure path:
- A poll creator calls the relevant Semaphore extension, such as
SemaphoreVoting.createPoll, with apollId/groupIdgreater thanSNARK_SCALAR_FIELD. - The surrounding contract accepts and stores that identifier, and the group or poll is created onchain.
- Members are added and the object appears operational from the application side.
- When a user later tries to verify or validate a proof for that poll, the verifier checks the public inputs.
- Because
groupIdis reused as the external nullifier, the verifier hitsinput[i] >= snark_scalar_fieldand reverts. - The poll or group therefore cannot process valid anonymous votes, signals, or whistleblowing proofs at all.
- A poll creator calls the relevant Semaphore extension, such as
- What the attacker or failure needed to be true:
- The extension contract needed to reuse
groupIdas a proof public input. - Object creation needed to accept arbitrary
uint256ids. - The verifier needed to keep enforcing the scalar-field bound on public inputs.
- No creation-time or pre-verification canonicalization could reduce or reject the oversized identifier.
- Users or integrators needed to assume that a successfully created poll was also proof-verifiable.
- The extension contract needed to reuse
- Technical anatomy worth remembering:
- This was the availability cousin of Semaphore’s earlier nullifier-alias replay bug. In both cases, the contract and verifier disagreed about the domain of one proof-facing integer.
- The bug is useful precisely because the verifier was behaving correctly. It was the surrounding contract surface that authored impossible state.
- Anonymous-action systems often treat
scope,external nullifier,poll id, andgroup idas convenient application-level labels. This incident preserves the lesson that once one of those labels crosses into proof public inputs, it stops being just an app identifier and becomes a finite-field object. - The report also highlighted that the problem was not confined to one extension: both voting and whistleblowing flows inherited the same mismatch because both reused
groupIdas the external nullifier.
- Representative contracts and artifacts:
- Reported vulnerable components:
SemaphoreGroup.sol,SemaphoreVoting.sol, andSemaphoreWhistleblowing.sol - Verifier-side bound called out in the report:
require(input[i] < snark_scalar_field, "verifier-gte-snark-scalar-field") - Public disclosure: GitHub issue
#90onsemaphore-protocol/semaphore - Fix reference: GitHub pull request
#91(Fix/group id value) - Concrete patched guard in
SemaphoreGroups._createGroup(...):require(groupId < SNARK_SCALAR_FIELD, "SemaphoreGroups: group id must be < SNARK_SCALAR_FIELD") - Regression coverage added in the same PR:
SemaphoreVoting.createPoll(...)now tests rejection of21888242871839275222246405745257275088548364400416034343698204186575808495618SemaphoreWhistleblowing.createEntity(...)now tests rejection of the same out-of-field id
- Package version bump in the fix PR:
@appliedzkp/semaphore-contracts0.6.0→0.6.1
- Reported vulnerable components:
- Response / mitigation:
- PSE proposed and merged the straightforward fix in PR
#91: reject anygroupIdthat is not strictly less thanSNARK_SCALAR_FIELDinside the sharedSemaphoreGroups._createGroup(...)path. - Placing the guard in the base group-creation primitive mattered: it protected both
SemaphoreVotingandSemaphoreWhistleblowingwithout asking each extension to remember its own field-boundary check. - The same PR added negative tests for oversized ids in both extension suites and shipped the patch as
@appliedzkp/semaphore-contracts0.6.1. - The broader mitigation lesson is that every extension reusing proof-facing identifiers must inherit the same scalar-field guardrails as the base contracts.
- PSE proposed and merged the straightforward fix in PR
Pattern tags
- public-input-domain-mismatch
- proof-facing-field-domain-canonicalization-discipline
- The decisive break was not
the verifier forgot to reject bad inputs; it wasthe application published a poll or group identifier as live proof-backed state before proving that identifier belonged to the verifier's finite-field domain at all.
Why the exploit worked
- Semaphore’s extension layer treated
groupIdas an ordinary application identifier during object creation, while the verifier already treated the same value as a field element bounded bySNARK_SCALAR_FIELD. - That mismatch mattered because
groupIdwas not just display metadata. Voting and whistleblowing flows reused it as the external nullifier public input that later decided whether a proof could verify. - The verifier-side guard therefore did not save the higher-level application semantics. By the time
verifyProof(...)reverted, the protocol had already published a poll or entity that looked successfully created but was permanently outside the proving system’s admissible domain. - The base fix location in
SemaphoreGroups._createGroup(...)is the useful clue about the real security boundary: the missing invariant wascreation-time proof usability, not a local verifier tweak inside each extension. - The sharper reusable lesson is that anonymous-action systems fail earlier than teams expect when proof-facing labels like
groupId,pollId,scope, orexternal nullifierare still treated as genericuint256transport values instead of one canonical field-safe object.
Three proof-object publication promotions this incident collapsed
-
A
groupId/pollIdexisted as application state at allSemaphoreVoting.createPoll(...)or the sibling whistleblowing creation flow accepted a caller-supplied identifier, stored it, and emitted the surroundinggroup createdreality.- At this point the protocol had only proved
a contract object now exists with this id.
-
That same identifier was actually a verifier-legal external-nullifier object
- The extensions silently promoted the raw stored
uint256into the proof system’s public-input domain by reusinggroupIdas the external nullifier. - But the verifier still required every public input to satisfy
input[i] < SNARK_SCALAR_FIELD, so an oversized id never earned that promotion.
- The extensions silently promoted the raw stored
-
The created poll/entity therefore deserved live anonymous-action authority
- Users and integrators were allowed to treat the created poll, group, or whistleblowing entity as a real anonymous-signaling surface even though no valid proof could ever pass for that identifier.
- The practical failure was not merely
bad input later reverts; it wasSemaphore published an object as live workflow infrastructure before the proving boundary had actually accepted its core identifier semantics.
Detection and audit pivots
- For every proof-backed object-creation path, ask the liveness question directly: does
poll created,group created, orentity createdalready imply that the object’s identifiers can pass the later verifier boundary under normal use? - Trace each user-supplied identifier from creation through proof verification and list where the first
< SNARK_SCALAR_FIELDguard actually runs. If that first guard sits only inside the verifier, the application may already be authoring dead-on-arrival state. - Review every extension that reuses a base proof primitive. Here the same omission hit both voting and whistleblowing because both reused
groupIdas the external nullifier without moving the field bound outward to creation time. - Build adversarial tests around maximum and out-of-field identifiers, not only nominal ids. A healthy suite should reject oversized
pollId/groupIdvalues before any user-visible object exists. - Separate
the verifier rejects impossible inputsfromthe protocol preserves one usable proof-backed object. The first property is local verifier hygiene; the second is the application-level invariant that actually decides whether anonymous voting or signaling remains live.
Failure-prevention checklist
- Enforce proof-field bounds at the shared creation primitive, not only inside later verifier calls. If
groupIdorpollIdwill become an external nullifier or other public input, reject out-of-field values before the poll or group exists. - Make
object created successfullyimplyobject is later proof-usable. Poll, group, and whistleblowing entity constructors should already guarantee that ordinary users can produce and verify proofs against the created object. - Treat
groupId,pollId,scope, andexternal nullifieras proof objects once the application reuses them that way. Reviewers should stop treating them as harmless application labels the moment they cross the verifier boundary. - Centralize finite-field guards in the common base path. Semaphore’s fix in
SemaphoreGroups._createGroup(...)is the healthy pattern because sibling extensions should inherit one canonical domain check rather than each remembering their own. - Add lifecycle tests, not only verifier tests:
create poll with oversized id -> add members -> attempt vote / signal / whistleblow. The important guarantee is that impossible ids never become live proof-backed state at all. - Exercise maximum-value and boundary-value identifiers in every extension that wraps the base group primitive. Shared zk integrations often fail through reused helper surfaces rather than one isolated feature.
- Document proof-facing semantic narrowing explicitly in the API. If callers supply a
uint256that really meansfield element, the interface and error messages should say so before integrators build dead-on-arrival objects.
Audit prompts
- Which caller-supplied identifiers later become verifier public inputs, nullifiers, scopes, or Merkle-domain values rather than remaining ordinary application metadata?
- Does the first
< SNARK_SCALAR_FIELDcheck run before object creation, or only after the protocol has already published a poll, group, or entity that users will assume is live? - Can one shared base primitive guarantee proof usability for every extension, or are voting, whistleblowing, and other wrappers each re-implementing field-domain checks inconsistently?
- If a poll or group id is out of field, what exact user-visible failure appears first: creation rejection, proof-generation failure, verifier revert, or permanent silent inoperability?
- Are tests proving the stronger invariant
created object remains proof-usable, or only the weaker invariantverifier rejects impossible inputs later? - During upgrades or migrations, could imported ids, admin-created objects, or wrapper contracts bypass the same field-domain guard that normal creation paths now enforce?
Lessons that should already be standard
- A proof-backed object should have one truth from birth: if a poll or group can be created, it should already satisfy the verifier’s numeric domain.
The verifier rejects bad inputs lateris not a sufficient application invariant once users can already create or depend on proof-backed objects that will never verify.- Generic
uint256labels stop being generic as soon as the protocol reuses them as external nullifiers or other public inputs. The semantic type has already narrowed even if the Solidity type did not. - The healthiest fix is usually outward. Moving the field check to shared object creation is better than asking every later verifier, extension, or UI path to rediscover that the object was impossible.
- Availability failures in proof systems still belong in the exploit corpus. A protocol that can author dead-on-arrival anonymous-voting or whistleblowing objects has already broken a security-relevant invariant even without theft.
Why this incident matters
- It is a clean non-theft exemplar for the proofs/verifiers corpus: proof integration bugs can destroy liveness and correctness even when there is no forged proof or stolen key.
- It preserves the important design lesson that
object successfully created onchainmust implyobject can participate in the proof system it was created for; otherwise protocols can quietly publish unusable state. - It also rounds out the newer
public-input domain mismatchpattern by showing the availability failure mode alongside Semaphore’s earlier replay variant.
Internal linkages
- Core recurring pattern: public-input-domain-mismatch.
- Positive-design companion: proof-facing-field-domain-canonicalization-discipline — the clean
object created successfully, but verifier can never honor it laterexemplar for why proof-facing ids must be field-bounded before poll or group creation. - Object-lifecycle companion: creation-time-proof-usability-and-dead-on-arrival-object-prevention — use this when the sharper question is why poll creation was allowed to mint a user-visible anonymous-voting object before the protocol had proved that the later proof lifecycle could ever honor its
groupId. - Replay cousin in the same protocol family: semaphore-nullifier-field-modulus-aliasing-double-signal-replay-near-miss-2019-07-25.
- Close library-level cousin: zk-kit-incremental-binary-quin-tree-zero-leaf-snark-scalar-range-check-omission-near-miss-2022-03-09 — same
created successfully, unusable once the proving boundary sees the valueshape, but with Merkle zero defaults instead of group identifiers. - Adjacent proof-statement family: witness-public-input-binding-failure — that note is about the proof relation being wrong; this note is about contract state entering a prover/verifier flow under the wrong numeric domain.
Control surface
-
The leverage sat in poll/group creation, the reuse of
groupIdas an external nullifier public input, and the missing creation-time< SNARK_SCALAR_FIELDguard. -
The decisive question was not
can the verifier reject bad inputs?butcan the contract avoid creating proof-backed objects whose identifiers the verifier can never honor? -
Sources:
-
Last reviewed: 2026-08-03 UTC