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

Re-flagging Good-Faith CORRUPTED Attacker After 180 Days Silently Produces Expired Claim Window, Permanently Locking Correct Whitehat Out of Bounty

Author Revealed upon completion

Root + Impact

When the moderator re-flags a good-faith CORRUPTED outcome to correct a mistakenly-named attacker more than 180 days after the pool's first good-faith CORRUPTED flag, the corruptedClaimDeadline is recomputed from the original _firstGoodFaithCorruptedAt timestamp (which is never reset). The deadline is already expired, so the newly-named correct whitehat cannot claim the bounty. The full pool is swept to the sponsor's recoveryAddress — a permanent, irreversible loss of the entire bounty for the intended beneficiary.

Description

Normal behavior: flagOutcome(CORRUPTED, true, attacker) names a whitehat attacker and starts a 180-day claim window via corruptedClaimDeadline. DESIGN.md §4 documents that re-flagging is explicitly supported to "fix a typo'd outcome/attacker before any participant locks in the wrong distribution." Re-flagging is gated only by claimsStarted == false.

The issue: _firstGoodFaithCorruptedAt is set exactly once — on the first-ever good-faith CORRUPTED flag — and is never reset for the lifetime of the pool (lines 363-372). On re-flag, corruptedClaimDeadline is always derived from this original anchor:

// src/ConfidencePool.sol L363-372
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
// @> Set ONCE, never reset — this is the root cause
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
// @> Deadline always anchored to the FIRST flag, even on corrective re-flag
// @> with a different attacker. May already be in the past.
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
}

If >180 days have elapsed since the first flag, the re-computed deadline is in the past. The state machine allows the transition (claimsStarted is still false), the attacker field updates, OutcomeFlagged emits — but the liveness property is broken: the newly-named attacker cannot claim (ClaimWindowExpired), and anyone can sweep the pool to recoveryAddress via sweepUnclaimedCorrupted().

The gap: DESIGN.md §4 says re-flagging exists to "fix a typo'd attacker" (unqualified promise). The implementation silently fails at this purpose after 180 days. The code comment at L368-369 acknowledges the mechanism but DESIGN.md — the authoritative reference per the contest brief — does not document this limitation as a known issue or accepted tradeoff.

Risk

Likelihood: Low

  • Moderator must initially name the wrong attacker address (typo, identity dispute, unresponsive whitehat)


  • 180 days must elapse before the correction is made

  • The correction window is generous, but real-world Safe Harbor agreements involve legal coordination, identity verification, and DAO governance delays that can extend beyond 180 days

  • No attacker action required; purely a function of moderator operational timeline

Impact: High

  • Complete, permanent, irreversible loss of the correctly-named whitehat's entire bounty entitlement (up to 100% of snapshotTotalStaked + snapshotTotalBonus)

  • Full pool diverted to the sponsor's recoveryAddress — a windfall to the sponsor at the whitehat's expense

  • The failure is silent: the re-flag transaction succeeds, emits OutcomeFlagged, and updates the attacker field, giving no on-chain signal that the correction is useless

  • Permissionless: once the re-flag lands with an expired deadline, any third party can immediately trigger sweepUnclaimedCorrupted() — no window for intervention

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @title PoC: corruptedClaimDeadline expiry on attacker re-flag
/// @notice Demonstrates that when a moderator re-flags a good-faith CORRUPTED outcome
/// with a different attacker address >180 days after the first good-faith flag,
/// the newly-named correct attacker cannot claim the bounty, and anyone can
/// sweep the full pool to recoveryAddress.
///
/// Root cause: `_firstGoodFaithCorruptedAt` is set only ONCE (never reset), so
/// `corruptedClaimDeadline` always anchors to the timestamp of the first-ever
/// good-faith CORRUPTED flag. Re-flagging the attacker address reuses the
/// original window, which may already be in the past.
contract PoC_CorruptedBountyDeadlineExpired is BaseConfidencePoolTest {
/// @notice Full PoC: re-flag attacker >180 days after first flag → window expired
function test_poc_corruptedClaimDeadline_expiredOnReflag() external {
// ── 1. Setup: alice stakes, carol contributes bonus ────────────────────
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
address wrongAttacker = makeAddr("wrongAttacker");
address rightAttacker = makeAddr("rightAttacker");
// ── 2. First good-faith flag with wrong attacker at time T ────────────
uint256 flagTime = vm.getBlockTimestamp();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, wrongAttacker);
assertEq(pool.attacker(), wrongAttacker, "initially wrong attacker");
uint256 expectedDeadline = flagTime + pool.CORRUPTED_CLAIM_WINDOW();
assertEq(
pool.corruptedClaimDeadline(),
expectedDeadline,
"deadline anchored to first flag"
);
// ── 3. Warp past the 180-day claim window ─────────────────────────────
vm.warp(flagTime + 200 days);
// ── 4. Moderator re-flags with the CORRECT attacker ───────────────────
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, rightAttacker);
assertEq(pool.attacker(), rightAttacker, "attacker corrected");
assertEq(
pool.corruptedClaimDeadline(),
expectedDeadline,
"deadline still anchored to first flag (already expired)"
);
assertGt(
block.timestamp,
pool.corruptedClaimDeadline(),
"current time is past deadline"
);
// ── 5. rightAttacker cannot claim: ClaimWindowExpired ─────────────────
vm.prank(rightAttacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(rightAttacker),
0,
"rightAttacker received nothing"
);
// ── 6. Anyone (bob) sweeps the full pool to recoveryAddress ───────────
uint256 poolBalance = token.balanceOf(address(pool));
assertGt(poolBalance, 0, "pool has balance to sweep");
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(bob);
pool.sweepUnclaimedCorrupted();
uint256 recoveryAfter = token.balanceOf(recovery);
assertEq(
recoveryAfter - recoveryBefore,
poolBalance,
"recovery received full pool balance"
);
assertEq(
token.balanceOf(address(pool)),
0,
"pool fully drained"
);
// ── 7. rightAttacker still has nothing ────────────────────────────────
assertEq(
token.balanceOf(rightAttacker),
0,
"rightAttacker bounty was zeroed"
);
}
}

Run: forge test --match-contract PoC_CorruptedBountyDeadlineExpired -vvv

Result: [PASS] test_poc_corruptedClaimDeadline_expiredOnReflag() (gas: 619243)

Recommended Mitigation

Option 1 (preferred): Reset _firstGoodFaithCorruptedAt when the attacker address changes on re-flag, giving a fresh 180-day window to a newly-named identity while preserving the non-extendable deadline for same-attacker re-confirmations:

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

Option 2 (simpler, fail-loud): Revert the re-flag if the recomputed corruptedClaimDeadline would already be expired, forcing the moderator to use an alternate resolution path rather than silently committing a dead state:

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

Both options preserve the invariant that the deadline must never be extendable for the same attacker while ensuring the documented "fix a typo'd attacker" workflow actually works.

Support

FAQs

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

Give us feedback!