Summary
Because the SablierV2LockupLinear._calculateStreamedAmount
function executes division before multiplication, when a stream is cancelled, the recipient of the stream can receive less than the recipient is entitled to, and the sender of the stream can receive more than the sender is entitled to.
Vulnerability Details
When block.timestamp
is after _cliffs[streamId]
and before _streams[streamId].endTime
, calling the following SablierV2LockupLinear._calculateStreamedAmount
function would executes UD60x18 elapsedTimePercentage = elapsedTime.div(totalDuration)
before UD60x18 streamedAmount = elapsedTimePercentage.mul(depositedAmount)
. Because division is executed before multiplication, streamedAmount
can be smaller than what it should be.
https://github.com/Cyfrin/2024-05-Sablier/blob/1fe69e059dcbd71728bdfaa58dad85ff199ee6dc/v2-core/src/SablierV2LockupLinear.sol#L189-L230
function _calculateStreamedAmount(uint256 streamId) internal view override returns (uint128) {
uint256 cliffTime = uint256(_cliffs[streamId]);
uint256 blockTimestamp = block.timestamp;
if (cliffTime > blockTimestamp) {
return 0;
}
uint256 endTime = uint256(_streams[streamId].endTime);
if (blockTimestamp >= endTime) {
return _streams[streamId].amounts.deposited;
}
unchecked {
uint256 startTime = uint256(_streams[streamId].startTime);
UD60x18 elapsedTime = ud(blockTimestamp - startTime);
UD60x18 totalDuration = ud(endTime - startTime);
UD60x18 elapsedTimePercentage = elapsedTime.div(totalDuration);
UD60x18 depositedAmount = ud(_streams[streamId].amounts.deposited);
UD60x18 streamedAmount = elapsedTimePercentage.mul(depositedAmount);
if (streamedAmount.gt(depositedAmount)) {
return _streams[streamId].amounts.withdrawn;
}
return uint128(streamedAmount.intoUint256());
}
}
When the following SablierV2Lockup.cancel
function is called, the SablierV2Lockup._cancel
function below is called, which executes uint128 streamedAmount = _calculateStreamedAmount(streamId)
. Since streamedAmount
can be smaller than what it should be, _streams[streamId].amounts.refunded
, which equals amounts.deposited - streamedAmount
, can be bigger than what it should be.
https://github.com/Cyfrin/2024-05-Sablier/blob/1fe69e059dcbd71728bdfaa58dad85ff199ee6dc/v2-core/src/abstracts/SablierV2Lockup.sol#L256-L271
function cancel(uint256 streamId) public override noDelegateCall notNull(streamId) {
...
_cancel(streamId);
}
https://github.com/Cyfrin/2024-05-Sablier/blob/1fe69e059dcbd71728bdfaa58dad85ff199ee6dc/v2-core/src/abstracts/SablierV2Lockup.sol#L551-L617
function _cancel(uint256 streamId) internal {
uint128 streamedAmount = _calculateStreamedAmount(streamId);
Lockup.Amounts memory amounts = _streams[streamId].amounts;
...
uint128 senderAmount;
unchecked {
senderAmount = amounts.deposited - streamedAmount;
}
uint128 recipientAmount = streamedAmount - amounts.withdrawn;
...
_streams[streamId].amounts.refunded = senderAmount;
address sender = _streams[streamId].sender;
address recipient = _ownerOf(streamId);
IERC20 asset = _streams[streamId].asset;
asset.safeTransfer({ to: sender, value: senderAmount });
...
if (recipient.code.length > 0) {
try ISablierV2Recipient(recipient).onLockupStreamCanceled({
streamId: streamId,
sender: sender,
senderAmount: senderAmount,
recipientAmount: recipientAmount
}) { } catch { }
}
}
Impact
When a stream is cancelled, the recipient of the stream can receive an amount that is lower than what the recipient is entitled to, and the sender of the stream can receive an amount that is higher than what the sender is entitled to.
Proof of Concept
Please add the following test file in v2-core\test\unit\fuzz
. This testFuzz_SablierV2LockupLinear_calculateStreamedAmountFunctionExecutesDivisionBeforeMultiplication
test will pass to demonstrate the described scenario.
pragma solidity >=0.8.22 <0.9.0;
import "forge-std/src/Test.sol";
import { UD60x18, ud } from "@prb/math/src/UD60x18.sol";
contract chTest is Test {
function testFuzz_SablierV2LockupLinear_calculateStreamedAmountFunctionExecutesDivisionBeforeMultiplication(uint128 deposited, uint40 startTime, uint40 blockTimestamp, uint40 endTime) external {
vm.assume(blockTimestamp > startTime);
vm.assume(endTime > blockTimestamp);
uint256 startTime = uint256(startTime);
UD60x18 elapsedTime = ud(blockTimestamp - startTime);
UD60x18 totalDuration = ud(endTime - startTime);
UD60x18 elapsedTimePercentage = elapsedTime.div(totalDuration);
UD60x18 depositedAmount = ud(deposited);
UD60x18 streamedAmount = elapsedTimePercentage.mul(depositedAmount);
uint128 streamedAmountReturned = uint128(streamedAmount.intoUint256());
uint128 streamedAmountExpected = uint128(elapsedTime.intoUint256() * depositedAmount.intoUint256() / totalDuration.intoUint256());
assertLe(streamedAmountReturned, streamedAmountExpected);
vm.assume(streamedAmountReturned < streamedAmountExpected);
assertLt(streamedAmountReturned, streamedAmountExpected);
}
}
Tools Used
Manual Review
Recommended Mitigation
Instead of executing UD60x18 elapsedTimePercentage = elapsedTime.div(totalDuration)
before UD60x18 streamedAmount = elapsedTimePercentage.mul(depositedAmount)
when block.timestamp
is after _cliffs[streamId]
and before _streams[streamId].endTime
, the SablierV2LockupLinear._calculateStreamedAmount
function can be updated to return the following value.
uint128(elapsedTime.intoUint256() * depositedAmount.intoUint256() / totalDuration.intoUint256())