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

ConfidencePool.initialize enforces neither the factory stake-token allowlist nor the agreement-owner check, letting anyone deploy factory-unregistered impersonation pools over valid agreements with arbitrary tokens

Author Revealed upon completion

Root + Impact

The pool-level initialize omits the two curation gates that live only in Factory.createPool, enabling rogue clones over any valid agreement with any token — a phishing/impersonation surface.

Description

  • Pools are meant to be created through Factory.createPool, which checks the stake token is allowlisted and that msg.sender owns the agreement.

  • The implementation is a public minimal-proxy target, and its initialize validates only zero-addresses / expiry / minStake / isAgreementValid — not the token allowlist and not agreement ownership. Anyone can Clones.clone(impl) and initialize over a real valid agreement they do not own, with a non-allowlisted or malicious token, naming themselves owner / moderator / recovery.

// src/ConfidencePoolFactory.sol — the only place the gates exist
@> if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed(); // L77
@> if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator(); // L82
// src/ConfidencePool.sol — initialize has NEITHER check
function initialize(...) external initializer {
...
if (!IBattleChainSafeHarborRegistry(safeHarborRegistry_).isAgreementValid(agreement_)) {
revert InvalidAgreement();
}
@> // no allowlist check, no agreement-owner check
...
}

Risk

Likelihood:

  • Occurs whenever an attacker clones the public implementation directly and initializes it over a valid registered agreement.

  • Realized only when a victim is socially engineered into depositing into a pool that is absent from factory.getPoolsByAgreement.

Impact:

  • Rogue impersonation pool can route a victim's deposits to the attacker's recoveryAddress via a self-flagged CORRUPTED.

  • Bounded: rogue clones hold only voluntarily-deposited funds; they cannot collide with or touch factory-registered pools (different CREATE2 deployer) or protocol funds.

Proof of Concept

This shows an attacker cloning the public implementation and initializing a pool over a valid agreement they do not own, using a non-allowlisted token and naming themselves owner/moderator/recovery — none of which initialize rejects.

// test/poc/RogueCloneInitialize.t.sol
function testAnyoneCanInitializeRogueCloneOverUnownedAgreementWithArbitraryToken() external {
address mallory = makeAddr("mallory");
ConfidencePool rogue = ConfidencePool(Clones.clone(address(new ConfidencePool())));
vm.prank(mallory);
rogue.initialize(
agreement, // agreement mallory does NOT own
address(token), // non-allowlisted token; pool never consults the allowlist
address(safeHarborRegistry),
mallory, block.timestamp + 31 days, ONE, mallory, mallory, _defaultScope()
);
assertEq(rogue.outcomeModerator(), mallory); // mallory controls every privileged role
assertEq(rogue.recoveryAddress(), mallory);
assertEq(rogue.owner(), mallory);
}

Recommended Mitigation

Restrict initialize to the factory, so the factory's allowlist + ownership gates cannot be bypassed:

+ address public factory;
+
function initialize(...) external initializer {
+ // clones are only legitimate when created (and gated) by the factory
+ factory = msg.sender;
...
}

(Alternative: re-check allowedStakeToken and IAgreement(agreement).owner() inside initialize itself.)

Support

FAQs

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

Give us feedback!