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

flagOutcome has no expiry bound, so a post-term breach flagged good-faith CORRUPTED pays an unprivileged named whitehat the entire pool at the expense of a staker who already survived her term

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: docs/DESIGN.md section 2 is explicit about what happens when a pool reaches its deadline (expiry) while the protocol is still attackable:

    "A pool reaching expiry while the registry is still in an active-risk state means the agreement survived the full term the stakers underwrote. Resolving EXPIRED and returning principal + bonus is the intended payout — not an 'escape.' There is no breach to settle."

    Stakers sign up to cover a fixed window of time. If nothing bad happens to the covered contracts during that window, they get their money back plus their share of the bonus.

  • The bug, in seven steps:

    1. Registry goes UNDER_ATTACK (real danger, sealed — riskWindowStart is now nonzero).

    2. Pool reaches expiry — still UNDER_ATTACK, nothing bad has actually happened yet.

    3. Nobody calls claimExpired() in that window.

    4. Registry flips to CORRUPTED (the real breach happens, 2 days late — after the term the staker underwrote).

    5. The moderator sees this and calls flagOutcome, marking it CORRUPTED and naming a whitehat.

    6. The whitehat calls claimAttackerBounty() and gets the whole pool.

    7. The staker has no button left to press — she can't do anything anymore.

    The root cause: flagOutcome() never checks block.timestamp or expiry anywhere. It only checks what the registry says right now. So step 5 succeeds regardless of how long after expiry the breach in step 4 actually happened.

// ConfidencePool.sol
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
// @> No `block.timestamp` / `expiry` check anywhere in this function. The ONLY gate on
// @> resolving CORRUPTED is what the registry says RIGHT NOW -- there is no upper bound
// @> tying the breach to the term the staker actually signed up for.
...
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
...
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
...
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
// @> Whoever is named here (step 5/6) is entitled to the WHOLE pool, regardless of whether
// @> the breach happened during the staker's term or two days after it ended.
...
}
  • No documentation anywhere covers this specific race. Every mention of the word "race" in docs/DESIGN.md, README.md, protocol-readme.md, and every code comment was checked — there are exactly four, and none of them is this one:

    1. DESIGN §4 — the re-flag correction window (can the moderator change their mind after a claim?). Different question entirely.

    2. DESIGN §5, "the no-risk-window CORRUPTED race" — requires riskWindowStart == 0 (no risk ever observed). This finding's precondition is the opposite: riskWindowStart is already sealed and nonzero (step 1), because real risk genuinely happened during the term. §5's whole justification — "we can't tell if this was in-scope, so default to the staker-favorable outcome" — doesn't apply here; there's no such ambiguity in this scenario.

    3. DESIGN §7, "contested public race" — about biasing the bonus formula's upper bound (T) by delaying pokeRiskWindow(). A completely different mechanism (bonus math, not CORRUPTED eligibility).

    4. DESIGN §9 — the staker-vs-flagOutcome race via withdraw(). Doesn't apply here either: withdraw() is already permanently dead the moment riskWindowStart sealed in step 1, long before the breach in step 4 happens.

    Zero of the four documented races share this scenario's precondition (riskWindowStart != 0) or its trigger (a breach observed after expiry). This gap is genuinely undocumented.

  • Why this survives the "the moderator is trusted" objection. The moderator in step 5 isn't acting against its documented role — docs/DESIGN.md section 8 authorizes flagging CORRUPTED whenever "the in-scope contracts were the breach surface," with no mention of timing, and naming a genuine whitehat in good faith is exactly what the moderator is supposed to do. That is precisely why this finding is built around the good-faith path in step 5/6, not a bad-faith one: in a bad-faith flag, the swept funds would land on recoveryAddress — the sponsor's own wallet — so the only "beneficiary" would be a party already inside the pool's trust boundary. Here, the beneficiary in step 6 is the whitehat — an unprivileged, external, named third party with no special relationship to the pool. The moderator's untimed action in step 5 chains directly into the bountyEntitlement formula, and that formula pays an outsider the entire pool, principal included.

  • The evidence gets erased too. riskWindowEnd is capped at expiry no matter how late the real observation happens:

// ConfidencePool.sol — _markRiskWindowEnd
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
// @> A breach observed 2 days AFTER expiry (step 4) gets permanently recorded as if it were
// @> observed exactly AT expiry. On-chain there is no trace this was a post-term breach.
riskWindowEnd = uint32(t);
  • The remaining honest limit of this finding: the staker isn't defenseless. If she (or literally anyone) calls claimExpired() at step 2/3 before the breach lands, she locks in EXPIRED and forecloses the moderator's later flag entirely. The bug is that correct resolution depends entirely on who transacts first — nothing in the contract enforces the right outcome if nobody rushes to claim.

Risk

Likelihood: Low

  • Needs risk to materialize during the term (step 1 — common, that's the pool's purpose), survive to expiry still attackable (step 2 — the documented normal case per section 2), and then the actual breach lands after expiry before anyone calls claimExpired() (steps 3–4). That is a race any staker can win with one transaction the instant expiry arrives.

  • Not an admin abusing a privilege — the moderator (step 5) is following the documented rule for good-faith CORRUPTED.

Impact: Medium

  • If the staker loses the race, she loses 100% of her principal to an unprivileged third party (step 6), on funds the docs say she already survived to keep.

  • Staked capital is at risk, not just bonus.

  • Bounded by the fact that the correct outcome is always one permissionless transaction away, and by the brief self-correction window the moderator has before the whitehat actually claims.

Proof of Concept

To run: paste the function below into test/unit/ConfidencePool.t.sol (anywhere inside the existing contract ConfidencePoolTest block), then:

forge test --match-path test/unit/ConfidencePool.t.sol --match-test "test_PoC_postTermSlashing" -vv

No new imports needed — that file already imports IConfidencePool, IAttackRegistry, PoolStates, and extends BaseConfidencePoolTest.

/// @notice PoC -- flagOutcome has no expiry bound. Steps 1-7 exactly as described above:
/// a post-term breach is flagged good-faith CORRUPTED, and an unprivileged named whitehat
/// receives the entire pool -- the staker's already-earned principal included.
function test_PoC_postTermSlashing_sevenSteps() external {
uint256 stakeAmt = 1000 * ONE;
uint256 bonusAmt = 500 * ONE;
_stake(alice, stakeAmt);
_contributeBonus(bob, bonusAmt);
uint32 expiry_ = pool.expiry();
// Step 1: registry goes UNDER_ATTACK. Real danger, sealed.
vm.warp(expiry_ - 11 days);
_passThroughUnderAttack();
assertGt(pool.riskWindowStart(), 0, "step 1: risk window sealed -- real danger happened");
// Step 2: pool reaches expiry, STILL under attack. Nothing bad has happened yet.
vm.warp(expiry_);
assertEq(
uint256(attackRegistry.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"step 2: still attackable at expiry -- DESIGN.md section 2 says EXPIRED is correct here"
);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "step 3: nobody claimed");
// Step 4: registry flips to CORRUPTED. The real breach happens, 2 days AFTER the term.
vm.warp(expiry_ + 2 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Step 5: moderator flags CORRUPTED, good faith, naming an unprivileged whitehat.
// flagOutcome has NO expiry check -- nothing stops this from succeeding.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "step 5: post-term breach resolves CORRUPTED");
// The expiry cap launders the evidence: observed 2 days late, recorded as if at expiry.
assertEq(pool.riskWindowEnd(), expiry_, "VULN: post-term observation recorded as at-expiry");
// Step 6: the whitehat claims and receives the ENTIRE pool.
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), stakeAmt + bonusAmt, "step 6: whitehat received the whole pool");
// Step 7: the staker has no button left to press.
vm.prank(alice);
vm.expectRevert(IConfidencePool.OutcomeNotSet.selector);
pool.claimSurvived();
}
/// @dev Control: the ONLY thing that saves the staker is winning the race at step 2/3 --
/// calling claimExpired() before the breach lands. One call moved earlier, opposite outcome.
function test_PoC_postTermSlashing_control() external {
uint256 stakeAmt = 1000 * ONE;
uint256 bonusAmt = 500 * ONE;
_stake(alice, stakeAmt);
_contributeBonus(bob, bonusAmt);
uint32 expiry_ = pool.expiry();
vm.warp(expiry_ - 11 days);
_passThroughUnderAttack();
// The ONLY difference: staker resolves at expiry, as section 2 says she may.
vm.warp(expiry_);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice) - aliceBefore, stakeAmt + bonusAmt, "staker keeps principal + bonus");
// The post-term breach can no longer touch her -- outcome is already final.
vm.warp(expiry_ + 2 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
}

Result:

[PASS] test_PoC_postTermSlashing_sevenSteps()
[PASS] test_PoC_postTermSlashing_control()

Vulnerability (breach flagged first) Control (staker claims first)
Who transacts first at/after expiry moderator's flagOutcome(CORRUPTED, true, whitehat) staker's claimExpired()
Outcome CORRUPTED EXPIRED
Staker receives 0 1500e18 (stake + bonus)
Unprivileged whitehat receives 1500e18 0 (never named)

Recommended Mitigation

Require the terminal observation behind a CORRUPTED flag to have landed at or before expiry. If the breach was only ever observed after the term ended, fall through to EXPIRED instead — the payout docs/DESIGN.md section 2 already says is correct in that situation.

--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@ function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
+
+ // A CORRUPTED registry observed only after `expiry` describes a breach outside the term
+ // the staker underwrote (DESIGN.md section 2). Require the terminal observation to have
+ // landed at/before expiry for CORRUPTED to be resolvable via the moderator's flag.
+ if (newOutcome == PoolStates.Outcome.CORRUPTED && block.timestamp > expiry && riskWindowEnd == expiry) {
+ revert InvalidOutcome();
+ }

Limit of this fix: it uses riskWindowEnd == expiry as a proxy for "observed after expiry," which works given the existing cap but can't distinguish "observed exactly at expiry" from "observed later and capped." A more precise fix records an uncapped terminal-observation timestamp separately and gates on that directly.

Support

FAQs

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

Give us feedback!