code-423n4 / 2024-05-loop-findings

4 stars 4 forks source link

`claim` and `claimAndStake` will always revert when 0x API uses Uniswap_V3 #107

Closed howlbot-integration[bot] closed 4 months ago

howlbot-integration[bot] commented 4 months ago

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L412-L413 https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L448-L464

Vulnerability details

Severity Medium (Likelihood: High Impact: Low)

Description

claim / claimAndStake doesn't seem to be working as expected. When non-ETH are claimed and Uniswap_V3 is used by 0x API, _decodeUniswapV3Data will be used to decode the 0x response, but it doesn't decode properly and will always revert which seems to warrant Medium severity (but border line, as change in the loop's back-end could resolve the issue without having to change the smart contract - see recommendation section).

Impact

claim / claimAndStake function call will revert when 0x API uses Uniswap_V3 todo the swap from LRT token to ETH.

PoC

Use the following in Remix, which is the decoding code used by the smart contract.

1. Create a smart contract in Remix with the following code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract LoopTest {

    /**
     * @notice Decodes the data sent from 0x API when UniswapV3 is used
     * @param _data      swap data from 0x API
     */
    function _decodeUniswapV3Data(bytes calldata _data)
        public
        pure
        returns (address inputToken, address outputToken, uint256 inputTokenAmount, address recipient, bytes4 selector)
    {
        uint256 encodedPathLength;
        assembly {
            let p := _data.offset
            selector := calldataload(p)
            p := add(p, 36) // Data: selector 4 + lenght data 32
            inputTokenAmount := calldataload(p)
            recipient := calldataload(add(p, 64))
            encodedPathLength := calldataload(add(p, 96)) // Get length of encodedPath (obtained through abi.encodePacked)
            inputToken := shr(96, calldataload(add(p, 128))) // Shift to the Right with 24 zeroes (12 bytes = 96 bits) to get address
            outputToken := shr(96, calldataload(add(p, add(encodedPathLength, 108)))) // Get last address of the hop
        }
    }

    /**
     * @notice Decodes the data sent from 0x API when other exchanges are used via 0x TransformERC20 function
     * @param _data      swap data from 0x API
     */
    function _decodeTransformERC20Data(bytes calldata _data)
        public
        pure
        returns (address inputToken, address outputToken, uint256 inputTokenAmount, bytes4 selector)
    {
        assembly {
            let p := _data.offset
            selector := calldataload(p)
            inputToken := calldataload(add(p, 4)) // Read slot, selector 4 bytes
            outputToken := calldataload(add(p, 36)) // Read slot
            inputTokenAmount := calldataload(add(p, 68)) // Read slot
        }
    }

}

2. Hit the 0x API to simulate a swap of 1 ether from pufETH to ETH.

curl --location --request GET 'https://api.0x.org/swap/v1/quote?buyToken=0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&sellToken=0xD9A442856C234a39a81a089C06451EBAa4306a72&sellAmount=1000000000000000000' --header '0x-api-key: 89798b75-e786-4a46-bd22-d6cf8b8dd3d5'

3. Extract the data, in run it against both function in Remix created in step 1.

That returned the following response at the time of writing. We can see that quote.orders[0].source == Uniswap_V3.

{"chainId":1,"price":"0.999634133813602445","grossPrice":"1.001120798198927852","estimatedPriceImpact":"0.6152","value":"0","gasPrice":"20500000000","gas":"159820","estimatedGas":"159820","protocolFee":"0","minimumProtocolFee":"0","buyTokenAddress":"0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","buyAmount":"999634133813602445","grossBuyAmount":"1001120798198927852","sellTokenAddress":"0xd9a442856c234a39a81a089c06451ebaa4306a72","sellAmount":"1000000000000000000","grossSellAmount":"1000000000000000000","sources":[{"name":"0x","proportion":"0"},{"name":"Uniswap","proportion":"0"},{"name":"Uniswap_V2","proportion":"0"},{"name":"Curve","proportion":"0"},{"name":"Balancer","proportion":"0"},{"name":"Balancer_V2","proportion":"0"},{"name":"BancorV3","proportion":"0"},{"name":"SushiSwap","proportion":"0"},{"name":"DODO","proportion":"0"},{"name":"DODO_V2","proportion":"0"},{"name":"CryptoCom","proportion":"0"},{"name":"Lido","proportion":"0"},{"name":"MakerPsm","proportion":"0"},{"name":"KyberDMM","proportion":"0"},{"name":"Uniswap_V3","proportion":"1"},{"name":"Curve_V2","proportion":"0"},{"name":"ShibaSwap","proportion":"0"},{"name":"Synapse","proportion":"0"},{"name":"Synthetix","proportion":"0"},{"name":"Aave_V2","proportion":"0"},{"name":"Compound","proportion":"0"},{"name":"KyberElastic","proportion":"0"},{"name":"Maverick_V1","proportion":"0"},{"name":"PancakeSwap_V3","proportion":"0"}],"allowanceTarget":"0xdef1c0ded9bec7f1a1670819833240f027b25eff","sellTokenToEthRate":"0.99273582844700569","buyTokenToEthRate":"1","to":"0xdef1c0ded9bec7f1a1670819833240f027b25eff","data":"0x415565b0000000000000000000000000d9a442856c234a39a81a089c06451ebaa4306a72000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000dbbd8cecbc6aaee00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000044000000000000000000000000000000000000000000000000000000000000004e000000000000000000000000000000000000000000000000000000000000005e000000000000000000000000000000000000000000000000000000000000000210000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000036000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d9a442856c234a39a81a089c06451ebaa4306a72000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000002e00000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000012556e69737761705633000000000000000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000dc120ebd25d144d000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000e592427a0aece92de3edee1f18e0157c0586156400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002bd9a442856c234a39a81a089c06451ebaa4306a72000bb8c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000005481d0696695f000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000001000000000000000000000000d9a442856c234a39a81a089c06451ebaa4306a720000000000000000000000000000000000000000000000000000000000000000869584cd000000000000000000000000100000000000000000000000000000000000001100000000000000000000000000000000b7e96dd11e9099123664e6b10856ee40","decodedUniqueId":"0xb7e96dd11e9099123664e6b10856ee40","guaranteedPrice":"0.989622925831613166","orders":[{"type":0,"source":"Uniswap_V3","makerToken":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","takerToken":"0xd9a442856c234a39a81a089c06451ebaa4306a72","makerAmount":"1001120798198927852","takerAmount":"1000000000000000000","fillData":{"router":"0xe592427a0aece92de3edee1f18e0157c05861564","path":"0xd9a442856c234a39a81a089c06451ebaa4306a72000bb8c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","gasUsed":9410,"routerVersion":1},"fill":{"input":"1000000000000000000","output":"1001120798198927852","adjustedOutput":"998685275198927900","gas":118806}}],"fees":{"zeroExFee":{"feeType":"volume","feeToken":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","feeAmount":"1486664385325407","billingType":"on-chain"}},"auxiliaryChainData":{}}
0x415565b0000000000000000000000000d9a442856c234a39a81a089c06451ebaa4306a72000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000dbbd8cecbc6aaee00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000044000000000000000000000000000000000000000000000000000000000000004e000000000000000000000000000000000000000000000000000000000000005e000000000000000000000000000000000000000000000000000000000000000210000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000036000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d9a442856c234a39a81a089c06451ebaa4306a72000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000002e00000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000012556e69737761705633000000000000000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000dc120ebd25d144d000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000e592427a0aece92de3edee1f18e0157c0586156400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002bd9a442856c234a39a81a089c06451ebaa4306a72000bb8c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000005481d0696695f000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000001000000000000000000000000d9a442856c234a39a81a089c06451ebaa4306a720000000000000000000000000000000000000000000000000000000000000000869584cd000000000000000000000000100000000000000000000000000000000000001100000000000000000000000000000000b7e96dd11e9099123664e6b10856ee40

_decodeUniswapV3Data result:

address: inputToken 0x0000000000000000000000000000000000000000
address: outputToken 0x00000000000000000000000000000000000005E0
uint256: inputTokenAmount 1364068194842176056990105843868530818345537040110
address: recipient 0x0000000000000000000000000dbbD8cEcBc6Aaee
bytes4: selector 0x415565b0

_decodeTransformERC20Data result:

address: inputToken 0xD9A442856C234a39a81a089C06451EBAa4306a72
address: outputToken 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE
uint256: inputTokenAmount 1000000000000000000
bytes4: selector 0x415565b0

Result

The assumption is that _decodeUniswapV3Data would be called in _validateData in such case based on the following logic seen in PrelaunchPoints0x.test.ts, so when the user would want to claim throught the loop's front-end, the loop's back-end would provide _exchange == 0 based on the test currently written.

      const exchange = quote.orders[0] ? quote.orders[0].source : ""
      const exchangeCode = exchange == "Uniswap_V3" ? 0 : 1

      // Claim
      await prelaunchPoints
        .connect(depositor)
        .claim(claimToken, 100, exchangeCode, quote.data)

Consequently this scenario will always revert, and will be occuring very often as 0x API seems to select almost always Uniswap_V3 for the swap. Somehow, seems like _decodeTransformERC20Data should always be called, but I didn't dig the reasoning further. I guess an easy way for the team to fix it (even without changing the smart contract) would be to force _exchange == 1 in the loop's back-end even for Uniswap_V3.

Tools Used

Manual inspection, Remix, Foundry

Recommended Mitigation Steps

Use _decodeTransformERC20Data even for Uniswap_V3. In my humble opinion I think this report still warrant a Medium severity for the fact of having exposed the problematic situation in details, even if this can probably be resolved without changing the smart contract.

Assessed type

Error

0xd4n1el commented 4 months ago

This is indeed a problem on the test implementations, however TransformERC20 always give ETH as an outputToken and Uniswap_V3 always give WETH as outputToken so the validations in the contract are correct and not a vulnerability in the contract itself

koolexcrypto commented 4 months ago

From 0x => UniswapV3Feature contract, encodedPath expects the last token to be WETH.

    /// @dev Sell a token for ETH directly against uniswap v3.
    /// @param encodedPath Uniswap-encoded path, where the last token is WETH.
    /// @param sellAmount amount of the first token in the path to sell.
    /// @param minBuyAmount Minimum amount of ETH to buy.
    /// @param recipient The recipient of the bought tokens. Can be zero for sender.
    /// @return buyAmount Amount of ETH bought.
    function sellTokenForEthToUniswapV3(
        bytes memory encodedPath,
        uint256 sellAmount,
        uint256 minBuyAmount,
        address payable recipient
    ) public override returns (uint256 buyAmount) {

UniswapV3Feature:sellTokenForEthToUniswapV3

In this case, this validation is correct.

  if (outputToken != address(WETH)) {
                revert WrongDataTokens(inputToken, outputToken);
            }
c4-judge commented 4 months ago

koolexcrypto marked the issue as primary issue

c4-judge commented 4 months ago

koolexcrypto marked the issue as unsatisfactory: Invalid