The Confidence Pool distributes bonus rewards using a k=2 time-weighted formula where each staker's score is amount × (T − entryTime)², with T = riskWindowEnd (the first-observed terminal timestamp). To prevent a DAO state rewind from re-enabling withdrawals after the risk window opens, withdraw() correctly applies a one-way latch on riskWindowStart. No equivalent latch exists for riskWindowEnd in either stake() or contributeBonus().
Both stake() (line 227) and contributeBonus() (line 271) gate deposits through the same shared helper:
When the DAO registry rewinds from PRODUCTION back to UNDER_ATTACK, the pool's riskWindowEnd (T) remains permanently sealed. Because _assertDepositsAllowed is pure and reads no state, it sees only the live UNDER_ATTACK status and allows both deposits to proceed.
An attacker who stakes at entry time E where E > T receives score amount × (E − T)². Since E > T, this is a large positive integer in uint256 — no overflow, no negative arithmetic — but the quadratic growth of (E − T)² makes a small late stake dominate all legitimate early-staker scores. At E − T = 4000 seconds and T − S = 800 seconds (PoC values below): attacker score = 10 × 4000² = 160 000 000; Alice score = 100 × 800² = 64 000 000. The attacker with 10× less capital captures 71% of the bonus pool.
The contributeBonus() entry point compounds the damage: a sponsor contributing bonus during the rewind window (unaware riskWindowEnd is already sealed) inflates snapshotTotalBonus that the attacker then claims disproportionately.
Likelihood:
DESIGN.md §11 explicitly acknowledges the DAO registry as an external trusted singleton subject to benign state corrections; rewinds from PRODUCTION back to UNDER_ATTACK are a documented scenario. An attacker watches riskWindowEnd go non-zero on-chain and waits for any registry rewind to execute.
The protocol already applies a rewind-protection latch for withdraw() on riskWindowStart, making the missing equivalent latch on riskWindowEnd for deposits a directly analogous and foreseeable gap.
Impact:
An attacker with a fraction of the staked capital captures the majority of the bonus pool by staking at time E >> T, producing a k=2 score amount × (E − T)² that grows quadratically with the gap between the attacker's entry time and the sealed terminal timestamp.
Honest early stakers who bore genuine protocol risk through the entire active window receive near-zero bonus despite holding the majority of staked capital.
contributeBonus() sharing the same missing latch means a second-pass exploit: bonus contributed by any party during the rewind window is silently absorbed into the snapshot that the attacker will drain.
Steps to Reproduce:
Create test/RewindExploit.t.sol and paste the complete code below.
Run: forge test --match-test test_Attack_RewindAndContributeBonusExploit -vvv
Observe: Alice (100 ETH staked early) receives only ~28 ETH bonus. The attacker (10 ETH staked after riskWindowEnd was sealed) steals ~71 ETH. The contributeBonus() call at step 7 also succeeds despite riskWindowEnd being sealed — demonstrating the same missing latch on a second entry point.
Note on timestamps: Foundry's default
block.timestampstarts at 1. The pool is created withexpiry = block.timestamp + 60 days = 5 184 001. All warps in this test (maxwarp(5000)) remain well within that window. A leadingvm.warp(1)is added explicitly for reproducibility.
Expected output:
Math verification (no overflow, no negatives):
T = riskWindowEnd = 1000, S = riskWindowStart = 200, E = 5000
Alice clamped to S: score = 100 × (1000 − 200)² = 100 × 640 000 = 64 000 000
Attacker at E > T: score = 10 × (5000 − 1000)² = 10 × 16 000 000 = 160 000 000 (positive)
Shares: 64/(64+160) = 28.6% for Alice, 160/224 = 71.4% for attacker
Change _assertDepositsAllowed from pure to view and add the riskWindowEnd one-way latch as its first check. This single two-line change simultaneously closes the vulnerability for both stake() and contributeBonus(), mirroring the existing pattern in withdraw():
This mirrors the identical pattern already used in withdraw() for the riskWindowStart latch:
After this fix, any stake() or contributeBonus() call made after riskWindowEnd is first observed — regardless of subsequent live registry state — will revert with StakingClosed().
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.