0xPARC StealthDrop ECDSA-Signature Nullifier Nondeterminism / Double-Claim Near Miss (2022-02-15)

  • Name: 0xPARC StealthDrop ECDSA-signature nullifier nondeterminism / double-claim near miss
  • Date: 2022-02-15
  • Protocol: 0xPARC StealthDrop
  • Chain: Gnosis Chain (xDai) / Ethereum ecosystem
  • Category: nullifier derivation nondeterminism / ECDSA signature malleability / anonymous airdrop replay / double-claim near miss
  • Loss: no public onchain theft was documented in the sources reviewed here, but the disclosed bug meant one StealthDrop eligibility right could be claimed more than once by presenting multiple verifier-acceptable signatures for the same underlying wallet and message
  • Summary: StealthDrop was an anonymous airdrop system that let an eligible public address prove membership in a Merkle tree, then claim tokens to a fresh anonymous account. To stop double-claims, the design recorded a nullifier onchain. The problem was that the nullifier was derived from an ECDSA signature rather than from one canonical right object. StealthDrop’s circuit proved that the signature was valid and that its corresponding public key belonged to the eligibility tree, but that did not make the signature unique. As 0xPARC later disclosed, one claimant could produce multiple valid signatures for the same claim right, which meant multiple valid proofs and multiple distinct nullifiers for the same underlying entitlement. The airdrop’s one-time-claim guarantee therefore depended on a witness artifact that was not uniquely determined.
  • Trigger / entry point:
    • The vulnerable flow was StealthDrop’s anonymous claim path, where an eligible address signed a message, generated a zk proof, and submitted a nullifier onchain to prevent replay.
    • StealthDrop’s public design described the zk proof as establishing three things: a signature came from the eligible public key, that public key belonged to the Merkle tree, and the nullifier hash was a one-way-function output of the signature.
    • Replay protection therefore depended on hash(signature)-style logic rather than on one canonical note secret or one canonical claim index.
    • The public disclosure thread on 2022-02-15 and 0xPARC’s later tracker entry both stated that this let the same claim be represented by multiple valid nullifiers.
  • Root cause:
    • StealthDrop confused valid authorization witness with canonical replay-prevention identity.
    • ECDSA signatures are not naturally unique encodings of authorization. At minimum, classic malleability gives paired signatures such as (r, s) and (r, N-s) for the same message, and the 0xPARC tracker states more broadly that multiple valid signatures could satisfy the circuit for one claim right. The StealthDrop README is useful evidence here because its original Nullifier Hash Construction section already recognized the (r, s) versus (r, N-s) issue and proposed low-s canonicalization, yet the later disclosure still concluded the broader signature-as-nullifier construction was unsafe.
    • The circuit checked signature validity, Merkle inclusion, and nullifier-hash consistency, but it did not constrain the signature-generation process tightly enough to make one eligibility right produce exactly one nullifier.
    • Because onchain replay protection only stored used nullifiers, every fresh valid signature-derived nullifier reopened the same economic right.
  • Failure path:
    1. An address eligible for the airdrop proves membership in the StealthDrop Merkle tree.
    2. The claimant signs the claim message and generates a zk proof showing the signature corresponds to the eligible public key.
    3. The system derives the claim nullifier from that signature and records it onchain after a successful anonymous claim.
    4. The same claimant produces a different but still verifier-acceptable signature for the same underlying wallet and message.
    5. The circuit still accepts the proof because the new signature is valid for the same authorization.
    6. The new signature yields a different nullifier.
    7. Onchain replay protection sees a fresh nullifier instead of a previously consumed right, so the same eligibility slot can be claimed again.
  • What the attacker needed to be true:
    • They needed one genuine address in the StealthDrop eligibility tree.
    • The circuit had to accept multiple signatures as valid witnesses for the same wallet/message authorization.
    • The nullifier had to be derived from the signature rather than from one canonical claim object.
    • Onchain replay protection had to key claims by nullifier alone.
    • The claim contract had to still hold redeemable airdrop inventory for repeated claims to matter economically.
  • Technical anatomy worth remembering:
    • StealthDrop’s design rationale made the hazard explicit in hindsight: it wanted a nullifier that was not trivially brute-forceable from the public eligibility list, so it looked for a hidden, signer-produced object and chose signatures. That solved privacy intuition but smuggled signature multiplicity into the replay boundary.
    • The repo’s own design writeup discussed classic ECDSA malleability and noted that (r, s) and (r, N-s) are both valid signatures for the same message, then suggested low-s canonicalization as the fix. The later vulnerability disclosure makes this incident more useful than a generic forgot low-s story: even after noticing the obvious malleability branch, the scheme still depended on a signer-produced ECDSA envelope as if it were a canonical nullifier source.
    • This is the witness-side sibling of nullifier alias bugs like Semaphore and ZkDrops. There, one logical nullifier crossed the verifier/storage boundary under two raw encodings. Here, the same logical claim right generated multiple nullifiers before storage ever saw them.
    • The fix direction confirms the real issue: 0xPARC wrote that avoiding the bug without sacrificing privacy required moving toward a non-malleable signature construction (PLUME) rather than merely adding one small replay check or one more app-side nullifier map.
  • Representative artifacts:
    • Public design repo: stealthdrop/stealthdrop
    • Public design text: README.md section Nullifier Hash Construction
    • Public disclosure thread: 0xPARC/status/1493705025385906179 (2022-02-15 UTC)
    • Repo edit acknowledging the bug and follow-on mitigation direction: README EDIT section referencing @0xB07DAD and PLUME / ERC-7524 work
    • Secondary technical summary: 0xPARC zk-bug-tracker entry 0xPARC StealthDrop: Nondeterministic Nullifier
  • Response / mitigation:
    • 0xPARC’s public tracker states that fixing the issue properly required constraining signature creation rather than merely signature validation, which in turn meant involving the private key in the circuit or moving to a signature design better suited for unique nullifier derivation.
    • The StealthDrop repository’s later EDIT points to the team’s follow-on PLUME work: a non-malleable signature construction on existing ECDSA keys, later proposed as ERC-7524, intended to eliminate the same authorization, many valid signature envelopes problem. That edit is a useful primary-source breadcrumb because it shows the team itself no longer treated low-s canonicalization as a sufficient safety story for signature-derived nullifiers.
    • The durable mitigation lesson is that replay protection should key off one canonical right object, not off a merely valid proof-of-possession artifact.

Pattern tags

Why the exploit worked

  • StealthDrop asked the proof to show this signature is valid for an eligible key, then quietly promoted that signature itself into the nullifier source as if a valid authorization envelope were also a canonical one-time claim object.
  • That assumption was false: one underlying eligibility right could keep the same wallet, message, and Merkle membership while still varying the signature witness enough to produce a fresh nullifier.
  • The public design materials make the lesson sharper, not weaker: the README had already noticed the obvious (r, s) versus (r, N-s) branch and suggested low-s handling, yet the later disclosure still concluded the broader signature as nullifier source design was unsafe.
  • Onchain replay protection therefore spent one signature-derived nullifier rather than one eligibility right, so every new verifier-acceptable signature reopened the same claim slot.

Three nullifier-right promotions this incident collapsed

  1. A valid authorization witness existed for the eligible address

    • StealthDrop correctly asked for an ECDSA signature that could be proven to come from the eligible public key and shown to belong to the Merkle tree.
    • That is only the ownership question: some signer-produced witness proves this address controls one claim right.
  2. That witness was over-promoted into the canonical replay-prevention identity for the claim right

    • The design then treated the signature itself, via hash(signature)-style nullifier derivation, as if a valid authorization envelope were also the one canonical hidden object that should define one-time semantics.
    • That promotion was the real mistake. One underlying eligibility right could still admit multiple verifier-acceptable signatures, so the protocol had not actually proved one right => one nullifier.
  3. That fresh nullifier still inherited live payout authority against the airdrop inventory

    • Once a signature-derived nullifier looked fresh, the claim path treated it as a newly consumed right and released tokens to the anonymous recipient.
    • The result was that a merely fresh witness-derived nullifier, rather than a canonically consumed eligibility slot, inherited the full economic authority of one StealthDrop redemption.

Detection and audit pivots

  • Hold the underlying right fixed — same eligible address, same message, same Merkle path semantics — then vary every admissible signature or witness degree of freedom and assert the nullifier either stays identical or the proof fails.
  • Ask the sharper design question explicitly: what hidden canonical right object is being consumed once here, and why is that object not merely whatever signature the wallet happened to return?
  • Diff the variables used for ownership / membership checks against the variables hashed into replay-prevention state. If the circuit proves one object but the nullifier is derived from a looser witness artifact, the boundary is suspect.
  • Treat partial canonicalization stories as red flags. We enforce low-s is not enough unless reviewers can also prove there are no remaining signer-choice, encoding, or widened-witness branches that still change the nullifier.

Why this incident matters

  • It is a clean exploit-corpus example of witness-side replay multiplicity: the contract did not need to mishandle field aliases because the nullifier was already many-to-one at derivation time.
  • It broadens the proofs/verifiers branch beyond field-modulus range bugs into a more application-shaped failure mode common in wallet-integrated zk systems: a signature can prove ownership without being a safe uniqueness primitive.
  • It also preserves a strong design lesson for anonymous claims, private voting, and identity-preserving distributions: privacy-driven nullifier schemes still need one canonical hidden object, not just any hard-to-invert witness. StealthDrop is especially useful because the public design doc itself had already noticed one obvious ECDSA malleability branch, so the surviving bug makes the deeper lesson harder to wave away as they just forgot low-s.

Failure-prevention checklist

  • Define one canonical hidden claim object before choosing a nullifier construction. The protocol should be able to say exactly what gets consumed once per eligible address, and that object should not be whatever signature the wallet returned.
  • Treat authorization and replay prevention as separate design problems. A witness can prove key ownership and still be an unsafe uniqueness primitive.
  • If signatures touch nullifier derivation at all, inventory every remaining degree of freedom: low-/high-s, signer nonce choice, alternate encodings, recovery-id handling, and wallet-specific normalization behavior.
  • Do not stop review at we patched one malleability branch. Require a positive proof that one eligibility right has exactly one admissible replay-prevention object.
  • Hold the semantic right fixed during testing and vary every signer-side or witness-side freedom. Every admissible variant should either collide to one nullifier or fail verification.
  • Keep nullifier derivation adjacent to the constraints that make its inputs unique. If the proof validates one object but the nullifier hashes a looser witness artifact, the replay boundary is already drifting.
  • Prefer protocol-native hidden secrets, canonical note indices, or other uniquely constrained internal objects over signer-produced envelopes when privacy is required.
  • During wallet, signature-library, or circuit migrations, re-prove the one-right/one-nullifier property instead of assuming old canonicalization arguments still hold.
  • Monitor for impossible economics such as repeated claims that trace back to one eligibility slot even when every onchain nullifier is distinct.

Audit prompts

  • What exact semantic right is being consumed once here, and where is that object made canonical before nullifier derivation?
  • If the eligible wallet, message, and Merkle membership stay fixed, what remaining degrees of freedom can still change the nullifier?
  • Is the protocol deriving replay-prevention state from a canonical right object or from a witness artifact that merely proves possession of that right?
  • What proves that low-s handling, if present, closes the entire multiplicity problem rather than only one obvious ECDSA branch?
  • Could two different valid signatures for the same wallet/message pair still satisfy the circuit and produce distinct nullifiers?
  • Do ownership checks, membership checks, and nullifier derivation consume the same constrained representation of every shared input?
  • If privacy requirements ruled out a public address-derived nullifier, what hidden protocol-owned object replaced it, and why is that object unique?
  • What property test demonstrates that one eligibility slot cannot produce multiple verifier-acceptable nullifiers across wallet implementations or signature adapters?
  • If the signature scheme or wallet API changes later, what review step re-establishes that replay protection still tracks rights rather than envelopes?

Lessons that should already be standard

  • A valid signature is not automatically a unique identifier. Authorization envelopes and replay-prevention identities are different objects.
  • Privacy does not remove the need for canonicality. Hiding the right is only safe if the hidden object is still uniquely defined by the protocol.
  • Fixing one malleability branch is not the same as proving one-right/one-nullifier semantics.
  • Replay bugs can be born inside the witness before any contract-level storage aliasing appears. The nullifier map can be perfectly consistent and still protect the wrong thing.
  • Wallet-facing zk systems need to review signer behavior as part of the security boundary, not just verifier arithmetic and onchain bookkeeping.
  • The healthy design question is not does this witness prove ownership? but does one ownership right admit exactly one replay-prevention object?
  • When a design chooses hash(signature) because it feels hidden and convenient, reviewers should assume the burden of proof has increased, not decreased.

Internal linkages

Control surface