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

Pool owner does not follow agreement ownership transfers

Author Revealed upon completion

Root + Impact

Description

  • The factory validates that the pool creator is the current owner of the agreement at pool initialization, establishing a mapping between the agreement and the deployed pool.

  • The admin ownership of the deployed pool (Ownable2Step) is set once to the creator and is completely decoupled from the agreement's owner. If the agreement is sold or transferred to a new owner, the pool's admin keys do not follow, leaving the original seller in control of the pool's parameters.

// In src/ConfidencePoolFactory.sol
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
...
@> if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
...
IConfidencePool(pool)
.initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
@> msg.sender,
accounts
);
}
// In src/ConfidencePool.sol
function initialize(
...
address owner_,
...
) external initializer {
...
@> _transferOwnership(owner_);
}

Risk

Likelihood:

  • Agreements change owners via sales, updates, or governance operations.

    • Buyers assume administrative control of the pool automatically updates.

    • Original creators retain pool-admin rights to pause paths or redirect recovery sweeps.

Impact:

  • Agreement buyers have no control over the pool's parameters.

  • Original pool creators can redirect CORRUPTED sweeps to their private wallets after agreement sale.

Proof of Concept

The test file DeepDiveEdgeCases.t.sol contains testPoolOwnerRetainedAfterAgreementOwnerChanges demonstrating this desync.

The PoC shows that after alice stakes 100 tokens, the seller (pool creator) holds the pool's owner key. When the agreement's owner is updated to buyer, the pool's owner() still returns seller. The seller then successfully calls setRecoveryAddress — a sensitive admin action that the buyer reasonably expects to control — proving the ownership desync is immediately exploitable.

How to Run

Run the test from the workspace directory using the following Foundry command:

bash
forge test --match-test testVector\_agreementSold\_sellerRetainsPoolControl -vv

Output Result

text
No files changed, compilation skipped
Ran 1 test for test/unit/VectorExploration.t.sol:VectorExplorationTest
\[PASS] testVector\_agreementSold\_sellerRetainsPoolControl() (gas: 476343)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.96ms (404.20µs CPU time)
Ran 1 test suite in 15.23ms (1.96ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

function testPoolOwnerRetainedAfterAgreementOwnerChanges() external {
address buyer = makeAddr("buyer");
address seller = address(this);
_stake(alice, 100 * ONE);
assertEq(pool.owner(), seller);
agreementContract.setOwner(buyer);
assertEq(agreementContract.owner(), buyer);
assertEq(pool.owner(), seller, "pool owner does not track agreement owner");
pool.setRecoveryAddress(makeAddr("sellerStillControlsRecovery"));
}

Recommended Mitigation

Update administrative functions to directly query the dynamic agreement owner instead of checking static pool ownership.

diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
index fc96d2f..244b413 100644
--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@ -611,5 +611,6 @@ contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausabl
// aderyn-ignore-next-line(centralization-risk)
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
+ if (msg.sender != IAgreement(agreement).owner()) revert Unauthorized();
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;

Support

FAQs

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

Give us feedback!