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_FIELD would 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 SemaphoreVoting and SemaphoreWhistleblowing reused groupId as the proof system’s external nullifier. That made groupId a direct public input to Verifier.verifyProof(...), which explicitly rejected any public input >= SNARK_SCALAR_FIELD. But SemaphoreGroups still let callers create groups, polls, and whistleblowing entities with arbitrary uint256 identifiers. 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.sol and downstream extension contracts such as SemaphoreVoting.createPoll.
    • Those extensions used groupId as 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 / groupId before storing and exposing the new group.
  • Root cause:
    • Semaphore’s application-layer object creation and its verifier consumed the same identifier under different numeric assumptions.
    • Solidity treated groupId as any valid uint256.
    • 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:
    1. A poll creator calls the relevant Semaphore extension, such as SemaphoreVoting.createPoll, with a pollId / groupId greater than SNARK_SCALAR_FIELD.
    2. The surrounding contract accepts and stores that identifier, and the group or poll is created onchain.
    3. Members are added and the object appears operational from the application side.
    4. When a user later tries to verify or validate a proof for that poll, the verifier checks the public inputs.
    5. Because groupId is reused as the external nullifier, the verifier hits input[i] >= snark_scalar_field and reverts.
    6. The poll or group therefore cannot process valid anonymous votes, signals, or whistleblowing proofs at all.
  • What the attacker or failure needed to be true:
    • The extension contract needed to reuse groupId as a proof public input.
    • Object creation needed to accept arbitrary uint256 ids.
    • 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.
  • 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, and group id as 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 groupId as the external nullifier.
  • Representative contracts and artifacts:
    • Reported vulnerable components: SemaphoreGroup.sol, SemaphoreVoting.sol, and SemaphoreWhistleblowing.sol
    • Verifier-side bound called out in the report: require(input[i] < snark_scalar_field, "verifier-gte-snark-scalar-field")
    • Public disclosure: GitHub issue #90 on semaphore-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 of 21888242871839275222246405745257275088548364400416034343698204186575808495618
      • SemaphoreWhistleblowing.createEntity(...) now tests rejection of the same out-of-field id
    • Package version bump in the fix PR: @appliedzkp/semaphore-contracts 0.6.0 → 0.6.1
  • Response / mitigation:
    • PSE proposed and merged the straightforward fix in PR #91: reject any groupId that is not strictly less than SNARK_SCALAR_FIELD inside the shared SemaphoreGroups._createGroup(...) path.
    • Placing the guard in the base group-creation primitive mattered: it protected both SemaphoreVoting and SemaphoreWhistleblowing without 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-contracts 0.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.

Pattern tags

Why the exploit worked

  • Semaphore’s extension layer treated groupId as an ordinary application identifier during object creation, while the verifier already treated the same value as a field element bounded by SNARK_SCALAR_FIELD.
  • That mismatch mattered because groupId was 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 was creation-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, or external nullifier are still treated as generic uint256 transport values instead of one canonical field-safe object.

Three proof-object publication promotions this incident collapsed

  1. A groupId / pollId existed as application state at all

    • SemaphoreVoting.createPoll(...) or the sibling whistleblowing creation flow accepted a caller-supplied identifier, stored it, and emitted the surrounding group created reality.
    • At this point the protocol had only proved a contract object now exists with this id.
  2. That same identifier was actually a verifier-legal external-nullifier object

    • The extensions silently promoted the raw stored uint256 into the proof system’s public-input domain by reusing groupId as 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.
  3. 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 was Semaphore 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, or entity created already 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_FIELD guard 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 groupId as 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 / groupId values before any user-visible object exists.
  • Separate the verifier rejects impossible inputs from the 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 groupId or pollId will become an external nullifier or other public input, reject out-of-field values before the poll or group exists.
  • Make object created successfully imply object 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, and external nullifier as 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 uint256 that really means field 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_FIELD check 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 invariant verifier 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 later is not a sufficient application invariant once users can already create or depend on proof-backed objects that will never verify.
  • Generic uint256 labels 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 onchain must imply object 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 mismatch pattern by showing the availability failure mode alongside Semaphore’s earlier replay variant.

Internal linkages

Control surface