ConfidencePool.stake() accepts aggregate stake as long as its absolute entry-time moments fit at the deposit timestamp. When the registry later enters UNDER_ATTACK, _markRiskWindowStart() replaces those moments with
totalEligibleStake * riskWindowStart^2 without checking that the accepted aggregate is still representable at the later timestamp.
An aggregate A accepted at time E can therefore overflow one second later at E + 1. The overflow rolls back every attempt to observe the active-risk state. After the pool expires, claimExpired() reaches the same observation and panics before returning principal, even though the documented behavior requires active-risk pools to resolve EXPIRED.
A separate large staker can bring the aggregate to the unsafe boundary and freeze an independent victim's A/4 position. The freeze lasts while the agreement remains UNDER_ATTACK; a later registry transition to PRODUCTION or CORRUPTED bypasses risk-start normalization and restores settlement. This report therefore claims a conditional, potentially indefinite freeze—not a permanent loss or attacker profit.
Root cause:
stake() stores stake-weighted absolute timestamps at the current entry time:
Neither the pool nor the factory limits the token's raw supply or the aggregate timestamp-square domain. The factory only checks a boolean token allowlist
(createPool(),
setStakeTokenAllowed()).
On the first active-risk observation,
_observePoolState() calls
_markRiskWindowStart():
Let:
At entry, A * E^2 <= type(uint256).max, so the real stake() calls succeed. At E + 1, however:
so _markRiskWindowStart() panics. Because the whole transaction reverts, riskWindowStart remains zero and the next observation retries the same overflowing expression.
This breaks the explicit expiry guarantee. claimExpired()
calls _observePoolState() before taking its intentionally broad non-terminal EXPIRED branch:
The design document states that expiry during UNDER_ATTACK is normal and must return principal; it specifically rejects deferring active-state expiry because that would trap honest stakers. The arithmetic panic prevents the intended branch from being reached.
The freeze is not automatically time-bounded by the registry. In the pinned dependency,
_getAgreementState()
returns UNDER_ATTACK whenever attackApproved remains set, before checking the original
promotion deadline. Only an authorized attack moderator can call
promote()
or markCorrupted(). Thus an agreement can honestly remain attackable past the pool's independent
expiry until an external privileged actor chooses a terminal transition.
This is distinct from an overflow in the later _bonusShare() claim calculation. Rewriting that claim formula to use elapsed-time or full-precision arithmetic leaves this trace unchanged because it panics earlier while the pool is still UNRESOLVED. Conversely, a local guard or
representation change in _markRiskWindowStart() does not repair _bonusShare().
Preconditions and attack path:
The factory owner allowlists a standard ERC-20 whose raw supply reaches the unsafe domain.
An agreement owner creates a pool using that token.
At E = 2,000,000,000, Alice stakes A/4 and Bob stakes the remaining 3A/4. Alice is the victim; Bob's deposit brings the pool-wide aggregate to A.
One second later, the honest BattleChain registry enters the normal UNDER_ATTACK state.
Any call that observes the state, including pokeRiskWindow(), panics in
_markRiskWindowStart() and rolls back.
The registry remains UNDER_ATTACK through the pool's expiry. Alice calls claimExpired(), which panics before resolving the pool or transferring her principal.
No malicious token callback, fee-on-transfer behavior, rebase, compromised owner, compromised moderator, corrupt registry, direct storage write, or impossible lifecycle transition is used.
With a fixed-supply OpenZeppelin ERC-20 reporting 50 decimals, A represents approximately 289.48 million display tokens and Alice's A/4 position represents approximately 72.37 million display tokens. ERC-20 decimals() is optional metadata and does not constrain raw arithmetic.
Likelihood:
The failure occurs when the factory allowlists a standard ERC-20 whose raw supply reaches the unsafe domain and public deposits bring aggregate stake across the arithmetic boundary.
A fixed-supply, no-fee, non-rebasing token reporting 50 decimals reaches the demonstrated aggregate boundary at approximately 289.48 million display tokens. The repository supports standard ERC-20s and does not constrain decimals, raw supply, or aggregate stake.
A normal registry transition into UNDER_ATTACK routes observation through the overflowing _markRiskWindowStart() expression. No malicious token behavior, compromised role, invalid lifecycle transition, or exact-block race is needed.
The unusual token parameter and large aggregate position make likelihood Medium under the CodeHawks impact/likelihood guidance.
Impact:
Alice cannot withdraw once the registry is active, and her post-expiry claimExpired() reverts before any outcome, obligation, or balance mutation. Her A/4 principal remains in the pool even though Bob supplied the stake that crossed the aggregate boundary.
The impact persists for as long as the agreement remains UNDER_ATTACK. The real registry has no automatic timeout from that state: the attack moderator must request promotion or mark the agreement corrupted, or the registry DAO must force a terminal transition. Alice cannot trigger
those repairs through the pool.
A later PRODUCTION or CORRUPTED state does restore settlement because terminal states call _markRiskWindowEnd() without calling _markRiskWindowStart(). The PoC exercises that recovery and does not claim permanent freezing, theft, or attacker profit.
The impact is Medium: funds are unavailable and the pool's documented expiry mechanism is broken, but an external authorized lifecycle transition repairs settlement. Medium impact and Medium likelihood produce Medium severity under the CodeHawks matrix.
Create test/eidolon/BCCPBS03SubmissionPoC.t.sol with the following contents:
Enforce a worst-case arithmetic-domain invariant before accepting stake. Because riskWindowStart <= expiry, the aggregate required by _markRiskWindowStart() must satisfy:
Apply the check before changing user or global accumulators. Also retain a defensive bound in _markRiskWindowStart() so future accounting paths cannot create an unresolvable pool.
Alternatively, replace absolute Unix-timestamp moments with an elapsed-time representation whose range is bounded by the pool duration. Fixing only _bonusShare() does not repair this finding; It overflows earlier while opening the risk window.
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.