recoveryAddress is the destination for every CORRUPTED-path fund movement: the full bad-faith sweep (claimCorrupted), the unclaimed remainder of a good-faith bounty after the 180-day claim window (sweepUnclaimedCorrupted), and unclaimed bonus dust under SURVIVED/EXPIRED (sweepUnclaimedBonus). Two other sponsor-controlled parameters with comparable staker impact — expiry and pool scope — are each protected by a one-way latch (expiryLocked, scopeLocked) that permanently freezes them once staker reliance begins (first stake / registry leaving pre-attack staging, respectively).
recoveryAddress has no equivalent latch, and — unlike attacker/bountyEntitlement, which are snapshotted at flagOutcome time specifically so the payout target can't be moved after resolution — every sweep function reads recoveryAddress live, at the moment of transfer. This means the pool owner can redirect the entire sweep destination at any time, including after the outcome has already been resolved to CORRUPTED and a sweep is imminent, with no on-chain mechanism giving stakers a chance to react.
Compare to flagOutcome, which deliberately freezes the attacker/bounty target the moment CORRUPTED is flagged:
Likelihood:
This requires only the pool's existing Ownable2Step owner (the sponsor who created the pool) to call one already-existing, correctly-permissioned function (setRecoveryAddress) they can call at any time in the pool's life — no exotic preconditions, no timing race against another actor, no reliance on a bug elsewhere. It fires whenever the sponsor chooses to call it before a pending sweep executes.
claimCorrupted, sweepUnclaimedCorrupted, and sweepUnclaimedBonus are all permissionless, so any third party's routine cleanup transaction sitting in the mempool is a visible target: the sponsor calls setRecoveryAddress first (or bundles it via a contract wallet) so their transaction is mined before the sweep, at zero cost beyond one function call's gas.
A one-time key compromise of the sponsor's address is sufficient at any point during the pool's lifetime, not only at creation, since the lever stays live for as long as the pool holds a balance destined for CORRUPTED-path resolution.
Impact:
Complete redirection of stakers' principal and bonus away from the recovery address they verified before depositing, with no way for stakers to contest or exit it: withdraw() is already permanently disabled by the time CORRUPTED is reachable (riskWindowStart != 0 is a precondition for CORRUPTED, and the same flag latches withdraw shut), so there is no defensive action available even though RecoveryAddressUpdated is emitted on-chain.
Applies to every dollar destined for recoveryAddress across all three CORRUPTED/cleanup paths, not a bounded subset — the bad-faith full-pool sweep, the good-faith unclaimed remainder (up to the full 180-day claim window), and residual bonus dust are all exposed identically.
Run RecoveryAddressFrontRun.t.sol:
Run:
Actual output against the in-scope contracts at the audited commit:
Load-bearing excerpt from the -vvvv trace — the setRecoveryAddress call lands after OutcomeFlagged, and claimCorrupted's Transfer/ClaimCorrupted events pay the new address, not the one stakers saw at deposit time:
Freeze recoveryAddress at the moment the outcome resolves to CORRUPTED, mirroring how attacker/bountyEntitlement are already snapshotted in the same function — sweep functions should pay the frozen value, not the live storage slot:
Apply the same substitution in sweepUnclaimedCorrupted. sweepUnclaimedBonus (SURVIVED/EXPIRED path) would need its own resolution-time snapshot, taken wherever outcome is first set to SURVIVED/EXPIRED (flagOutcome and claimExpired), for the same reason. A lighter-weight alternative — if freezing is judged too disruptive to the sponsor's legitimate need to update recoveryAddress pre-resolution — is a timelock on setRecoveryAddress (e.g. a minimum delay between the call and its effect) long enough for stakers to observe and react before it applies to a pending or future sweep.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.