ConfidencePool.initialize is external initializer but performs no caller authentication. The initializer modifier only blocks re-initialization of an already-initialized instance — it does not restrict who may perform the first initialization. Because ConfidencePoolFactory.poolImplementation is a public state variable, anyone can Clones.clone the implementation directly and call initialize, bypassing every guard that ConfidencePoolFactory.createPool enforces (stake-token allowlist, sponsor authorization, factory pause, and the hardcoded defaultOutcomeModerator). The rogue deployer appoints themselves as both pool owner and outcome moderator, then self-deals the entire pool via the good-faith CORRUPTED bounty path.
ConfidencePool.initialize (src/ConfidencePool.sol:179-219) only validates its arguments (zero-address, expiry lead, min stake, agreement validity) — it never checks who is calling it:
The initializer modifier (from OpenZeppelin's Initializable) prevents re-initialization of an already-initialized clone, but it does not restrict who performs the first initialization. Any caller can initialize a fresh clone.
ConfidencePoolFactory.poolImplementation is declared address public (src/ConfidencePoolFactory.sol:24), so anyone can read the implementation address on-chain and clone it directly with OpenZeppelin's public Clones.clone:
createPool, not in initializeConfidencePoolFactory.createPool (src/ConfidencePoolFactory.sol:67-114) enforces four guards that initialize does not replicate:
| Guard | createPool location |
Present in initialize? |
|---|---|---|
allowedStakeToken[stakeToken] allowlist |
line 77 | No |
IAgreement(agreement).owner() == msg.sender (sponsor auth) |
line 82 | No |
whenNotPaused (factory pause) |
line 74 | No |
Hardcoded defaultOutcomeModerator as the moderator |
line 95 | No — caller supplies outcomeModerator_ |
By cloning the implementation directly and calling initialize, an attacker bypasses all four. The attacker also controls owner_, collapsing the sponsor/moderator trust separation that the factory enforces by passing msg.sender as owner and the DAO-controlled defaultOutcomeModerator as moderator.
Once the attacker owns a rogue clone and is its moderator, the good-faith CORRUPTED bounty path lets them drain the entire pool:
The attacker stages CORRUPTED on their own agreement (agreement creation is permissionless on BattleChain — isAgreementValid only checks the agreement was factory-deployed, not who owns it).
As the self-appointed moderator, the attacker calls flagOutcome(CORRUPTED, goodFaith=true, attacker=attackerSelf). This is accepted whenever the registry reads CORRUPTED (src/ConfidencePool.sol:347), and sets bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus — the entire pool.
The attacker calls claimAttackerBounty, which pays min(remaining, freeBalance) to attacker (src/ConfidencePool.sol:432-453) — sweeping the full pool balance to themselves within the 180-day claim window.
A rogue clone is indistinguishable from a factory-created pool at the bytecode level — both are EIP-1167 minimal proxies pointing at the same implementation. Any off-chain integration that constructs pools from ad-hoc addresses (rather than strictly enumerating ConfidencePoolFactory.getPoolsByAgreement) will treat the rogue clone as legitimate.
For any staker who deposits into a rogue clone:
Principal loss: the attacker, acting as moderator, self-deals the entire pool via the good-faith CORRUPTED bounty path. The staker's claimSurvived/claimExpired paths are foreclosed once outcome == CORRUPTED.
Bonus loss: the bonus pool is swept alongside the principal in the same claimAttackerBounty call.
Trust-separation break: the protocol's design assumes the pool owner (sponsor) and outcome moderator are different addresses (the factory hardcodes defaultOutcomeModerator precisely to enforce this). The rogue clone collapses them into one attacker-controlled address.
The attacker also bypasses the stake-token allowlist, so a rogue clone can accept fee-on-transfer or rebasing tokens that the factory natspec (src/ConfidencePoolFactory.sol:26-30) explicitly excludes because they "erode the pool balance below tracked liabilities and permanently lock later claims."
The attacker sweeps the entire 700-token pool (500 principal + 200 bonus) with no factory involvement and no privileged role beyond what they granted themselves via the unauthenticated initialize call.
Add caller authentication to ConfidencePool.initialize so that only the designated factory can initialize a clone. The cleanest approach is to store the factory address in the implementation and require msg.sender == factory:
Since ConfidencePool is deployed once by the factory (or by the factory owner) and then cloned, factory is baked into the implementation bytecode at deployment time. Clones inherit the same factory value, so only the legitimate factory's createPool can initialize them. A direct Clones.clone + initialize call from any other address reverts.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.