ZK-Kit Incremental Binary/Quin Tree Zero-Leaf SNARK_SCALAR_FIELD Range-Check Omission Near Miss (2022-03-09)
- Name: ZK-Kit incremental binary/quin tree zero-leaf
SNARK_SCALAR_FIELDrange-check omission near miss - Date: 2022-03-09
- Protocol: ZK-Kit
incremental-merkle-tree.sol - Chain: Ethereum ecosystem
- Category: public-input domain mismatch / Merkle zero-value initialization mismatch / verifier-level denial of service / library-level near miss
- Loss: no theft was reported, but callers could initialize proof-backed trees with zero values outside the proving field, creating trees whose later verification or removal flows could revert once those zeroes appeared as proof siblings
- Summary: In March 2022, PSE’s security team disclosed that ZK-Kit’s Solidity incremental Merkle tree libraries let callers initialize
IncrementalBinaryTreeandIncrementalQuinTreewith anyuint256 zerovalue even though downstream proof logic expected field-safe values belowSNARK_SCALAR_FIELD. The vulnerable boundary was clean and familiar: Solidity accepted an unrestricted integer at tree-creation time, but later verifier-style logic required sibling nodes to satisfy< SNARK_SCALAR_FIELD. If an oversized zero value propagated into a Merkle path, functions such asverify()duringremove()could revert, leaving the tree created but unable to participate soundly in the proof-backed flows that depended on it. The fix was one line in the right place: reject out-of-field zero values at initialization for both tree variants. - Trigger / entry point:
- The issue centered on the
init()functions in ZK-Kit’s Solidity tree libraries, especiallyIncrementalBinaryTree.init()and the matchingIncrementalQuinTree.init()path patched in the same fix. - Those initializers accepted a caller-supplied
uint256 zeroleaf value with no check that it fit inside the SNARK scalar field. - Downstream verification logic later required Merkle proof siblings to satisfy
proofSiblings[i] < SNARK_SCALAR_FIELD. - That meant the same tree value was treated as an unrestricted Solidity integer on entry and as a field element once proof verification paths ran.
- The issue centered on the
- Root cause:
- The library exposed a proof-facing tree parameter as a raw
uint256API input without enforcing the proving system’s numeric domain at the caller boundary. - Zero values are not cosmetic defaults in incremental Merkle trees; they become actual path elements and therefore part of later proof verification semantics.
- The verification side already knew that sibling nodes must be field-safe.
- The initialization side failed to preserve that same invariant when the tree was first created.
- As a result, the protocol could author Merkle trees whose stored default path values were valid Solidity state but invalid zk-facing inputs.
- The library exposed a proof-facing tree parameter as a raw
- Failure path:
- A caller initializes an incremental binary or quin tree with
zero >= SNARK_SCALAR_FIELD. - The library accepts the value and stores it as the default zero node used to derive higher empty-tree nodes.
- The tree now exists onchain and may look healthy to application code that only checks depth or root bookkeeping.
- Later, a proof-backed flow such as verification during removal encounters one of those zero-derived path elements as a
proofSibling. - The verification logic enforces
proofSiblings[i] < SNARK_SCALAR_FIELDand reverts because the tree was initialized with an out-of-field value. - The result is a proof-backed tree that can be created but can fail once the proving / verification boundary actually uses its stored zero values.
- A caller initializes an incremental binary or quin tree with
- What the attacker or failure needed to be true:
- The application had to use ZK-Kit’s tree libraries in a context where tree nodes later crossed into zk verification semantics.
- The caller had to be able to choose the tree’s initial zero value.
- The initializer had to omit the scalar-field check.
- Later verification or removal logic had to enforce field-domain safety on siblings or equivalent proof-path values.
- Integrators had to assume that a successfully initialized tree was also safe for later zk-backed operations.
- Technical anatomy worth remembering:
- This was not a bad verifier or a broken hash function. The dangerous gap sat between tree initialization and later proof consumption.
- The zero leaf matters because empty-tree defaults are not inert metadata; they are real Merkle-path material that reappears in proofs whenever empty branches are traversed.
- In practical terms, one bad
zeroat initialization poisons the whole precomputed empty-branch ladder above it. The impossible value is not confined to one leaf slot; it is hashed upward into the library’s default-path state and can later surface as a sibling anywhere an empty branch is proven. - The issue is the Merkle-tree analogue of Semaphore’s
groupIdbug: the same value family crossed from a broaduint256contract surface into a finite-field proof surface without a single canonical domain check. - The patch is useful evidence of the true invariant location. Rather than loosening verification, the fix moved the field check outward to
init()so impossible trees could not be created in the first place. - The same fix was applied to both the binary and quin-tree libraries in the same patch series, which is a helpful reminder that proof-facing numeric invariants tend to be library-wide rather than function-local.
- Representative contracts and artifacts:
- Reported component:
packages/incremental-merkle-tree.sol/contracts/IncrementalBinaryTree.sol - Same-fix sibling component:
packages/incremental-merkle-tree.sol/contracts/IncrementalQuinTree.sol - Reported failure site:
IncrementalBinaryTree.verify()when called fromIncrementalBinaryTree.remove()with aproofSiblingderived from the oversized zero value - Reported issue: GitHub issue
zk-kit/zk-kit#23 - Fix artifact: GitHub pull request
zk-kit/zk-kit#24 - Patched binary-tree guard:
require(zero < SNARK_SCALAR_FIELD, "IncrementalBinaryTree: leaf must be < SNARK_SCALAR_FIELD") - Same-patch quin-tree guard:
require(zero < SNARK_SCALAR_FIELD, "IncrementalBinaryTree: leaf must be < SNARK_SCALAR_FIELD") - Release bump shipped with the fix:
@zk-kit/incremental-merkle-tree.sol0.3.0 -> 0.3.1
- Reported component:
- Response / mitigation:
- ZK-Kit merged a focused two-commit patch series on 2022-03-09 that added
zero < SNARK_SCALAR_FIELDchecks in bothIncrementalBinaryTree.init()andIncrementalQuinTree.init(), then bumped@zk-kit/incremental-merkle-tree.solto0.3.1. - The mitigation strategy was the right one for this bug class: reject impossible proof-facing state at creation time rather than relying on deeper verifier paths to fail later.
- The durable lesson is broader than
validate this one constructor argument: empty-tree defaults, precomputed zero ladders, and helper-library initialization parameters are part of the proof surface and need the same field-domain discipline as explicit nullifiers or public inputs.
- ZK-Kit merged a focused two-commit patch series on 2022-03-09 that added
Pattern tags
- public-input-domain-mismatch
- proof-facing-field-domain-canonicalization-discipline
- The decisive break was not
verify()forgot one range check; it wasinit()` publishing empty-tree state that the later proof system would never accept as valid sibling material. Tree successfully createdandtree can later participate in proof-backed insert / remove / verify flowswere treated as different truths even though applications would naturally read them as the same object-level guarantee.- Empty defaults were treated like harmless configuration even though one out-of-field
zerovalue actually poisoned the entire precomputed zero ladder above it.
Three proof-object publication promotions this incident collapsed
-
A tree-shaped object existed as application state at all
IncrementalBinaryTree.init()andIncrementalQuinTree.init()accepted a caller-suppliedzero, stored it, and built the empty-node ladder above it.- At this point the protocol had only proved
a tree object now exists with this default zero semantics.
-
That created tree was promoted into canonical proof-usable tree truth
- This was the decisive failure. The same
zerofamily thatinit()accepted as an unrestricted Solidityuint256was later expected to reappear as field-safe sibling material under< SNARK_SCALAR_FIELD. - Once an oversized
zeroseeded the empty-branch ladder, the library had already collapsedtree was publishedintotree is verifier-legal proof infrastructureeven though later proof paths would reject the very default material the initializer had authored.
- This was the decisive failure. The same
-
That accepted created-tree truth still deserved live proof-backed workflow authority
- Applications and integrators were still allowed to treat the created tree as real anonymous-action or proof-backed lifecycle infrastructure even though
remove()/verify()could later revert when those zero-derived siblings surfaced. - The practical failure was not merely
bad sibling later reverts; it wasthe library published a user-visible proof-backed object before the proving boundary had actually accepted its core default-path semantics.
- Applications and integrators were still allowed to treat the created tree as real anonymous-action or proof-backed lifecycle infrastructure even though
Why the exploit worked
- ZK-Kit split one proof-facing object across two incompatible numeric domains.
init()acceptedzeroas an unrestricted Solidityuint256, while later proof-consumption paths treated the same family of values as finite-field elements that must satisfy< SNARK_SCALAR_FIELD. - The bug mattered because the supplied
zerowas not inert metadata. It seeded the empty-tree ladder, so an oversized initialization value could be hashed upward into many later sibling positions rather than staying confined to one leaf slot. - The verifier-side guard therefore did not fully protect the higher-level application invariant. By the time
verify()reverted on an oversized sibling, the library had already authored a tree that looked valid to callers but was unusable for the proof-backed lifecycle it was supposed to support. - The patch location is the important clue about the real security boundary: the fix did not loosen
verify()or add a special case inremove(); it moved the field-domain check outward toinit()so impossible trees could never become live state. - The sharper reusable lesson is
created onchain != usable in the proof systemunless proof-facing defaults, cached empty values, and initialization parameters are canonicalized before the object exists.
Detection and audit pivots
- Trace every constructor or
init()parameter that can later appear as Merkle siblings, public inputs, witness material, cached empty nodes, or nullifier/domain values. If the first field-domain check only appears deep inside proof verification, the protocol may already be authoring dead-on-arrival proof state. - Treat empty-tree defaults and precomputed zero ladders as part of the proof surface, not as convenience constants. Any value reused automatically across many future proof paths deserves the same field-bound checks as explicit user-submitted proof inputs.
- Build lifecycle tests, not just local verifier tests:
create tree with oversized zero -> insert/use/remove/verify later. The important property is not merelyverify rejects bad siblings; it isa successfully initialized proof-backed tree remains proof-usable under its advertised lifecycle. - Centralize proof-facing range checks at the earliest shared creation primitive. ZK-Kit’s one-line guard in both
IncrementalBinaryTree.init()andIncrementalQuinTree.init()is the healthy pattern because downstream callers and extensions should not have to remember the field bound themselves. - Be suspicious whenever an API says
uint256but the surrounding proving system really meansfield element. Merkle-tree helpers, poll identifiers, nullifiers, scopes, zero values, and other setup constants often become proof objects long after the call site that first accepted them.
Why this incident matters
- It is a strong library-level exemplar for the proofs/verifiers corpus because it shows how zk bugs often begin before any proof is generated: state can be authored with values the proving system will never soundly accept.
- It broadens the
public-input domain mismatchpattern beyond nullifiers and external nullifiers into Merkle-tree initialization, which is a common hidden dependency in anonymous-action systems. - It also preserves an integration lesson that shows up repeatedly in proof-heavy stacks: when a library says
uint256, the real security object may still befield element, and the API should enforce that early.
Failure-prevention checklist
- Enforce field-domain bounds at object creation, not only at later proof verification. If
zerowill ever become a Merkle sibling or cached empty node, reject out-of-field values before the tree exists. - Treat empty-tree defaults as proof material, not configuration trivia. Reviewers should ask whether every precomputed zero node, cached branch value, or helper-derived default can legally appear inside the proving system.
- Make
initialized treeimplylater proof-usable tree. A successfulinit()should already guarantee that subsequentinsert(),verify(), andremove()paths will not discover impossible state the initializer was allowed to publish. - Reuse one shared field-guard helper across binary-tree, quin-tree, and wrapper-level creation surfaces so proof-domain checks do not drift between sibling libraries.
- Add lifecycle tests that start from edge-case creation values and continue through ordinary proof-backed flows.
Reject bad sibling in verify()is weaker thanbad zero can never seed live tree state at all. - Audit every proof-facing
uint256for hidden semantic narrowing. If an API parameter eventually becomes a Merkle path element, nullifier, scope, or public input, document and enforce that narrower semantic type up front. - Keep library release notes and version bumps explicit when the fix changes object-creation validity. Integrators need to know that upgrading from
0.3.0to0.3.1is not cosmetic; it closes a tree-initialization invariant hole. - During review, trace which automatically reused values can poison many later proof paths at once. One oversized
zeromattered because it seeded the whole empty-branch ladder, not because it sat in one isolated slot.
Audit prompts
- Which caller-supplied initialization values can later reappear as Merkle siblings, cached empty nodes, public inputs, or witness-side path elements?
- Does tree creation already prove those values are field-safe, or does the first real domain check appear only when proof verification or removal runs later?
- Can the library publish a tree that looks healthy to application code but is dead on arrival for its intended proof-backed lifecycle?
- Are binary-tree, quin-tree, factory, and wrapper code paths all enforcing the same field-domain invariant, or did one sibling surface quietly widen back to raw
uint256semantics? - If one default value is out of field, how far does that contamination spread through precomputed branch state, cached zeros, emitted roots, or downstream integration assumptions?
- Do tests exercise
create with bad zero -> attempt later verify/remove, not justverify rejects bad proofSiblingin isolation? - When integrators wrap this library, what single helper or type discipline prevents them from reintroducing the same out-of-field initialization bug at a higher layer?
- If the proof system changed fields or tree hashing assumptions tomorrow, where would reviewers re-establish that stored zero values and later verifier expectations still describe the same object?
Lessons that should already be standard
- Empty defaults are not harmless metadata in proof-backed Merkle systems. They are future proof-path material and should be reviewed like any other public input or nullifier.
Verifier rejects impossible values lateris not enough once the protocol can already publish impossible proof-backed state now.- Library initialization is part of the zk security boundary. A bad
init()can be as real a proof bug as a bad verifier because it decides which objects are allowed to exist at all. - One caller-facing
uint256often hides a stricter semantic type. If reviewers cannot name that narrower type explicitly, the API is probably too loose. - The healthiest fix is usually outward, not deeper: push the field check to object creation rather than teaching later consumers to survive poisoned state.
- Precomputed ladders and cached defaults magnify small admission mistakes. One out-of-field seed can turn into many future proof failures because helper state is reused automatically.
- A proof-backed object should have one truth from birth: if it can be created, it should already satisfy the numeric domain the later proof system expects.
Internal linkages
- Core recurring pattern: public-input-domain-mismatch.
- Positive-design companion: proof-facing-field-domain-canonicalization-discipline — the library-level
initialized with impossible proof-path materialexemplar for why tree defaults must be field-safe before they become stored path state. - Object-lifecycle companion: creation-time-proof-usability-and-dead-on-arrival-object-prevention — use this when the sharper question is why
init()was allowed to publish a tree object before the library had proved that its later proof-backed remove / verify lifecycle could honor the seeded zero ladder. - Close protocol-adjacent cousin: semaphore-groupid-external-nullifier-range-check-omission-inoperable-poll-near-miss-2022-03-07 — same
contract accepts raw integer, verifier later demands field elementfailure shape, but on group identifiers rather than Merkle zero values. - Replay variant in the same family: semaphore-nullifier-field-modulus-aliasing-double-signal-replay-near-miss-2019-07-25.
- Adjacent split-statement family: witness-public-input-binding-failure — that note covers proofs of the wrong relation; this incident is simpler and earlier, with caller-facing state violating the verifier’s numeric domain before any proof statement ambiguity is needed.
Control surface
-
The leverage sat in tree initialization, zero-node derivation, and the mismatch between
init()accepting arbitraryuint256values and later verification paths requiring field-safe Merkle siblings. -
The decisive question was not
will the verifier reject bad siblings?butcan the library prevent callers from ever building a proof-backed tree whose default path values the verifier cannot honor? -
Sources:
-
Last reviewed: 2026-08-04 UTC