LoopFi - Topmark'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: 30/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
upgraded by judge
:robot:_07_group
duplicate-33

External Links

Lines of code

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

Vulnerability details

Impact

Risk of Fund Loss to Protocol as User can Claim Entire Eth Balance of Contract instead of just their own Eth Claim Value in the PrelaunchPoints.sol contract during claim function call.

Proof of Concept

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

The function above shows how _claim(...) function is implemented in the PrelaunchPoints.sol contract, it can be noted from the pointers that when token is Non Eth a swap is done before a claim of Eth value is done and deposited to receiver address, from the first pointer, the protocol assumes there should not be any Eth in the contract which is why the last pointer assigns the complete balance of eth in contract as claimed Amount instead of the actual Amount that is to be claimed. However the implementation of _fillQuote(...) function which handles the swap to determine the Eth value to be claimed as provided below paints a different picture of the Eth that should be in balance. As noted from the pointers below the value of Eth Bought is derived by comparing the Eth balance before and after without assuming it is previously empty, this is the correct way to derived the actual Eth that should be claimed. Now back to the _claim(...) function above, it is this boughtETHAmount from _fillQuote(...) function that should be used as claimedAmount value and not using the entire balance of the contract as wrongly done in the last pointer above which risks a user claiming the whole contract value instead of just its personal funds, a bad actor simply needs to keep track of the contract balance to claim at the perfect time to get excess fund above its claimable range

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

As adjusted in the code below the actual boughtEthAmount from sell token should be returned by the _fillQuote(...) function and this value should be used to represent claimedAmount and not the overall balance of the contract as previously done

--- function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal {
+++ function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal  returns (uint256 boughtETHAmount ) {
        ...
        // 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) {
            ...
        } 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 boughtETHAmount =  _fillQuote(IERC20(_token), userClaim, _data); 
            // Convert swapped ETH to lpETH (1 to 1 conversion)
---            claimedAmount = address(this).balance; 
+++            claimedAmount = boughtETHAmount; 
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

Assessed type

ETH-Transfer

#0 - c4-judge

2024-05-15T13:30:56Z

koolexcrypto marked the issue as duplicate of #18

#1 - c4-judge

2024-06-05T07:29:36Z

koolexcrypto changed the severity to 2 (Med Risk)

#2 - c4-judge

2024-06-05T07:39:06Z

koolexcrypto marked the issue as partial-75

#3 - c4-judge

2024-06-05T09:38:59Z

koolexcrypto marked the issue as partial-50

#4 - c4-judge

2024-06-05T09:41:00Z

koolexcrypto changed the severity to 3 (High Risk)

#5 - c4-judge

2024-06-05T09:41:27Z

koolexcrypto marked the issue as duplicate of #33

#6 - Topmark1

2024-06-05T14:33:12Z

I believe this finding is worth full reward as it noted the major problem which is the use of address(this).balance for claimedAmount just as noted in the primary report at https://github.com/code-423n4/2024-05-loop-findings/issues/33 and it offers more explanation and elaboration than the bot primary report at https://github.com/code-423n4/2024-05-loop-findings/issues/18 which is also getting full reward, and some other examples just to mention a few, Great Judging @koolexcrypto thanks for the hard work

#7 - koolexcrypto

2024-06-10T08:21:34Z

Hi @Topmark1

The issues doesn't clearly explain where the funds in the contract comes from. It is just assumes to have funds which shouldn't happen normally. For this, it is not fair to give it full credit.

#8 - c4-judge

2024-06-11T07:54:08Z

koolexcrypto marked the issue as partial-25

#9 - Topmark1

2024-06-11T07:58:34Z

I don't seem to understand why this issue is being downgraded again instead of an upgrade???, you gave reasons for giving it a partial 50 which I would have argued but don't want to extended the PQA for a mere duplicate but suddenly downgrading it even lower seems rather unfair, thanks

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