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

Factory pause gates creation but not reconfiguration or upgrades

Author Revealed upon completion

Root + Impact

Description

ConfidencePoolFactory.pause() blocks only createPool(). The factory owner can still replace the Safe Harbor registry, pool implementation, default outcome moderator, and token allowlist, and can execute a UUPS upgrade while the factory reports paused() == true.

Allowing upgrades during a pause can be useful for remediation, and all affected calls require the trusted owner. The gap is that unpause() performs no review or binding to the configuration that was paused. Creation can therefore resume against materially different dependencies without an on-chain acknowledgement of those changes. If the owner key is compromised during an incident, the pause does not contain its configuration or upgrade authority.

Only createPool() carries OpenZeppelin's whenNotPaused modifier:

src/ConfidencePoolFactory.sol
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) { // @> The pause bit is checked only here.
// ...
}
function setSafeHarborRegistry(address newSafeHarborRegistry) external onlyOwner { // @> Callable while paused.
// ...
}
function setPoolImplementation(address newPoolImplementation) external onlyOwner { // @> Callable while paused.
// ...
}
function setDefaultOutcomeModerator(address newDefaultOutcomeModerator) external onlyOwner { // @> Callable while paused.
// ...
}
function setStakeTokenAllowed(address token, bool allowed) external onlyOwner { // @> Callable while paused.
// ...
}
function _authorizeUpgrade(address) internal override onlyOwner {} // @> UUPS upgrades ignore pause state.

unpause() simply clears the pause bit. It does not verify that factory bytecode or creation dependencies match a reviewed configuration:

function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause(); // @> Creation resumes against whatever configuration is live at this moment.
}

A compromised or mistaken owner can thus pause the factory, replace poolImplementation or safeHarborRegistry, upgrade the factory proxy, and later resume creation. Subsequent clones use the changed implementation and permanently store the changed registry and moderator. A malicious dependency can render new pools unusable or control their lifecycle and payouts.

The blast radius is bounded. Factory pause never freezes already-deployed clones, and changing factory configuration does not rewrite storage in those pools. The risk applies to the factory itself and pools created after resumption.

Risk

Likelihood:

  • Every configuration setter and UUPS upgrade is deterministically callable during pause; no unusual state is required.

  • Exploit impact requires factory-owner compromise or an authorized operator making or approving an unsafe maintenance change.

  • Maintenance commonly occurs while creation is paused, making configuration drift during the paused interval operationally plausible even when benign.

Impact:

  • New pools after unpause can be cloned from a hostile or incompatible pool implementation and can inherit a malicious registry or moderator.

  • A factory upgrade can change arbitrary factory behavior while monitoring still reports the system as paused.

  • Existing pools are separate clones and are not modified by this sequence, limiting the impact to future creation and factory state.

Proof of Concept

Create test/audit/CP050FactoryPauseConfiguration.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockConfidencePoolFactoryV2} from "test/mocks/MockConfidencePoolFactoryV2.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract CP050FactoryPauseConfigurationTest is Test {
bytes4 internal constant ENFORCED_PAUSE_SELECTOR = bytes4(keccak256("EnforcedPause()"));
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
ConfidencePoolFactory internal factory;
MockSafeHarborRegistry internal originalRegistry;
MockAgreement internal agreement;
MockERC20 internal originalToken;
address internal agreementOwner = makeAddr("agreementOwner");
address internal originalModerator = makeAddr("originalModerator");
address internal recovery = makeAddr("recovery");
function setUp() external {
originalRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(agreementOwner);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
originalRegistry.setAgreementValid(address(agreement), true);
originalToken = new MockERC20();
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(originalRegistry), address(poolImplementation), originalModerator)
)
)
)
);
factory.setStakeTokenAllowed(address(originalToken), true);
}
function test_PausedFactoryCanBeReconfiguredAndUpgradedBeforeCreationResumes() external {
factory.pause();
vm.prank(agreementOwner);
vm.expectRevert(ENFORCED_PAUSE_SELECTOR);
factory.createPool(
address(agreement), address(originalToken), block.timestamp + 31 days, ONE, recovery, _scope()
);
MockSafeHarborRegistry replacementRegistry = new MockSafeHarborRegistry();
replacementRegistry.setAgreementValid(address(agreement), true);
ConfidencePool replacementPoolImplementation = new ConfidencePool();
MockERC20 replacementToken = new MockERC20();
address replacementModerator = makeAddr("replacementModerator");
factory.setSafeHarborRegistry(address(replacementRegistry));
factory.setPoolImplementation(address(replacementPoolImplementation));
factory.setDefaultOutcomeModerator(replacementModerator);
factory.setStakeTokenAllowed(address(replacementToken), true);
MockConfidencePoolFactoryV2 factoryV2 = new MockConfidencePoolFactoryV2();
factory.upgradeToAndCall(address(factoryV2), "");
assertTrue(factory.paused(), "reconfiguration and upgrade leave the pause bit set");
assertEq(MockConfidencePoolFactoryV2(address(factory)).version(), "v2");
assertEq(address(factory.safeHarborRegistry()), address(replacementRegistry));
assertEq(factory.poolImplementation(), address(replacementPoolImplementation));
assertEq(factory.defaultOutcomeModerator(), replacementModerator);
factory.unpause();
vm.prank(agreementOwner);
ConfidencePool created = ConfidencePool(
factory.createPool(
address(agreement), address(replacementToken), block.timestamp + 31 days, ONE, recovery, _scope()
)
);
assertEq(address(created.safeHarborRegistry()), address(replacementRegistry));
assertEq(created.outcomeModerator(), replacementModerator);
assertEq(address(created.stakeToken()), address(replacementToken));
}
function _scope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP050FactoryPauseConfiguration.t.sol -vv.

  2. Confirm that the test passes. Pool creation reverts while paused, but all dependency changes and the V2 UUPS upgrade succeed. After unpause, creation resumes and the resulting pool stores the replacement registry, moderator, and token selected during the paused interval.

Recommended Mitigation

Treat pause as a maintenance state with an explicit configuration-resume handshake. Record a digest of creation-critical configuration, increment a maintenance nonce on every paused-period mutation or upgrade, and require a separate guardian or governance executor to approve the exact digest before creation resumes. This retains the ability to deploy a fix while paused without letting unpause() silently accept arbitrary drift.

src/ConfidencePoolFactory.sol
+address public pauseGuardian;
+uint256 public maintenanceNonce;
+bytes32 public approvedResumeConfiguration;
+
+function creationConfigurationHash() public view returns (bytes32) {
+ return keccak256(
+ abi.encode(
+ address(safeHarborRegistry),
+ poolImplementation,
+ defaultOutcomeModerator,
+ maintenanceNonce
+ )
+ );
+}
+
+function approveResume(bytes32 expectedConfiguration) external onlyPauseGuardian {
+ if (!paused()) revert ExpectedPause();
+ if (expectedConfiguration != creationConfigurationHash()) revert ConfigurationChanged();
+ approvedResumeConfiguration = expectedConfiguration;
+}
function setPoolImplementation(address newPoolImplementation) external onlyOwner {
if (newPoolImplementation == address(0)) revert ZeroAddress();
address old = poolImplementation;
poolImplementation = newPoolImplementation;
+ if (paused()) {
+ ++maintenanceNonce;
+ delete approvedResumeConfiguration;
+ }
emit PoolImplementationUpdated(old, newPoolImplementation);
}
-function unpause() external onlyOwner {
+function unpause() external onlyOwner {
+ if (approvedResumeConfiguration != creationConfigurationHash()) {
+ revert ConfigurationNotApproved();
+ }
+ delete approvedResumeConfiguration;
_unpause();
}

Apply the nonce invalidation to all creation-critical setters and the UUPS authorization path. Use separate multisig or timelocked roles for owner/upgrade execution and resume approval. If the intended semantics are merely “pause new creation,” rename and document the control accordingly, and have monitoring compare the factory implementation slot plus dependency configuration before and after every paused interval.

Support

FAQs

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

Give us feedback!