Flow

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

Unauthorized Withdrawal Due to Insufficient Access Control

Summary

The withdraw function in the SablierFlow contract allows unauthorized parties to withdraw funds from a stream. This vulnerability arises from improper access control checks, leading to loss of funds and compromising the security of the contract.

Vulnerability Details

The issue lies in the access control logic within the withdraw function. Specifically, the condition intended to restrict withdrawals to authorized parties is not functioning correctly.

@=> function _isCallerStreamRecipientOrApproved(uint256 streamId) internal view returns (bool) {
address recipient = _ownerOf(streamId);
return msg.sender == recipient || isApprovedForAll({ owner: recipient, operator: msg.sender })
|| getApproved(streamId) == msg.sender;
}
function _withdraw(
uint256 streamId,
address to,
uint128 amount
)
internal
returns (uint128 withdrawnAmount, uint128 protocolFeeAmount)
{
// Check: the withdraw amount is not zero.
if (amount == 0) {
revert Errors.SablierFlow_WithdrawAmountZero(streamId);
}
// Check: the withdrawal address is not zero.
if (to == address(0)) {
revert Errors.SablierFlow_WithdrawToZeroAddress(streamId);
}
// Check: `msg.sender` is neither the stream's recipient nor an approved third party, the withdrawal address
// must be the recipient.
@=> if (to != _ownerOf(streamId) && !_isCallerStreamRecipientOrApproved(streamId)) {
@=> revert Errors.SablierFlow_WithdrawalAddressNotRecipient({ streamId: streamId, caller: msg.sender, to: to });
}
uint8 tokenDecimals = _streams[streamId].tokenDecimals;
// Another logic
}
  • to != _ownerOf(streamId): Checks if the destination address (to) is not the same as the stream NFT owner (_ownerOf(streamId)). The NFT owner is the legitimate recipient of the stream.

  • !_isCallerStreamRecipientOrApproved(streamId): Checks whether the function caller (msg.sender) is not a stream recipient or an approved third party.

If either of the above two conditions is met, the transaction is canceled. This means that the withdrawal was not allowed because the destination address did not match or the caller did not have the proper authorization. Errors.SablierFlow_WithdrawalAddressNotRecipient: A special error that provides information about the rejection reason, including streamId, caller (function caller), and to (destination address).

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity >=0.8.22;
import "forge-std/src/Test.sol";
import "../src/SablierFlow.sol";
import "../src/types/DataTypes.sol";
import "../src/libraries/Helpers.sol";
import "../src/libraries/Errors.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract SablierFlowTest is Test {
SablierFlow private sablierFlow;
address private admin;
address private sender;
address private recipient;
IERC20 private token;
UD21x18 private ratePerSecond;
uint256 private streamId;
function setUp() public {
// Initialize addresses
admin = address(0x1);
sender = address(0x2);
recipient = address(0x3);
// Deploy a mock ERC20 token
token = new MockERC20("Mock Token", "MTK", 18);
// Mint tokens to the sender
uint256 initialBalance = 1000e18; // 1000 tokens
MockERC20(address(token)).mint(sender, initialBalance);
// Set a rate per second
ratePerSecond = UD21x18.wrap(1e18); // 1 token per second
// Deploy the SablierFlow contract
sablierFlow = new SablierFlow(admin, IFlowNFTDescriptor(address(0)));
// Approve the SablierFlow contract to spend tokens on behalf of the sender
vm.prank(sender);
token.approve(address(sablierFlow), type(uint256).max);
// Create a stream
streamId = sablierFlow.create(sender, recipient, ratePerSecond, token, true);
}
function testWithdrawUnauthorized() public {
uint128 depositAmount = 1000e18; // 1000 tokens
uint128 withdrawAmount = 500e18; // 500 tokens
// Simulate the sender calling the function
vm.prank(sender);
// Deposit into the stream
sablierFlow.deposit(streamId, depositAmount, sender, recipient);
// Simulate time passing to accrue withdrawable balance
vm.warp(block.timestamp + 3600); // Fast forward 1 hour
// Simulate an unauthorized caller
address unauthorizedCaller = address(0x6);
vm.prank(unauthorizedCaller);
// It should revert with the message SablierFlow_WithdrawalAddressNotRecipient
// Attempt to withdraw from the stream
sablierFlow.withdraw(streamId, recipient, withdrawAmount);
}
}
// Mock ERC20 token for testing
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol, uint8 decimals) ERC20(name, symbol) {
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}

The test result will show that the withdrawal to an unapproved recipient or third party was successful which should revert with the message “SablierFlow_WithdrawalAddressNotRecipient”.

Impact

Unauthorized parties can withdraw funds, leading to direct financial loss for legitimate users.

Tools Used

  • Manual review

  • Foundry

Recommendations

Ensure that the access control logic correctly verifies the authorization of the caller.

Updates

Lead Judging Commences

inallhonesty Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

juggernaut63 Submitter
8 months ago
inallhonesty Lead Judge
8 months ago
inallhonesty Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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