FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

Sealed Risk-Window Rewinds Reopen Deposits and Allow Post-Window Stakers to Capture the Bonus Pool

Author Revealed upon completion

Root + Impact

Description

  • The pool is designed so that deposits are only accepted while the risk window is live, and rewards are weighted by how early a user entered relative to the sealed end of the risk window, riskWindowEnd (T). Once T is sealed (set to non-zero), it is intended to represent the final, immutable end of the reward-accruing period — no valid deposit should ever be able to occur after it.

The issue is that deposit eligibility and reward-window finality are checked against two different, non-synchronized sources of truth. stake() only checks the live state of the external AttackRegistry, while the bonus calculation relies on the permanently sealed riskWindowEnd. If the registry performs a benign rewind from a terminal state (e.g. PRODUCTION) back to an active state (UNDER_ATTACK) — which the protocol's own threat model acknowledges as normal, recurring behavior — stake() reopens even though riskWindowEnd is already sealed and can never be reset. This allows entryTime > T, and because the bonus formula is evaluated through its polynomial expansion, the resulting negative (T - entryTime) is squared into a large positive score instead of reverting.

function stake(uint256 amount) external {
@> // Only checks the CURRENT live registry state — has no awareness
@> // that riskWindowEnd may already be permanently sealed.
@> _assertDepositsAllowed(_observePoolState());
_updateUserStake(msg.sender, amount);
// ...
}
function _observePoolState() internal returns (State state) {
state = attackRegistry.currentState();
@> // Seal is one-way and permanent — but nothing here re-checks
@> // whether the seal has ALREADY happened before allowing stake()
@> // to proceed above.
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd(); // riskWindowEnd = block.timestamp, forever
}
}
function _bonusShare(address user) internal view returns (uint256) {
uint256 entryTime = userEntryTime[user];
@> // No invariant check that entryTime <= riskWindowEnd.
@> // A post-seal entryTime produces (T - entryTime) < 0,
@> // which is squared into a large POSITIVE score via the
@> // polynomial expansion, instead of reverting.
return amount * (riskWindowEnd - entryTime) ** 2;
}

Risk

Likelihood: Medium

  • This will occur any time the upstream AttackRegistry performs a benign rewind from a terminal state (PRODUCTION) back to an active state (UNDER_ATTACK) after riskWindowEnd has already been sealed once — a scenario the protocol's own design documentation acknowledges as a normal, recurring part of the threat model (e.g., false-positive attack flags being cleared and re-triggered by moderators over the life of the pool).

This will occur without any privileged access or coordination: once the registry is in the rewound UNDER_ATTACK state, any unprivileged user observing this on-chain can call stake() and profit, since _assertDepositsAllowed only reads the live registry state.

This will occur repeatedly and permanently once triggered: because riskWindowEnd has no reset path anywhere in the contract, the exploitable window remains open for the entire remaining lifetime of the pool after the first benign rewind, not just a single transaction.

Impact: High

  • Honest, risk-bearing stakers who deposited before or during the actual risk window have their bonus share diluted or almost entirely wiped out, since the quadratic weighting grows unbounded the further after T a malicious deposit occurs.

An attacker with zero risk exposure (depositing only after the window has effectively closed) can capture a disproportionate majority of the bonus pool — demonstrated in the PoC as ~90% of the pool with only a 15-day post-seal delay — directly inverting the documented purpose of the quadratic weighting mechanism.

Proof of Concept


Add the following test to test/unit/ConfidencePool.k2Bonus.t.sol. (Ensure import {console} from "forge-std/console.sol"; is present at the top of the file).

function testQuadraticBonusTheftViaStateRewind() external {
// Honest user deposits before the risk window.
_stake(alice, 100 * ONE);
// Registry enters an active-risk state.
_enterRisk();
// Five days of actual risk pass.
vm.warp(vm.getBlockTimestamp() + 5 days);
// First terminal observation seals riskWindowEnd.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
pool.pokeRiskWindow();
uint256 T = pool.riskWindowEnd();
assertGt(T, 0, "riskWindowEnd should be sealed");
// Benign upstream rewind reopens the live state.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// The attacker waits until 15 days after the sealed T.
vm.warp(vm.getBlockTimestamp() + 15 days);
// Deposit succeeds even though the bonus window already ended.
_stake(bob, 100 * ONE);
uint256 bobEntryTime =
pool.userSumStakeTime(bob) / (100 * ONE);
assertGt(
bobEntryTime,
T,
"Bob must enter after the sealed riskWindowEnd"
);
// Fund the bonus pool.
_contributeBonus(carol, 10_000 * ONE);
// Resolve the pool as survived.
_flagSurvived();
uint256 aliceBonus = _claim(alice) - 100 * ONE;
uint256 bobBonus = _claim(bob) - 100 * ONE;
assertEq(
pool.riskWindowEnd(),
T,
"riskWindowEnd must remain sealed"
);
assertApproxEqAbs(
aliceBonus + bobBonus,
10_000 * ONE,
2,
"Claims should account for the contributed bonus"
);
assertGt(
bobBonus,
aliceBonus * 8,
"CRITICAL: Post-window depositor captured the bonus pool"
);
}

Recommended Mitigation


Add regression tests confirming stake() reverts with DepositsClosedAfterRiskWindowSealed after riskWindowEnd is sealed, including across multiple PRODUCTION ↔ UNDER_ATTACK rewind cycles, and a fuzz test asserting riskWindowEnd never changes after its first non-zero assignment.

+ error DepositsClosedAfterRiskWindowSealed();
function stake(uint256 amount) external {
_assertDepositsAllowed(_observePoolState());
+ // Hard latch: once riskWindowEnd is sealed, no further deposits
+ // may be accepted, regardless of any subsequent benign rewind
+ // on the upstream registry's live state.
+ if (riskWindowEnd != 0) {
+ revert DepositsClosedAfterRiskWindowSealed();
+ }
_updateUserStake(msg.sender, amount);
// ...
}
function _bonusShare(address user) internal view returns (uint256) {
uint256 entryTime = userEntryTime[user];
+ // Defense-in-depth invariant: entryTime must never exceed the
+ // sealed window. A failure here indicates a lifecycle bypass
+ // elsewhere in the contract and must not silently mint an
+ // inflated score.
+ require(entryTime <= riskWindowEnd, "entryTime exceeds sealed riskWindowEnd");
return amount * (riskWindowEnd - entryTime) ** 2;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Missing riskWindowEnd rewind-latch in _assertDepositsAllowed lets late stakes/bonus theft via k=2 quadratic scoring

Impact - Medium Bonus pool only, principal always returns under SURVIVED/EXPIRED, but the quadratic advantage is unbounded and a tiny late stake can take nearly all of it. Likelihood - Low It needs a DAO registry migration to land inside the window where a pool is unresolved with riskWindowEnd already sealed, and the attacker still has to be watching. No attacker action can create the precondition.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!