code-423n4 / 2024-01-salty-findings

11 stars 6 forks source link

WBTC-WETH collateral pool for USDS can be abused to mint excessive USDS when the pool doesn't have enough liquidity #890

Closed c4-bot-1 closed 7 months ago

c4-bot-1 commented 7 months ago

Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/CollateralAndLiquidity.sol#L221-L236 https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/Pools.sol#L409-L419 https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/Pools.sol#L235-L276 https://github.com/code-423n4/2024-01-salty/blob/main/src/arbitrage/ArbitrageSearch.sol#L107-L108

Vulnerability details

Impact

When the WBTC-WETH pool (which is used as the collateral for USDS) has somewhat low liquidity, an attacker can bypass the arbitrage rebalancing logic to inflate the value of their LP tokens using a flashloan to mint an excessive amount of USDS (at no cost). They can then dump this USDS on the market, completely destroying the stablecoin.

Proof of Concept

Consider the following starting state: 1e8 WBTC = 30k USD, and 1e18 WETH = 2k USD. Let's assume the WBTC-WETH collateral pool has 1e7 WBTC and 15e17 WETH, meaning there is a total of 6k USD in assets. Let's assume there is 1e18 LP tokens outstanding for this pool, in which the attacker has only 1%, or 1e16 LP tokens (60 USD).

Generally when minting USDS with a collateralization ratio of 200%, this means that the attacker can mint only 30e18 USDS. Now lets see how the attacker can circumvent this & mint an excessively large amount of USDS.

Step (1): Attacker creates a smart contract to perform the following attack. They first flashloan borrow (15e22-15e17) WETH from Balancer, or another platform with 0 fees. With this they call Pools:depositSwapWithdraw. This triggers the internal call Pools:_adjustReservesForSwapAndAttemptArbitrage which is defined as follows:

function _adjustReservesForSwapAndAttemptArbitrage( IERC20 swapTokenIn, IERC20 swapTokenOut, uint256 swapAmountIn, uint256 minAmountOut ) internal returns (uint256 swapAmountOut)
    {
    // Place the user swap first
@>    swapAmountOut = _adjustReservesForSwap( swapTokenIn, swapTokenOut, swapAmountIn );
    ...
@>    uint256 arbitrageProfit = _attemptArbitrage( swapTokenIn, swapTokenOut, swapAmountIn );

    emit SwapAndArbitrage(msg.sender, swapTokenIn, swapTokenOut, swapAmountIn, swapAmountOut, arbitrageProfit);
    }

This calls _adjustReservesForSwap which (condensed) runs the following logic:

...
reserve1 += amountIn;
amountOut = reserve0 * amountIn / reserve1;
reserve0 -= amountOut;
...
require( (reserve0 >= PoolUtils.DUST) && (reserve1 >= PoolUtils.DUST), "Insufficient reserves after swap");
...

Here reserve1 is WETH, which we said earlier was 15e17. reserve0 is WBTC, which we said earlier is 1e7. The attacker uses the (15e22-15e17) flashloaned WETH to swap into WBTC. The following output is calculated:

reserve1 = 15e17+(15e22-15e17)=15e22
amountOut = 1e7*(15e22-15e17)/15e22=9999900
reserve0 = 1e7-9999900=100

Note that reserve0 = 100 >= DUST, as DUST is 100.

Next in the _adjustReservesForSwapAndAttemptArbitrage function call, _attemptArbitrage is called, which is defined as follows:

function _attemptArbitrage( IERC20 swapTokenIn, IERC20 swapTokenOut, uint256 swapAmountIn ) internal returns (uint256 arbitrageProfit )
    {
    // Determine the value of swapTokenIn (in WETH) that the user has specified as it will impact the arbitrage trade size
    uint256 swapAmountInValueInETH = _determineSwapAmountInValueInETH(swapTokenIn, swapAmountIn);
    if ( swapAmountInValueInETH == 0 )
        return 0;

    // Determine the arbitrage path for the given user swap.
    // Arbitrage path returned as: weth->arbToken2->arbToken3->weth
@>    (IERC20 arbToken2, IERC20 arbToken3) = _arbitragePath( swapTokenIn, swapTokenOut );

    (uint256 reservesA0, uint256 reservesA1) = getPoolReserves( weth, arbToken2);
    (uint256 reservesB0, uint256 reservesB1) = getPoolReserves( arbToken2, arbToken3);
@>    (uint256 reservesC0, uint256 reservesC1) = getPoolReserves( arbToken3, weth);

    // Determine the best amount of WETH to start the arbitrage with
@>    uint256 arbitrageAmountIn = _bisectionSearch(swapAmountInValueInETH, reservesA0, reservesA1, reservesB0, reservesB1, reservesC0, reservesC1 );

    // If arbitrage is viable, then perform it
@>    if (arbitrageAmountIn > 0)
@>        arbitrageProfit = _arbitrage(arbToken2, arbToken3, arbitrageAmountIn);
@>    }

Importantly, since this swap was from WETH->WBTC, the arbitragePath returned from the _arbitragePath call will be WETH->SALT->WBTC->WETH, and that arbToken3 will be WBTC. This means that reservesC0 will reference the amount of WBTC in the WBTC-WETH pool, which is now 100.

ArbitrageSearch:_bisectionSearch is then run, which has the following snippet of code:

if ( reservesA0 <= PoolUtils.DUST || reservesA1 <= PoolUtils.DUST || reservesB0 <= PoolUtils.DUST || reservesB1 <= PoolUtils.DUST || reservesC0 <= PoolUtils.DUST || reservesC1 <= PoolUtils.DUST )
    return 0;

Since reservesC0 is 100, this if-statement results in _bisectionSearch returning 0, which then means that the _arbitrage function is not called. Ultimately this means that the pool will remain imbalanced, as the arbitrage logic could not be run.

Step (2): The attacker then calls CollateralAndLiquidity:borrowUSDS, in which they set amountBorrowed equal to the output of CollateralAndLiquidity:maxBorrowableUSDS. This amount is ultimately based on CollateralAndLiquidity:userCollateralValueInUSD, which is defined as follows:

function userCollateralValueInUSD( address wallet ) public view returns (uint256)
    {
    uint256 userCollateralAmount = userShareForPool( wallet, collateralPoolID );
    if ( userCollateralAmount == 0 )
        return 0;

    uint256 totalCollateralShares = totalShares[collateralPoolID];

    // Determine how much collateral share the user currently has
    (uint256 reservesWBTC, uint256 reservesWETH) = pools.getPoolReserves(wbtc, weth);

    uint256 userWBTC = (reservesWBTC * userCollateralAmount ) / totalCollateralShares;
    uint256 userWETH = (reservesWETH * userCollateralAmount ) / totalCollateralShares;

    return underlyingTokenValueInUSD( userWBTC, userWETH );
    }

Recall that the attacker has 1e17 LP tokens, while there are 1e18 outstanding. Here userWBTC = 100 * 1 / 100 = 1, and userWETH = 15e22 * 1 / 100 = 15e20. Since each 1e18 WETH is worth 2k, 15e20 WETH = 3_000_000 USD. With a collateralization ratio of 200%, this means the attacker can mint 1_500_000e18 USDS.

Step (3): After minting the excessive USDS, the attacker will swap their 9999900 WBTC back through the WBTC-WETH pool, which will yield them exactly the same starting amount of WETH. They will then repay the flashloan, ending up with an excess of 1_500_000e18 USDS, using only a starting 60 USD of collateral.

Tools Used

Manual review

Recommended Mitigation Steps

Generally it is not considered good practice to price LP simply through the use of the spot token reserves for that pool. Consider an alternative approach such as this.

Assessed type

Other

c4-judge commented 7 months ago

Picodes marked the issue as duplicate of #945

c4-judge commented 6 months ago

Picodes marked the issue as satisfactory

c4-judge commented 6 months ago

Picodes changed the severity to 2 (Med Risk)