setRecoveryAddress performs only two checks: onlyOwner and newRecoveryAddress != address(0). It does not check whether the pool outcome has been resolved:
The resolution functions — flagOutcome and the claimExpired auto-CORRUPTED branch — snapshot totalEligibleStake, totalBonus, and the k=2 sums at resolution, but never snapshot or freeze recoveryAddress. The three sweep paths that consume it all read the storage variable directly:
| Function | Line | Context |
|---|---|---|
claimCorrupted |
ConfidencePool.sol:423 |
Bad-faith CORRUPTED: sweeps balanceOf(pool) — entire TVL |
sweepUnclaimedCorrupted |
ConfidencePool.sol:468 |
Good-faith CORRUPTED: remainder after bounty deadline |
sweepUnclaimedBonus |
ConfidencePool.sol:506 |
SURVIVED/EXPIRED: unclaimed bonus/excess |
The gap is a failure to apply the same staker-reliance protection to recoveryAddress that the design explicitly applies to expiry. DESIGN.md §10 states the rationale for expiryLocked:
"This protects staker reliance: once anyone has deposited against a given deadline, the sponsor cannot move it."
Stakers equally rely on recoveryAddress when deciding to deposit — it determines where their principal goes in the worst case — yet it receives no equivalent protection.
Under bad-faith CORRUPTED, claimCorrupted sets toSweep = stakeToken.balanceOf(address(this)) and transfers the entire amount to the live recoveryAddress. A post-resolution repoint diverts all staker principal plus all contributed bonus to the sponsor's chosen address. The function is permissionless, so the sponsor (or any bot) can trigger the sweep immediately.
Stakers cannot exit: withdrawals are permanently disabled once riskWindowStart seals (from UNDER_ATTACK onward). They committed capital against a specific recovery destination and have no on-chain mechanism to detect or prevent a post-resolution change.
The pool design locks sponsor-controlled parameters once stakers commit capital — expiry is frozen on first stake via expiryLocked (ConfidencePool.sol:229-231), scope is frozen once the registry leaves pre-attack staging. However, recoveryAddress — which determines where 100% of staker principal goes under bad-faith CORRUPTED — is never frozen. The sponsor can call setRecoveryAddress (ConfidencePool.sol:611-618) after the outcome is sealed, and every sweep function reads the value live at call time. This lets the sponsor silently substitute the sweep destination after stakers are irrevocably locked in and the outcome is finalized.
Impact: High. The vulnerability allows the sponsor to divert 100% of the pool's TVL (all staker principal + bonus) to an address of their choosing.
Likelihood: Low/Medium. Requires a malicious sponsor, coupled with either a moderator falsely flagging bad-faith CORRUPTED (collusion) or the 180-day moderator absence backstop triggering. While these preconditions lower the likelihood, the protocol explicitly models the 180-day backstop as a valid fallback path.
Overall Risk: Medium. The finding highlights a severe design inconsistency in how sponsor-controlled parameters are locked, leading to a complete loss of funds under specific, documented protocol states.
Place in test/unit/RecoveryAddressMutationPoC.t.sol and run:
Output:
Manual review, Foundry
Snapshot recoveryAddress at resolution time and use the snapshot in all sweep paths. This mirrors how snapshotTotalStaked and snapshotTotalBonus are already captured at flagOutcome/claimExpired:
Then in the claimExpired auto-CORRUPTED branch:
And replace all sweep-path references:
This preserves the sponsor's ability to update recoveryAddress pre-resolution (the §10 trust surface) while ensuring that once an outcome is determined, the sweep destination is locked to what stakers verified.
Alternatively, the simpler one-line fix of adding if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet(); to setRecoveryAddress achieves the same protection with less code change.
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.