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

Pool scope validation trusts self-declared agreement scope over authoritative AttackRegistry binding

Author Revealed upon completion

Description:

ConfidencePool._replaceScope validates each scope account against the agreement's isContractInScope, which is a self-declared mapping populated by the agreement owner. It does not cross-reference AttackRegistry.getAgreementForContract, the authoritative binding. This means a factory-created agreement can advertise any BattleChain address in its scope regardless of whether the AttackRegistry recognizes that agreement as the binding agreement for the address.

https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/main/src/ConfidencePool.sol#L749-L776

Root + Impact

Description:
When a pool is created, _replaceScope accepts scope accounts based solely on the agreement's own isContractInScope check. The
AttackRegistry.getAgreementForContract binding — the authoritative on-chain record of which agreement governs a contract — is never consulted.

// ConfidencePool.sol — _replaceScope
function _replaceScope(address[] calldata accounts) internal {
// ...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
// @> Only checks self-declared scope on the agreement
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
// @> Never checks: AttackRegistry.getAgreementForContract(account) == agreement
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
}

Risk:

Likelihood: Low

Reason 1: Exploitation requires the vanity agreement to be registered in AttackRegistry via requestUnderAttack, which demands a bond and DAO approval (approveAttack). The DAO would reject a vanity agreement attempting to register contracts already bound to an honest agreement.

Reason 2: Without AttackRegistry registration, the vanity agreement stays in NOT_DEPLOYED state, preventing flagOutcome from reaching CORRUPTED and blocking the drain path entirely.

Impact:

Impact 1: A pool could publish a scope list that includes contracts not governed by its agreement, misleading stakers about coverage boundaries.

Impact 2: If the DAO misconfigures or colludes to approve a vanity registration, the missing binding check allows the pool to accept unbound accounts, enabling a drain via the CORRUPTED → claimCorrupted path.

Proof of Concept:

// test/poc/VanityScopeBindingBypass.t.sol

Added setAgreementForContract and getAgreementForContract to the MockAttackRegistry.sol — these are needed to demonstrate the binding mismatch.

// 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 {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
/// @notice Unit test: pool's _replaceScope checks isContractInScope (self-declared) but does NOT
/// check getAgreementForContract (authoritative AttackRegistry binding).
///
/// This proves the scope validation trusts the agreement's own scope cache rather than the
/// on-chain binding. The mock does NOT model the real AttackRegistry state machine (per-agreement
/// states, DAO approval, bond collection), so this test does NOT prove end-to-end exploitability.
/// In production, a vanity agreement that was never registered via requestUnderAttack stays in
/// NOT_DEPLOYED state and cannot reach CORRUPTED — blocking the drain path.
contract VanityScopeBindingBypassTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarbor;
ConfidencePoolFactory internal factory;
ConfidencePool internal poolImpl;
MockAgreement internal vanityAgreement;
MockAgreement internal bindingAgreement;
address internal famousProtocol = makeAddr("FamousProtocol");
address internal attackerToy = makeAddr("AttackerToy");
address internal attacker = makeAddr("attacker");
address internal attackerRecovery = makeAddr("attackerRecovery");
address internal moderator = makeAddr("moderator");
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarbor = new MockSafeHarborRegistry();
poolImpl = new ConfidencePool();
vanityAgreement = new MockAgreement(attacker);
bindingAgreement = new MockAgreement(makeAddr("honestProtocolOwner"));
// Vanity agreement self-declares FamousProtocol in its scope cache.
vanityAgreement.setContractInScope(famousProtocol, true);
vanityAgreement.setContractInScope(attackerToy, true);
// AttackRegistry binding: FamousProtocol is bound to the HONEST agreement.
// The vanity agreement is NOT the Binding Agreement for FamousProtocol.
attackRegistry.setAgreementForContract(famousProtocol, address(bindingAgreement));
safeHarbor.setAttackRegistry(address(attackRegistry));
safeHarbor.setAgreementValid(address(vanityAgreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarbor), address(poolImpl), moderator)
)
)
)
);
factory.setStakeTokenAllowed(address(token), true);
}
function _vanityScope() internal view returns (address[] memory accounts) {
accounts = new address[](2);
accounts[0] = famousProtocol;
accounts[1] = attackerToy;
}
/// @dev Proves: pool accepts FamousProtocol into scope based on vanity agreement's
/// self-declared isContractInScope, even though AttackRegistry binding points to a
/// different agreement. This is the scope validation gap.
function test_PoolAcceptsUnboundAccount_ScopeValidationGap() external {
// Precondition: FamousProtocol is bound to honest agreement, not vanity.
assertEq(
attackRegistry.getAgreementForContract(famousProtocol),
address(bindingAgreement),
"FamousProtocol Binding is the honest protocol agreement"
);
assertTrue(vanityAgreement.isContractInScope(famousProtocol), "vanity self-declares FamousProtocol");
assertTrue(
attackRegistry.getAgreementForContract(famousProtocol) != address(vanityAgreement),
"FamousProtocol is NOT Bound to the vanity agreement"
);
// Pool creation succeeds despite the binding mismatch.
uint256 expiry = block.timestamp + 31 days;
vm.prank(attacker);
address poolAddr = factory.createPool(
address(vanityAgreement), address(token), expiry, ONE, attackerRecovery, _vanityScope()
);
ConfidencePool pool = ConfidencePool(poolAddr);
// Pool accepted FamousProtocol into its published scope.
assertTrue(pool.isAccountInScope(famousProtocol), "pool accepted unbound FamousProtocol into published scope");
assertTrue(pool.isAccountInScope(attackerToy));
}
/// @dev Proves: a single FamousProtocol account (no toy) also passes scope validation.
function test_PoolAcceptsSingleUnboundAccount() external {
address[] memory scope = new address[](1);
scope[0] = famousProtocol;
assertTrue(vanityAgreement.isContractInScope(famousProtocol));
assertTrue(attackRegistry.getAgreementForContract(famousProtocol) != address(vanityAgreement));
vm.prank(attacker);
address poolAddr =
factory.createPool(address(vanityAgreement), address(token), block.timestamp + 31 days, ONE, attackerRecovery, scope);
assertTrue(ConfidencePool(poolAddr).isAccountInScope(famousProtocol));
}
/// @dev Proves: an account with Binding==address(0) (never registered) also passes.
function test_PoolAcceptsZeroBindingAccount() external {
address ghost = makeAddr("NeverLinkedContract");
vanityAgreement.setContractInScope(ghost, true);
// binding defaults to address(0)
address[] memory scope = new address[](1);
scope[0] = ghost;
assertEq(attackRegistry.getAgreementForContract(ghost), address(0));
vm.prank(attacker);
address poolAddr =
factory.createPool(address(vanityAgreement), address(token), block.timestamp + 31 days, ONE, attackerRecovery, scope);
assertTrue(ConfidencePool(poolAddr).isAccountInScope(ghost));
}
/// @dev Documents the invariant the pool SHOULD enforce but currently does not.
/// Run after implementing a binding check — expect createPool/initialize to revert.
function test_Reference_BindingCheckWouldBlockVanityScope() external view {
address binder = attackRegistry.getAgreementForContract(famousProtocol);
address vanity = address(vanityAgreement);
// Invariant the pool SHOULD enforce per scope account, but currently does not:
assertTrue(binder != vanity, "binding != pool.agreement, a check would catch this");
assertTrue(vanityAgreement.isContractInScope(famousProtocol), "self-declare alone is not Binding");
}
}

Recommended Mitigation

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
// ...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
+ // Verify the AttackRegistry binding matches this pool's agreement.
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ if (attackRegistry != address(0)) {
+ address binding = IAttackRegistry(attackRegistry).getAgreementForContract(account);
+ if (binding != address(0) && binding != agreement) {
+ revert AccountNotBoundToPoolAgreement(account);
+ }
+ }
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}

Support

FAQs

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

Give us feedback!