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

Premature Finality Lock via 1-Wei Donation in `claimCorrupted` Prevents Moderator Re-flagging

Author Revealed upon completion

Root + Impact

Description

The protocol gives the Moderator a "re-flag" window to correct outcome typos before the first legitimate claim sets claimsStarted = true (docs/DESIGN.md §4). While sweepUnclaimedBonus() explicitly avoids setting this latch to prevent griefing via dust donations, claimCorrupted() lacks this protection.

An attacker can bypass the toSweep == 0 check by transferring 1 wei directly to the pool. Calling the permissionless claimCorrupted() sweeps the dust and unconditionally flips claimsStarted to true, prematurely and maliciously closing the Moderator's correction window.

function claimCorrupted() external nonReentrant {
// ...
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep(); // @> Bypassed by 1-wei direct transfer
// ...
if (!claimsStarted) claimsStarted = true; // @> Unconditionally flips the finality latch
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

Risk

Likelihood:

  • Any actor can trigger this permissionlessly at virtually zero cost (1 wei) by monitoring for flagOutcome events.

Impact:

  • The Moderator's administrative re-flag window is permanently bricked.

  • Typo'd outcomes (e.g., wrong goodFaith status or attacker address) become irreversibly locked, preventing whitehats from receiving legitimate bounties.

Proof of Concept

Explanation:

  1. The pool is in a CORRUPTED state but currently has no claimable balance. The Moderator's re-flag window is open.

  2. The attacker donates 1 wei of stakeToken directly to the pool contract.

  3. The attacker calls claimCorrupted(), which succeeds because toSweep > 0. This unconditionally sets claimsStarted = true.

  4. The Moderator attempts to call flagOutcome to correct a previous typo, but it reverts with OutcomeAlreadySet.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract PoC is BaseConfidencePoolTest {
function testExploit_donationThenClaimCorruptedLocksModeratorReflag() external {
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// 1. Moderator flags outcome; re-flag window is open
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertFalse(pool.claimsStarted());
// 2. Attacker donates 1 wei to bypass NothingToSweep
token.mint(address(pool), 1);
// 3. Attacker calls claimCorrupted, flipping claimsStarted to true
vm.prank(bob);
pool.claimCorrupted();
assertTrue(pool.claimsStarted());
// 4. Moderator is permanently locked out of correcting the outcome
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
}
}

Recommended Mitigation

Only set the claimsStarted finality latch if actual legitimate protocol funds (corruptedReserve) are being swept, ignoring dust donations.

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();
+ bool sweepingRealFunds = corruptedReserve > 0;
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
- if (!claimsStarted) claimsStarted = true;
+ // Prevent 1 wei donations from locking the re-flag window
+ if (!claimsStarted && sweepingRealFunds) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}

Support

FAQs

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

Give us feedback!