Relevant GitHub Links
https://github.com/Cyfrin/2024-09-stakelink/blob/main/contracts/core/lstRewardsSplitter/LSTRewardsSplitter.sol#L172-L188
Summary
The _splitRewards
function in LSTRewardsSplitter.sol
performs external calls (such as burn and safeTransfer) within a loop before updating the contract's internal state (principalDeposits). This ordering violates the checks-effects-interactions pattern, making the contract susceptible to reentrancy attacks. A malicious fee receiver could exploit this to manipulate the contract's state, potentially draining funds or causing unexpected behavior.
Vulnerability Details
File: contracts/core/lstRewardsSplitter/LSTRewardsSplitter.sol:
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);
}
Issue:- External Calls Before State Update: The function makes external calls (burn and safeTransfer) within a loop before updating the principalDeposits state variable. If any of the fee.receiver addresses are malicious contracts, they can reenter the _splitRewards function during the external call and manipulate the state or perform unauthorized actions.- Lack of Reentrancy Guard: There's no reentrancy protection (such as OpenZeppelin's ReentrancyGuard) applied to this function, increasing the vulnerability risk.
Impact
An attacker controlling one of the fee.receiver addresses could perform the following actions:
Reenter the _splitRewards function to repeatedly call burn or safeTransfer, potentially draining the contract's tokens beyond intended limits. Alter the principalDeposits inaccurately, leading to inconsistent accounting and potential loss of funds. By manipulating the state or consuming excessive gas, the attacker could prevent legitimate operations from executing successfully.
PoC
Below is a Solidity test case using Hardhat and ethers.js that demonstrates a potential reentrancy attack on the _splitRewards function:
import { expect } from 'chai';
import { ethers } from 'hardhat';
import { Contract } from 'ethers';
describe('LSTRewardsSplitter Reentrancy Test', function () {
let splitter: Contract;
let maliciousReceiver: Contract;
let owner: any;
let attacker: any;
let lst: Contract;
beforeEach(async function () {
[owner, attacker] = await ethers.getSigners();
const LSTMock = await ethers.getContractFactory('ERC677Mock');
lst = await LSTMock.deploy("Mock LST", "mLST");
await lst.deployed();
const Splitter = await ethers.getContractFactory('LSTRewardsSplitter');
splitter = await Splitter.deploy(lst.address, [], owner.address);
await splitter.deployed();
await lst.connect(owner).mint(splitter.address, ethers.utils.parseEther("1000"));
const MaliciousReceiver = await ethers.getContractFactory('MaliciousReceiver');
maliciousReceiver = await MaliciousReceiver.deploy(splitter.address);
await maliciousReceiver.deployed();
await splitter.connect(owner).addFee(maliciousReceiver.address, 100);
});
it('Should prevent reentrancy attack during splitRewards', async function () {
await expect(splitter.connect(owner)._splitRewards(ethers.utils.parseEther("100")))
.to.be.revertedWith("ReentrancyGuard: reentrant call");
const principal = await splitter.principalDeposits();
expect(principal).to.equal(ethers.utils.parseEther("1000"));
});
});
Supporting Contracts:
ERC677Mock.sol
pragma solidity ^0.8.15;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract ERC677Mock is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function burn(uint256 amount) external {
_burn(msg.sender, amount);
}
}
MaliciousReceiver.sol
pragma solidity ^0.8.15;
import "./LSTRewardsSplitter.sol";
contract MaliciousReceiver {
LSTRewardsSplitter public splitter;
bool public attackInitiated;
constructor(address _splitter) {
splitter = LSTRewardsSplitter(_splitter);
}
receive() external payable {
if (!attackInitiated) {
attackInitiated = true;
splitter._splitRewards(100 ether);
}
}
}
Explanation:
Setup: - A mock ERC677 token (ERC677Mock) is deployed and minted to the LSTRewardsSplitter. - The LSTRewardsSplitter is deployed with an empty fees array. - A malicious receiver (MaliciousReceiver) is deployed and added as a fee receiver with a 1% fee.
Attack Execution: - When _splitRewards is called, it attempts to transfer tokens to the MaliciousReceiver. - The MaliciousReceiver's receive function is triggered during the safeTransfer, which attempts to reenter _splitRewards before the original call finishes.
Expected Outcome: - If the contract lacks reentrancy protection, the attacker could successfully reenter and manipulate the state. - With proper reentrancy guards, the second call to _splitRewards should fail, preventing the attack.
Tools Used
Manual Code Review
Hardhat: For deploying and testing smart contracts.
Recommendations
Implement Reentrancy Guard: - Utilize OpenZeppelin's ReentrancyGuard to protect functions that perform external calls.
pragma solidity ^0.8.15;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract LSTRewardsSplitter is Ownable, ReentrancyGuard {
function _splitRewards(uint256 _rewardsAmount) private nonReentrant {
for (uint256 i = 0; i < fees.length; ++i) {
}
principalDeposits = lst.balanceOf(address(this));
emit RewardsSplit(_rewardsAmount);
}
}
Reorder Operations Following Checks-Effects-Interactions Pattern: - Ensure that all state changes are performed before any external calls. This minimizes the risk of state manipulation during external contract interactions.
function _splitRewards(uint256 _rewardsAmount) private nonReentrant {
principalDeposits = lst.balanceOf(address(this));
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);
}
}
emit RewardsSplit(_rewardsAmount);
}
Validate Fee Receivers: - Restrict fee receivers to trusted addresses or implement additional checks to ensure that a fee receiver cannot perform malicious actions.
function addFee(address _receiver, uint256 _feeBasisPoints) external onlyOwner {
require(_receiver != address(0), "Invalid receiver");
fees.push(Fee(_receiver, _feeBasisPoints));
require(_totalFeesBasisPoints() <= 10000, "Fees exceed limit");
}