zk-email-verify RevealSubstring LessThan Exclusive-Upper-Bound Bit-Width Omission Near Miss (2025-11-12)
- Name:
zk-email-verifyRevealSubstringLessThanexclusive-upper-bound bit-width omission near miss - Date: 2025-11-12
- Protocol:
zk-email-verify/RevealSubstring - Chain: Ethereum ecosystem
- Category: bit-length precondition omission / exclusive-upper-bound comparator overflow / library-level near miss
- Loss: no public live exploit was documented, but
RevealSubstring’s boundary checks were instantiated outside circomlibLessThan(n)’s intended domain whenever an exclusive upper-bound constant itself was a power of two; the helper could therefore prove a weaker or malformed substring-bound statement than downstream circuits thought they were getting - Summary:
zk-email-verify’sRevealSubstring(maxLength, maxSubstringLength, shouldCheckUniqueness)helper added threeLessThan(...)checks to keep substring start, substring length, andstart + lengthinside bounds. The mistake was subtle and classic: each comparator width was computed from the exclusive upper-bound constant itself. That is safe only when the constant is strictly below the comparator’s maximum representable value. WhenmaxLength,maxSubstringLength + 1, ormaxLength + 1is a power of two, the constant equals2^nwhileLessThan(n)expects both operands to fit in[0, 2^n - 1]. The result is a hidden-domain bug right at the range-check boundary: the circuit still looks like it is provingsubstringStartIndex < maxLength,substringLength <= maxSubstringLength, andsubstringStartIndex + substringLength <= maxLength, but it is doing so with a comparator call whose semantic preconditions have already failed. The public bug report pointed to commit800b9a1, and the follow-up fix PR corrected the formulas to leave one extra representable value of headroom —log2Ceil(maxLength + 1),log2Ceil(maxSubstringLength + 2), andlog2Ceil(maxLength + 2)— while also adding a focused regression test. - Trigger / entry point:
- The affected helper was
packages/circuits/helpers/reveal-substring.circominzk-email-verify. RevealSubstringused circomlibLessThan(n)to validate three boundary conditions before extracting and optionally uniqueness-checking a revealed substring.- The vulnerable formulas derived
nfrom the exclusive upper-bound constants themselves instead of from the maximum value any comparator input might actually take. - That becomes wrong whenever one of those constants is a power of two.
- The repository’s own test circuit instantiated
RevealSubstring(256, 16, 1), so the first check concretely becameLessThan(8)([substringStartIndex, 256])— exactly the kind ofconstant = 2^ncall shape the report flagged.
- The affected helper was
- Root cause:
- The circuit treated
log2Ceil(bound)as if it were automatically the right comparator width for a proof ofx < bound. - But when the bound itself is an input to
LessThan(n), the comparator needs enough width to represent the bound too, not just honest values below it. RevealSubstringtherefore encodedexclusive upper boundas if it werelargest representable operand, collapsing two different facts into one formula.- The circuit was proving
some comparator relation over field elementsrather than the sharper intended claimthese dynamic indices are bounded by these exact exclusive upper limits inside the comparator's sound domain.
- The circuit treated
- Failure path:
- A caller instantiated
RevealSubstringwith a power-of-two boundary such asmaxLength = 256, or with some other parameters that mademaxSubstringLength + 1ormaxLength + 1a power of two. - The helper computed comparator widths as
log2Ceil(maxLength),log2Ceil(maxSubstringLength + 1), andlog2Ceil(maxLength + 1). - At least one resulting
LessThan(n)call then received an upper-bound constant equal to2^n, which is outside the helper’s documented input domain. - The boundary check bit no longer cleanly meant
this witness is inside the intended substring bound; it only meantthis witness satisfied a comparator invocation that was already operating outside its intended width assumptions. - Any downstream circuit reusing
RevealSubstringinherited that weaker or malformed range statement at exactly the point where substring extraction was supposed to become semantically safe.
- A caller instantiated
- What the attacker or failure needed to be true:
- A downstream circuit had to rely on the vulnerable
RevealSubstringimplementation introduced in commit800b9a1. - Parameters had to make one of the exclusive upper-bound constants land exactly on a power-of-two boundary.
- Witness-controlled substring indices or lengths had to matter to correctness, uniqueness checks, or proof semantics downstream.
- No later constraint or wrapper could independently re-prove the same bounds with correct headroom.
- A downstream circuit had to rely on the vulnerable
- Technical anatomy worth remembering:
- This is the
exclusive upper bound is itself a comparator operandversion ofbit-length precondition omission. The bug is not thatLessThanwas missing; the bug is that the circuit forgot thatboundalso has to fit in the comparator’s operand domain. - The smell is
LessThan(log2Ceil(bound))([x, bound]). For strict< boundchecks, that formula fails exactly where systems often like to parameterize things: powers of two. - The repository’s own
RevealSubstring(256, 16, 1)test fixture is the crisp preserved example.256is a natural byte-oriented bound, but it makes the vulnerable start-index check instantiate the forbiddenbound = 2^nshape directly. - The fix is revealing because it is tiny and semantic. Nothing about substring extraction changed; only the comparator widths gained one extra representable value of headroom so the boundary checks finally proved the intended statement instead of a folklore approximation of it.
- This is the
- Representative circuits and artifacts:
- Vulnerable helper:
packages/circuits/helpers/reveal-substring.circom - Vulnerable start-index check:
LessThan(log2Ceil(maxLength))([substringStartIndex, maxLength]) - Vulnerable length check:
LessThan(log2Ceil(maxSubstringLength + 1))([substringLength, maxSubstringLength + 1]) - Vulnerable sum check:
LessThan(log2Ceil(maxLength + 1))([sum, maxLength + 1]) - Public issue report:
zkemail/zk-email-verify#289([Bug] Incorrect LessThan bit-widths when comparing against constants) - Public fix PR:
zkemail/zk-email-verify#321(fix: correct RevealSubstring LessThan bit widths) - Concrete power-of-two instantiation in repo tests:
packages/circuits/tests/test-circuits/reveal-substring-test.circomwithcomponent main = RevealSubstring(256, 16, 1); - Fix shape: widen comparator widths to
log2Ceil(maxLength + 1),log2Ceil(maxSubstringLength + 2), andlog2Ceil(maxLength + 2)and addpackages/circuits/tests/reveal-substring-bit-width.test.ts
- Vulnerable helper:
- Response / mitigation:
- The public fix PR updates all three comparator-width formulas to leave one representable value of headroom for the exclusive upper-bound constants themselves.
- The PR also adds a regression test that checks for the corrected formulas in source, preserving the exact boundary arithmetic that failed here.
- As of this review, the public issue and fix PR were still open, so the preserved lesson is not only
how to fix itbut alsohow easy it is for a helper-domain bug to linger in a widely reused proof component after disclosure.
Pattern tags
- bit-length-precondition-omission
RevealSubstringvisibly had range checks, but the checks were parameterized with comparator widths that stopped one bit too early whenever the exclusive upper-bound constant itself hit a power of two.- The decisive failure was not
validation result ignored; it wasvalidation helper called outside the width domain that made its result mean the intended bound statement.
Why the exploit worked
- The helper conflated
bit length of the exclusive boundwithbit length required to represent every operand passed into the comparator. - That distinction disappears for non-power-of-two constants, which is why the bug is easy to miss in review. It becomes sharp exactly at power-of-two boundaries, where protocol authors often prefer to parameterize lengths, buffer sizes, or byte-oriented limits.
- Once
bound = 2^n, the call shapeLessThan(log2Ceil(bound))([x, bound])no longer proves the intended semantic claim. The circuit still consumes the comparator output, but it is consuming the output of a helper that has already left its sound domain. - This is why the incident belongs in
bit-length precondition omission, notvalidation-output nonconsumption. The checks were present and enforced; the hidden width premise that made those checks meaningful was not.
Detection and audit pivots
- Grep for
LessThan(log2Ceil(or similarComparator(widthFromBound)patterns and inspect every place where the bound constant is also passed as an operand. - Treat
x < boundandbound fits in the comparator domainas separate proof obligations. They only look like one formula until the first power-of-two parameter shows up. - Add tests for
bound = 2^n,bound = 2^n + 1, and adjacent parameterizations rather than stopping at small non-power-of-two examples. - When auditing reusable substring, slice, index, or parser helpers, check whether boundary parameters are natural byte multiples or powers of two; those configurations are where exclusive-upper-bound comparator bugs hide most cleanly.
- Distinguish
helper called,helper output enforced, andhelper output meaningful. This family fails at the third step.
Why this incident matters
- It gives the corpus a very clean
power-of-two exclusive upper boundexemplar for thebit-length precondition omissionfamily, complementing Dark Forest’s signed-coordinate comparator case and circom-ecdsa’s bigint-output canonicalization case. - It preserves a review habit that matters far beyond email circuits: any time a helper compares a dynamic value against a constant upper bound, the constant itself still has to fit the helper’s operand domain.
- It also shows how these bugs can survive in innocuous-looking parsing or reveal helpers rather than only in obviously monetary circuits. Once such a helper becomes a shared proof component, the bug’s blast radius is every later circuit that inherits its boundary semantics.
Failure-prevention checklist
- For every strict upper-bound check of the form
x < bound, size the comparator from the largest operand it will see, not from the honest range ofxalone. - Treat
bound is representableas an explicit constraint requirement wheneverboundis passed into a proof helper as data rather than staying compile-time folklore. - Add regression tests specifically for power-of-two bounds and
+1/+2headroom formulas. - Keep reusable substring and parser helpers self-checking: if a template advertises bounded extraction, it should prove the exact comparator-domain assumptions internally.
- Review byte-oriented constants (
32,64,128,256,1024) with extra suspicion in Circom comparator code; they are where hidden width mistakes often become real. - Preserve source-level tests for width formulas in addition to witness examples so future refactors cannot silently collapse the headroom again.
- When a helper is library-level, treat open bug reports as transitive risk for downstream circuits until the patched statement is what production actually compiles.
Audit prompts
- If this comparator checks
x < bound, where is the proof thatbounditself fits in the comparator’s operand domain? - What happens when the configured bound is exactly
2^nrather than2^n - 1? - Is the circuit sizing its comparator from
the semantic boundor fromthe largest actual operand value? - Are natural byte-sized or power-of-two configuration values disproportionately likely to hit a hidden helper-domain boundary?
- Would a source-level regression test fail if someone rewrote
log2Ceil(bound + 1)back tolog2Ceil(bound)during refactoring? - Does the downstream circuit rely on this helper as a complete semantic admission check, or is there another correctly bounded wrapper later in the statement?
Lessons that should already be standard
x < boundis not one proof obligation whenbounditself is also a runtime comparator operand.- Power-of-two bounds are where width folklore breaks.
- A visible range check can still be semantically weak if its helper is instantiated one bit outside its sound domain.
- Reusable parser or substring helpers are proof-boundary code, not harmless utility code.
- The smallest arithmetic patch often reveals the missing statement invariant most clearly: here it was one extra representable value of headroom for each exclusive upper-bound constant.
- Open fix PRs still count as live review evidence that the old proof statement remains reachable somewhere.
Internal linkages
- Core recurring pattern: bit-length-precondition-omission.
- Comparator-family cousin: dark-forest-v0-3-rangeproof-lessthan-bit-length-precondition-omission-near-miss-2021-09-30 — same
LessThan-domain family, but there the omitted width proof sat on shifted signed coordinates rather than on exclusive upper-bound constants. - Bigint-family cousin: circom-ecdsa-bigmod-remainder-range-check-omission-noncanonical-remainder-near-miss-2022-03-30 — same hidden-width family, but on output canonicalization rather than bound-as-operand comparator sizing.
- Contrast family: validation-output-nonconsumption — there a computed validation result is dropped; here the validation result is enforced, but the comparator instantiation never proved the domain assumptions that make the result semantically trustworthy.
- Positive-design companion: helper-domain-proof-locality-and-output-canonicalization-discipline — the sharper healthy-side companion when the question is where boundary-parameter admission, helper invocation, and downstream semantic reuse should have stayed one local proof story.
- Adjacent statement-binding companion: proof-validation-output-consumption-and-statement-binding-discipline — useful when review needs to widen from
did we size the comparator correctly?to the broader questionwhich helper preconditions and helper outputs actually became part of the proved statement?
Control surface
-
The leverage sat in
RevealSubstring’s three boundary checks, the exact formulas that converted semantic bounds into comparator widths, and the shared assumption thatlog2Ceil(bound)was enough even whenbounditself was a comparator operand. -
The recurring healthy-side question is simple: did the circuit prove
substring metadata is inside the intended exclusive bounds, or onlythese witnesses satisfied a comparator call whose own width assumptions were off by one representable value? -
Sources:
- https://github.com/zkemail/zk-email-verify/issues/289
- https://api.github.com/repos/zkemail/zk-email-verify/issues/289
- https://github.com/zkemail/zk-email-verify/pull/321
- https://api.github.com/repos/zkemail/zk-email-verify/pulls/321
- https://patch-diff.githubusercontent.com/raw/zkemail/zk-email-verify/pull/321.patch
- https://raw.githubusercontent.com/zkemail/zk-email-verify/main/packages/circuits/helpers/reveal-substring.circom
- https://raw.githubusercontent.com/zkemail/zk-email-verify/main/packages/circuits/tests/test-circuits/reveal-substring-test.circom
-
Last reviewed: 2026-08-03 UTC