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
:
pragma solidity >=0.8.22;
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);
}
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");
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:
Import the Mock:
+ import { ERC20MockRevertOnTransfer } from "./mocks/ERC20MockRevertOnTransfer.sol";
Initilize the Mock:
ERC20Mock internal tokenWithoutDecimals;
ERC20Mock internal tokenWithProtocolFee;
ERC20Mock internal dai;
ERC20Mock internal usdc;
ERC20MissingReturn internal usdt;
+ ERC20MockRevertOnTransfer internal tokenRevertOnTransfer;
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
{
vm.warp({ newTimestamp: OCT_1_2024 });
resetPrank({ msgSender: users.sender });
UD21x18 maxRPM = UD21x18.wrap(340282366920938463463374607431768211455);
uint256 streamId = createDefaultStream(maxRPM, IERC20(address(tokenRevertOnTransfer)));
deposit(streamId, type(uint96).max/2);
deposit(streamId, (type(uint96).max/2) + 10);
vm.warp({ newTimestamp: WARP_ONE_MONTH });
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
.