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

Permissionless claimCorrupted instantly forecloses the moderator's documented re-flag correction of a bad-faith-CORRUPTED misflag, sweeping 100% of a term-survivor's principal + bonus to recoveryAddress

Author Revealed upon completion

Root + Impact

Description

  • DESIGN §4 gives the moderator a re-flag correction window "so the moderator can fix a typo'd outcome/attacker before any participant locks in the wrong distribution," closing on the first claim (claimsStarted). §8 requires the moderator to flag SURVIVED (full refund) when the registry is CORRUPTED but the breach fell outside this pool's committed scope.

  • The correction window has no protection for the bad-faith-CORRUPTED direction. On an out-of-scope breach where the correct outcome is SURVIVED, if the moderator first misflags flagOutcome(CORRUPTED, false, address(0)) and intends to correct it, any permissionless caller front-runs the correction with claimCorrupted(): bad-faith skips the MustClaimBountyFirst gate (:410), sweeps balanceOf(this) = principal + bonus to the sponsor-controlled recoveryAddress (:413, :423), and latches claimsStarted = true (:422). The moderator's corrective flagOutcome(SURVIVED, ...) then reverts OutcomeAlreadySet (:327). Honest stakers who survived (out-of-scope breach) lose 100% of principal + bonus. §4's "a staker claiming first is exercising a correct outcome, not usurping one" is TRUE only for the SURVIVED direction (a self-interested staker claiming their own correct principal); it is FALSE for bad-faith CORRUPTED, where the caller is any address, executing the incorrect misflag, moving other stakers' funds to a third party — with zero grace timer or self-interest gate, unlike the good-faith path's 180-day CORRUPTED_CLAIM_WINDOW.

// flagOutcome — re-flag allowed while claimsStarted is false (the correction window)
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// claimCorrupted — permissionless; bad-faith skips the bounty gate, sweeps the WHOLE balance, and LATCHES
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
@> if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst(); // skipped: bad-faith
uint256 toSweep = stakeToken.balanceOf(address(this)); // = principal + bonus
...
@> if (!claimsStarted) claimsStarted = true; // forecloses the correction
stakeToken.safeTransfer(recoveryAddress, toSweep); // 100% to sponsor-controlled recovery
}

Risk

Likelihood:

  • The registry is CORRUPTED but the breach was out of this pool's scope (§8 → correct outcome SURVIVED).

  • The moderator misflags bad-faith CORRUPTED first (the exact class of mistake the §4 re-flag window exists to fix), then attempts to correct to SURVIVED; a permissionless actor — a griefer, or the sponsor who controls recoveryAddress and directly benefits — front-runs the correction with claimCorrupted().

Impact:

  • 100% of every staker's principal + bonus is swept to recoveryAddress on a breach the pool's in-scope contracts survived. The correction the protocol documents as available is permanently foreclosed.

Proof of Concept

// test/rig/F5ClaimCorruptedForeclosesCorrection.t.sol (3/3 green on pristine)
// CONTROL — no front-run → moderator corrects CORRUPTED→SURVIVED → Alice recovers full 140e18 (100 + 40 bonus)
// BUG:
vm.prank(griefer); pool.claimCorrupted(); // 140e18 swept to recoveryAddress, claimsStarted latched
vm.prank(moderator);
vm.expectRevert(); // OutcomeAlreadySet — correction foreclosed
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(aliceGain, 0, "survivor of an out-of-scope breach receives nothing");
assertEq(token.balanceOf(recovery), 140e18, "100% principal + bonus to the sponsor-controlled recovery");
// PROPERTY: with correction Alice=140, with front-run Alice=0 — the front-run is the sole manufacturer.

Recommended Mitigation

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
+ // Give bad-faith CORRUPTED the same front-run protection the good-faith path already has:
+ // block the permissionless sweep until a short moderator-only correction window elapses,
+ // symmetric to CORRUPTED_CLAIM_WINDOW, so a griefer/sponsor cannot instantly lock in a misflag
+ // that sweeps every staker's principal before the documented §4 correction can be applied.
+ if (!goodFaith && block.timestamp < outcomeFlaggedAt + MODERATOR_CORRUPTED_GRACE) {
+ revert AwaitingModeratorCorrectionWindow();
+ }
uint256 toSweep = stakeToken.balanceOf(address(this));
...
}

Honest caveat (disclosed for the judge)

A defender may argue this sits inside §4's balance-accounting rationale ("once value has left, a corrective re-flag cannot be honored") — and claimCorrupted does latch. The counter: §4's window exists specifically to let the moderator fix a misflag, and here the protection is one-directional — the good-faith path has the 180-day CORRUPTED_CLAIM_WINDOW, the bad-faith path has none, so a permissionless caller (not a self-interested staker exercising a correct outcome) locks in the incorrect outcome and sweeps every staker's principal to a third party before the documented correction can run. The claimer profits nothing → filed as contested Medium, not a clean-theft High.

Support

FAQs

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

Give us feedback!