FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

`pokeRiskWindow()` rolls back the scope lock, allowing coverage replacement after an attack-request rejection

Author Revealed upon completion

Description

  • The pool owner may modify the covered accounts only during NOT_DEPLOYED or NEW_DEPLOYMENT. Observing any later registry state must set scopeLocked permanently, protecting the coverage commitment made to stakers.

  • During ATTACK_REQUESTED, _observePoolState() sets scopeLocked = true. However, this state does not set riskWindowStart or riskWindowEnd, causing pokeRiskWindow() to revert and roll back the scope lock. When the registry subsequently rejects the attack request and returns to NOT_DEPLOYED, the owner can replace the pool scope.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
// @> Both markers remain zero during ATTACK_REQUESTED, so this
// @> reverts and rolls back the scopeLocked update below.
if (riskWindowStart == 0 && riskWindowEnd == 0) {
revert RiskWindowNotReached();
}
}
function _observePoolState()
internal
returns (IAttackRegistry.ContractState state)
{
state = _getAgreementState();
if (
!scopeLocked
&& state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
// @> Executed during ATTACK_REQUESTED, but rolled back by
// @> the subsequent revert in pokeRiskWindow().
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
// ATTACK_REQUESTED satisfies neither condition.
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}

Risk

Likelihood:

  • This occurs when the registry reaches ATTACK_REQUESTED, no successful stake, withdrawal, or bonus contribution persists the scope lock, and a caller uses pokeRiskWindow().

  • The exploitable window appears when the registry moderator subsequently rejects the attack request, returning the agreement to NOT_DEPLOYED, after which the pool owner replaces the scope.

Impact:

  • Existing stakers lose the permanent scope commitment they expected once the agreement passed pre-attack staging.

  • The owner can broaden or narrow coverage, potentially changing a future breach from SURVIVED to CORRUPTED or vice versa. This can change the destination of the entire pool’s principal and bonus.

Proof of Concept

Add the following test to ConfidencePool.scope.t.sol:

function testPokeRollbackAllowsScopeReplacementAfterRejectedRequest()
external
{
_seedAgreementScope();
// The agreement leaves pre-attack staging.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.ATTACK_REQUESTED
);
// pokeRiskWindow temporarily sets scopeLocked, but subsequently
// reverts because neither risk-window marker was created.
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
// The revert rolled back the expected permanent scope lock.
assertFalse(pool.scopeLocked());
// This reproduces the effect of AttackRegistry.rejectAttackRequest(),
// which deletes the agreement information and returns NOT_DEPLOYED.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.NOT_DEPLOYED
);
// The owner can now replace the coverage commitment.
pool.setPoolScope(_multiAccountScope());
address[] memory updatedScope = pool.getScopeAccounts();
assertEq(updatedScope.length, 3);
assertEq(updatedScope[0], ACCOUNT_X);
assertEq(updatedScope[1], ACCOUNT_Y);
assertEq(updatedScope[2], ACCOUNT_Z);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
}

Recommended Mitigation

Treat persisting the scope lock as a successful observation, even when neither risk-window timestamp was created:

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) {
+ if (
+ riskWindowStart == 0
+ && riskWindowEnd == 0
+ && !scopeLocked
+ ) {
revert RiskWindowNotReached();
}
}

This allows an ATTACK_REQUESTED observation to complete and permanently store scopeLocked = true, while calls during NOT_DEPLOYED and NEW_DEPLOYMENT continue reverting as before.

Updates

Lead Judging Commences

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

Sponsor can atomically self-trigger ATTACK_REQUESTED and roll back scopeLocked via a reverting setPoolScope/pokeRiskWindow, reopening scope after a DAO rejection

Impact - Low Nothing moves when the scope is swapped, and the state afterward is pre-attack, so any staker who notices can withdraw in full. _replaceScope emits ScopeUpdated, which puts the change on-chain where it can be seen. Real loss needs a restarted attack cycle that breaches one of the substituted accounts with the original staker still in the pool, at which point their principal funds a CORRUPTED payout for coverage they never chose. Likelihood - Medium The step the sponsor cannot force is the moderator rejecting the attack request, though rejections are an ordinary registry operation. The rest they can arrange. Neither observation path can seal the lock in this state, and pausing the pool shuts the deposit routes, leaving only a full-exit withdraw to do it. So reaching the mutable-scope condition is not hard; what holds the severity down is how much has to go wrong afterward for anyone to actually lose money.

Support

FAQs

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

Give us feedback!