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

Non-resettable `_firstGoodFaithCorruptedAt` can cause corrected whitehat attacker to lose entire bounty after 180-day reflag

Author Revealed upon completion

When a moderator flags a pool as good-faith CORRUPTED with the wrong attacker address and later tries to correct it via reflag after 180 days, the corrected attacker cannot claim their bounty because the claim deadline is permanently anchored to the first flag's timestamp. The entire pool is then swept to recoveryAddress by anyone, and the legitimate whitehat receives nothing. The protocol exposes a pre-claim correction mechanism, but after the first good-faith bounty window expires, correcting the attacker address still succeeds while producing an attacker who cannot claim.

Description

The ConfidencePool contract allows the moderator to reflag the outcome before any claims have been made. According to DESIGN.md Section 4, this is the documented correction mechanism, and it is gated by value movement (first claim), not by any timer:

DESIGN.md Section 4 — "The re-flag correction window closes on the first claim (by design)":

flagOutcome may be re-flagged pre-claim so the moderator can fix a typo'd outcome/attacker before any participant locks in the wrong distribution. The window closing on the first claim (not on a grace timer) is deliberate

Additionally, DESIGN.md Section 12 describes the bounty flow and states the pool is reserved for the named whitehat for 180 days:

DESIGN.md Section 12 — "CORRUPTED bounty mechanics":

the pool is reserved for the named whitehat for CORRUPTED_CLAIM_WINDOW (180 days), after which anyone sweeps the remainder to recoveryAddress.

Reading these two sections together, the expected behavior is: the moderator can correct a wrong attacker address at any time before the first claim, and the corrected whitehat should receive a usable claim path, or the correction should revert. Instead, the correction succeeds while leaving the corrected attacker unable to claim.

However, the code does not deliver this. The reflag guard in flagOutcome() correctly checks only claimsStarted:

// src/ConfidencePool.sol:322-327
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

So as long as no one has claimed, the moderator can reflag freely. When reflagging to good-faith CORRUPTED, the contract updates attacker and bountyEntitlement correctly:

// src/ConfidencePool.sol:354-362
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
// ...
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;

However, the claim deadline is computed from _firstGoodFaithCorruptedAt, which is only set once and never reset:

// src/ConfidencePool.sol:363-372
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);

The if (_firstGoodFaithCorruptedAt == 0) guard means this timestamp is permanently locked to whenever the first good-faith CORRUPTED flag was set. On any subsequent reflag, corruptedClaimDeadline is recomputed from the same original timestamp, so it always equals the original deadline regardless of when the correction happens.

When the corrected attacker tries to claim via claimAttackerBounty(), the deadline check blocks them:

// src/ConfidencePool.sol:432-437
function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();

Since block.timestamp is now past the original deadline, the call reverts with ClaimWindowExpired. At this point, sweepUnclaimedCorrupted() becomes callable by anyone because the deadline has passed:

// src/ConfidencePool.sol:456-468
function sweepUnclaimedCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (!goodFaith) revert NotGoodFaithCorrupted();
if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
uint256 amount = stakeToken.balanceOf(address(this));
if (amount == 0) revert NothingToSweep();
corruptedReserve = 0;
bountyClaimed = bountyEntitlement;
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, amount);

The entire pool balance is sent to recoveryAddress and claimsStarted is set to true, permanently locking out any further reflag attempts.

There is also no workaround available to the moderator. No sequence of reflagging (e.g., toggling through SURVIVED and back to good-faith CORRUPTED) can reset _firstGoodFaithCorruptedAt because it is a write-once variable with no reset path anywhere in the contract.

Likelihood and Impact Details

DESIGN.md Section 4 says the reflag correction window closes on the first claim, not on a timer. Section 12 says the named whitehat is reserved the pool for 180 days. Together, the expected behavior is: if no one has claimed, the moderator can correct the attacker address and the corrected whitehat gets a usable bounty window.

What actually happens is different. After 180 days from the original flag, the moderator's correction succeeds on-chain (no revert, attacker and bountyEntitlement update correctly), but the corrected attacker's claimAttackerBounty() call reverts with ClaimWindowExpired because the deadline is still anchored to the original flag's timestamp. The entire pool (snapshotTotalStaked + snapshotTotalBonus) is then swept to recoveryAddress by anyone via sweepUnclaimedCorrupted(), and the legitimate whitehat receives nothing.

This scenario is realistic in cases where the wrong attacker address was provided due to a typo or miscommunication, and legal or identity verification processes to confirm the correct whitehat take longer than 180 days. The wrong address holder never claims (they may not even know about it), so the pool sits idle with claimsStarted = false the entire time, giving no on-chain signal that anything is wrong until the correction attempt fails.

If the protocol allows correcting the named attacker before any claim, the corrected attacker should receive a usable and predictable claim window. Currently, the window is inherited from the wrong attacker, which can be shortened or already expired.

Recommended Mitigation Steps

Remove the if (_firstGoodFaithCorruptedAt == 0) guard so the timestamp resets on every good-faith CORRUPTED reflag:

if (willBeGoodFaithCorrupted) {
- if (_firstGoodFaithCorruptedAt == 0) {
- _firstGoodFaithCorruptedAt = uint32(block.timestamp);
- }
+ _firstGoodFaithCorruptedAt = uint32(block.timestamp);
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
}

The anti-extension concern (moderator gaming the deadline by toggling outcomes) is already fully addressed by claimsStarted. Once any participant claims, claimsStarted flips to true and flagOutcome() reverts with OutcomeAlreadySet. So the moderator can never extend the deadline past the point where value has moved. Resetting the timestamp on reflag simply ensures that a corrected attacker always gets a usable 180-day window, which is what DESIGN.md Section 4 promises.

PoC

The PoC walks through the exact scenario: moderator flags with the wrong attacker, 181 days pass without any claims, moderator corrects to the right attacker, and the corrected attacker's claim reverts while anyone can sweep the pool to recoveryAddress.

Create a file at test/audit/PoC_ReflagDeadline.t.sol and paste the code below, then run with:

forge test --match-test test_correctedAttackerLosesBountyAfterExpiredDeadline -vvv
// 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";
/// @title PoC: Corrected attacker loses bounty after 180-day reflag
/// @notice DESIGN.md S4 says the reflag correction window closes on the first claim,
/// not on a timer. But _firstGoodFaithCorruptedAt is set once and never reset,
/// so a reflag after 180 days gives the corrected attacker an expired deadline.
/// The entire pool is then sweepable to recoveryAddress by anyone.
contract PoC_ReflagDeadline is BaseConfidencePoolTest {
address internal wrongAttacker = makeAddr("wrongAttacker");
address internal correctAttacker = makeAddr("correctAttacker");
function test_correctedAttackerLosesBountyAfterExpiredDeadline() external {
// --- Setup: fund the pool ---
_stake(alice, 100 ether);
_stake(bob, 50 ether);
_contributeBonus(carol, 50 ether);
// Transition registry through UNDER_ATTACK to seal riskWindowStart,
// then to terminal CORRUPTED.
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
uint256 totalPool = pool.totalEligibleStake() + pool.totalBonus();
// =====================================================================
// Step 1: Moderator flags good-faith CORRUPTED with the WRONG attacker
// =====================================================================
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, wrongAttacker);
uint256 originalDeadline = pool.corruptedClaimDeadline();
assertEq(pool.attacker(), wrongAttacker);
assertEq(pool.bountyEntitlement(), totalPool);
assertGt(originalDeadline, block.timestamp);
assertFalse(pool.claimsStarted());
// =====================================================================
// Step 2: 181 days pass — wrong attacker never claims (wrong address)
// claimsStarted remains false the entire time.
// =====================================================================
vm.warp(block.timestamp + 181 days);
assertFalse(pool.claimsStarted());
assertGt(block.timestamp, originalDeadline);
// =====================================================================
// Step 3: Moderator corrects the attacker — reflag succeeds because
// claimsStarted is still false (DESIGN.md S4 correction window).
// =====================================================================
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, correctAttacker);
// The reflag updated the attacker and bounty entitlement...
assertEq(pool.attacker(), correctAttacker);
assertEq(pool.bountyEntitlement(), totalPool);
assertFalse(pool.claimsStarted());
// ...but the deadline is STILL the original expired one.
assertEq(pool.corruptedClaimDeadline(), originalDeadline);
assertGt(block.timestamp, pool.corruptedClaimDeadline());
// =====================================================================
// Step 4: Correct attacker tries to claim — REVERTS (expired deadline)
// =====================================================================
uint256 correctAttackerBalBefore = token.balanceOf(correctAttacker);
vm.prank(correctAttacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
// Correct attacker received nothing.
assertEq(token.balanceOf(correctAttacker), correctAttackerBalBefore);
// =====================================================================
// Step 5: Anyone sweeps the entire pool to recoveryAddress
// =====================================================================
uint256 poolBalance = token.balanceOf(address(pool));
uint256 recoveryBalBefore = token.balanceOf(recovery);
// Permissionless — called by an arbitrary address.
address anyone = makeAddr("anyone");
vm.prank(anyone);
pool.sweepUnclaimedCorrupted();
// Recovery got everything. Correct attacker got nothing.
assertEq(token.balanceOf(recovery) - recoveryBalBefore, poolBalance);
assertEq(token.balanceOf(correctAttacker), correctAttackerBalBefore);
assertEq(token.balanceOf(address(pool)), 0);
}
}

Support

FAQs

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

Give us feedback!