ConfidencePool.initialize() has no check that the caller is the factory or the agreement owner. Any address can clone poolImplementation (a public variable) and call initialize() with themselves as outcomeModerator_ and recoveryAddress_. The resulting rogue pool is on-chain indistinguishable from a factory-deployed pool. When the real agreement goes CORRUPTED, the attacker calls flagOutcome(CORRUPTED, ...) and claimCorrupted() on their rogue pool, sweeping 100% of staker funds to themselves.
ConfidencePoolFactory.createPool() enforces agreement ownership before deploying a pool:
However, this check only executes when createPool() is called. The attacker never calls the factory. They read factory.poolImplementation (a public state variable), deploy a clone directly, and call initialize() on it. The factory's code path is never entered.
ConfidencePool.initialize() is external initializer with no caller authorization:
Phase 1 — Setup (before any hack)
Attacker reads factory.poolImplementation (public).
Attacker calls Clones.clone(poolImplementation) directly — no factory involved.
Attacker calls roguePool.initialize() with:
agreement_ = any real, valid agreement (passes isAgreementValid)
outcomeModerator_ = attacker address
recoveryAddress_ = attacker address
All other parameters identical to what a legitimate pool would use
initialize() succeeds. Rogue pool is live. It is NOT registered in factory._poolsByAgreement.
Phase 2 — Stakers deposit
Stakers encounter the rogue pool address (posted publicly, shared in community channels, or discovered via on-chain events). They inspect it on a block explorer and see:
| Field | Legitimate Pool | Rogue Pool |
|---|---|---|
| Bytecode | poolImplementation clone |
identical |
agreement |
real valid agreement | same |
stakeToken |
real token | same |
safeHarborRegistry |
real registry | same |
isAgreementValid() |
passes | passes |
outcomeModerator |
DAO address | attacker address |
recoveryAddress |
sponsor address | attacker address |
In factory.getPoolsByAgreement() |
YES | NO |
Stakers have no reason to check factory.getPoolsByAgreement() — they have no baseline to compare outcomeModerator or recoveryAddress against, and the pool passes every on-chain validity check they would reasonably perform. The DESIGN.md states "stakers should verify pool parameters before depositing," but provides no mechanism for stakers to know what the correct parameter values should be. This is a protocol-level failure, not a staker error.
Stakers deposit during NEW_DEPLOYMENT or UNDER_ATTACK state (staking is open in these states per _assertDepositsAllowed).
Phase 3 — Real protocol gets hacked
A whitehat exploits the real protocol. The BattleChain DAO verifies the report and calls markCorrupted(agreement) on the AttackRegistry. Registry state transitions to CORRUPTED. Staking is now closed on all pools.
Phase 4 — Attacker drains the rogue pool
Attacker calls roguePool.flagOutcome(CORRUPTED, false, address(0)):
onlyModerator: msg.sender == outcomeModerator → attacker == attacker ✓
Registry gate: state == IAttackRegistry.ContractState.CORRUPTED → true ✓
Sets outcome = CORRUPTED, corruptedReserve = snapshotTotalStaked + snapshotTotalBonus
Attacker calls roguePool.claimCorrupted():
Sweeps stakeToken.balanceOf(roguePool) → safeTransfer(recoveryAddress, toSweep) → safeTransfer(attacker, 100% of staker funds)
100% loss of all staker funds deposited into the rogue pool.
The attacker contributes zero funds and performs zero hacking. They are a parasite on the real hack event.
The legitimate pool's stakers are unaffected — the attacker has no access to the legitimate pool.
The attack is not a design choice: DESIGN.md documents every intentional behavior explicitly and contains no mention of unauthorized pool deployment as accepted.
The attacker cannot call flagOutcome(CORRUPTED, ...) before the real agreement goes CORRUPTED — the registry gate reverts:
The AttackRegistry is a DAO-controlled singleton the attacker does not control. This is the sole constraint that caps severity at Medium.
Medium — High impact (total loss of staker funds), but the CORRUPTED precondition is outside the attacker's control. The social engineering barrier is negligible: the attacker only needs to make the pool address discoverable; stakers cannot on-chain distinguish it from a legitimate pool without specialized knowledge of the factory registry.
Add an ownership check inside ConfidencePool.initialize() that validates outcomeModerator_ against the registry's canonical per-agreement moderator. This ensures the attacker cannot set themselves as moderator even if they bypass the factory:
getAttackModerator(address) is confirmed to exist on the deployed AttackRegistry (tested in test/fork/BattleChainInterfaceDrift.fork.t.sol:76-77 and implemented in MockAttackRegistry). This binds outcomeModerator to the registry's canonical value, making it impossible for an attacker to appoint themselves as moderator regardless of how the pool is deployed.
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.