FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: low

`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.

Support

FAQs

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

Give us feedback!