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

Permissionless settlement functions can front-run recovery address updates, causing funds to be transferred to an outdated recovery address

Author Revealed upon completion

Permissionless settlement functions can front-run recovery address updates, causing funds to be transferred to an outdated recovery address

Description

The vulnerability arises from the lack of synchronization between setRecoveryAddress() and the permissionless settlement functions that transfer funds to recoveryAddress.

The issue occurs as follows:

  • The pool reaches a state where settlement functions become available (e.g., claimCorrupted(), sweepUnclaimedCorrupted(), or sweepUnclaimedBonus()).

  • The current recoveryAddress becomes invalid, compromised, or no longer controlled by the owner.

  • The owner submits a transaction to update the recovery address using:

setRecoveryAddress(newRecoveryAddress)
  • Since settlement functions are permissionless, an external party monitoring the mempool can detect the pending setRecoveryAddress() transaction.

  • The attacker submits a settlement transaction with higher priority fees, causing it to execute before the owner's update transaction.

  • The settlement function reads the old recoveryAddress value and transfers the funds to the outdated address.

  • The owner's setRecoveryAddress() transaction executes afterward, but the settlement has already been finalized, making the update ineffective for the transferred funds.


```javascript
/// @inheritdoc IConfidencePool
@> function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
// aderyn-fp-next-line(reentrancy-state-change)
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
// Clamp the decrement — `toSweep` can exceed the original reserve when post-resolution
// donations have inflated the balance.
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}
@> function sweepUnclaimedCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (!goodFaith) revert NotGoodFaithCorrupted();
if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
// aderyn-fp-next-line(reentrancy-state-change)
uint256 amount = stakeToken.balanceOf(address(this));
if (amount == 0) revert NothingToSweep();
corruptedReserve = 0;
bountyClaimed = bountyEntitlement;
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, amount);
emit UnclaimedCorruptedSwept(msg.sender, recoveryAddress, amount);
}
@> function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
// Reserve principal still owed to non-claimers plus any bonus they're entitled to. When
// `riskWindowStart == 0` (no observable risk), `_bonusShare` returns 0 for everyone, so
// the bonus is not owed to any staker and the entire snapshotTotalBonus is sweepable.
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
// aderyn-fp-next-line(reentrancy-state-change)
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
// Bonus is only unreserved when no staker is owed it (no risk window, or no stakers left).
// In that case the sweep removes it from the pool, so drop it from the live `totalBonus`
// too — keeping the accounting honest for any later re-snapshot. Clamp to `totalBonus` so
// swept donations/dust (never counted in it) can't over-decrement or underflow.
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
// wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
// documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
```

Risk

Likelihood:

  • It can always happen.

Impact:

  • Funds are sent to the outdated recovery address.



Recommended Mitigation

* Add the `onlyOwner` modifier to these functions
```diff
- function claimCorrupted() external nonReentrant {
+ function claimCorrupted() external nonReentrant onlyOwner {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
// aderyn-fp-next-line(reentrancy-state-change)
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
// Clamp the decrement — `toSweep` can exceed the original reserve when post-resolution
// donations have inflated the balance.
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}
```
```diff
- function sweepUnclaimedCorrupted() external nonReentrant {
+ function sweepUnclaimedCorrupted() external nonReentrant onlyOwner {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (!goodFaith) revert NotGoodFaithCorrupted();
if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
// aderyn-fp-next-line(reentrancy-state-change)
uint256 amount = stakeToken.balanceOf(address(this));
if (amount == 0) revert NothingToSweep();
corruptedReserve = 0;
bountyClaimed = bountyEntitlement;
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, amount);
emit UnclaimedCorruptedSwept(msg.sender, recoveryAddress, amount);
}
```
```diff
- function sweepUnclaimedBonus() external nonReentrant {
+ function sweepUnclaimedBonus() external nonReentrant onlyOwner {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
// Reserve principal still owed to non-claimers plus any bonus they're entitled to. When
// `riskWindowStart == 0` (no observable risk), `_bonusShare` returns 0 for everyone, so
// the bonus is not owed to any staker and the entire snapshotTotalBonus is sweepable.
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
// aderyn-fp-next-line(reentrancy-state-change)
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
// Bonus is only unreserved when no staker is owed it (no risk window, or no stakers left).
// In that case the sweep removes it from the pool, so drop it from the live `totalBonus`
// too — keeping the accounting honest for any later re-snapshot. Clamp to `totalBonus` so
// swept donations/dust (never counted in it) can't over-decrement or underflow.
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
// wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
// documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
```

Support

FAQs

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

Give us feedback!