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

Permissionless bad-faith `claimCorrupted()` can latch finality before the moderator corrects the outcome to good-faith CORRUPTED

Author Revealed upon completion

Description

  • DESIGN.md section 4 says flagOutcome may be re-flagged before the first claim so the moderator can fix an incorrect outcome or attacker address before the wrong distribution is locked in:

flagOutcome may be re-flagged pre-claim so the moderator can fix a typo'd outcome/attacker before any participant locks in the wrong distribution.

The window closing on the first claim (not on a grace timer) is deliberate, not a front-runnable race.

Why this is not an access-control / front-running finding: no privileged action is captured.

  • In the bad-faith CORRUPTED path, claimCorrupted() is permissionless and immediately latches claimsStarted. This allows any address to sweep the pool to recoveryAddress before the moderator can correct a bad-faith flag into a good-faith flag naming the whitehat. The good-faith path has a protection: claimCorrupted() reverts until the named attacker has claimed the bounty. The bad-faith path has no equivalent correction window.

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
@> if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
...
if (!goodFaith) {
@> bountyClaimed = bountyEntitlement;
}
@> if (!claimsStarted) claimsStarted = true;
@> stakeToken.safeTransfer(recoveryAddress, toSweep);
}
  • This matters when the moderator initially flags bad-faith CORRUPTED because a breach is confirmed but the good-faith attacker address is not yet known. Before the moderator updates the flag, an unprivileged caller can call claimCorrupted(), locking finality and preventing the later good-faith correction.

Risk

Likelihood:

  • This occurs when the moderator uses bad-faith CORRUPTED as an intermediate state before the final good-faith attacker address is known, and an external caller invokes claimCorrupted() before the correction.

  • The issue is avoidable when the moderator never uses a bad-faith placeholder or uses a controlled good-faith placeholder instead.

Impact:

  • The pool is not drained to an arbitrary attacker; it is sent to the configured recoveryAddress, which is already the intended destination for bad-faith CORRUPTED.

  • The harm is that the correction path is foreclosed and a later-identified good-faith attacker/whitehat cannot receive the bounty.

Proof of Concept

Attack path:

  1. Stakers and bonus funds are present.

  2. The registry reaches CORRUPTED.

  3. The moderator flags bad-faith CORRUPTED while the whitehat address is not yet known.

  4. Any address calls claimCorrupted().

  5. Funds are swept to recoveryAddress and claimsStarted is set.

  6. The moderator can no longer re-flag good-faith CORRUPTED, and the whitehat cannot claim the bounty.

Add this test to a fresh PoC file and run:

forge test --match-contract BadFaithCorruptedFrontrun -vv
function test_badFaithClaimCorrupted_foreclosesGoodFaithWhitehat() external {
_stake(alice, STAKE);
_contributeBonus(dave, BONUS);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertEq(pool.claimsStarted(), false);
address caller = makeAddr("caller");
vm.prank(caller);
pool.claimCorrupted();
assertEq(pool.claimsStarted(), true);
assertEq(token.balanceOf(recovery), STAKE + BONUS);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(attacker);
vm.expectRevert(IConfidencePool.BountyAlreadyClaimed.selector);
pool.claimAttackerBounty();
}

A control test confirms the asymmetry: when the pool is already flagged good-faith CORRUPTED, claimCorrupted() reverts with MustClaimBountyFirst until the named attacker has claimed.

Recommended Mitigation

Add a short correction delay before a bad-faith CORRUPTED sweep can be claimed, or otherwise require moderator confirmation for the first bad-faith sweep. This preserves the existing good-faith protection while giving the moderator time to correct a provisional bad-faith flag.

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
+ if (!goodFaith && block.timestamp < outcomeFlaggedAt + CORRECTION_DELAY) {
+ revert CorrectionWindowOpen();
+ }
...
}

Alternatively, avoid using bad-faith CORRUPTED as a placeholder operationally. For example, the moderator can wait until the attacker address is known or flag good-faith with a controlled placeholder address, then re-flag before claims begin. This operational workaround is why this issue is rated Low.

Support

FAQs

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

Give us feedback!