Summary
StakingPool.sol rewards can be sandwich attacked with a flash loan. Exploiters can frontrun the StakingPool::updateStrategyRewards function which transfers tokens in and deposit a huge amount of assets before this. Then, they can backrun it and withdraw the profit from the rewards that just came in and even pay off the flash loan.
Vulnerability Details
The StakingPool::updateStrategyRewards function brings in the new rewards in the StakingPool and increases the totalStaked variable:
StakingPool.sol
function updateStrategyRewards(uint256[] memory _strategyIdxs, bytes memory _data) external {
if (msg.sender != rebaseController && !_strategyExists(msg.sender)) {
revert SenderNotAuthorized();
}
_updateStrategyRewards(_strategyIdxs, _data);
}
function _updateStrategyRewards(uint256[] memory _strategyIdxs, bytes memory _data) private {
int256 totalRewards;
uint256 totalFeeAmounts;
uint256 totalFeeCount;
address[][] memory receivers = new address[][]();
uint256[][] memory feeAmounts = new uint256[][]();
for (uint256 i = 0; i < _strategyIdxs.length; ++i) {
IStrategy strategy = IStrategy(strategies[_strategyIdxs[i]]);
(int256 depositChange, address[] memory strategyReceivers, uint256[] memory strategyFeeAmounts) =
@> strategy.updateDeposits(_data);
totalRewards += depositChange;
if (strategyReceivers.length != 0) {
receivers[i] = strategyReceivers;
feeAmounts[i] = strategyFeeAmounts;
totalFeeCount += receivers[i].length;
for (uint256 j = 0; j < strategyReceivers.length; ++j) {
totalFeeAmounts += strategyFeeAmounts[j];
}
}
}
if (totalRewards != 0) {
@> totalStaked = uint256(int256(totalStaked) + totalRewards);
}
if (totalRewards > 0) {
.
.
.
}
OperatorVCS.sol
function updateDeposits(bytes calldata _data)
external
override
onlyStakingPool
returns (int256 depositChange, address[] memory receivers, uint256[] memory amounts)
{
.
.
.
uint256 balance = token.balanceOf(address(this));
.
.
.
if (balance != 0) {
@> token.safeTransfer(address(stakingPool), balance);
newTotalDeposits -= balance;
}
totalDeposits = newTotalDeposits;
totalPrincipalDeposits = newTotalPrincipalDeposits;
}
The exploiter can deposit before rewards update, get some shares which will be worth more assets after the rewards come in and totalStaked gets increased.
function getSharesByStake(uint256 _amount) public view returns (uint256) {
uint256 totalStaked = _totalStaked();
if (totalStaked == 0) {
return _amount;
} else {
return (_amount * totalShares) / totalStaked;
}
}
function getStakeByShares(uint256 _amount) public view returns (uint256) {
if (totalShares == 0) {
return _amount;
} else {
return (_amount * _totalStaked()) / totalShares;
}
}
Let's say there are currently 10e18 totalShares and 20e18 totalStaked
Exploiter frontruns the updateStrategyRewards function and deposits 100e18 assets
According to getSharesByStake, exploiter receives 100e18 * 10e18 / 20e18 = 50e18 shares
updateStrategyRewards increases the totalStaked by 5e18
StakingPool now has 20e18 + 100e18 + 5e18 = 125e18 totalStaked and 10e18 + 50e18 = 60e18 totalShares
Exploiter's 50e18 shares are now worth 50e18 * 125e18 / 60e18 = 104166666666666666666 = 104.16e18 assets
Exploiter can now backrun and withdraw his 1 transaction profit
Impact
Rewards distribution mechanism can be easily exploited and is flashloan-able. Normal stakers will not get the rewards they deserve and exploiters can get a huge portion of them in 1 transaction.
Tools Used
Manual review
Recommendations
Make the deposit a 2-transaction process. Queue every deposit and require users to call another function in a different block to finalize it.