Summary
In SablierFlow's withdrawal mechanism causes incorrect debt accounting when users withdraw amounts larger than the snapshot debt. The issue results in users receiving less funds than entitled due to improper handling of ongoing debt calculations when the snapshot time is reset.
Vulnerability Details
https://github.com/Cyfrin/2024-10-sablier/blob/963bf61b9d8ffe3eb06cbcf1c53f0ab88dbf0eb0/src/SablierFlow.sol#L826-L836
if (amountScaled <= _streams[streamId].snapshotDebtScaled) {
_streams[streamId].snapshotDebtScaled -= amountScaled;
}
else {
_streams[streamId].snapshotDebtScaled = totalDebtScaled - amountScaled;
_streams[streamId].snapshotTime = uint40(block.timestamp);
}
if you look at this test below
function testPartialWithdrawalsWithSnapshot() public {
vm.startPrank(sender);
streamId = sablier.createAndDeposit(sender, recipient, ratePerSecond, IERC20(address(token)), true, 100e18);
vm.stopPrank();
vm.warp(block.timestamp + 30);
vm.prank(recipient);
(uint128 withdrawn1,) = sablier.withdraw(streamId, recipient, 15e18);
vm.warp(block.timestamp + 20);
vm.prank(recipient);
uint128 withdrawable = uint128(sablier.withdrawableAmountOf(streamId));
(uint128 withdrawn2,) = sablier.withdraw(streamId, recipient, 25e18);
uint256 totalWithdrawn = withdrawn1 + withdrawn2;
uint256 expectedStreamed = 50e18;
assertEq(totalWithdrawn, expectedStreamed, "Incorrect total withdrawn amount");
}
Test Results:
[FAIL: Incorrect total withdrawn amount: 40000000000000000000 != 50000000000000000000] testPartialWithdrawalsWithSnapshot()
Logs:
First withdrawal amount: 15000000000000000000
Total debt after first withdrawal: 15000000000000000000
Withdrawable amount after first withdrawal: 15000000000000000000
Withdrawable amount before second withdrawal: 35000000000000000000
Second withdrawal amount: 25000000000000000000
Total withdrawn: 40000000000000000000
Expected streamed: 50000000000000000000
Final total debt: 10000000000000000000
The test demonstrates that:
After 30 seconds, user withdraws 15e18 tokens
After 50 seconds total (20 more seconds), user should be able to withdraw 35e18 more tokens
Actually receives only 25e18 in second withdrawal
Total withdrawn (40e18) is less than entitled amount (50e18)
Impact
Users receive 20% less funds than entitled (demonstrated in test)
Funds remain locked in the contract
Affects all streams where withdrawals exceed snapshot debt
Tools Used
Manual code review
Unit tests:
testPartialWithdrawalsWithSnapshot()
testSnapshotDebtAccounting()
testStreamSolvency()
Recommendations
Replace the current withdrawal logic with:
if (amountScaled <= _streams[streamId].snapshotDebtScaled) {
_streams[streamId].snapshotDebtScaled -= amountScaled;
} else {
uint256 ongoingDebtScaled = _ongoingDebtScaledOf(streamId);
uint256 remainingAmount = amountScaled - _streams[streamId].snapshotDebtScaled;
uint256 consumedTime = (remainingAmount * 1e18) / _streams[streamId].ratePerSecond.unwrap();
_streams[streamId].snapshotTime = uint40(_streams[streamId].snapshotTime + consumedTime);
_streams[streamId].snapshotDebtScaled = 0;
}