Summary
checkUpkeep function misses to validate a limit on the number of withdrawals processed.
And WithdrawalPool contract´s _finalizeWithdrawals function may revert due to missing to validate the max. number of withdrawals.
If many withdrawal requests accumulate, the gas required to process them all can exceed the block gas limit, causing the transaction to revert and halting the withdrawal process for users.
Vulnerability Details
When users request withdrawals that exceed the WithdrawalPool's available balance, their requests are queued in the queuedWithdrawals array.
The performUpkeep function is designed to process these queued withdrawals by calling _finalizeWithdrawals. And this one is triggered by querying checkUpKeep off-chain.
However checkUpkeep does not account for queue size:
Contract: WithdrawalPool.sol
333: function checkUpkeep(bytes calldata) external view returns (bool, bytes memory) {
334: if (
335: _getStakeByShares(totalQueuedShareWithdrawals) != 0 &&
336: priorityPool.canWithdraw(address(this), 0) != 0 &&
337: block.timestamp > timeOfLastWithdrawal + minTimeBetweenWithdrawals
338: ) {
339: return (true, "");
340: }
341: return (false, "");
342: }
So if above condition returns true, the CRON job can call performUpkeep to execute them and calls _finalizeWithdrawals at the end:
Contract: WithdrawalPool.sol
348: function performUpkeep(bytes calldata _performData) external {
349: uint256 canWithdraw = priorityPool.canWithdraw(address(this), 0);
350: uint256 totalQueued = _getStakeByShares(totalQueuedShareWithdrawals);
351: if (
352: totalQueued == 0 ||
353: canWithdraw == 0 ||
354: block.timestamp <= timeOfLastWithdrawal + minTimeBetweenWithdrawals
355: ) revert NoUpkeepNeeded();
356:
357: timeOfLastWithdrawal = uint64(block.timestamp);
358:
359: uint256 toWithdraw = totalQueued > canWithdraw ? canWithdraw : totalQueued;
360: bytes[] memory data = abi.decode(_performData, (bytes[]));
361:
362: priorityPool.executeQueuedWithdrawals(toWithdraw, data);
363: >>> _finalizeWithdrawals(toWithdraw);
364: }
However, _finalizeWithdrawals iterates over the entire queuedWithdrawals array without any limit, which can lead to excessive gas consumption if the array is large.
Contract: WithdrawalPool.sol
422: function _finalizeWithdrawals(uint256 _amount) internal {
423: uint256 sharesToWithdraw = _getSharesByStake(_amount);
424: uint256 numWithdrawals = queuedWithdrawals.length;
425:
426: totalQueuedShareWithdrawals -= sharesToWithdraw;
427:
428: for (uint256 i = indexOfNextWithdrawal; i < numWithdrawals; ++i) {
429: uint256 sharesRemaining = queuedWithdrawals[i].sharesRemaining;
430:
431: if (sharesRemaining < sharesToWithdraw) {
432:
433: sharesToWithdraw -= sharesRemaining;
434: continue;
435: }
436:
437: if (sharesRemaining > sharesToWithdraw) {
438:
439: queuedWithdrawals[i] = Withdrawal(
440: uint128(sharesRemaining - sharesToWithdraw),
441: uint128(
442: queuedWithdrawals[i].partiallyWithdrawableAmount +
443: _getStakeByShares(sharesToWithdraw)
444: )
445: );
446: indexOfNextWithdrawal = i;
447: withdrawalBatches.push(
448: WithdrawalBatch(uint128(i - 1), uint128(_getStakeByShares(1 ether)))
449: );
450: } else {
451:
452: indexOfNextWithdrawal = i + 1;
453: withdrawalBatches.push(
454: WithdrawalBatch(uint128(i), uint128(_getStakeByShares(1 ether)))
455: );
456: }
457:
458: sharesToWithdraw = 0;
459: break;
460: }
461:
462:
463: assert(sharesToWithdraw == 0);
464:
465: emit WithdrawalsFinalized(_amount);
466: }
Impact
DOS
Tools Used
Manual Review
Recommendations
Modify the checkUpkeep for checking a threshold qeueu size.