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

Critical factory config setters (setSafeHarborRegistry, setPoolImplementation, setDefaultOutcomeModerator) take effect in one transaction with no timelock, so a compromised owner re-roots the trust anchor for all future pools instantly

Author Revealed upon completion

Root + Impact

Description

The factory owner configures the shared trust anchors that every future pool inherits at creation: the SafeHarbor registry (the source of truth for whether an agreement is valid and what on-chain state it is in), the pool implementation that is cloned into each new pool, and the default outcome moderator baked into each pool. Stakers rely on these being honest when they deposit into a pool created afterward.

Each of setSafeHarborRegistry (ConfidencePoolFactory.sol:134), setPoolImplementation (:145) and setDefaultOutcomeModerator (:154) is a single onlyOwner SSTORE guarded by a zero-address check only. There is no timelock, no two-step propose/accept, and no delay, so a compromised or coerced owner key repoints the registry to an attacker contract, swaps the pool implementation, or installs a malicious default moderator in one transaction, and every pool created after that block silently inherits the malicious trust anchor with no on-chain window in which stakers could observe the change and stay away.

function setSafeHarborRegistry(address newSafeHarborRegistry) external onlyOwner { // :134
if (newSafeHarborRegistry == address(0)) revert ZeroAddress();
@> safeHarborRegistry = newSafeHarborRegistry; // :137 takes effect this block, no delay / no two-step
emit SafeHarborRegistryUpdated(...);
}
// same shape, same missing-timelock:
@> function setPoolImplementation(address newPoolImplementation) external onlyOwner { ... } // :145
@> function setDefaultOutcomeModerator(address newDefaultOutcomeModerator) external onlyOwner { ... } // :154
// consumed here on every new pool:
// createPool -> IConfidencePool(clone).initialize(..., safeHarborRegistry, defaultOutcomeModerator, ...)

Risk

Likelihood:

  • The change lands the instant the owner (or a compromised owner key) sends the transaction. There is no queue, delay, or two-step in which stakers could observe the pending change and avoid pools created under the new trust anchor.

  • Every pool created after the change reads the new registry / implementation / moderator at createPool with no per-pool opt-in, so the exposure grows with each subsequent pool.

Impact:

  • A malicious registry can validate a bogus agreement or misreport an agreement's on-chain state, corrupting resolution for every future pool; a malicious default moderator controls those pools' outcome flags; a swapped implementation changes the code that future stakers' funds sit in.

  • Existing pools are unaffected because they snapshot their trust anchors at creation, so the blast radius is future pools only. That bound is what keeps this at Low rather than higher.

Proof of Concept

The test deploys the real factory, swaps the registry with setSafeHarborRegistry in a single call (no elapsed time), and shows a pool created afterward inherits the attacker registry while an already-deployed pool keeps the honest one.

// test/unit/TimelockRegistrySwap.t.sol
function test_exploit_registrySwapRepointsFuturePoolTrustAnchorWithNoDelay() public {
// honest pool created under the honest registry
ConfidencePool poolBefore = _createPool();
vm.prank(owner);
factory.setSafeHarborRegistry(address(evilRegistry)); // one tx, no delay
ConfidencePool poolAfter = _createPool();
assertEq(address(poolBefore.safeHarborRegistry()), address(honestRegistry));
assertEq(address(poolAfter.safeHarborRegistry()), address(evilRegistry)); // future pool re-rooted
}

Output (forge test --match-contract TimelockRegistrySwap -vv, forge 1.7.1):

[PASS] test_exploit_registrySwapRepointsFuturePoolTrustAnchorWithNoDelay() (gas: 1169396)
honest pool riskWindowStart: 0
future pool riskWindowStart: 1750000000
future pool trust anchor is evilRegistry: true
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Put the three trust-anchor setters behind a timelock (queue with a delay, then execute) or a two-step propose/accept, so any change to a registry / implementation / default moderator is observable on-chain before it takes effect and stakers can decline pools created under a pending change.

- function setSafeHarborRegistry(address newSafeHarborRegistry) external onlyOwner {
- if (newSafeHarborRegistry == address(0)) revert ZeroAddress();
- safeHarborRegistry = newSafeHarborRegistry;
- emit SafeHarborRegistryUpdated(old, newSafeHarborRegistry);
- }
+ function queueSafeHarborRegistry(address newRegistry) external onlyOwner {
+ if (newRegistry == address(0)) revert ZeroAddress();
+ pendingRegistry = newRegistry;
+ pendingRegistryEta = block.timestamp + TIMELOCK_DELAY;
+ emit SafeHarborRegistryQueued(newRegistry, pendingRegistryEta);
+ }
+ function executeSafeHarborRegistry() external onlyOwner {
+ if (pendingRegistry == address(0) || block.timestamp < pendingRegistryEta) revert TimelockNotReady();
+ safeHarborRegistry = pendingRegistry;
+ emit SafeHarborRegistryUpdated(old, pendingRegistry);
+ delete pendingRegistry;
+ }

Support

FAQs

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

Give us feedback!