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

Factory accepts EOA pool implementation and silently creates unusable pool clones

Author Revealed upon completion

Root + Impact

Description

ConfidencePoolFactory lets the factory owner replace the pool implementation used by future clones. The setter rejects only the zero address, then createPool() clones the configured address and calls initialize() on the clone before recording and emitting the pool deployment.

The setter does not verify that the new implementation address contains contract code. When the implementation is accidentally set to an EOA, createPool() still succeeds: the clone exists, the high-level call targets the clone, and the clone's delegatecall into the EOA returns successfully with empty data. As a result, the official factory records and announces a pool that was never initialized and cannot operate as a ConfidencePool.

function setPoolImplementation(address newPoolImplementation) external onlyOwner {
if (newPoolImplementation == address(0)) revert ZeroAddress();
address old = poolImplementation;
// @> Missing check: newPoolImplementation.code.length can be zero.
poolImplementation = newPoolImplementation;
emit PoolImplementationUpdated(old, newPoolImplementation);
}
pool = Clones.cloneDeterministic(poolImplementation, salt);
// @> This call succeeds at the clone layer even when the clone delegates to an EOA.
IConfidencePool(pool)
.initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
);

Risk

Likelihood:

  • The condition occurs when the factory owner misconfigures poolImplementation to an EOA or another address with no code.

  • Every valid later createPool() call during that configuration creates and indexes a broken clone instead of reverting.

Impact:

  • The factory emits PoolCreated and getPoolsByAgreement() returns an address that is not an initialized pool.

  • Agreement owners and off-chain consumers can treat the official factory event and registry entry as a valid launch, causing false pool announcements, wasted gas, and loss of availability until the implementation pointer is corrected and affected pools are recreated.

Proof of Concept

Clone and pin the audited repository:

git clone https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools.git
cd 2026-07-bc-confidence-pools
git checkout 2201c0e6336f780d848ab3f83fe080b24a204a90
git submodule update --init --recursive

Save the following file as test/poc/EoaImplementationClonePoC.t.sol:

// 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 {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract EoaImplementationClonePoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockSafeHarborRegistry internal registry;
ConfidencePoolFactory internal factory;
MockAgreement internal agreement;
address internal agreementOwner = makeAddr("agreementOwner");
address internal defaultModerator = makeAddr("defaultModerator");
address internal recovery = makeAddr("recovery");
address internal eoaImplementation = makeAddr("eoaImplementation");
address internal staker = makeAddr("staker");
function setUp() public {
token = new MockERC20();
registry = new MockSafeHarborRegistry();
agreement = new MockAgreement(agreementOwner);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
registry.setAgreementValid(address(agreement), true);
ConfidencePoolFactory impl = new ConfidencePoolFactory();
ConfidencePool poolImplementation = new ConfidencePool();
ERC1967Proxy proxy = new ERC1967Proxy(
address(impl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(registry), address(poolImplementation), defaultModerator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function testEoaImplementationCreatesBrokenCloneButStakeDoesNotTransferTokens() external {
assertEq(eoaImplementation.code.length, 0, "test precondition: implementation is an EOA");
factory.setPoolImplementation(eoaImplementation);
vm.prank(agreementOwner);
address created =
factory.createPool(address(agreement), address(token), block.timestamp + 31 days, ONE, recovery, _scope());
assertGt(created.code.length, 0, "factory deployed an EIP-1167 clone");
assertEq(factory.poolCountByAgreement(address(agreement)), 1, "factory recorded the clone");
assertEq(factory.getPoolsByAgreement(address(agreement))[0], created, "factory indexed the clone");
(bool ok, bytes memory data) = created.staticcall(abi.encodeWithSelector(bytes4(keccak256("stakeToken()"))));
assertTrue(ok, "view call to broken clone succeeds at proxy layer");
assertEq(data.length, 0, "no implementation code returned initialized pool state");
token.mint(staker, 10 * ONE);
vm.prank(staker);
token.approve(created, 10 * ONE);
vm.prank(staker);
IConfidencePool(created).stake(10 * ONE);
assertEq(token.balanceOf(staker), 10 * ONE, "stake() logic never ran, so staker kept tokens");
assertEq(token.balanceOf(created), 0, "broken clone did not receive stake tokens");
assertEq(token.allowance(staker, created), 10 * ONE, "allowance was not consumed");
}
function _scope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
}
}

Run the PoC with Foundry:

forge test --match-path test/poc/EoaImplementationClonePoC.t.sol -vvv

Observed output summary with forge Version: 1.7.2-nightly, Solidity 0.8.26:

Ran 1 test for test/poc/EoaImplementationClonePoC.t.sol:EoaImplementationClonePoC
[PASS] testEoaImplementationCreatesBrokenCloneButStakeDoesNotTransferTokens() (gas: 238140)
Suite result: ok. 1 passed; 0 failed; 0 skipped
Ran 1 test suite: 1 tests passed, 0 failed, 0 skipped (1 total tests)

The passing assertions show that the factory deployed and indexed the clone, a pool view call returned no initialized state, and a later stake() call did not execute the pool staking logic.

Recommended Mitigation

Validate that the configured implementation contains code before storing it. One approach is to add a dedicated error and reject EOAs in setPoolImplementation():

+error NotAContract();
+
function setPoolImplementation(address newPoolImplementation) external onlyOwner {
if (newPoolImplementation == address(0)) revert ZeroAddress();
+ if (newPoolImplementation.code.length == 0) revert NotAContract();
address old = poolImplementation;
poolImplementation = newPoolImplementation;
emit PoolImplementationUpdated(old, newPoolImplementation);
}

For stronger defense, the setter could also perform an interface or initialization compatibility check on a temporary clone before accepting the implementation.

Support

FAQs

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

Give us feedback!