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

Scope lock can be rolled back through reverting ATTACK_REQUESTED observations, allowing post-stake scope mutation

Author Revealed upon completion

Description

Independent triage conclusion: valid issue.

The issue is real and exists within the in-scope system. ConfidencePool advertises that pool scope becomes fixed once the registry leaves pre-attack staging, but that one-way guarantee can be bypassed because setPoolScope() and pokeRiskWindow() both call _observePoolState() before reverting in ATTACK_REQUESTED. The revert rolls back scopeLocked = true, so the lock is not persisted. If BattleChain later rejects the attack request and rewinds the agreement to NOT_DEPLOYED, the sponsor can still replace scope after users have already staked.

This is distinct from the accepted trust model in docs/DESIGN.md: the accepted model assumes the pool's local scope is a fixed commitment once locking conditions are met. Here, the bug prevents that commitment from becoming fixed at all after a real post-staging observation.

Affected code

Risk

scopeLocked is intended to be a one-way latch that becomes true on the first interaction observing any registry state other than NOT_DEPLOYED or NEW_DEPLOYMENT.

The problem is that the lock is set inside _observePoolState(), but some callers invoke _observePoolState() before they perform checks that can revert:

  • setPoolScope() calls _observePoolState(), then immediately reverts with ScopePostLockImmutable() if scopeLocked is true.

  • pokeRiskWindow() calls _observePoolState(), then reverts with RiskWindowNotReached() if both riskWindowStart and riskWindowEnd are still zero.

When the registry is ATTACK_REQUESTED:

  1. _observePoolState() sees a post-staging state and sets scopeLocked = true.

  2. setPoolScope() still reverts because it now sees scopeLocked == true, or pokeRiskWindow() reverts because ATTACK_REQUESTED is neither active-risk nor terminal.

  3. The revert unwinds the transaction and rolls scopeLocked back to false.

BattleChain's upstream rejectAttackRequest() path can legitimately rewind ATTACK_REQUESTED back to NOT_DEPLOYED. After that rewind, the owner regains access to setPoolScope() while stakers may still be in the pool. The sponsor can then replace the originally committed BattleChain accounts even though the pool already experienced a real post-staging registry observation.

That breaks the documented pool-level invariant that scope is fixed once the registry has moved past staging.

Preconditions

  • At least one user has already staked into the pool.

  • The registry reaches ATTACK_REQUESTED.

  • Before any successful pool interaction persists the scope lock, a reverting observation path is triggered:

    • owner calls setPoolScope(), or

    • any account calls pokeRiskWindow().

  • The upstream BattleChain registry later rejects the attack request and returns the agreement to NOT_DEPLOYED.

Impact

The sponsor can mutate the insured account set after users have already deposited against the original scope commitment.

That can later change whether a future corruption event is treated as in-scope or out-of-scope for this pool and therefore change how pool value is distributed. Even though users still have withdrawal opportunities while the registry remains pre-risk, the protocol's advertised fixed-scope guarantee is broken and deposited capital can remain exposed to a different scope than the one users originally underwrote.

Baseline severity assessment: Medium.

Exploit cost/attack complexity

Exploit cost is low.

Attack complexity is low to medium:

  • the reverting observation can be triggered cheaply,

  • the upstream rewind path is part of the real BattleChain state machine,

  • the only meaningful timing requirement is triggering the reverting observation before another successful pool interaction persists scopeLocked.

PoC Source

Scenario:

  1. A staker deposits while the registry is still in NEW_DEPLOYMENT.

  2. The registry moves to ATTACK_REQUESTED.

  3. A reverting observation path is used:

    • setPoolScope(newScope) by the owner, or

    • pokeRiskWindow() by any account.

  4. _observePoolState() sees the post-staging state and sets scopeLocked = true, but the outer function reverts and rolls the lock back.

  5. The upstream registry rewinds to NOT_DEPLOYED through rejectAttackRequest().

  6. The owner successfully replaces scope while the original stake is still live.

How to run:

forge test --match-path test/audit/ConfidencePool.scopeRollback.t.sol -vv

Full source code PoC:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
/*
PoC title:
- Scope lock rollback after reverting ATTACK_REQUESTED observation
Target:
- src/ConfidencePool.sol
How to run:
- forge test --match-path test/audit/ConfidencePool.scopeRollback.t.sol -vv
Expected output:
- [PASS] testExploit_permissionlessPokeDoesNotPersistScopeLockInAttackRequested()
- [PASS] testExploit_scopeLockRollbackAfterRevertingAttackRequestedObservation()
*/
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract ConfidencePoolScopeRollbackPoCTest is BaseConfidencePoolTest {
address internal constant ACCOUNT_X = address(0xA1);
address internal constant ACCOUNT_Y = address(0xA2);
function testExploit_scopeLockRollbackAfterRevertingAttackRequestedObservation() external {
address[] memory replacementScope = new address[](2);
replacementScope[0] = ACCOUNT_X;
replacementScope[1] = ACCOUNT_Y;
agreementContract.setContractInScope(ACCOUNT_X, true);
agreementContract.setContractInScope(ACCOUNT_Y, true);
_stake(alice, 100 * ONE);
assertFalse(pool.scopeLocked(), "scope starts mutable in NEW_DEPLOYMENT");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(replacementScope);
assertFalse(pool.scopeLocked(), "revert rolled back the supposed one-way lock");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(replacementScope);
address[] memory liveScope = pool.getScopeAccounts();
assertEq(liveScope.length, 2, "scope replaced after rollback");
assertEq(liveScope[0], ACCOUNT_X);
assertEq(liveScope[1], ACCOUNT_Y);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "original committed scope removed");
assertTrue(pool.isAccountInScope(ACCOUNT_X), "new account admitted post-stake");
assertTrue(pool.isAccountInScope(ACCOUNT_Y), "new account admitted post-stake");
assertEq(pool.eligibleStake(alice), 100 * ONE, "existing stake remains exposed to the mutated scope");
}
function testExploit_permissionlessPokeDoesNotPersistScopeLockInAttackRequested() external {
address[] memory replacementScope = new address[](2);
replacementScope[0] = ACCOUNT_X;
replacementScope[1] = ACCOUNT_Y;
agreementContract.setContractInScope(ACCOUNT_X, true);
agreementContract.setContractInScope(ACCOUNT_Y, true);
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.prank(bob);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "permissionless reverting observation also rolls the lock back");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(replacementScope);
assertTrue(pool.isAccountInScope(ACCOUNT_X), "owner can still mutate scope after outsider poke");
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "original committed scope removed");
}
}

PoC Results

Last validated result:

Compiling 1 files with Solc 0.8.26
Solc 0.8.26 finished in 25.50s
Compiler run successful!
Ran 2 tests for test/audit/ConfidencePool.scopeRollback.t.sol:ConfidencePoolScopeRollbackPoCTest
[PASS] testExploit_permissionlessPokeDoesNotPersistScopeLockInAttackRequested() (gas: 462285)
[PASS] testExploit_scopeLockRollbackAfterRevertingAttackRequestedObservation() (gas: 470369)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 3.58ms (1.32ms CPU time)
Ran 1 test suite in 19.31ms (3.58ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Suggested Fix

Persist the scope lock from any first post-staging observation even when the outer entry point would otherwise revert.

Two practical approaches:

  1. Refactor setPoolScope() and pokeRiskWindow() so they do not call a revert-sensitive observation path before checking whether their own post-observation conditions would fail.

  2. Derive scope immutability directly from current registry state, or add a dedicated irreversible latch that cannot be rolled back by later caller-specific reverts once a post-staging state has been observed.

In either case, once ATTACK_REQUESTED or any later state has been observed, future rewinds to NOT_DEPLOYED must not restore scope mutability for an already-funded pool.

Mitigation

One practical mitigation is to separate "observe and persist the first post-staging scope lock" from caller-specific revert paths.

The key property is:

  • after the first observed state outside NOT_DEPLOYED / NEW_DEPLOYMENT, the transaction must not revert after setting scopeLocked, or the lock will be rolled back with the rest of the frame.

A simple mitigation pattern is to make the first post-staging observation a non-reverting state-persistence path, then stop the caller from mutating scope without undoing the lock:

function _lockScopeIfPostStaging(IAttackRegistry.ContractState state) internal {
if (
!scopeLocked
&& state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
IAttackRegistry.ContractState state = _getAgreementState();
_lockScopeIfPostStaging(state);
// Do not revert after the first post-staging observation if doing so would roll back the lock.
if (scopeLocked) {
return;
}
_replaceScope(accounts);
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
IAttackRegistry.ContractState state = _getAgreementState();
_lockScopeIfPostStaging(state);
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}

The exact final patch can differ, but the fix must guarantee that the first successful observation of ATTACK_REQUESTED or any later state leaves the pool in a permanently locked-scope state, even if the original caller used setPoolScope() or pokeRiskWindow().

Support

FAQs

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

Give us feedback!