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

Missing lifecycle lock on recoveryAddress allows Sponsor to redirect the entire CORRUPTED sweep after resolution, including a named whitehat's bounty

Author Revealed upon completion

Root + Impact

Description

Every other sponsor-controlled lever that determines fund destiny is locked once it becomes security-relevant: expiry locks permanently on the first stake (expiryLocked), and pool scope locks permanently once the registry leaves pre-attack staging (scopeLocked) — both documented and individually justified in the project's own docs/DESIGN.md §8/§10.

recoveryAddress — the address that receives the entire pool, including stakers' principal, under bad-faith CORRUPTED — has no equivalent lock anywhere in its lifecycle. The Sponsor (onlyOwner) can call setRecoveryAddress at any point, including after CORRUPTED is already flagged and public on-chain, and the permissionless sweep functions (claimCorrupted, sweepUnclaimedCorrupted) read it live at transfer time rather than from the snapshot flagOutcome takes of every other resolution-relevant value.

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
@> // No check against `outcome`/`claimsStarted` — callable at ANY lifecycle point.
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
function setExpiry(uint256 newExpiry) external onlyOwner {
@> if (expiryLocked) revert ExpiryLocked(); // <- the lock recoveryAddress is missing
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
...
}

docs/DESIGN.md §10 titles the Sponsor's powers a "trust surface" — enumerated, bounded trust, not blanket exemption — distinct from §11's "out of the adversarial model" language reserved only for registry/moderator compromise. Every other lever on that same trust surface gets a matching limitation; recoveryAddress is the one left without it. The contest's own README.md shows the identical pattern independently: expiry and scope each get a paired LIMITATIONS bullet; recoveryAddress gets only the RESPONSIBILITIES bullet, with no counterpart anywhere in the same actor list.

Risk

Likelihood:

  • The Sponsor already holds onlyOwner legitimately by design — redirecting the sweep destination requires only one additional call, at any point up to and including the moment funds are swept, with no extra access, no race against another party, and no capital beyond gas.

  • Both sweep functions are permissionless, so once recoveryAddress is redirected, any third party (not necessarily the Sponsor) can immediately trigger the transfer.

Impact:

  • 100% of the pool's staked principal is redirected to an address the Sponsor was never committed to (confirmed in PoC 1, bad-faith path).

  • A named whitehat's entire earned bounty — the full pool, per the project's own bounty mechanics (§12) — is redirected away from them if unclaimed within the 180-day window (confirmed in PoC 2, good-faith path).

Proof of Concept

Three independent Foundry tests confirm the exploit end-to-end: PoC 1 shows the Sponsor redirecting the sweep destination immediately after a bad-faith CORRUPTED flag; PoC 2 shows the same redirection stealing a named whitehat's bounty in the good-faith path after the 180-day claim window; PoC 3 confirms why the recommended fix below is outcome-gated rather than an unconditional freeze.

// PoC 1 — bad-faith path (full file: test/RecoveryAddressNoLock.t.sol)
function testRecoveryAddressChangeableAfterCorruptedFlagged() public {
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0)); // bad-faith, already public
vm.prank(sponsor);
pool.setRecoveryAddress(sponsorControlledDrain); // no revert
vm.prank(address(0x7A12D)); // permissionless — not even the sponsor
pool.claimCorrupted();
assertEq(token.balanceOf(sponsorControlledDrain), STAKE_AMOUNT); // PASSES
assertEq(token.balanceOf(originalRecoveryAddress), 0); // PASSES
}
// [PASS] forge test -vvvv --match-contract RecoveryAddressNoLock_PoCTest
// PoC 2 — good-faith path, whitehat's bounty stolen (full file: test/RecoveryAddressNoLock_GoodFaith.t.sol)
function testSponsorStealsWhitehatBountyViaSweep() public {
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehatAttacker); // good-faith, named
vm.warp(block.timestamp + pool.CORRUPTED_CLAIM_WINDOW() + 1); // whitehat never claimed
vm.prank(sponsor);
pool.setRecoveryAddress(sponsorControlledDrain); // no revert, even now
vm.prank(address(0x9999)); // permissionless
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(whitehatAttacker), 0); // PASSES — bounty stolen
assertEq(token.balanceOf(sponsorControlledDrain), STAKE_AMOUNT); // PASSES
}
// [PASS] forge test -vvvv --match-contract RecoveryAddressNoLock_GoodFaith_PoCTest
// PoC 3 — confirms the mitigation trade-off empirically (full file: test/BlacklistedRecoveryAddress.t.sol)
// If stakeToken supports issuer-side blacklisting and recoveryAddress is later blacklisted,
// claimCorrupted() reverts permanently — and setRecoveryAddress (this report's own finding)
// is TODAY the only working rescue. This is why the mitigation below is outcome-gated, not
// an unconditional freeze.
// [PASS] testBlacklistedRecoveryAddressBlocksClaimCorrupted()
// [PASS] testControl_SetRecoveryAddressIsTodaysOnlyEscapeHatch()

Recommended Mitigation

Gate setRecoveryAddress on outcome == UNRESOLVED, mirroring the exact lock pattern already used for expiryLocked and scopeLocked elsewhere in the contract. This closes both PoC 1 and PoC 2: once flagOutcome(CORRUPTED, ...) succeeds, outcome is no longer UNRESOLVED, so any subsequent setRecoveryAddress call reverts — for both the immediate bad-faith sweep and the 180-day-later good-faith sweep — while still preserving the Sponsor's legitimate ability to correct recoveryAddress during normal, unresolved operation. The guard is outcome-gated rather than an unconditional freeze specifically because of PoC 3: a full freeze would remove the only currently-working rescue path if stakeToken supports issuer-side blacklisting and recoveryAddress is later blacklisted independently of the Sponsor.

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
+ // Mirrors expiryLocked/scopeLocked: pre-resolution flexibility preserved,
+ // post-resolution mutability closed. See PoC 3 — an unconditional freeze
+ // instead of this outcome-gated lock would trade one failure mode for
+ // another if stakeToken supports issuer-side blacklisting.
+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

Support

FAQs

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

Give us feedback!