Description
Normal behavior:
A Pool Sponsor creates a
ConfidencePoolfor a BattleChain Safe Harbor agreement they own. Stakers deposit capital trusting that the pool only resolvesCORRUPTED- sweeping their principal and the bonus pool torecoveryAddress- when the underlying agreement suffers a real, on-chain-attested breach, with the pool'soutcomeModeratoracting as the trusted arbiter of that judgement.
The issue:
ConfidencePoolFactory.createPoolrequires the pool creator to beagreement.owner(), and that same address becomes the pool's owner (the Sponsor). In the out-of-scope but load-bearingAttackRegistrydependency,_registerAgreementunconditionally setsattackModerator = agreementOwnerthe moment the owner requests attack mode for their own agreement - a completely normal, non-adversarial step every legitimate pool goes through.AttackRegistry.markCorrupted()is gated only byonlyAttackModerator, with no DAO signature, no bond requirement, and no evidence of an actual attack. So the Sponsor - the same address who setrecoveryAddressto their own wallet - can callmarkCorrupted()directly and fabricate a terminalCORRUPTEDstate with zero real breach. Afterexpiry + MODERATOR_CORRUPTED_GRACE(180 days) with no correction,ConfidencePool.claimExpired()'s permissionless, mechanical bad-faith auto-CORRUPTED backstop finalizes it, permanently locking out the honestoutcomeModerator(flagOutcomerevertsOutcomeAlreadySet), andclaimCorrupted()sweeps 100% of the pool to the Sponsor.
Root cause:
Risk
Likelihood:
Every pool sponsor is, by construction of ConfidencePoolFactory.createPool's ownership check, the agreement owner - and becomes the registry's attackModerator the moment they take the normal, expected step of requesting attack mode for their own agreement, before any staker ever deposits.
The pool's outcomeModerator is a single DAO-controlled default address responsible for watching every pool across every sponsor; a sponsor timing markCorrupted() right after stakers fund the pool, then waiting out the 180-day MODERATOR_CORRUPTED_GRACE, only needs that one specific pool to go unwatched for the window - a realistic condition at scale, and nothing on-chain distinguishes a fabricated CORRUPTED from a genuine one.
Impact:
100% of staked principal and all contributed bonus is swept in a single claimCorrupted() call to a recoveryAddress the Sponsor freely chose at pool creation - complete, deterministic fund loss for every honest staker and bonus contributor.
The mechanical claimExpired auto-resolution permanently sets claimsStarted = true, locking the pool's own honest outcomeModerator out of correcting the outcome (flagOutcome reverts OutcomeAlreadySet) - there is no in-protocol remedy once the grace window elapses.
Proof of Concept
Verified against the real, unmodified BattleChain AttackRegistry/Agreement/AgreementFactory/BattleChainSafeHarborRegistry stack (not test mocks) and the real, unmodified in-scope ConfidencePool/ConfidencePoolFactory.
Exact steps to reproduce (real registry stack, not mocks - isolated in a standalone Foundry project because the real AttackRegistry/Agreement pin exact pragma solidity 0.8.34 while in-scope ConfidencePool/ConfidencePoolFactory pin exact pragma solidity 0.8.26, mutually exclusive in one solc run; test/Harness.sol deploys the real stack via vm.deployCode, then the 0.8.26 test drives the real in-scope contracts against it):
forge installed.
cd into the PoC project (contains test/Harness.sol + test/SponsorAttackModeratorCorruption.t.sol.
Run:
Output: [PASS] testSponsorSelfInflictedCorruptionDrainsEntirePool() (gas: 659830). Trace shows, in order:
Recommended Mitigation
Impact- High Every token in the pool leaves: all staker principal plus the entire bonus, sent to an address the sponsor picked and can still change after people have deposited. Stakers have no defense once it starts, because withdraw is dead the moment riskWindowStart seals, and the mechanical resolution sets claimsStarted, which locks the moderator out of any later correction. Likelihood - Very Low The sponsor holds the role by default on every pool, and markCorrupted carries no penalty since it leaves the bond claimable, so arming this is free and unilateral once the DAO has approved the agreement into UNDER_ATTACK. What it still needs is the pool's outcome moderator staying silent for the whole term plus the 180-day grace, and one flagOutcome(SURVIVED) call anywhere in that stretch defeats it outright.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.