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

Sponsor can front-run CORRUPTED resolution to redirect the full pool to a new `recoveryAddress`

Author Revealed upon completion

Root + Impact

The sponsor can front-run a CORRUPTED resolution with setRecoveryAddress(attackerControlled) and redirect the entire pool sweep to a scam address, leaving stakers with nothing.

Description

  • setRecoveryAddress is an owner-only function that lets the sponsor designate a fallback address for unclaimed and CORRUPTED-path sweeps. Stakers inspect this address before depositing as a trust assumption.

  • setRecoveryAddress has no timing guard — it can be called at any point, including after the outcome is finalized, after claims have started, and between partial sweeps. In the CORRUPTED path, the sponsor can front-run the moderator's flagOutcome(CORRUPTED, ...) transaction, point recoveryAddress to a scam wallet, and have the entire pool swept there.

// @> setRecoveryAddress: no guards on outcome, claimsStarted, or any lock
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address old = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(old, newRecoveryAddress);
}

Risk

Likelihood:

  • A CORRUPTED registry transition becomes visible in the mempool and the moderator's flagOutcome transaction is pending — the sponsor front-runs it with setRecoveryAddress

  • The sponsor can also change recoveryAddress between partial sweeps under good-faith CORRUPTED to redirect residual funds

Impact:

  • Under bad-faith CORRUPTED, the entire pool (principal + bonus) is swept to the sponsor's scam address instead of the address stakers verified at deposit time

  • Under good-faith CORRUPTED, the post-bounty residual is redirected to the sponsor's new address

Proof of Concept

// File: test/deepdive/DeepDivePoC.sol
function testPoC10_ownerFrontRunsCorruptedSweep() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// Move through active risk so a moderator CORRUPTED flag is possible.
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Sponsor front-runs the moderator flag with a new recovery address.
address scamWallet = makeAddr("scamWallet");
pool.setRecoveryAddress(scamWallet);
assertEq(pool.recoveryAddress(), scamWallet);
// Moderator flags bad-faith CORRUPTED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Full pool is swept to the new address, not the one Alice verified.
pool.claimCorrupted();
assertEq(token.balanceOf(scamWallet), 150 * ONE, "full pool swept to scam wallet");
assertEq(token.balanceOf(recovery), 0, "original recovery gets nothing");
assertEq(token.balanceOf(alice), 0, "alice gets nothing");
}

Run:

forge test --match-path test/deepdive/DeepDivePoC.sol --match-test testPoC10 -vv

Recommended Mitigation

+ bool public recoveryAddressLocked;
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ if (recoveryAddressLocked) revert RecoveryAddressLocked();
address old = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(old, newRecoveryAddress);
}
+ // In stake():
+ if (!recoveryAddressLocked) recoveryAddressLocked = true;

Alternatively, freeze at resolution time:

+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();

The first option preserves sponsor flexibility during pre-staking setup; the second is simpler but locks after first resolution.

Support

FAQs

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

Give us feedback!