_bonusShare() computes the k=2 time-weighted score amount·(T − entry)² in expanded polynomial form, building the two positive uint256 intermediates before the cancelling subtraction:
The factored result stake·(T − R)² (where R = riskWindowStart, T = outcomeFlaggedAt) fits uint256 comfortably. But the expanded intermediate stake·(T² + R²) is far larger, and because Solidity 0.8 arithmetic is checked, an intermediate over 2²⁵⁶ − 1 panics (0x11) instead of computing the small, in-range final value. A claim that should succeed reverts permanently.
The same values are accepted without overflow at deposit time, because stake() only ever multiplies by a single small timestamp, never by T² + R²:
So there is an asymmetry: a stake magnitude that is valid on the way in (and valid when the risk window opens, stake·R²) becomes un-payable on the way out (stake·(T² + R²)), and the funds cannot leave the pool by any path.
Arming the branch is permissionless. _bonusShare() skips the vulnerable math only when snapshotTotalBonus == 0:
Any account can call contributeBonus(1) — a single raw unit — to make snapshotTotalBonus != 0 and arm the overflow for every staker in the pool.
The freeze is total and permanent. Once armed, a zero-stake account resolves the pool EXPIRED via claimExpired() (the resolution branch does not call _bonusShare, so it does not revert), which latches claimsStarted = true. From that point:
every real staker's claimSurvived() / claimExpired() re-evaluates the expanded polynomial and panics;
withdraw() is disabled (post-risk, riskWindowStart != 0);
flagOutcome() re-flagging is locked (claimsStarted);
sweepUnclaimedBonus() reserves the whole balance (totalEligibleStake is never decremented because no claim succeeds) and reverts NothingToSweep.
All principal + bonus is stranded in a non-upgradeable clone with no recovery path. The attacker gains nothing (pure griefing) and pays one raw unit.
Source:
Likelihood: Low
The overflow requires a single staker (or the global sum) to reach ≈ 1.89e58 base units of stake. Translated by decimals():
decimals() |
tokens to trigger | realistic? |
|---|---|---|
| 18 (normal) | ~1.9e40 | no — exceeds any supply |
| 24 | ~1.9e34 | no |
| 45 | ~1.9e13 | amount plausible, but a 45-decimal token is unheard of |
| 54 | ~18,900 | amount plausible, 54-decimal token is not |
| 60 (this PoC) | ~0.019 | tiny amount, but a 60-decimal token is not realistic |
There is no combination of realistic decimals (≤ ~24) and realistic supply that reaches it. It fundamentally needs a ≥ ~50-decimal ERC20, placed on the factory owner's allowlist.
Given such a token, the trigger is fully permissionless: an ordinary boundary-size stake, a one-unit contributeBonus, and a zero-stake claimExpired. No privileged role, no collusion.
Impact: Medium (classified) — technically severe if triggered
The consequence if triggered is a permanent, irreversible loss of availability of all principal + bonus in the affected pool clone: no upgrade, no admin recovery, and every exit path (claim / withdraw / sweep / re-flag) is blocked.
Classified Medium rather than High because it is bounded to a single pool clone and cannot occur without the factory owner allowlisting a pathological-decimals() token. Medium impact × Low likelihood → Low overall.
The contest compatibility statement is "ERC20 (standard only; fee-on-transfer and rebasing tokens are NOT supported)" — it excludes only FoT and rebasing and sets no bound on decimals() (README L121-L132).
The PoC token is unmodified OpenZeppelin ERC20 accounting; only the display-only decimals() is overridden, which OZ documents as an arbitrary uint8. It is a "standard ERC20" by the literal compatibility class.
Neither ConfidencePool nor ConfidencePoolFactory has any decimals or stake-magnitude guard (zero decimals references in the in-scope source), so the expanded-form overflow is unmitigated in-scope code.
test/repro/K2ClaimOverflow.t.sol deploys the real in-scope ConfidencePoolFactory behind an ERC1967 proxy, allowlists a plain-OZ ERC20 whose only unusual property is decimals() == 60, uses a realistic minStake of 1e57 (0.001 token), stakes a boundary-size position of < 0.02 token, and compares two pools that differ only by a one-unit bonus. The control pool (zero bonus) returns principal; the victim pool's staker claim reverts arithmeticError and the full balance stays locked.
Target commit: 58e8ba4ce3f3277866e4926f3140e597f9554a1e
PoC file SHA-256: ad12b0e050b9a3b95cf843eafaa18986126379b741d7cd65451712e09b4f47ab
Run:
Observed result:
The control pool returns principal; the victim pool's claim reverts arithmeticError, eligibleStake never advances, sweepUnclaimedBonus reverts NothingToSweep, and the full balance stays locked.
Either (a) bound the stake magnitude so the expanded polynomial cannot overflow at the largest possible T = expiry, or (b) evaluate the score in factored form / with 512-bit intermediates so the oversized positive terms never materialize. Math.mulDiv on the final fraction alone does not help, because the panic occurs building the numerator before any division.
Magnitude-guard sketch (the first stake locks expiry, so T is bounded):
Public contest repo had zero issues at review time; its only PR was unrelated lint cleanup. docs/DESIGN.md, protocol-readme.md, and the shipped tests do not disclose or guard this expanded-form overflow. Private contest duplicates during the live window cannot be ruled out.
Testing used only local deployments, a mock token, and the pinned target commit. No live transaction was broadcast, no real funds moved, and no third-party or production system was accessed.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.