LoopFi - sandy's results

A dedicated lending market for Ethereum carry trades. Users can supply a long tail of Liquid Restaking Tokens (LRT) and their derivatives as collateral to borrow ETH for increased yield exposure.

General Information

Platform: Code4rena

Start Date: 01/05/2024

Pot Size: $12,100 USDC

Total HM: 1

Participants: 47

Period: 7 days

Judge: Koolex

Id: 371

League: ETH

LoopFi

Findings Distribution

Researcher Performance

Rank: 3/47

Findings: 1

Award: $386.08

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

284.4444 USDC - $284.44

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
:robot:_42_group
edited-by-warden
duplicate-33

External Links

Lines of code

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

Vulnerability details

Impact

After you lock some amount of ETH/WETH or LRTs for certain LoopActivation period(max 120days), 7 days TIMELOCK and up to startClaimDate(can be months, years), you are finally allowed to claim equivalent amount of lpEth using the _claim() function.

    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 {
            uint256 userClaim = userStake * _percentage / 100;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // 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);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

Now, if you stake ETH/WETH, you are directly transferred lpETH in 1 to 1 conversion ratio. But for LRTs, first the LRT token needs to be swapped into ETH. It is done with _fillQuote() function. Before _fillQuote() function is executed inside _claim() function, It is assumed that there should not be any ETH in the contract. It is because the amount of lpETH minted for receiver is equal to amount of ETH this contract receives after _fillQuote() function is executed.

            // 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);

Now, Consider this scenario:

  1. Alice is a malicious user who locks 0.001(any minimum) amount of any LRT token.
  2. Now, After some time startClaimDate is reached and _claim() function can now be called.
  3. Alice then deposits any amount of ETH(lets assume 100ETH) into the contract and calls the _claim() function.
  4. Inside the _claim() function, _fillQuote() function executes and 0.001 LRT locked is converted to 0.001 ETH(lets assume).
  5. Now, instead of Alice getting 0.001 lpETH, she will get 100.001 lpETH as claimedAmount is set to address(this).balance.
  6. Alice instantly gets 100 lpETH without even locking that amount.

Note: Alice can also call claimAndStake() funciton to stake that amount and get even more rewards. Alice can execute above hack anytime as there is no time-limit to claim.

Proof of Concept

Theoretical PoC is given above. Coded can't be provided as it requires a valid calldata for the swap of LRT from the exchanges and that calldata is validated and also _fillQuote() is called with same calldata.

Tools Used

Manual Analysis

_fillQuote() function should return boughtETHAmount and that same boughETHAmount should be used by _claim() function.

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

        require(_sellToken.approve(exchangeProxy, _amount));

        (bool success,) = payable(exchangeProxy).call{value: 0}(_swapCallData);
        if (!success) {
            revert SwapCallFailed();
        }

        // Use our current buyToken balance to determine how much we've bought.
        boughtETHAmount = address(this).balance - boughtETHAmount;
        emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
    }
    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 {
            uint256 userClaim = userStake * _percentage / 100;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

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

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

Assessed type

Other

#0 - c4-judge

2024-05-15T14:26:29Z

koolexcrypto marked the issue as duplicate of #6

#1 - c4-judge

2024-05-15T14:27:28Z

koolexcrypto marked the issue as partial-75

#2 - c4-judge

2024-05-31T09:58:21Z

koolexcrypto marked the issue as duplicate of #33

#3 - c4-judge

2024-06-05T08:50:47Z

koolexcrypto marked the issue as full credit

#4 - c4-judge

2024-06-05T09:55:19Z

koolexcrypto marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter