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

Scope lock observations can roll back after rejected attack requests

Author Revealed upon completion

Description

The scopeLocked latch is designed to become permanent when the pool first observes the registry in any state outside NOT_DEPLOYED or NEW_DEPLOYMENT, fixing the pool's BattleChain scope after the agreement leaves pre-attack staging. The public maintenance paths that observe this transition write scopeLocked = true inside _observePoolState() but then revert in the same transaction, rolling back the latch. When setPoolScope() calls _observePoolState() in ATTACK_REQUESTED state, the function sets the latch but then immediately reverts with ScopePostLockImmutable(). When pokeRiskWindow() observes ATTACK_REQUESTED, it sets scopeLocked but reverts with RiskWindowNotReached() because no risk window markers exist in that state. If the registry returns to NOT_DEPLOYED via rejectAttackRequest and no successful pool interaction persisted scopeLocked during the ATTACK_REQUESTED interval, the pool owner can call setPoolScope() in NOT_DEPLOYED and replace the scope even though the agreement already moved past pre-attack staging.

function setPoolScope(address[] calldata accounts) external onlyOwner {
@> _observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
@> _observePoolState();
@> 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
) {
@> scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
}

Risk

Likelihood:

  • Requires a valid upstream transition to ATTACK_REQUESTED followed by a valid return to NOT_DEPLOYED, such as the documented rejectAttackRequest path

  • No successful pool interaction persists scopeLocked during the ATTACK_REQUESTED interval because pokeRiskWindow() reverts and rolls back the lock

  • The decisive scope replacement is owner-only via setPoolScope(), so exploitation requires the sponsor/pool owner to abuse a boundary that should already have closed

  • The sponsor can pause inflows and passive stakers may not withdraw merely to preserve scope, making the condition plausible but not routine

Impact:

  • Existing stakers remain in a pool whose scope can be replaced after the agreement crossed the documented lock boundary

  • A malicious or conflicted sponsor can swap in a broader or riskier subset of the agreement scope, increasing the chance that a later in-scope CORRUPTED outcome causes stakers to lose principal and bonus under coverage they did not originally accept

  • Indexers and users relying on ScopeLocked as the commitment point can be misled because attempted observations leave no durable on-chain trace

Recommended Mitigation

Make pokeRiskWindow() treat a newly-set scopeLocked as useful work and not revert in ATTACK_REQUESTED, even when no risk window markers exist:

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

Refactor setPoolScope() so that if it observes a post-staging state it persists the lock and returns without replacing scope, rather than reverting after the write:

function setPoolScope(address[] calldata accounts) external onlyOwner {
IAttackRegistry.ContractState state = _observePoolState();
if (scopeLocked) {
return;
}
_replaceScope(accounts);
}

Support

FAQs

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

Give us feedback!