FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

`pokeRiskWindow()` rolls back the scope lock when it first observes `ATTACK_REQUESTED`

Author Revealed upon completion

Root + Impact

Description

  • The pool scope should become permanently immutable on the first interaction that observes the registry outside NOT_DEPLOYED or NEW_DEPLOYMENT. Therefore, observing ATTACK_REQUESTED must persist scopeLocked = true, even though this state does not begin the bonus risk window.

  • pokeRiskWindow() calls _observePoolState(), which sets scopeLocked = true in ATTACK_REQUESTED. Because neither risk-window marker is set in that state, pokeRiskWindow() subsequently reverts with RiskWindowNotReached(), rolling back the scope lock and its event. Following a legitimate upstream rejection from ATTACK_REQUESTED to NOT_DEPLOYED, the sponsor can replace the scope even though the pool already observed the attack request.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
// @> ATTACK_REQUESTED sets scopeLocked but neither risk marker. This revert
// @> rolls back the supposedly one-way scope lock.
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
) {
// @> This write and event are reverted by pokeRiskWindow().
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}

Risk

Likelihood:

  • Occurs when the first pool interaction during ATTACK_REQUESTED is the advertised permissionless pokeRiskWindow() call.

  • Becomes exploitable when the registry moderator subsequently rejects the attack request, returning the upstream agreement to NOT_DEPLOYED before another successful pool interaction persists the lock.

Impact:

  • The sponsor can replace the pool's advertised coverage after the protocol lifecycle says that coverage became immutable.

  • Existing stakers can be exposed to a materially different set of insured contracts than the scope they relied upon when staking.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract ScopeLockRollbackPoC is BaseConfidencePoolTest {
address internal constant NEW_SCOPE_ACCOUNT = address(0xBEEF);
function test_PokeRollsBackScopeLockInAttackRequested() external {
agreementContract.setContractInScope(NEW_SCOPE_ACCOUNT, true);
// ATTACK_REQUESTED is the first state that must permanently lock scope.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
// The transition observed by poke was rolled back with the revert.
assertFalse(pool.scopeLocked());
// A legitimate rejected request returns the agreement to NOT_DEPLOYED.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
address[] memory replacement = new address[](1);
replacement[0] = NEW_SCOPE_ACCOUNT;
pool.setPoolScope(replacement);
assertTrue(pool.isAccountInScope(NEW_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.scopeLocked());
}
}

Recommended Mitigation

Treat locking scope as useful work and do not revert the transaction that first persists it.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ bool scopeWasUnlocked = !scopeLocked;
_observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ if (riskWindowStart == 0 && riskWindowEnd == 0) {
+ // ATTACK_REQUESTED may have performed the one-way scope lock.
+ if (scopeWasUnlocked && scopeLocked) return;
+ revert RiskWindowNotReached();
+ }
}
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!