The withdraw function in the StakingPool contract contains a severe race condition vulnerability that allows users to over-withdraw their staked tokens by executing multiple withdrawals in quick succession.
The vulnerable logic is connected to:
The _withdrawLiquidity function which withdraws from strategies to top up the pool balance
The _burn function which burns the user's staked balance (but uses the potentially stale toWithdraw amount)
The totalStaked state variable which tracks total staked amounts (and is decremented by toWithdraw)
Occurs because the function does not recheck the user's staked balance after potentially topping up the pool's balance by withdrawing from strategies. This can lead to a situation where a user can execute multiple withdrawals in quick succession, each one using stale balance information, and thereby drain more tokens than they are entitled to.
Here's how it happens:
The withdraw function first checks if the requested withdrawal amount exceeds the pool's current balance.
If so, it attempts to withdraw the deficit from the pool's strategies using _withdrawLiquidity.
After that, it only checks that the pool's updated balance is sufficient for the withdrawal.
However, it does not recheck the user's actual staked balance (their share of the total pool) after potentially updating the pool balance.
This allows a second withdrawal to be executed using stale balance information, potentially exceeding the user's real staked amount.
The issue stems from the fact that toWithdraw is not updated after calling _withdrawLiquidity, so the subsequent _burn, totalStaked update, and token.safeTransfer all use a potentially stale value.
The fact that toWithdraw is not updated after potentially topping up the pool balance by calling _withdrawLiquidity.
As a result, the subsequent _burn, totalStaked update, and token.safeTransfer all use a toWithdraw amount that may exceed the user's actual current staked balance, allowing them to over-withdraw tokens.
Consider the following scenario:
User stakes 100 tokens, so balanceOf(user) = 100 and totalStaked = 100
All 100 tokens are deposited into strategies, so token.balanceOf(pool) = 0
User calls withdraw(user, user, 80, data)
Pool balance is 0 < 80, so 80 tokens are withdrawn from strategies
Pool balance is now 80 >= 80, so the withdraw succeeds
_burn(user, 80), so balanceOf(user) = 20, and totalStaked = 20
80 tokens are transferred to user, so token.balanceOf(pool) = 0 again
User calls withdraw(user, user, 30, data)
Pool balance is 0 < 30, so 30 tokens are withdrawn from strategies
Pool balance is now 30 >= 30, so the second withdraw also succeeds
_burn(user, 30), so balanceOf(user) = -10, and totalStaked = -10
30 tokens are transferred to user, so in total they withdrew 110 tokens
The user was able to withdraw a total of 110 tokens despite only having staked 100 initially. This is made possible by the race condition vulnerability in the withdraw function.
Users can exploit this to withdraw more tokens than they actually staked
This will drain the pool's strategies and deficit the totalStaked accounting
Vs
The withdraw function must recheck the user's current staked balance after topping up the pool from strategies, and use the minimum of that updated balance and the requested withdrawal amount for the final transfer and burn. By rechecking balanceOf(_account) after potentially updating the pool balance, and taking the minimum of that and the original toWithdraw amount, we ensure that users can never withdraw more than their actual current staked balance.
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.