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

Re-flagging good-faith CORRUPTED after a round-trip anchors the claim deadline in the past, permanently time-barring the legitimately-named whitehat and routing the whole pool to recoveryAddress

Author Revealed upon completion

Description

For a good-faith CORRUPTED resolution the pool earmarks the entire pool (all staked principal + all bonus) to the moderator-named whitehat, claimable via claimAttackerBounty within a 180-day window; the moderator may re-flag before the first claim to correct a mistake.

The claim deadline is anchored to the first-ever good-faith CORRUPTED flag and is never re-based, even when the outcome leaves and re-enters good-faith CORRUPTED. A moderator who flags good-faith CORRUPTED, re-flags away during a dispute, and only re-enters good-faith CORRUPTED more than 180 days later produces a deadline already in the past — the rightful whitehat can never claim, and the whole pool is swept to recoveryAddress. This is not a mere delay to a neutral sink: recoveryAddress is sponsor-controlled and mutable at any time, including after the good-faith flag. Composing the two, a moderator operational error (a >180-day dispute round-trip) lets the sponsor point recoveryAddress at its own wallet and capture the whitehat's entire earmarked bounty — value the protocol reserved for the whitehat, redirected to an unintended party.

// flagOutcome — deadline anchored to the FIRST good-faith flag, never re-based:
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
@> corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW); // stale on re-entry
}
// claimAttackerBounty — whitehat time-barred once the stale deadline passes:
@> if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
// setRecoveryAddress — sponsor can re-point the sweep sink AT ANY TIME, incl. after the flag:
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
@> recoveryAddress = newRecoveryAddress; // no outcome / timing gate
}
// sweepUnclaimedCorrupted — after the stale deadline, the WHOLE pool goes to recoveryAddress:
@> stakeToken.safeTransfer(recoveryAddress, amount);

Risk

Likelihood:

  • Occurs when a good-faith CORRUPTED resolution is disputed and the moderator re-enters good-faith CORRUPTED more than CORRUPTED_CLAIM_WINDOW (180 days) after the first good-faith flag — a realistic long-dispute / correction pattern on a long-lived agreement.

  • Occurs while the sponsor has pointed (or re-points, post-flag) recoveryAddress at an address it controls — the default position for a self-interested sponsor.

  • The trigger is a moderator operational error, not malice: a malicious moderator has a simpler path (flag bad-faith CORRUPTED directly), so this is a genuine code flaw, not an assumed-honest-actor issue.

Impact:

  • The moderator-named whitehat is permanently denied their entire earmarked bounty (the whole pool).

  • That bounty is captured by the sponsor via a self-controlled recoveryAddress, converting a good-faith outcome (funds → whitehat) into a sponsor windfall — defeating the core good-faith incentive and transferring value to an unintended party.

Proof of Concept

test/audit/ChainEscalation.t.sol — the full chain plus a control proving both legs are required:

function testChain_L02xI02_sponsorCapturesWhitehatBounty() external {
address sponsorWallet = makeAddr("sponsorWallet");
_stake(alice, 100 * ONE); _stake(bob, 100 * ONE); _contributeBonus(carol, 50 * ONE); // pool V = 250
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // whole pool earmarked to whitehat
uint256 deadline0 = pool.corruptedClaimDeadline();
pool.setRecoveryAddress(sponsorWallet); // [leg 2] sponsor re-points recovery to self
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0)); // [leg 1] re-flag away (dispute)
vm.warp(deadline0 + 1 days); // ... past the non-rebased window
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // re-enter: deadline STILL deadline0 (past)
vm.prank(attacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty(); // whitehat time-barred
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(sponsorWallet), 250 * ONE); // SPONSOR captured the whole pool
assertEq(token.balanceOf(attacker), 0); // whitehat got nothing
}
// Control (also passes): WITHOUT the stranding, the whitehat claims in-window and receives the bounty;
// the sponsor's re-pointed recovery gets 0 — proving the impact needs BOTH legs.

Recommended Mitigation

Two independent fixes; either one breaks the chain:

// (1) Reject re-entry onto an already-expired window so a re-flag can never silently strand the whitehat:
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
+ require(block.timestamp < _firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW, "window already elapsed");
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
}
// (2) Freeze recoveryAddress once an outcome is set, so the sponsor cannot re-point the sweep sink:
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
recoveryAddress = newRecoveryAddress;
}

Support

FAQs

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

Give us feedback!