Dark Forest v0.3 RangeProof / LessThan(bits) Bit-Length Precondition Omission Near Miss (2021-09-30)
- Name: Dark Forest v0.3
RangeProof/LessThan(bits)bit-length precondition omission near miss - Date: 2021-09-30
- Protocol: Dark Forest v0.3
- Chain: Ethereum
- Category: bit-length precondition omission / comparator width underconstraint / out-of-bounds coordinate near miss
- Loss: no public exploitation was documented, but the game acknowledged that a malicious player could otherwise satisfy the coordinate range proof with oversized values and initialize or act at locations outside the intended bounded map radius
- Summary: Dark Forest’s early Circom
RangeProoflogic tried to prevent coordinate-overflow tricks by routing signed-coordinate bounds through circomlib’sLessThan(bits)comparator. The bug was thatLessThan(bits)only behaves as intended when both operands already fit inside the expected bit width; the circuit never proved that hidden precondition. In the originalRangeProof(bits, max_abs_value)template, the shifted coordinate expressionmax_abs_value + inand the bound itself were fed intoLessThan(bits)without anyNum2Bits-style width proof. That meant a malicious witness could use larger field elements whose semantics wrapped outside the comparator’s intended domain, satisfy the proof, and makein-rangemean something weaker than the game designers thought. Dark Forest fixed the bug by moving the width proof into the circuit itself: it added explicitNum2Bitschecks for both the shifted coordinate andmax_abs_value, widened the comparator calls toLessThan(bits+1), and made the range proof prove the hidden bit-length assumptions that had previously lived only in reviewer intuition. - Trigger / entry point:
- Dark Forest’s Circom circuits used a reusable
RangeProofhelper to argue that a coordinate’s absolute value stayed below a configured maximum. - That helper delegated the actual comparison to circomlib’s
LessThan(bits)template. LessThan(bits)assumes its inputs already fit within the declared bit width.- The original
RangeProof(bits, max_abs_value)template never proved thatmax_abs_value + inormax_abs_valuesatisfied that width requirement.
- Dark Forest’s Circom circuits used a reusable
- Root cause:
- The circuit treated
LessThan(bits)as if it were both a comparator and an input-width proof. - In reality, the comparator only made sense once the surrounding circuit had already constrained the operands into the expected width.
- Because the bit-length assumption stayed implicit, the proved statement weakened from
this coordinate is inside the intended bounded intervaltothis arithmetic relation holds for some field elements that happen to driveLessThan(bits)the right way. - The safety check against overflow therefore existed in code structure but not yet in full constraint semantics.
- The circuit treated
- Failure path:
- A player chose witness values for
inthat were semantically outside the intended coordinate bound but still exploitable inside the field arithmetic seen by the circuit. - The original
RangeProof(bits, max_abs_value)computedmax_abs_value + inand fed it intoLessThan(bits)-based lower and upper bound checks. - Because neither the shifted input nor
max_abs_valuehad been range-constrained to the comparator’s intended width,LessThan(bits)was operating outside its semantic domain. - The comparator could therefore return outputs consistent with an in-range witness even though the underlying coordinate was not inside the intended bounded interval.
- The proof would then certify a weaker statement than the game expected, reopening the coordinate-overflow / out-of-bounds-location risk the range proof was supposed to close.
- A player chose witness values for
- What the attacker needed to be true:
- The game had to rely on the original
RangeProof(bits, max_abs_value)helper rather than the patched version. - The attacker had to control witness values for the coordinate input.
- The circuit had to keep trusting
LessThan(bits)without separately proving the bit length of the comparator operands. - No downstream contract or game logic could independently reject the resulting oversized coordinate once the proof was accepted.
- The game had to rely on the original
- Technical anatomy worth remembering:
- This is a classic
helper is fine, caller forgot the helper's domain assumptionsbug. circomlib’sLessThan(bits)was not broken; the outer circuit simply treated it as stronger than it was. - The subtlety mattered because Dark Forest was already thinking about overflow. The bug was not
nobody considered wide witnesses; it wasthe mitigation itself still depended on an unproved width precondition. - The production fix shows the real invariant location. Dark Forest did not replace
LessThan; it addedNum2Bits(bits+1)forin + (1 << bits)andNum2Bits(bits)formax_abs_value, then widened the comparator calls toLessThan(bits+1)so the comparison finally lived on a proved-width domain. - This incident is a clean proof-circuit reminder that
range proofis not a label; it is the exact set of constraints that force the compared object into a range-safe representation before the comparison runs.
- This is a classic
- Representative circuits and artifacts:
- Vulnerable helper shape published in the Dark Forest circuit writeup:
template RangeProof(bits, max_abs_value)using twoLessThan(bits)comparators without explicit width proofs on the inputs - Vulnerable comparator dependency: circomlib
LessThan(n) - Fixed helper:
template RangeProof(bits)with explicitNum2Bits(bits+1)andNum2Bits(bits)checks plusLessThan(bits+1)comparisons - Public writeup that disclosed the subtle bug:
ZKPs for Engineers: A look at the Dark Forest ZKPs(Bonus 1: Range Proofs) - Public fix commit:
darkforest-eth/circuits@1b5c8440a487614d4a3e6ed523df0aee71a05b6e
- Vulnerable helper shape published in the Dark Forest circuit writeup:
- Response / mitigation:
- Dark Forest updated the published circuit design and production range-proof implementation to add the missing width constraints.
- The fixed
RangeProof(bits)explicitly proves that bothmax_abs_valueand the shifted signed input fit inside the widths that make the comparator meaningful. - The durable lesson is that helper-gadget preconditions belong in constraints, not in code comments or reviewer background knowledge.
Pattern tags
- bit-length-precondition-omission
- Dark Forest’s
RangeProoflooked like an overflow defense, but theLessThan(bits)comparators were only sound once the circuit had separately proved that the compared values actually fit in the expected width. - The decisive failure was not
comparison output ignored; it wascomparison consumed with hidden domain assumptions still unproved.
Three range-boundary promotions this incident collapsed
-
A comparator-backed range-check object existed at all
- Dark Forest really did wrap signed-coordinate bounds in a reusable
RangeProofhelper and really did call circomlib’sLessThan(bits)on the shifted coordinate and the configured bound. - That only answered the gadget-existence question:
some range-looking helper sat next to the game boundary.
- Dark Forest really did wrap signed-coordinate bounds in a reusable
-
That helper output was treated as canonical in-range truth for this exact coordinate object
- The live bug sat here. The circuit quietly promoted
the comparator returned the expected relation on these field elementsintothe shifted coordinate and bound had already been proved to live inside the comparator's semantic width domain. - In other words, Dark Forest treated a comparator result as map-bound truth before proving the hidden bit-length facts that made the comparator’s answer mean
in rangerather than merelysatisfies this arithmetic relation under a wider field interpretation.
- The live bug sat here. The circuit quietly promoted
-
That accepted range truth still deserved map-entry authority
- Once the helper output was read as real coordinate-bounds truth, it could authorize world initialization or movement at locations the bounded-map design meant to exclude.
- The dangerous authority question was therefore not only
did the comparator run?butwhy did a comparator result earned on an unproved-width domain still get to decide where a player may exist in the game world?
Why the exploit worked
- The original circuit used the right-looking primitive for the wrong proof shape.
LessThan(bits)can support a range proof, but only after the circuit first proves its operands really arebits-wide values. - Dark Forest’s early helper skipped that step. It pushed a shifted signed coordinate and the claimed bound into
LessThan(bits)directly, so the comparator’s output no longer had the narrow semantic meaning the surrounding game logic assumed. - That is why the bug sits in the
bit-length precondition omissionfamily rather thanvalidation-output nonconsumption. The result bit was consumed; the hidden premise that made the result meaningful was not. - The fix’s structure is the important preserved lesson: prove width first, compare second.
Detection and audit pivots
- Grep every Circom
LessThan(bits),GreaterEqThan(bits),Num2Bits, or bound-check helper and ask where the compared values are themselves proven to fit the required width. - Distinguish
output bit is enforcedfromoutput bit is semantically meaningful. A comparator can still be fully wired into the statement and remain unsafe if the surrounding circuit never constrained its domain. - For signed-coordinate or shifted-range constructions like
max + in, range-check the shifted object directly rather than assuming the shift made it safe. - Fuzz boundary-adjacent witnesses that differ only by hidden width: honest in-range values, the same values with higher bits set, and field-wraparound variants should not all satisfy the same range proof.
- When a team says
we added a range proof to stop overflow, inspect the exact helper preconditions, not just the helper name.
Why this incident matters
- It is one of the cleanest public examples of a proof helper being reused correctly at the API level but unsafely at the statement level.
- It gives the corpus a concrete comparator-width exemplar for the
bit-length precondition omissionfamily, which otherwise risks staying too abstract. - It also preserves an important review habit for zk-heavy applications: if a gadget only works for
n-bit inputs, the proof must establishn-bit inputs before the gadget’s output can become game truth.
Failure-prevention checklist
- Treat every comparator width parameter as a hard precondition that must be re-proved at each call site.
- Constrain shifted signed values like
max + indirectly; do not assume the arithmetic transformation itself keeps them inside the comparator’s semantic domain. - Add
Num2Bitsor equivalent width proofs beforeLessThan/GreaterEqThanstyle gadgets, not after the fact in prose. - Keep helper wrappers honest: if a template is called
RangeProof, it should itself prove the width assumptions that make the comparison sound. - Test malicious witnesses that exceed the intended width while preserving the same apparent semantic claim.
- During audits, separate
helper output consumedbugs fromhelper precondition omittedbugs; both are dangerous, but they fail for different reasons. - Prefer library-local checked wrappers over open-coded gadget compositions that require each caller to remember invisible domain assumptions.
- Document the exact semantic type of every coordinate, bound, and shifted comparison object before converting it into field arithmetic.
Audit prompts
- Where is the proof that each
LessThan(bits)operand is actually abits-wide value? - If the circuit shifts a signed value before comparison, where is the proof that the shifted object still lives in the intended comparator domain?
- Would adding higher bits to the witness preserve satisfiability even though the semantic coordinate is now out of bounds?
- Is this circuit relying on a helper’s common usage pattern, or on an explicitly proven statement about the helper inputs?
- Does the wrapper template prove width and representation, or does it merely expose a narrower-looking name over a looser gadget composition?
- If the proof claims
coordinate is inside map radius, what exact constraints turn that into more thancomparator returned 1 on some field elements?
Lessons that should already be standard
We used a comparatoris not the same claim aswe proved a range.- Helper-gadget names are not constraints. Their documented preconditions must be reified in the surrounding circuit.
- Consuming a validator output is insufficient if the validator was never operating on a proved-valid domain.
- Shifted signed arithmetic is a favorite place for width assumptions to disappear behind tidy-looking algebra.
- The cleanest fix is usually not a new gadget; it is proving the missing premise that made the original gadget meaningful.
- Public zk writeups are worth revisiting after disclosure notes. Dark Forest’s own article now preserves the exact subtlety reviewers should watch for elsewhere.
Internal linkages
- Core recurring pattern: bit-length-precondition-omission.
- Contrast family: validation-output-nonconsumption — there the validator’s success bit is computed but never enforced; here the success bit is enforced, but the circuit never proved the width assumptions that make the bit mean what reviewers think it means.
- Library-level cousin: circom-ecdsa-bigmod-remainder-range-check-omission-noncanonical-remainder-near-miss-2022-03-30 — same hidden-width family, but on bigint remainder representation rather than coordinate comparisons.
- Positive-design companion: helper-domain-proof-locality-and-output-canonicalization-discipline — the sharper healthy-side companion when the question is where
LessThan(bits)-style helper-domain admission, helper invocation, and semantic output use should have stayed one local proof story instead of a reviewer-memory obligation. - Adjacent statement-binding companion: proof-validation-output-consumption-and-statement-binding-discipline — useful when widening review from
did we prove the helper preconditions?to the broader questiondid every helper assumption and helper output become explicit statement truth?
Control surface
-
The leverage sat in Dark Forest’s reusable range-proof helper, the exact
LessThan(bits)call sites inside it, and the missing width constraints on the shifted coordinate and bound values that the comparator was trusted to interpret. -
The bug was not that the game forgot to use a range proof. The bug was that the proof stopped one step too early and never proved the hidden bit-length facts that made the chosen comparator a real range proof.
-
Sources:
- https://blog.zkga.me/df-init-circuit
- https://github.com/darkforest-eth/circuits/commit/1b5c8440a487614d4a3e6ed523df0aee71a05b6e
- https://api.github.com/repos/darkforest-eth/circuits/commits/1b5c8440a487614d4a3e6ed523df0aee71a05b6e
- https://raw.githubusercontent.com/0xPARC/zk-bug-tracker/main/README.md
-
Last reviewed: 2026-08-04 UTC