Liquid Staking

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

DoS due to Out Of Gas Vulnerability due to Large Withdrawal Arrays in `getBatchIds` Function

Summary

The getBatchIds function within the smart contract contains a vulnerability that can lead to an Out-of-Gas (OOG) error and Denial-of-Service (DoS). This vulnerability arises when a large number of withdrawal requests are accumulated for a single user, causing the _withdrawalIds array to grow excessively. The function’s nested loop structure, which iterates over _withdrawalIds and withdrawalBatches arrays, increases gas consumption linearly with the array size, potentially leading to gas limit exhaustion.

Vulnerability Details

  • Function Involved: getBatchIds(uint256[] memory _withdrawalIds)

  • Issue: The _withdrawalIds array, passed as a parameter to getBatchIds, can become very large as a result of users making frequent or small withdrawals. The function contains a nested loop that iterates over both _withdrawalIds and the withdrawalBatches arrays, leading to an exponential increase in gas costs as the array size grows.

  • Scenario: Users can accumulate a large number of withdrawal requests, either intentionally or unintentionally, resulting in a long list of withdrawal IDs. When querying the corresponding batch IDs using getBatchIds, the large size of the _withdrawalIds array, combined with the nested loops, may cause the function to hit the gas limit, resulting in a transaction revert.

Here is a detailed path how the Denial-of-Service is possible:

Step 1: User Requests Withdrawals in Priority Pool

  • The user calls onTokenTransfer or withdraw which leads to _withdraw being called internally in the PriorityPool contract.

  • If there isn’t enough liquidity in the pool to satisfy the withdrawal request (i.e., toWithdraw > totalQueued), the contract will queue the remaining amount for withdrawal by calling withdrawalPool.queueWithdrawal.

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

Step 2: queueWithdrawal Adds Withdrawal to the Pool

  • When queueWithdrawal is called, it performs the following:

    1. Checks that the withdrawal amount is above minWithdrawalAmount.

    2. Transfers the amount from the user to the contract.

    3. Pushes a new Withdrawal struct to the queuedWithdrawals array.

    4. Updates the user's withdrawal list (queuedWithdrawalsByAccount) by appending the new withdrawalId.

queuedWithdrawals.push(Withdrawal(uint128(sharesAmount), 0));
queuedWithdrawalsByAccount[_account].push(withdrawalId);
withdrawalOwners[withdrawalId] = _account;
  • Each withdrawal request by the user results in a new withdrawal ID being created and stored in the mappings queuedWithdrawalsByAccount and withdrawalOwners.

Step 3: User Accumulates Many Withdrawal Requests

  • A single user can request many small withdrawals. For example:

    • If the contract allows the user to request multiple withdrawals and the user is not constrained by any time limits or minimum amount (other than minWithdrawalAmount), they can repeatedly call functions that trigger _withdraw and end up queuing many small withdrawals.

    • Each call to queueWithdrawal adds a new entry to the queuedWithdrawals array, and the user’s queuedWithdrawalsByAccount mapping grows in size.

    Over time, this results in the user having many withdrawal IDs.

Step 4: User Queries Withdrawal Batch Information

  • Eventually, the user wants to retrieve information about the batches corresponding to their many withdrawal requests or calls getFinalizedWithdrawalIdsByOwner function, which calls getBatchIds function.

    • The user calls getBatchIds, passing in the array of _withdrawalIds that was generated when they queued the withdrawals. This array can contain hundreds or even thousands of IDs, depending on how many withdrawal requests the user made.

function getBatchIds(uint256[] memory _withdrawalIds) public view returns (uint256[] memory) { // q Is it possible a DoS on this function?
uint256[] memory batchIds = new uint256[]();
for (uint256 i = 0; i < _withdrawalIds.length; ++i) {
uint256 batchId;
uint256 withdrawalId = _withdrawalIds[i];
for (uint256 j = withdrawalBatchIdCutoff; j < withdrawalBatches.length; ++j) {
uint256 indexOfLastWithdrawal = withdrawalBatches[j].indexOfLastWithdrawal;
if (withdrawalId <= indexOfLastWithdrawal) {
batchId = j;
break;
}
}
batchIds[i] = batchId;
}
return batchIds;
}
  • Here, the function does the following:

    1. Iterates over the entire _withdrawalIds array (loop over i).

    2. For each withdrawal ID, it then iterates over the entire withdrawalBatches array (loop over j), checking whether the withdrawalId matches the batch's indexOfLastWithdrawal.

    Since both loops can grow large, the gas consumption increases exponentially as the number of _withdrawalIds increases.

Step 5: Gas Limit Exceeded in getBatchIds

  • If the user has accumulated a large number of withdrawal IDs (say, 500–1000), and the withdrawalBatches array is also large, the nested loops will quickly cause the function to consume more gas than is allowed by the block gas limit.

  • As a result, the transaction will revert due to Out-of-Gas (OOG), causing the getBatchIds function to fail.

Step 6: Potential DoS Effect

  • Denial of Service for Large Withdrawal Requests:

    • Any user (or attacker) with a large number of queued withdrawals can trigger this gas exhaustion issue.

    • Once the number of _withdrawalIds becomes too large to process within a single transaction (due to the nested loops), it becomes impossible for the user to retrieve their batch IDs using getBatchIds.

    • The user’s withdrawal information becomes inaccessible via this function, effectively causing a Denial-of-Service for that user.

    • This DoS condition could also impact the whole contract if batch queries are essential for other parts of the contract's operation (e.g., processing withdrawals in batches).

PoC

If you run the testLargeInput function from below, you will see that it will revert due to OOG:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
contract DoSExample {
// a withdrawal batch created when some withdrawals were finalized
struct WithdrawalBatch {
// index of last withdrawal that was finalized in this batch
uint128 indexOfLastWithdrawal;
// the exchange rate of LSTs per underlying shares at the time of this batch
uint128 stakePerShares;
}
uint128 public withdrawalBatchIdCutoff = 3;
WithdrawalBatch[] public withdrawalBatches;
// Populate the batches to simulate the DoS
function populateBatches() public {
for (uint128 i = 1; i <= 500; i++) {
withdrawalBatches.push(WithdrawalBatch({
indexOfLastWithdrawal: i * 2, // Arbitrary index
stakePerShares: 100
}));
}
}
function testLargeInput() public {
uint256[] memory largeWithdrawalIds;
for (uint256 i = 0; i < 1000; i++) {
largeWithdrawalIds[i] = i + 1;
}
getBatchIds(largeWithdrawalIds);
}
function getBatchIds(uint256[] memory _withdrawalIds) public view returns (uint256[] memory) {
uint256[] memory batchIds = new uint256[]();
for (uint256 i = 0; i < _withdrawalIds.length; ++i) {
uint256 batchId;
uint256 withdrawalId = _withdrawalIds[i];
for (uint256 j = withdrawalBatchIdCutoff; j < withdrawalBatches.length; ++j) {
uint256 indexOfLastWithdrawal = withdrawalBatches[j].indexOfLastWithdrawal;
if (withdrawalId <= indexOfLastWithdrawal) {
batchId = j;
break;
}
}
batchIds[i] = batchId;
}
return batchIds;
}
}

Impact

  • Denial-of-Service (DoS): A user with a large number of withdrawal requests could unintentionally cause their query to fail due to an OOG error. This could prevent them from retrieving their batch IDs. Additionally, a malicious user could exploit this by intentionally submitting many small withdrawals to overload the system, potentially preventing other users from querying their batch IDs.

  • Gas Limit Issues: The vulnerability primarily affects contract performance by leading to excessively high gas costs, especially in situations where multiple users are queuing and querying withdrawals.

Tools Used

Manual Review

Recommendations

  • Batch Processing of Withdrawal Queries: Limit the number of withdrawal IDs that can be passed to getBatchIds in a single transaction. For instance, restrict users to querying only a maximum of 100 withdrawal IDs at a time, allowing the rest to be processed in subsequent calls.

  • Pagination Implementation: Introduce pagination to allow users to query their withdrawal IDs and batch IDs over several smaller transactions instead of handling them all at once. This would reduce gas costs per transaction.

  • Direct Mapping Optimization: Consider storing a direct mapping between each withdrawalId and its corresponding batchId. This would remove the need for iterating through the withdrawalBatches array and reduce the gas consumption by eliminating the nested loop.

  • Frequency or Volume Limits: Implement a limit on how frequently or how many withdrawal requests a single user can queue within a specific timeframe. This could prevent malicious users from spamming the system with small withdrawal requests.

Updates

Lead Judging Commences

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

M-1 Cyfrin not properly fixed - if someone forgets to withdraw the withdrawalBatches array is still ever increasing

Appeal created

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

M-1 Cyfrin not properly fixed - if someone forgets to withdraw the withdrawalBatches array is still ever increasing

Support

FAQs

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