Flow

Sablier
FoundryDeFi
20,000 USDC
View results
Submission Details
Severity: medium
Invalid

DoS in `withdrawMax` Due to Excessive Transfer Amounts

Vulnerability Details

The SablierFlow::withdrawMax function allows the recipient to withdraw the maximum amount of tokens streamed. However, this can create issues if the token amount to be withdrawn is extremely large. Certain ERC-20 tokens (such as COMP, UNI, or customized tokens) will revert transactions if the number of tokens transferred exceeds type(uint96).max.

If a user unknowingly deposits more than tokens into a stream (e.g., using a high-supply "meme" token) and sets a high ratePerSecond, the streamed amount will eventually surpass . When this limit is exceeded, it can cause a DoS issue in the withdrawMax function. Depending on the withdrawal amount requested, the withdraw function could also be impacted. This limitation may significantly disrupt the protocol’s functionality.

Impact

DoS on withdrawMax function.
Partial DoS on withdraw function.

PoC

For this PoC we need to arrange a couple of things:

First create a new ERC20MOck that reverts on tranfers bigger than type(uint96).max.

Paste the following code in a new file inside tests/mocks/*:
ERC20MockRevertOnTransfer.sol:

// SPDX-License-Identifier: UNLICENSED
// solhint-disable reason-string
pragma solidity >=0.8.22;
/// @notice An implementation of ERC-20 that reverts on transfers more than type(uint96).max
contract ERC20MockRevertOnTransfer {
uint8 public decimals;
string public name;
string public symbol;
uint256 public totalSupply;
mapping(address owner => mapping(address spender => uint256 allowance)) internal _allowances;
mapping(address account => uint256 balance) internal _balances;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(string memory name_, string memory symbol_, uint8 decimals_) {
name = name_;
symbol = symbol_;
decimals = decimals_;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function approve(address spender, uint256 value) public returns (bool) {
_approve(msg.sender, spender, value);
return true;
}
function burn(address holder, uint256 amount) public {
require(holder != address(0), "ERC20: burn from the zero address");
require(_balances[holder] >= amount, "ERC20: burn amount exceeds balance");
_balances[holder] -= amount;
totalSupply -= amount;
emit Transfer(holder, address(0), amount);
}
function mint(address beneficiary, uint256 amount) public {
require(beneficiary != address(0), "ERC20: mint to the zero address");
_balances[beneficiary] += amount;
totalSupply += amount;
emit Transfer(address(0), beneficiary, amount);
}
function _approve(address owner, address spender, uint256 value) internal virtual {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
_allowances[owner][spender] = value;
emit Approval(owner, spender, value);
}
// @dev This function checks if the number "n" is less than type(uint96).max
function safe96(uint256 n) internal pure returns (uint256) {
require(n < 2**96, "Transfer too big");
return n;
}
function transfer(address to, uint256 amount) public {
_transfer(msg.sender, to, amount);
}
function transferFrom(address from, address to, uint256 amount) public {
require(_allowances[from][msg.sender] >= amount, "ERC20: insufficient allowance");
_transfer(from, to, amount);
_approve(from, msg.sender, _allowances[from][msg.sender] - amount);
}
function _transfer(address from, address to, uint256 amount) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
require(_balances[from] >= amount, "ERC20: Not enough balance");
// Revert on transfer more than uint90
amount = safe96(amount);
_balances[from] = _balances[from] - amount;
_balances[to] = _balances[to] + amount;
emit Transfer(from, to, amount);
}
}

Then inside tests/Base.t.sol, add the new ERC20 Mock just created:

  1. Import the Mock:

+ import { ERC20MockRevertOnTransfer } from "./mocks/ERC20MockRevertOnTransfer.sol";
  1. Initilize the Mock:

ERC20Mock internal tokenWithoutDecimals;
ERC20Mock internal tokenWithProtocolFee;
ERC20Mock internal dai;
ERC20Mock internal usdc;
ERC20MissingReturn internal usdt;
+ ERC20MockRevertOnTransfer internal tokenRevertOnTransfer;
  1. On createAndLabelTokens function create the Mock and label it:

tokenWithoutDecimals = createToken("Token without Decimals", "TWD", 0);
tokenWithProtocolFee = createToken("Token with Protocol Fee", "TPF", 6);
dai = createToken("Dai stablecoin", "DAI", 18);
usdc = createToken("USD Coin", "USDC", 6);
usdt = new ERC20MissingReturn("Tether", "USDT", 6);
+ tokenRevertOnTransfer = new ERC20MockRevertOnTransfer("Revert On Transfer", "ROT", 6);
// Label the tokens.
vm.label(address(tokenWithoutDecimals), "TWD");
vm.label(address(tokenWithProtocolFee), "TPF");
vm.label(address(dai), "DAI");
vm.label(address(usdc), "USDC");
vm.label(address(usdt), "USDT");
+ vm.label(address(tokenRevertOnTransfer), "ROT");

Finally, in tests/integration/concrete/withdraw/withdraw.t.sol add an import and the test itself

Add the import UD21x18 to set the ratePerSecond in the test:

import { UD21x18 } from "@prb/math/src/UD21x18.sol";

And add the test:

function test_RevertOnTransferUint90()
external
{
// Go back to the starting point.
vm.warp({ newTimestamp: OCT_1_2024 });
resetPrank({ msgSender: users.sender });
// Max value of rate per second
UD21x18 maxRPM = UD21x18.wrap(340282366920938463463374607431768211455);
// Create the stream with the token that revert on transfers more than type(uint96).max
uint256 streamId = createDefaultStream(maxRPM, IERC20(address(tokenRevertOnTransfer)));
// We deposit more than type(uint96).max
deposit(streamId, type(uint96).max/2);
deposit(streamId, (type(uint96).max/2) + 10);
// Simulate the one month of streaming.
vm.warp({ newTimestamp: WARP_ONE_MONTH });
// DoS on maxWithdraw because the amount to transfer is bigger than type(uint96).max
resetPrank({ msgSender: users.recipient });
vm.expectRevert("Transfer too big");
(vars.actualWithdrawnAmount, vars.actualProtocolFeeAmount) = flow.withdrawMax(streamId, users.recipient);
}

Recommendations

Change the _streams[streamId].balance to uint90 instead of uint128.

Updates

Lead Judging Commences

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.