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

`_firstGoodFaithCorruptedAt` never resets ; reflag shortens attacker claim window

Author Revealed upon completion

Severity: Low ; requires moderator error + reflag, but silently shortens attacker deadline
Affected: ConfidencePool.flagOutcome at src/ConfidencePool.sol:364-372
Likelihood × Impact: Likelihood: Low × Impact: Medium

TL;DR

_firstGoodFaithCorruptedAt never resets across reflags. A moderator who corrects from CORRUPTED to SURVIVED and back silently shortens the attacker's 180-day bounty window.

Description

flagOutcome at lines 363-375 sets the good-faith CORRUPTED deadline:

// ConfidencePool.sol:363-375
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp); // line 366: set ONCE
}
// Reuses the original window on re-entry ; which may already be in the past
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW); // line 372
} else {
corruptedClaimDeadline = 0;
}

The _firstGoodFaithCorruptedAt variable is written only when it is zero (line 364). Once set, it persists through all subsequent reflags ; including reflags to SURVIVED (which do not reset it to zero because the else branch at line 373 only resets corruptedClaimDeadline, not _firstGoodFaithCorruptedAt).

When the moderator reflags back to good-faith CORRUPTED, _firstGoodFaithCorruptedAt is non-zero (from the first flag), so the if at line 364 is skipped, and corruptedClaimDeadline is recomputed from the STALE first-flag timestamp. The attacker's effective claim window shrinks.

Attack Path

  1. Nominated attacker X at T=0. Moderator flags gf-CORRUPTED with attacker X. _firstGoodFaithCorruptedAt = 0 + block.timestamp = T0. corruptedClaimDeadline = T0 + 180 days.

  2. Moderator corrects to SURVIVED at T=90 days. Realizes the breach was out-of-scope. Reflags SURVIVED. _firstGoodFaithCorruptedAt stays at T0 (not reset). corruptedClaimDeadline set to 0 (line 374).

  3. New evidence emerges at T=150 days. Breach was actually in-scope. Moderator reflags gf-CORRUPTED with attacker X again. _firstGoodFaithCorruptedAt is non-zero (still T0), so skipped. corruptedClaimDeadline = T0 + 180 days.

  4. Attacker X sees the new CORRUPTED flag and tries to claim. The deadline is T0+180, which is only 30 days away (from T150). If they don't claim within 30 days, they lose the bounty ; despite the reflag having just happened.

The attacker's window is silently shortened from 180 days to 30 days, with no event or notification of the reduced deadline.

If the attacker delays their claim based on the expectation of a full 180-day window from the most recent flag, they may find the window already expired. The bounty (up to snapshotTotalStaked + snapshotTotalBonus) is forfeited ; sweepUnclaimedCorrupted sweeps it to recoveryAddress after the deadline passes.

For a pool with 100 ETH staked + 50 ETH bonus, the attacker could lose 150 ETH if they wait more than the shortened window.

Scope Eligibility

ConfidencePool.sol is listed in the contest scope. The flagOutcome function is callable by the moderator. The _firstGoodFaithCorruptedAt variable is not documented in docs/DESIGN.md; the code comment at lines 368-370 describes the behavior as intentional but no external documentation exists.

Severity Calibration

CodeHawks severity rules: Low findings are issues not exploitable under normal circumstances but representing poor practices or deviations from best practices that could theoretically lead to issues.

This finding: requires moderator error (reflag cycle), which is abnormal operational behavior. The impact is medium (attacker loses bounty), but likelihood depends on moderator making errors. Severity: Low.

Known-Issue Distinction

No prior audit reports flag _firstGoodFaithCorruptedAt persistence. The inline comment at lines 368-370 says "Reuses the original window on re-entry ; which may already be in the past, leaving nothing to claim. Intended: the deadline must never be extendable." This comment describes the intent but the behavior is not in DESIGN.md. The finding is novel: it demonstrates a concrete path where this persistence causes harm.

Risk

  1. Moderator commits an error that requires reflagging from good-faith CORRUPTED to a non-CORRUPTED outcome (SURVIVED or bad-faith CORRUPTED). Likelihood: moderator is a protocol DAO with governance ; error probability is non-zero but low.

  2. Moderator later reflags back to good-faith CORRUPTED with the same attacker. Enough time passes that the original 180-day window is partially consumed. Likelihood: requires two moderator actions ; possible if new evidence emerges.

  3. Attacker observes the second flag and expects a fresh 180-day window, delaying their claim past the original deadline. Likelihood: attacker should monitor on-chain; a cautious attacker claims immediately. However, the contract provides no event or notification warning of the reduced window.

Historical occurrence: No reflag cycles have been observed on deployed pools (code is pre-launch). The testHunt_reflagCorruptedToSurvivedSavesStakers test in BughuntHunt6 confirms reflag works but does not test the deadline-shortening scenario.

Proof of Concept

forge test --match-contract BughuntFinal3 --match-test test_firstGoodFaithCorruptedAt_attackerLosesWindow -vvvv
// test/unit/BughuntFinal3.t.sol
function test_firstGoodFaithCorruptedAt_attackerLosesWindow() public {
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 firstDeadline = pool.corruptedClaimDeadline();
// Warp past the original deadline
vm.warp(firstDeadline + 1);
// Moderator reflags SURVIVED then back to gf-CORRUPTED
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Attacker tries to claim.ClaimWindowExpired (based on first flag, not reflag)
vm.prank(attacker);
vm.expectRevert(abi.encodeWithSignature("ClaimWindowExpired()"));
pool.claimAttackerBounty();
}

Recommended Mitigation

Reset _firstGoodFaithCorruptedAt to 0 when the outcome is changed away from good-faith CORRUPTED:

// ConfidencePool.sol:373-375
} else {
corruptedClaimDeadline = 0;
+ _firstGoodFaithCorruptedAt = 0;
}

Or, alternatively, always update _firstGoodFaithCorruptedAt to the current timestamp on each good-faith CORRUPTED flag:

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

The comment at lines 368-370 argues that the deadline should never be extendable. This is correct ; a reflag should not EXTEND the deadline. But resetting _firstGoodFaithCorruptedAt on reflag ensures the window is measured from the current flag, not a historical one. The concern about extension is addressed by the fact that each reflag consumes the time between flags ; the attacker cannot extend the window, only the moderator can restart it (by reflagging).

Support

FAQs

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

Give us feedback!