Flow

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

Snapshot Debt Wrong Calculation

Summary

A vulnerability was discovered in the SablierFlow smart contract where adjusting the ratePerSecond multiple times without advancing the block timestamp leads to incorrect snapshot debt accumulation. This results in inaccurate debt calculations, which can cause financial discrepancies and potential exploitation by malicious actors.

Vulnerability Details

In the SablierFlow contract, users can adjust the ratePerSecond of a stream using the adjustRatePerSecond function. The contract should accurately calculate the debt based on the time elapsed and the current rate. However, when multiple adjustments are made at the same timestamp (without advancing time), the snapshot debt increases incorrectly. The expected behavior is that if no time has advanced, the snapshot debt should remain unchanged after adjustments.

Issue Explanation:

  • Initial Setup:

    • A stream is created with an initial ratePerSecond.

    • Funds are deposited into the stream.

    • The snapshot debt is recorded before any rate adjustments.

  • Adjustments Without Time Advancement:

    • The ratePerSecond is adjusted multiple times without advancing the block timestamp.

    • The snapshot debt is recorded after adjustments.

    • Despite no time passing, the snapshot debt increases, which should not occur.

  • Debt Calculation After Time Advancement:

    • The block timestamp is advanced by 10 seconds.

    • The total debt is calculated and compared against the expected value based on the last ratePerSecond.

    • The total debt does not match the expected value, indicating incorrect debt accumulation.

POC

  1. Create the mock file: tests/mocks/ERC20Mock.sol with the following content:

    // SPDX-License-Identifier: UNLICENSED
    pragma solidity >=0.8.22;
    import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
    contract ERC20Mock is ERC20 {
    uint8 private _decimals;
    constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) {
    _decimals = decimals_;
    }
    function decimals() public view override returns (uint8) {
    return _decimals;
    }
    // Mint function for testing purposes
    function mint(address to, uint256 amount) public {
    _mint(to, amount);
    }
    }
  2. Create MockFlowNFTDescriptor file: tests/mocks/MockFlowNFTDescriptor.sol with the following content:

    // SPDX-License-Identifier: UNLICENSED
    pragma solidity >=0.8.22;
    import "../../src/interfaces/IFlowNFTDescriptor.sol";
    contract MockFlowNFTDescriptor is IFlowNFTDescriptor {
    function tokenURI(IERC721Metadata, uint256) external pure override returns (string memory) {
    return "Mock URI";
    }
    }
  3. then create the main test file: tests/SnapshotDebtAccumulationBugTest.t.sol with the following content and then run the main test with command: forge test --mt testSnapshotDebtAccumulationBug -vvvv

    // SPDX-License-Identifier: UNLICENSED
    pragma solidity >=0.8.22;
    import "forge-std/src/Test.sol";
    import "../src/SablierFlow.sol";
    import "./mocks/ERC20Mock.sol";
    import "./mocks/MockFlowNFTDescriptor.sol";
    import "../src/libraries/Errors.sol";
    contract MockERC20 is ERC20 {
    constructor() ERC20("Mock Token", "MTK") {
    _mint(msg.sender, 1e24); // Mint 1 million tokens to deployer
    console.log("MockERC20 deployed. Initial supply minted to:", msg.sender);
    }
    }
    contract SnapshotDebtAccumulationBugTest is Test {
    SablierFlow sablierFlow;
    MockERC20 token;
    address sender = address(0x1);
    address recipient = address(0x2);
    uint256 streamId;
    function setUp() public {
    console.log("Setting up the test environment...");
    // Deploy the ERC20 token and the SablierFlow contract
    token = new MockERC20();
    console.log("MockERC20 token deployed at address:", address(token));
    sablierFlow = new SablierFlow(address(this), IFlowNFTDescriptor(address(0)));
    console.log("SablierFlow contract deployed at address:", address(sablierFlow));
    // Distribute tokens to sender
    token.transfer(sender, 1e22); // 10,000 tokens
    console.log("Transferred 10,000 tokens to sender at address:", sender);
    // Approve SablierFlow contract to spend tokens on behalf of sender
    vm.startPrank(sender);
    console.log("Prank started as sender:", sender);
    token.approve(address(sablierFlow), type(uint256).max);
    console.log("Sender approved SablierFlow to spend unlimited tokens");
    vm.stopPrank();
    console.log("Prank stopped as sender");
    }
    function testSnapshotDebtAccumulationBug() public {
    console.log("Testing snapshot debt accumulation bug...");
    // Create a stream with an initial rate
    UD21x18 initialRate = UD21x18.wrap(1e21); // 1 token per second
    console.log("Initial rate per second set to:", initialRate.unwrap());
    vm.startPrank(sender);
    console.log("Prank started as sender:", sender);
    streamId = sablierFlow.create(sender, recipient, initialRate, IERC20(address(token)), true);
    console.log("Stream created with ID:", streamId);
    // Deposit funds into the stream
    uint128 depositAmount = 100e18; // 100 tokens
    sablierFlow.deposit(streamId, depositAmount, sender, recipient);
    console.log("Deposited", depositAmount, "tokens into stream ID:", streamId);
    // Record the snapshot debt before any adjustments
    uint256 snapshotDebtBefore = sablierFlow.getSnapshotDebtScaled(streamId);
    console.log("Snapshot debt before adjustments:", snapshotDebtBefore);
    // Adjust rate per second multiple times without advancing time
    UD21x18 newRate1 = UD21x18.wrap(2e21); // 2 tokens per second
    UD21x18 newRate2 = UD21x18.wrap(3e21); // 3 tokens per second
    console.log("Adjusting rate per second to:", newRate1.unwrap());
    sablierFlow.adjustRatePerSecond(streamId, newRate1);
    console.log("Adjusted rate per second to:", newRate1.unwrap());
    // Immediately adjust again without advancing time
    console.log("Adjusting rate per second to:", newRate2.unwrap());
    sablierFlow.adjustRatePerSecond(streamId, newRate2);
    console.log("Adjusted rate per second to:", newRate2.unwrap());
    // Record the snapshot debt after adjustments
    uint256 snapshotDebtAfter = sablierFlow.getSnapshotDebtScaled(streamId);
    console.log("Snapshot debt after adjustments:", snapshotDebtAfter);
    // Ensure the snapshot debt did not increase (due to zero elapsed time)
    assertEq(snapshotDebtAfter, snapshotDebtBefore, "Snapshot debt should not increase when time hasn't advanced");
    console.log("Verified snapshot debt did not increase without time advancement");
    // Stop acting as sender
    vm.stopPrank();
    console.log("Prank stopped as sender");
    // Move forward in time by 10 seconds
    vm.warp(block.timestamp + 10);
    console.log("Advanced time by 10 seconds. Current timestamp:", block.timestamp);
    // Calculate expected total debt manually
    // Since adjustments happened at the same timestamp, debt should be calculated only for the last rate
    uint256 expectedDebt = (10 * newRate2.unwrap()) / (10 ** (21 - 18)); // Adjust for decimals
    console.log("Expected total debt calculated as:", expectedDebt);
    // Get the total debt from the contract
    uint256 totalDebt = sablierFlow.totalDebtOf(streamId);
    console.log("Total debt from contract:", totalDebt);
    // Verify that the total debt matches the expected debt
    assertEq(
    totalDebt,
    expectedDebt,
    "Total debt should match expected value after multiple rate adjustments without time advancement"
    );
    console.log("Verified total debt matches expected value");
    }
    }

Explanation:

  • Setup Phase:

    • Deploy Contracts:

      • MockERC20 token is deployed, and an initial supply is minted to the deployer.

      • SablierFlow contract is deployed with necessary parameters.

      • Logs deployment addresses for clarity.

    • Token Distribution:

      • Transfers 10,000 tokens to the sender address.

      • Logs the transfer details.

    • Approval:

      • The sender approves SablierFlow to spend an unlimited amount of tokens on their behalf.

      • Uses vm.startPrank to simulate actions from the sender.

      • Logs the approval action.

  • Test Phase (testSnapshotDebtAccumulationBug):

    • Stream Creation:

      • Sets the initial ratePerSecond to 1 token per second.

      • Creates a new stream from sender to recipient.

      • Deposits 100 tokens into the stream.

      • Logs the stream creation and deposit actions.

    • Snapshot Debt Recording:

      • Records the snapshotDebtBefore any rate adjustments.

      • Logs the initial snapshot debt.

    • Rate Adjustments Without Time Advancement:

      • Adjusts the ratePerSecond to 2 tokens per second.

      • Immediately adjusts the ratePerSecond again to 3 tokens per second without advancing time.

      • Logs each rate adjustment.

    • Snapshot Debt Verification:

      • Records the snapshotDebtAfter adjustments.

      • Asserts that snapshotDebtAfter should equal snapshotDebtBefore since no time has passed.

      • Logs the verification result.

    • Time Advancement and Debt Calculation:

      • Advances the block timestamp by 10 seconds using vm.warp.

      • Manually calculates the expected total debt based on the last ratePerSecond.

      • Retrieves the totalDebt from the contract.

      • Asserts that the totalDebt matches the expected debt, which is going to fail, meaning the calculation is not done correctly

      • Logs the calculations and assertions.

Root Cause:

https://github.com/Drlaravel/img-debt/blob/main/debt.png

Underlying Issue:

The adjustRatePerSecond function does not correctly handle multiple adjustments made at the same timestamp. The snapshot debt is erroneously inc

Impact

  • Inaccurate Debt Accounting: Users may be overcharged or undercharged due to incorrect debt calculations, leading to financial losses or unjust gains.

  • Potential Exploitation: Malicious actors could exploit this flaw to manipulate debt calculations, draining funds from the contract or disrupting the streaming process.

  • Contract Integrity Risks: The reliability of the SablierFlow contract is compromised, affecting trust and usability among users and stakeholders.

Tools Used

  • Foundry: A fast and portable Ethereum development toolkit for compiling, testing, and deploying smart contracts.

  • Solidity Compiler (solc): Version 0.8.22, used for compiling the Solidity smart contracts.

  • Forge Std Library: Provides testing utilities and console logging for enhanced debugging.

  • Console Logs: Added via console.log statements for step-by-step tracing of the contract's internal state during execution.

Recommendations

  • Modify Debt Calculation Logic:

    • Update the adjustRatePerSecond function to ensure that debt calculations are only based on the elapsed time since the last update.

    • Implement checks to prevent snapshot debt from increasing if no time has advanced between adjustments.

  • Implement Time-Based Conditions:

    • Before updating the snapshot debt, verify if the block timestamp has advanced since the last rate adjustment.

    • If the timestamp is unchanged, the debt accumulation should not proceed.

  • Enhance Unit Testing:

    • Add comprehensive unit tests that cover scenarios with multiple rate adjustments occurring at the same timestamp.

    • Ensure tests validate that snapshot debt remains accurate in such cases.

  • Code Review and Auditing:

    • Perform an in-depth code review focusing on the debt calculation mechanisms within the contract.

    • Consider third-party security audits to identify and rectify potential vulnerabilities.

  • Update Documentation:

    • Clearly document the correct usage of the adjustRatePerSecond function, including any limitations or expected behaviors.

    • Provide guidelines on the implications of adjusting rates without time advancement.


By addressing this vulnerability, the SablierFlow contract will ensure accurate debt calculations, maintain financial integrity, and prevent potential exploitation arising from incorrect snapshot debt accumulation.

Updates

Lead Judging Commences

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.