Severity: Medium (High impact, Low likelihood). Successful exploitation can permanently lock every staker's principal and the entire sponsor bonus, or suppress risk observation so the bonus is recovered despite an active-risk period. Exploitation requires an owner-allowlisted ERC20 whose valid base-unit range reaches the overflow boundary.
ConfidencePool._bonusShare reconstructs each k=2 score from absolute Unix timestamps:
A stake can pass every deposit-time calculation yet break the pool in either of two ways:
A stake above type(uint256).max / (2 * t^2) can let the risk window open, but later makes either userPlus or plus overflow during every claim. The addition exceeds uint256 before the mathematically cancelling subtraction and reverts with panic 0x11.
A total stake near type(uint256).max / t^2 can fit at deposit time but make _markRiskWindowStart overflow after the timestamp advances. Every public pokeRiskWindow() then reverts. Once the registry reaches PRODUCTION, the pool resolves with riskWindowStart == 0, pays no bonus to stakers, and makes the entire bonus sweepable to recoveryAddress even though the agreement spent its term in active risk.
The factory accepts any owner-allowlisted standard ERC20 and applies no supply, balance, or decimals bound. A fixed-supply OpenZeppelin ERC20 with 60 decimals makes the claim-lock stake approximately 0.018904830895888358 nominal tokens at the test's production-scale timestamp. The PoC token mints its 2,000,000-token supply once in its constructor and has no post-deployment behavior beyond ordinary ERC20 transfers. The PoC places a 1,000,000-token bonus in the pool and proves both exploit modes through the production factory path.
The deposit path only proves that the stored term for the current entry timestamp fits:
Opening the risk window similarly proves that totalEligibleStake * riskWindowStart^2 fits. Neither check proves that the expanded positive side used during settlement fits:
The final mathematical score can be small or zero while these intermediate positive terms exceed 256 bits. Math.mulDiv is used only after userScore and globalScore have already been computed, so it does not protect this overflow.
Affected code: ConfidencePool.sol lines 696-719, especially lines 704-710. The unrestricted base-unit range reaches the pool through the ordinary allowlisted-token factory path in ConfidencePoolFactory.sol lines 66-114.
After a terminal PRODUCTION state and a SURVIVED outcome:
every claimSurvived call requires the overflowing global score and reverts;
withdraw is unavailable because the outcome is resolved;
claimExpired rejects a SURVIVED outcome;
sweepUnclaimedBonus reserves all remaining principal and snapshot bonus, so it cannot recover the funds;
the moderator can only reflag SURVIVED while the registry is PRODUCTION, which recreates the same snapshot and does not repair the arithmetic.
The result is permanent loss of access to all principal and bonus in that pool. The attack is a griefing attack rather than a theft, but the cost can be tiny relative to the locked bonus because bonus accounting is not multiplied by timestamps. The PoC locks a 1,000,000-token bonus using about 0.019 token of attacker stake.
The same root cause also defeats the protocol's documented permissionless observation defense. In the second PoC, every attempt to seal the active-risk window reverts. The bonus is therefore classified as unearned and swept to recovery, while the address that forced the overflow later recovers all of its principal. A sponsor can use this to advertise and fund a bonus, attract stakers, force the no-risk branch, and reclaim the bonus after stakers actually bore the active-risk period.
This is Medium under CodeHawks' impact/likelihood framework: funds are permanently at risk, while exploitation requires the specific but standards-compliant condition of a high-decimal ERC20 being allowlisted.
Save the following code in the contest repository as test/unit/ExpandedScoreOverflow.poc.t.sol, then run:
Observed result:
The tests use the production factory proxy, token allowlist, deterministic pool creation, a standard OpenZeppelin ERC20, and the normal stake, bonus, registry, flag, claim, and sweep entrypoints. test_standardHighDecimalStakePermanentlyBlocksAllSurvivedClaims proves permanent settlement failure. test_largeValidStakePreventsRiskObservationAndRecoversSponsorBonus proves that all risk-window observations can be forced to revert while the attacker later recovers principal.
A factory owner allowlists a standard ERC20 whose base-unit domain can reach the vulnerable range, such as the 60-decimal OpenZeppelin token in the PoC.
A sponsor creates a pool and contributes a valuable bonus.
Before the risk window opens, an attacker stakes type(uint256).max / (2 * block.timestamp^2) + 1 base units. The deposit-time products fit.
The agreement enters UNDER_ATTACK; pokeRiskWindow succeeds because the stored global square term still fits.
The agreement reaches PRODUCTION and the moderator flags SURVIVED.
Any staker calls claimSurvived. _bonusShare overflows while forming the expanded positive terms and the transaction reverts.
No alternative withdrawal or sweep path can release the reserved pool balance.
Victims stake and a sponsor funds the bonus before the active-risk period.
An attacker tops up total stake to floor(type(uint256).max / block.timestamp^2). All deposit-time products and additions fit.
After the timestamp advances and the registry enters UNDER_ATTACK, _markRiskWindowStart evaluates the same total stake at the larger timestamp and overflows. Every pokeRiskWindow and every other call that tries to persist the start marker reverts.
When the registry reaches PRODUCTION, flagOutcome(SURVIVED, ...) records only riskWindowEnd; riskWindowStart remains zero.
sweepUnclaimedBonus treats the full bonus as unreserved and sends it to recovery.
Stakers receive principal only. The attacker also receives all principal back, making the manipulation economically recoverable.
Avoid computing the score from large absolute-timestamp intermediate values. Two viable approaches are:
Store time offsets relative to riskWindowStart and compute the polynomial with bounded durations rather than Unix timestamps.
Enforce a stake-liability cap before accepting deposits that proves all worst-case positive terms fit for T <= expiry, including the addition of the stored square sum.
A robust implementation should also fuzz the exact boundary around type(uint256).max / (T^2 + entry^2), not only the later userScore * snapshotTotalBonus multiplication handled by Math.mulDiv.
docs/DESIGN.md does not document an amount/supply/decimals bound or this overflow.
The repository's high-magnitude regression uses 1e34, far below the failing approximately 1.89e58 base-unit boundary at the suite timestamp.
Public repository issues and pull requests contain no matching report; the only public PR is build-warning cleanup.
Exact GitHub/web searches for snapshotSumStakeTimeSq, userSumStakeTimeSq, and the expanded expressions with overflow returned no public match.
Live CodeHawks submissions are not publicly visible during the contest, so private duplicate status cannot be observed. The root cause is narrow and no public duplicate was found.
Focused factory-path PoC: 2 passed, 0 failed.
Baseline contest suite before the PoC: 256 passed, 0 failed, 2 RPC-dependent fork tests skipped.
Full suite with both PoCs and the scratch invariant harness: 263 passed, 0 failed, 2 RPC-dependent fork tests skipped.
Randomized accounting harness: five invariants passed over 2,000 runs at depth 100 each, one million calls total.
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.