FoundrySolidityLayer 2
7.25 ETH
View results
Submission Details
Impact: high
Likelihood: low
Invalid

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

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 month ago
inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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

Give us feedback!