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
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.