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

ConfidencePool Scope Validation Relies Solely on Self-Declared isContractInScope, Allowing Pools to Falsely Claim Coverage Over Contracts Bound to a Different Agreement

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: A pool's published scope is meant to be a trustworthy, on-chain commitment — stakers rely on getScopeAccounts()/isAccountInScope to know exactly which BattleChain contracts their stake is economically backing.

  • The issue: _replaceScope validates each account only via IAgreement(agreement).isContractInScope(account) — a purely self-declared cache with no ownership verification (Agreement.addAccounts/addOrSetChains never check the caller actually controls the listed address). The BattleChain interface itself documents this isn't sufficient: "A true return does NOT establish that this agreement is the Binding Agreement for the contract... resolve the Binding Agreement via AttackRegistry." The pool never performs that resolution. Confirmed live against the real BattleChain testnet: I deployed my own throwaway Agreement, self-declared the real demo agreement's real, already-bound contract as being in my scope too, and ConfidencePoolFactory.createPool accepted it without complaint — while AttackRegistry.getAgreementForContract still correctly pointed at the real agreement, not mine.

// src/ConfidencePool.sol
function _replaceScope(address[] calldata accounts) internal {
...
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);
@> }
@> // No cross-check against AttackRegistry.getAgreementForContract(account) == agreement
@> // -- the actual Binding Agreement the upstream docs say must be resolved first.
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
...
}
// lib/battlechain-safe-harbor-contracts/src/interface/IAgreement.sol
@> /// @notice Returns true if the contract is in this agreement's BattleChain scope cache.
@> /// A `true` return does NOT establish that this agreement is the Binding Agreement
@> /// for the contract — multiple agreements may include the same contract in their
@> /// scope. Whitehats must resolve the Binding Agreement via AttackRegistry before
@> /// relying on this agreement's terms.
function isContractInScope(address contractAddress) external view returns (bool);

Risk

Likelihood: High — creating the deceptive pool requires nothing beyond what's already freely, permissionlessly available: deploy an Agreement via the real AgreementFactory, self-declare any contract as in scope (zero validation), then createPool referencing that self-owned agreement (trivially passes owner() == msg.sender).

Impact: Medium — forcing a fake CORRUPTED flag requires the real DAO to cooperate (approveAttack is a genuine checkpoint before any attack-lifecycle progression), so this doesn't hand an attacker unilateral fund theft. But a DAO-independent path exists: if the DAO simply never acts on the bogus request within 14 days, the agreement auto-promotes straight to PRODUCTION, skipping the active-risk states entirely — riskWindowStart never opens, triggering the already-accepted "no observed risk → bonus swept to sponsor-controlled recoveryAddress" rule (design doc §5). Combined with a spoofed, convincing-looking scope, this lets a sponsor solicit deposits and bonus contributions from parties deceived into thinking they're backing a specific, real protocol, and collect the bonus pot. Independent of any theft, a spoofed pool resolving SURVIVED is a false, misleading on-chain confidence signal about a contract that was never actually covered by this pool at all — undermining the core value proposition of the protocol.

Proof of Concept

Fork test against live BattleChain testnet (test/fork/BattleChainScopeSpoofing.fork.t.sol):

// Ground truth: the real Binding Agreement for this contract is DEMO_AGREEMENT.
assertEq(IAttackRegistry(ATTACK_REGISTRY).getAgreementForContract(DEMO_AGREEMENT_IN_SCOPE), DEMO_AGREEMENT);
// Attacker deploys their own, completely unrelated agreement and self-declares the SAME
// real contract as being in ITS scope too -- nothing checks ownership.
address rogueAgreement = IAgreementFactory(AGREEMENT_FACTORY).create(details, attacker, salt);
assertTrue(IAgreement(rogueAgreement).isContractInScope(DEMO_AGREEMENT_IN_SCOPE));
// The real Binding Agreement is untouched -- still DEMO_AGREEMENT, not the rogue one.
assertEq(IAttackRegistry(ATTACK_REGISTRY).getAgreementForContract(DEMO_AGREEMENT_IN_SCOPE), DEMO_AGREEMENT);
// ConfidencePool accepts it anyway -- no call to AttackRegistry's binding-agreement
// resolution is ever made or required.
address poolAddr = factory.createPool(rogueAgreement, address(stakeToken), expiry, 1e18, recovery, scope);
assertTrue(IConfidencePool(poolAddr).isAccountInScope(DEMO_AGREEMENT_IN_SCOPE)); // passes

Recommended Mitigation

Cross-check AttackRegistry.getAgreementForContract — but only to reject a known conflict (the contract is already confirmed bound to a different agreement), not to require binding before any attack has been requested. A strict "must already be bound to this agreement" check would break the normal, intended flow of setting scope during pre-attack staging, before requestUnderAttack has ever been called.

// src/ConfidencePool.sol
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);
}
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ if (attackRegistry != address(0)) {
+ address boundAgreement = IAttackRegistry(attackRegistry).getAgreementForContract(account);
+ if (boundAgreement != address(0) && boundAgreement != agreement) {
+ revert AccountBoundToDifferentAgreement(account, boundAgreement);
+ }
+ }
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}

This blocks exactly the exploited case (claiming an already-established, real, valuable contract that's already bound elsewhere — the only scenario with real deceptive value) while still allowing fresh, never-yet-attacked contracts to be scoped normally pre-attack. Residual: a contract that has genuinely never called requestUnderAttack under any agreement (boundAgreement == address(0)) can still be claimed by an unrelated party — but spoofing an obscure, unproven contract carries far less deceptive value than spoofing an already-recognized one, so this residual is a much narrower exposure than the one closed here.

Add error AccountBoundToDifferentAgreement(address account, address boundAgreement); to IConfidencePool.sol.

Support

FAQs

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

Give us feedback!