Summary
Streams could be withdrawn and stolen from contract by malicious actors.
Vulnerability Details
In the SablierV2Lockup.sol (which Handles common logic between all Sablier V2 Lockup contracts), and is inherited by the SablierV2LockupDynamic.sol, SablierV2LockupLinear.sol and SablierV2LockupTranched.sol smart contracts. The contract has functions to enable the processing of withdraws by calling the following exposed withdrawal methods function withdraw (uint256 streamId, address to, uint128 amount) public
, function withdrawMax(uint256 streamId, address to) external
,function withdrawMaxAndTransfer(uint256 streamId, address newRecipient) external
and function withdrawMultiple(uint256[] calldata streamIds,uint128[] calldata amounts) external
, however most of the logic that ensures the integrity of the process takes place in the public withdraw function and the transfer proper done in the contract's internal withdraw function function _withdraw(uint256 streamId, address to, uint128 amount) internal
. The issue is the internal function does not carry out the necessary checks to ensure the integrity of the withdrawal process like the public withdraw function( function withdraw (uint256 streamId, address to, uint128 amount) public
) does. As Internal functions are accessible to any derived contract, this could lead to malicious users withdrawing tokens with valid streamid meant for a different recipient or recipients also withdrawing before stream expires, basically it exposes the contract to serious abuse. Internal functions can be called within the contract that defines them and in derived contracts. Since internal Function is marked as internal, it cannot be called by external contracts or users, but it can be called within the contract itself and in any derived contracts. Also, even if the contracts are already deployed on the blockchain, the internal functions can still be called through inheritance. When you deploy a new contract that inherits from another contract, the new contract's bytecode includes the inherited functions, making them available to the inheriting contract.
Impact
There is potential for loss of funds to users of the protocol, the impact is high although the likelihood is medium as a valid streamid would be required to ensure it succeeds. Moreover, Valid streamids shared between actors are susceptible to been abused.
Tools Used
manual review
Recommendations
The major logics used in the function withdraw (uint256 streamId, address to, uint128 amount) public
should instead be moved to the internal function where the transfer of tokens to the recipient address is actually carried out.
- function withdraw(
uint256 streamId,
address to,
uint128 amount
)
public
override
noDelegateCall
notNull(streamId)
updateMetadata(streamId)
{
if (_streams[streamId].isDepleted) {
revert Errors.SablierV2Lockup_StreamDepleted(streamId);
}
if (to == address(0)) {
revert Errors.SablierV2Lockup_WithdrawToZeroAddress(streamId);
}
if (amount == 0) {
revert Errors.SablierV2Lockup_WithdrawAmountZero(streamId);
}
address recipient = _ownerOf(streamId);
if (to != recipient && !_isCallerStreamRecipientOrApproved(streamId)) {
revert Errors.SablierV2Lockup_WithdrawalAddressNotRecipient(streamId, msg.sender, to);
}
uint128 withdrawableAmount = _withdrawableAmountOf(streamId);
if (amount > withdrawableAmount) {
revert Errors.SablierV2Lockup_Overdraw(streamId, amount, withdrawableAmount);
}
address sender = _streams[streamId].sender;
_withdraw(streamId, to, amount);
if (msg.sender != recipient && recipient.code.length > 0) {
try ISablierV2Recipient(recipient).onLockupStreamWithdrawn({
streamId: streamId,
caller: msg.sender,
to: to,
amount: amount
}) { } catch { }
}
if (msg.sender != sender && sender.code.length > 0 && sender != recipient) {
try ISablierV2Sender(sender).onLockupStreamWithdrawn({
streamId: streamId,
caller: msg.sender,
to: to,
amount: amount
}) { } catch { }
}
}
+ function withdraw(
uint256 streamId,
address to,
uint128 amount
)
public
override
noDelegateCall
notNull(streamId)
updateMetadata(streamId)
{
_withdraw(uint256 streamId, address to, uint128 amount);
}
The logic should be in the internal function to avoid bypass.
+
function _withdraw(uint256 streamId, address to, uint128 amount) internal {
if (_streams[streamId].isDepleted) {
revert Errors.SablierV2Lockup_StreamDepleted(streamId);
}
if (to == address(0)) {
revert Errors.SablierV2Lockup_WithdrawToZeroAddress(streamId);
}
if (amount == 0) {
revert Errors.SablierV2Lockup_WithdrawAmountZero(streamId);
}
address recipient = _ownerOf(streamId);
if (to != recipient && !_isCallerStreamRecipientOrApproved(streamId)) {
revert Errors.SablierV2Lockup_WithdrawalAddressNotRecipient(streamId, msg.sender, to);
}
uint128 withdrawableAmount = _withdrawableAmountOf(streamId);
if (amount > withdrawableAmount) {
revert Errors.SablierV2Lockup_Overdraw(streamId, amount, withdrawableAmount);
}
address sender = _streams[streamId].sender;
_withdraw(streamId, to, amount);
if (msg.sender != recipient && recipient.code.length > 0) {
try ISablierV2Recipient(recipient).onLockupStreamWithdrawn({
streamId: streamId,
caller: msg.sender,
to: to,
amount: amount
}) { } catch { }
}
if (msg.sender != sender && sender.code.length > 0 && sender != recipient) {
try ISablierV2Sender(sender).onLockupStreamWithdrawn({
streamId: streamId,
caller: msg.sender,
to: to,
amount: amount
}) { } catch { }
}
_streams[streamId].amounts.withdrawn = _streams[streamId].amounts.withdrawn + amount;
Lockup.Amounts memory amounts = _streams[streamId].amounts;
if (amounts.withdrawn >= amounts.deposited - amounts.refunded) {
_streams[streamId].isDepleted = true;
_streams[streamId].isCancelable = false;
}
IERC20 asset = _streams[streamId].asset;
asset.safeTransfer({ to: to, value: amount });
emit ISablierV2Lockup.WithdrawFromLockupStream(streamId, to, asset, amount);
}