Liquid Staking

Stakelink
DeFiHardhatOracle
50,000 USDC
View results
Submission Details
Severity: medium
Valid

Malicious user can dos the `removeSplitter` function in LSTRewardsSplitterController contract

Summary

Malicious users can front-run the removeSplitter function to transfer 1 wei lst token to the LSTRewardsSplitter and then the removeSplitter function will revert

Vulnerability Details

In removeSplitter function, if the balance of LST tokens is not equal to principalDeposits, the function calls splitter.splitRewards() :

uint256 balance = IERC20(lst).balanceOf(address(splitter));
uint256 principalDeposits = splitter.principalDeposits();
if (balance != 0) {
if (balance != principalDeposits) splitter.splitRewards();
splitter.withdraw(balance, _account);
}

The splitRewards function will distribute some of the currently held LST tokens to the fee receivers:

function splitRewards() external {
int256 newRewards = int256(lst.balanceOf(address(this))) - int256(principalDeposits);
if (newRewards < 0) {
principalDeposits -= uint256(-1 * newRewards);
} else if (newRewards == 0) {
revert InsufficientRewards();
} else {
_splitRewards(uint256(newRewards));
}
}
function _splitRewards(uint256 _rewardsAmount) private {
for (uint256 i = 0; i < fees.length; ++i) {
Fee memory fee = fees[i];
uint256 amount = (_rewardsAmount * fee.basisPoints) / 10000;
if (fee.receiver == address(lst)) {
IStakingPool(address(lst)).burn(amount);
} else {
lst.safeTransfer(fee.receiver, amount);
}
}
principalDeposits = lst.balanceOf(address(this));
emit RewardsSplit(_rewardsAmount);
}

However, after this distribution, the remaining balance of the LSTRewardsSplitter contract could be lower than the balance that was originally retrieved and intended for withdrawal.

When calling withdraw(balance, _account), if the balance has decreased due to the distribution of fees/withdrawals, this could result in an issue since the splitter will now attempt to withdraw more tokens than it has.

As a result, a malicious user can exploit the logic of the removeSplitter function by front-running it and transferring 1 wei LST token to LSTRewardsSplitter contract, effectively ensuring that the withdrawal logic fails due to an unexpected balance change in the LSTRewardsSplitter.

Here's how the exploit can occur in a sequence of steps:

  1. Observe the Blockchain State:

    • A malicious user observes the blockchain and sees that an owner is about to call the removeSplitter function for a specific splitter. They can predict or monitor the transaction and its subsequent effects on the balance.

  2. Transfer 1 Wei LST:

    • Just before (or at the same time as) the owner's transaction executes, the malicious actor submits a transaction that transfers 1 wei of LST tokens directly to the LSTRewardsSplitter contract. This can be done using an ERC20 transfer method.

  3. Call to removeSplitter Execution:

    • The owner’s transaction calls removeSplitter, which follows the logic of first checking the balance of the splitter.

    • If the balance was, for example, 100e18 LST during the initial balance check, this would allow the owner to assume they can withdraw this full amount.

  4. Effect of Front-Run:

    • With the addition of the 1 wei token from the malicious user, the balance might now read as (100e18+1) wei.

    • The function may then call splitter.splitRewards() if needed, which could alter the balance further.

  5. Withdrawal Error:

    • The withdraw function will fail because it tries to transfer the entire originally retrieved amount , but because of the misalignment caused by the front-run transaction and potential adjustments during splitRewards, it results in an insufficient balance state for the intended withdrawal.

Impact

Malicious users can dos removeSplitter function with a small or close to 0 capital cost.

Tools Used

Manual Review

Recommendations

Consider following fix:

function removeSplitter(address _account) external onlyOwner {
ILSTRewardsSplitter splitter = splitters[_account];
if (address(splitter) == address(0)) revert SplitterNotFound();
uint256 balance = IERC20(lst).balanceOf(address(splitter));
uint256 principalDeposits = splitter.principalDeposits();
if (balance != 0) {
if (balance != principalDeposits) splitter.splitRewards();
// refresh the balance again after splitting rewards
balance = IERC20(lst).balanceOf(address(splitter)); // Re-fetch balance
splitter.withdraw(balance, _account);
}
delete splitters[_account];
// Remaining code...
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

In `removeSplitter` the `principalDeposits` should be used as an input for `withdraw` instead of balance after splitting the existing rewards.

Support

FAQs

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