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

Newly designated attacker can receive an already-expired bounty claim window because `_firstGoodFaithCorruptedAt` is never refreshed

Author Revealed upon completion

Root + Impact

_firstGoodFaithCorruptedAt is never refreshed, so re-designating an attacker more than 180 days after the first good-faith CORRUPTED flag can assign an already-expired corruptedClaimDeadline. The designated attacker becomes permanently unable to claim their valid bounty, and the entire pool can instead be swept to recoveryAddress.

Description

  • Normal behavior: when the moderator flags a pool CORRUPTED in good faith and names a whitehat attacker, that attacker is entitled to claim the entire pool (snapshotTotalStaked + snapshotTotalBonus) via claimAttackerBounty() within CORRUPTED_CLAIM_WINDOW (180 days) of being named. flagOutcome can legitimately be called multiple times before any claim occurs, for example to correct the outcome or designate a different attacker, and each successful re-designation restores the named attacker's full bountyEntitlement.

  • The issue: corruptedClaimDeadline is computed from _firstGoodFaithCorruptedAt, a timestamp that is set once, on the very first good-faith CORRUPTED flag ever issued for the pool, and is never refreshed on subsequent flags. If a later good-faith CORRUPTED flag (naming the same or a different attacker) lands more than 180 days after that original anchor, the resulting corruptedClaimDeadline is already in the past at the moment the new designation is made. The flagOutcome call itself succeeds with no revert or warning, and bountyEntitlement is correctly restored to the full pool value, giving every on-chain indication that the newly-named attacker is entitled to claim. But their very first call to claimAttackerBounty() reverts ClaimWindowExpired, permanently. Since the deadline has already passed, sweepUnclaimedCorrupted() becomes immediately callable by anyone, redirecting the entire pool to recoveryAddress instead of the designated attacker who is recorded as entitled to the bounty.

Permalink: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L363-L375

if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
@> // Reuses the original anchor unconditionally on every subsequent re-designation, with no
@> // check that the resulting deadline is still in the future.
@> corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}

Risk

Likelihood:

  • Occurs whenever a pool's good-faith CORRUPTED outcome is re-flagged (naming the same or a different attacker) more than CORRUPTED_CLAIM_WINDOW (180 days) after the pool's first-ever good-faith CORRUPTED flag, with no claim having occurred in the interim.

  • This requires no adversarial behavior from any party. An ordinary, good-faith scope dispute that takes several months to resolve (plausible for real-world security-incident adjudication) reaches this state naturally, as does a moderator correcting course after an initially-named attacker becomes unable to claim (for example, due to an unrelated token blacklist).

Impact:

  • The validly-named whitehat attacker permanently loses their entire bounty entitlement. claimAttackerBounty() reverts on every attempt, with no recovery path (every further re-flag recomputes the same stale deadline, and no function anywhere in the codebase resets _firstGoodFaithCorruptedAt or otherwise refreshes the window).

  • The full pool (stake plus bonus) is redirected to recoveryAddress via the permissionless sweepUnclaimedCorrupted(), denying the recorded claimant funds the protocol's own on-chain state says they are owed.

  • The protocol's state becomes internally inconsistent: attacker and bountyEntitlement indicate the designated whitehat is entitled to the bounty, while corruptedClaimDeadline simultaneously makes that entitlement impossible to realize.

Proof of Concept

Create a test file in test/unit/ folder named ConfidencePool.staleCorruptedAnchor.t.sol and paste the following below in the test file:

// 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";
/// @notice PoC for: "Newly-Designated Attacker Can Receive an Already-Expired Bounty Claim
/// Window Because `_firstGoodFaithCorruptedAt` Is Never Refreshed".
///
/// Three tests, in order of how they build the case:
/// 1. `test_NarrativeReproduction_...` — the realistic dispute scenario (CORRUPTED -> SURVIVED
/// -> 200 days -> CORRUPTED naming a different attacker).
/// 2. `test_MinimalReproduction_...` — the smallest possible repro: two flagOutcome calls,
/// same attacker, no intermediate state. Proves the SURVIVED detour and the different-
/// attacker detail are both irrelevant — the only thing that matters is elapsed time.
/// 3. `test_Control_...` — proves the protocol behaves correctly when the
/// claim window has NOT lapsed, isolating the bug to exactly one condition.
contract ConfidencePoolStaleCorruptedAnchorTest is BaseConfidencePoolTest {
uint256 internal constant STAKE_AMOUNT = 10 * ONE;
uint256 internal constant BONUS_AMOUNT = 2 * ONE;
function _seedPoolAndGoCorrupted() internal {
_stake(alice, STAKE_AMOUNT);
_contributeBonus(dave, BONUS_AMOUNT);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
}
/// @dev Realistic scenario: moderator names attacker A, reconsiders and flags SURVIVED, then
/// >180 days later concludes the breach genuinely was in-scope and names a DIFFERENT
/// whitehat, bob. bob's designation succeeds and his entitlement is fully restored, but his
/// claim window is already expired the instant he's named.
function test_NarrativeReproduction_LongDisputeBricksNewlyNamedWhitehat() public {
_seedPoolAndGoCorrupted();
uint256 t0 = block.timestamp;
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.corruptedClaimDeadline(), t0 + 180 days, "initial deadline should be t0+180d");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.bountyEntitlement(), 0, "bounty entitlement reset on non-CORRUPTED re-flag");
vm.warp(t0 + 200 days);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, bob);
assertEq(pool.bountyEntitlement(), STAKE_AMOUNT + BONUS_AMOUNT, "bob entitled to the whole pool");
assertEq(pool.attacker(), bob, "bob should be the named attacker");
uint256 deadline = pool.corruptedClaimDeadline();
assertEq(deadline, t0 + 180 days, "deadline still anchored to the stale t0");
assertLt(deadline, block.timestamp, "deadline already in the past at the moment bob was named");
vm.prank(bob);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
assertEq(token.balanceOf(bob), 0, "bob receives nothing despite being validly named");
uint256 poolBalanceBefore = token.balanceOf(address(pool));
pool.sweepUnclaimedCorrupted(); // permissionless
assertEq(token.balanceOf(recovery), poolBalanceBefore, "entire pool redirected to recoveryAddress");
assertEq(token.balanceOf(address(pool)), 0, "pool fully drained");
assertEq(token.balanceOf(bob), 0, "bob still has nothing");
}
/// @dev Minimal reproduction: two moderator actions, SAME attacker, no intermediate state.
/// If this fails the same way, the bug is exactly and only: the anchor is never refreshed.
/// Nothing about the SURVIVED detour or naming a different attacker is load-bearing.
function test_MinimalReproduction_TwoFlagsSameAttacker() public {
_seedPoolAndGoCorrupted();
uint256 t0 = block.timestamp;
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.warp(t0 + 200 days);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // same attacker, re-affirmed
assertLt(pool.corruptedClaimDeadline(), block.timestamp, "deadline already expired at re-affirmation");
vm.prank(attacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
}
/// @dev Control: naming an attacker and claiming well within the 180-day window works exactly
/// as intended. This isolates the bug to "named after the stale anchor's window has already
/// lapsed" — the happy path is unaffected.
function test_Control_ClaimWithinWindowSucceeds() public {
_seedPoolAndGoCorrupted();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, bob);
vm.warp(block.timestamp + 30 days); // well within the 180-day window
vm.prank(bob);
pool.claimAttackerBounty();
assertEq(token.balanceOf(bob), STAKE_AMOUNT + BONUS_AMOUNT, "bob receives the full pool within the window");
}
}

Run with:

forge test --match-contract ConfidencePoolStaleCorruptedAnchorTest -vvv

Expected Output:

Ran 3 tests for test/unit/ConfidencePool.staleCorruptedAnchor.t.sol:ConfidencePoolStaleCorruptedAnchorTest
[PASS] test_Control_ClaimWithinWindowSucceeds() (gas: 571222)
[PASS] test_MinimalReproduction_TwoFlagsSameAttacker() (gas: 573232)
[PASS] test_NarrativeReproduction_LongDisputeBricksNewlyNamedWhitehat() (gas: 616821)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 1.79ms (1.18ms CPU time)
Ran 1 test suite in 9.57ms (1.79ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation

Refresh _firstGoodFaithCorruptedAt (or otherwise recompute corruptedClaimDeadline) whenever a new good-faith CORRUPTED designation would otherwise inherit an already-expired claim window, while preserving the invariant that moderators cannot extend an already-live claim window through repeated re-flagging. Concretely: only reuse the existing anchor if _firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW is still in the future at the time of the new flag; otherwise treat the flag as establishing a fresh window anchored to the current timestamp.

Support

FAQs

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

Give us feedback!