LoopFi - SBSecurity'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: 26/47

Findings: 1

Award: $71.11

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

71.1111 USDC - $71.11

Labels

bug
3 (High Risk)
partial-25
sufficient quality report
:robot:_primary
:robot:_01_group
duplicate-33

External Links

Lines of code

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

Vulnerability details

Impact

User can by passing 0 as a _percentage, receive lpETH based on the ethers in the contract.

Proof of Concept

If the PrelaunchPoints.sol contract has native ether, any user can get lpETH for them without sending anything.

The user only needs to have a balance in the contract of the token that passes when he perform the attack to pass this check - it can be 1 wei.

uint256 userStake = balances[msg.sender][_token];
if (userStake == 0) {
    revert NothingToClaim();
}

User will call claim() with:

  • _token - anyToken
  • _percentage - 0
  • Exchange - TransformERC20
  • _data:
    • TRANSFORM_SELECTOR = 0x415565b0
    • inputToken = the token provided in _token
    • outputToken = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE
    • inputTokenAmount = 0
function claim(address _token, uint8 _percentage, Exchange _exchange, bytes calldata _data)

Then in _claim() it will enter the else statement, userClaim will become 0 because its - userStake * 0 / 100 = 0.

Then _validateData() will pass correctly, his balance will not be changed. When _fillQuote() is called, nothing will be exchanged because it will pass userClaim which is 0, so no ethers will enter the contract, and boughtETHAmount will remain the same. The claimedAmount will then be the contract balance and will be deposited into lpETH, by passing him as receiver, causing him to receive lpETH based on the Ether balance in the contract without having to sent anything**.**

PrelaunchPoints.sol#L240-L266

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

PrelaunchPoints.sol#L405-L442

function _validateData(address _token, uint256 _amount, Exchange _exchange, bytes calldata _data) internal view {
    address inputToken;
    address outputToken;
    uint256 inputTokenAmount;
    address recipient;
    bytes4 selector;

    if (_exchange == Exchange.UniswapV3) {
        (inputToken, outputToken, inputTokenAmount, recipient, selector) = _decodeUniswapV3Data(_data);
        if (selector != UNI_SELECTOR) {
            revert WrongSelector(selector);
        }
        // UniswapV3Feature.sellTokenForEthToUniswapV3(encodedPath, sellAmount, minBuyAmount, recipient) requires `encodedPath` to be a Uniswap-encoded path, where the last token is WETH, and sends the NATIVE token to `recipient`
        if (outputToken != address(WETH)) {
            revert WrongDataTokens(inputToken, outputToken);
        }
    } else if (_exchange == Exchange.TransformERC20) {
        (inputToken, outputToken, inputTokenAmount, selector) = _decodeTransformERC20Data(_data);
        if (selector != TRANSFORM_SELECTOR) {
            revert WrongSelector(selector);
        }
        if (outputToken != ETH) {
            revert WrongDataTokens(inputToken, outputToken);
        }
    } else {
        revert WrongExchange();
    }

    if (inputToken != _token) {
        revert WrongDataTokens(inputToken, outputToken);
    }
    if (inputTokenAmount != _amount) {
        revert WrongDataAmount(inputTokenAmount);
    }
    if (recipient != address(this) && recipient != address(0)) {
        revert WrongRecipient(recipient);
    }
}

PrelaunchPoints.sol#L491-L505

function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal {
    // Track our balance of the buyToken to determine how much we've bought.
    uint256 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);
}

Tools Used

Manual Review

I think the purpose of boughtETHAmount was to deposit in lpETH only if there is some swap that increases the native balance, so _claim() should be changed to deposit in lpETH only boughtETHAmount which is calculated in _fillQuote() this can be done by returning boughtETHAmount from _fillQuote() and then passing it to lpETH.deposit{value: boughtETHAmount}(_receiver).

Assessed type

Math

#0 - c4-judge

2024-06-10T19:13:27Z

koolexcrypto marked the issue as duplicate of #33

#1 - c4-judge

2024-06-11T08:01:27Z

koolexcrypto marked the issue as partial-25

#2 - c4-judge

2024-06-11T08:09:08Z

koolexcrypto marked the issue as not a duplicate

#3 - c4-judge

2024-06-11T08:09:30Z

koolexcrypto marked the issue as duplicate of #33

#4 - c4-judge

2024-06-11T08:09:43Z

koolexcrypto marked the issue as partial-25

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