LI.FI contest - kirk-baird's results

Bridge & DEX Aggregation.

General Information

Platform: Code4rena

Start Date: 24/03/2022

Pot Size: $75,000 USDC

Total HM: 15

Participants: 59

Period: 7 days

Judge: gzeon

Id: 103

League: ETH

LI.FI

Findings Distribution

Researcher Performance

Rank: 5/59

Findings: 8

Award: $5,507.65

🌟 Selected for report: 3

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: 0xDjango

Also found by: hake, kirk-baird, rayn, shenwilly

Labels

bug
duplicate
3 (High Risk)
resolved

Awards

2219.3568 USDC - $2,219.36

External Links

Lines of code

https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Facets/GenericSwapFacet.sol#L22-L30

Vulnerability details

Impact

When calling Swapper._executeSwaps() there are no checks to ensure the received token matches the final swapped token. If these are different it may result in user funds being locked in the contract.

This issue is present in each of the following functions:

  • GenericSwapFacet.swapTokensGeneric()
  • HopFacet.swapAndStartBridgeTokensViaHop()
  • AnyswapFacet.swapAndStartBridgeTokensViaAnyswap()
  • CBridgeFacet.swapAndStartBridgeTokensViaCBridge()
  • NXTPFacet.swapAndCompleteBridgeTokensViaNXTP()
  • NXTPFacet.swapAndStartBridgeTokensViaNXTP()

Proof of Concept

For example consider GenericSwapFacet.swapTokensGeneric(). When calling _executeSwaps() the final swapped will be _swapData[_swapData.length - 1].receivingAssetId there is no gaurantee this matches _lifiData.receivingAssetId.

Since _lifiData.receivingAssetId is the asset that is measured and transferred to the user any discrepancies between these two will result in user funds being lost to the protocol. That is because they will need to transfer funds in to perform the swap but will not receive the funds out.

function swapTokensGeneric(LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData) public payable { uint256 receivingAssetIdBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId); // Swap _executeSwaps(_lifiData, _swapData); uint256 postSwapBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId) - receivingAssetIdBalance; LibAsset.transferAsset(_lifiData.receivingAssetId, payable(msg.sender), postSwapBalance);

Consider adding the checks

  • GenericSwapFacet.swapTokensGeneric() => require(_lifiData.receivingAssetId == _swapData[_swapData.length - 1].receivingAssetId) in .
  • HopFacet.swapAndStartBridgeTokensViaHop() => require(sendingAssetId == _swapData[_swapData.length - 1].receivingAssetId)
  • AnyswapFacet.swapAndStartBridgeTokensViaAnyswap() => Depending on if it is ERC20 or native tokens
    • require(underlyingToken == _swapData[_swapData.length - 1].receivingAssetId)
    • require(LibAsset.NATIVE_ASSETID == _swapData[_swapData.length - 1].receivingAssetId)
  • CBridgeFacet.swapAndStartBridgeTokensViaCBridge() => Depending on if it is ERC20 or native tokens
    • require(_cBridgeData.token == _swapData[_swapData.length - 1].receivingAssetId)
    • require(LibAsset.NATIVE_ASSETID == _swapData[_swapData.length - 1].receivingAssetId)
  • NXTPFacet.swapAndCompleteBridgeTokensViaNXTP() = > require(finalAssetId == _swapData[_swapData.length - 1].receivingAssetId)
  • NXTPFacet.swapAndStartBridgeTokensViaNXTP() = > require(sendingAssetId == _swapData[_swapData.length - 1].receivingAssetId)

Note it's also worth checking _swapData.length > 0.

#0 - H3xept

2022-04-12T10:22:37Z

A check for final token balance has been added in lifinance/lifi-contracts@15d103256ba5bbd1e8ed9de58fda72f2c084d875

#1 - gzeoneth

2022-04-16T18:00:23Z

Sponsor confirmed with fix.

#2 - gzeoneth

2022-04-16T18:17:04Z

Duplicate #75

Findings Information

🌟 Selected for report: kirk-baird

Also found by: cccz, dirk_y, hickuphh3, rayn

Labels

bug
2 (Med Risk)
disagree with severity
resolved

Awards

665.807 USDC - $665.81

External Links

Lines of code

https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/AnyswapFacet.sol#L35-L53

Vulnerability details

Impact

In AnyswapFacet.sol we parse arbitrary data in _anyswapData allowing an attacker to drain funds (ERC20 or native tokens) from the LiFi contract.

Functions effected:

  • AnyswapFacet.startBridgeTokensViaAnyswap()
  • AnyswapFacet.swapAndStartBridgeTokensViaAnyswap()

Proof of Concept

This attack works in AnyswapFacet.startBridgeTokensViaAnyswap() by having a malicious _anyswapData.token which may change the value return in IAnyswapToken(_anyswapData.token).underlying();.

First we have the first call to IAnyswapToken(_anyswapData.token).underlying(); return a malicious ERC20 contract in the attackers control. This allows for transferring these malicious ERC20 tokens to pass the required balance checks.

uint256 _fromTokenBalance = LibAsset.getOwnBalance(underlyingToken); LibAsset.transferFromERC20(underlyingToken, msg.sender, address(this), _anyswapData.amount); require( LibAsset.getOwnBalance(underlyingToken) - _fromTokenBalance == _anyswapData.amount, "ERR_INVALID_AMOUNT" );

The function will then call _startBridge() which again does address underlyingToken = IAnyswapToken(_anyswapData.token).underlying(); we have the malicious _anyswapData.token return a different address, one which the LiFi contract has balance for (a native token or ERC20).

We will therefore execute the following which will either approve or transfer funds to _anyswapData.router for a different underlyingtoken to the one which supplied the funds to LiFi.

address underlyingToken = IAnyswapToken(_anyswapData.token).underlying(); if (underlyingToken == IAnyswapRouter(_anyswapData.router).wNATIVE()) { IAnyswapRouter(_anyswapData.router).anySwapOutNative{ value: _anyswapData.amount }( _anyswapData.token, _anyswapData.recipient, _anyswapData.toChainId ); return; } if (_anyswapData.token != address(0)) { // Has underlying token? if (underlyingToken != address(0)) { // Give Anyswap approval to bridge tokens LibAsset.approveERC20(IERC20(underlyingToken), _anyswapData.router, _anyswapData.amount); IAnyswapRouter(_anyswapData.router).anySwapOutUnderlying( _anyswapData.token, _anyswapData.recipient, _anyswapData.amount, _anyswapData.toChainId ); } else { // Give Anyswap approval to bridge tokens LibAsset.approveERC20(IERC20(_anyswapData.token), _anyswapData.router, _anyswapData.amount); IAnyswapRouter(_anyswapData.router).anySwapOut( _anyswapData.token, _anyswapData.recipient, _anyswapData.amount, _anyswapData.toChainId ); } } }

Since _anyswapData.router is an address in the attackers control they either are transferred native tokens or they have an allowance of ERC20 tokens that they can spend arbitrarily.

The attack is almost identical in swapAndStartBridgeTokensViaAnyswap()

Consider whitelisting both Anyswap tokens and Anyswap routers (using two distinct whitelists) restricting the attackers ability to use malicious contracts for this attack.

Consider also only calling IAnyswapToken(_anyswapData.token).underlying() once and passing this value to _startBridge().

#0 - H3xept

2022-04-12T09:49:08Z

We fixed the issue by not allowing underlying() to be called multiple times in one transaction (lifinance/lifi-contracts@a8d6336c2ded97bdbca65b64157596b33f18f70d)

We disagree with the severity as no funds should ever be left in the contract by design.

#1 - gzeoneth

2022-04-16T15:56:05Z

Keeping this and all duplicates as Med Risk. There can be fund leftover in the contract under normal operation, for example this tx. In fact, ~$300 worth of token is left in the LI.Fi smart contract on ETH mainnet 0x5a9fd7c39a6c488e715437d7b1f3c823d5596ed1 as of block 14597316. I don't think this is High Risk because the max amount lost is no more than allowed slippage, which can be loss to MEV too.

Findings Information

🌟 Selected for report: hake

Also found by: Kenshin, Ruhum, VAD37, WatchPug, csanuragjain, hickuphh3, hyh, kirk-baird, obront, pmerkleplant, rayn, shw, tintin, wuwe1

Labels

bug
duplicate
2 (Med Risk)
disagree with severity

Awards

77.3842 USDC - $77.38

External Links

Lines of code

https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibSwap.sol#L29-L42 https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/GenericSwapFacet.sol#L22-L43

Vulnerability details

Impact

The function will transfer tokens from msg.sender if and only if the current balance of the token is less than the fromAmount for a swap as seen in the lines below.

if (!LibAsset.isNativeAsset(fromAssetId) && LibAsset.getOwnBalance(fromAssetId) < fromAmount) { LibAsset.transferFromERC20(fromAssetId, msg.sender, address(this), fromAmount); }

This poses two potential threats. The first is if a genuine user is making a swap with multiple paths and has incorrectly calculated fromAmount for the second swap (e.g. if the price has changed). If LibAsset.getOwnBalance(fromAssetId) < fromAmount where LibAsset.getOwnBalance(fromAssetId) should be the output of the previous swap (though it includes residual balance). Then the user will be charged the entire fromAmount which may revert if the user does not have sufficient funds or has not approved the LiFi contract. The user should not be charged then entire fromAmount since they have already contributed LibAsset.getOwnBalance(fromAssetId) from the previous swap. So even if a user was just one wei short due (possibly due to a price decrease) they will be forced to transfer the entire fromAmount for the next swap step.

The second issue is if there is any balance in the LiFi protocol the user may call LibSwap.swap() with fromAmount = LibAsset.getOwnBalance(fromAssetId) as a result transferFromERC20() will not be called. The function will continue to swap the asset and the user will receive the swapped tokens. Therefore, the user has not paid anything yet swapped the current balance of a ERC20 to in LiFi contract for another ERC20 token to which the attacker will receive.

Proof of Concept

Call GenericSwapToken.swapTokensGeneric() with fromAmount = LibAsset.getOwnBalance(fromAssetId) pick any output token and this will be transferred to the attacker.

function swap(bytes32 transactionId, SwapData calldata _swapData) internal { uint256 fromAmount = _swapData.fromAmount; uint256 toAmount = LibAsset.getOwnBalance(_swapData.receivingAssetId); address fromAssetId = _swapData.sendingAssetId; if (!LibAsset.isNativeAsset(fromAssetId) && LibAsset.getOwnBalance(fromAssetId) < fromAmount) { LibAsset.transferFromERC20(fromAssetId, msg.sender, address(this), fromAmount); } if (!LibAsset.isNativeAsset(fromAssetId)) { LibAsset.approveERC20(IERC20(fromAssetId), _swapData.approveTo, fromAmount); } // solhint-disable-next-line avoid-low-level-calls (bool success, bytes memory res) = _swapData.callTo.call{ value: msg.value }(_swapData.callData); ...
function swapTokensGeneric(LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData) public payable { uint256 receivingAssetIdBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId); // Swap _executeSwaps(_lifiData, _swapData); uint256 postSwapBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId) - receivingAssetIdBalance; LibAsset.transferAsset(_lifiData.receivingAssetId, payable(msg.sender), postSwapBalance); emit LiFiTransferStarted( _lifiData.transactionId, _lifiData.integrator, _lifiData.referrer, _lifiData.sendingAssetId, _lifiData.receivingAssetId, _lifiData.receiver, _lifiData.amount, _lifiData.destinationChainId, block.timestamp ); }

To mitigate this issue consider passing a boolean variable as a function parameter in swap() this variable will indicate if it is the first step (i.e. first external call) in the swap path. If it is the first step require fromAmount to be transferred from the msg.sender or require fromAmount == msg.value for the native token.

The following steps must use the toAmount from the previous step. To do this modify swap() to return toAmount and have Swapper._executeSwap() pass previousToAmount as a function parameter to LibSwap.swap().

#0 - H3xept

2022-04-12T09:44:18Z

Duplicate of #66

Findings Information

🌟 Selected for report: kirk-baird

Also found by: TomFrenchBlockchain, VAD37, WatchPug, hyh, rayn, wuwe1

Labels

bug
2 (Med Risk)
disagree with severity
sponsor acknowledged

Awards

385.2169 USDC - $385.22

External Links

Lines of code

https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibSwap.sol#L42 https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/Swapper.sol#L12-L23

Vulnerability details

Impact

msg.value is attached multiple times to external swap calls in LibSwap.swap().

If Swapper._executeSwaps() is called with the native token as the swapData.fromAssetId more than once and msg.value > 0 then more value will be transferred out of the contract than is received since msg.value will be transferred out _swapData.length times.

The impact is that the contract can have all the native token balance drained by an attacker who has makes repeated swap calls from the native token into any other ERC20 token. Each time the original msg.value of the sender will be swapped out of the contract. This attack essentially gives the attacker _swapData.length * msg.value worth of native tokens (swapped into another ERC20) when they should only get msg.value.

Proof of Concept

Swapper._executeSwaps() iterates over a list of SwapData calling LibSwap.swap() each time (note this is an internal call).

    function _executeSwaps(LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData) internal {
        // Swap
        for (uint8 i; i < _swapData.length; i++) {
            require(
                ls.dexWhitelist[_swapData[i].approveTo] == true && ls.dexWhitelist[_swapData[i].callTo] == true,
                "Contract call not allowed!"
            );

            LibSwap.swap(_lifiData.transactionId, _swapData[i]);
        }
    }
}

Inside LibSwap.swap() we make an external call to _swapData.callTo with value : msg.value. Due to the loop in Swapper._executeSwaps() this repeatedly sends the original msg.value in the external call.

        (bool success, bytes memory res) = _swapData.callTo.call{ value: msg.value }(_swapData.callData);

This issue may be mitigated by only allowing fromAssetId to be the native token once in _swapData in Swapper._executeSwaps(). If it occurs more than once the transaction should revert.

#0 - H3xept

2022-04-12T10:02:33Z

We are aware that the contract allows users to use latent funds, although we disagree on it being an issue as no funds (ERC20 or native) should ever lay in the contract. To make sure that no value is ever kept by the diamond, we now provide refunds for outstanding user value (after bridges/swaps).

#1 - gzeoneth

2022-04-16T16:18:35Z

Adjusting this and all duplicates as Med Risk. There can be fund leftover in the contract under normal operation, for example this tx. In fact, ~$300 worth of token is left in the LI.Fi smart contract on ETH mainnet 0x5a9fd7c39a6c488e715437d7b1f3c823d5596ed1 as of block 14597316. I don't think this is High Risk because the max amount lost is no more than allowed slippage, which can be loss to MEV too. Not a duplicate of #66 since msg.value is the vector here.

#2 - ezynda3

2022-05-03T10:09:37Z

This has been fixed in the most recent version of src/Helpers/Swapper.sol which sweeps any latent funds back to the user's wallet.

Findings Information

🌟 Selected for report: hyh

Also found by: danb, kirk-baird, pmerkleplant

Labels

bug
duplicate
2 (Med Risk)

Awards

924.732 USDC - $924.73

External Links

Lines of code

https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibSwap.sol#L29-L46

Vulnerability details

Impact

msg.value is attached to external swap calls in LibSwap.swap() even if the fromAssetId is not the native token.

The function LibSwap.swap() will always include msg.value as seen on the line _swapData.callTo.call{ value: msg.value }(_swapData.callData);. If the sender has sent msg.value in the transaction, for example if they have multiple swap paths and the first one is the native token. Then this value is included for all other swap paths.

The impact is that there is either insufficient balance in the contract in which case the transaction will always revert. Alternatively, if there is sufficient balance in the contract then it will be transferred in the external call potentially draining the contract of funds.

Proof of Concept

In the function below msg.value is always sent in the external call even if the fromAssetId is not the native token.

    function swap(bytes32 transactionId, SwapData calldata _swapData) internal {
        uint256 fromAmount = _swapData.fromAmount;
        uint256 toAmount = LibAsset.getOwnBalance(_swapData.receivingAssetId);
        address fromAssetId = _swapData.sendingAssetId;
        if (!LibAsset.isNativeAsset(fromAssetId) && LibAsset.getOwnBalance(fromAssetId) < fromAmount) {
            LibAsset.transferFromERC20(fromAssetId, msg.sender, address(this), fromAmount);
        }

        if (!LibAsset.isNativeAsset(fromAssetId)) {
            LibAsset.approveERC20(IERC20(fromAssetId), _swapData.approveTo, fromAmount);
        }

        // solhint-disable-next-line avoid-low-level-calls
        (bool success, bytes memory res) = _swapData.callTo.call{ value: msg.value }(_swapData.callData);
        if (!success) {
            string memory reason = LibUtil.getRevertMsg(res);
            revert(reason);
        }
...

Consider only attaching value to the external call if fromAssetId is the native token. Alternatively do not allow for the native token to be used in the protocol and instead enforce it to be swapped in an ERC20.

#0 - H3xept

2022-04-11T12:47:47Z

#1 - gzeoneth

2022-04-16T16:40:55Z

Changing to Med Risk to align with #53

Findings Information

🌟 Selected for report: kirk-baird

Also found by: ACai, hake, rayn

Labels

bug
2 (Med Risk)
resolved

Awards

924.732 USDC - $924.73

External Links

Lines of code

https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Facets/DiamondCutFacet.sol#L14-L22 https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Facets/CBridgeFacet.sol#L92-L121 https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Facets/AnyswapFacet.sol#L74-L110 https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Facets/NXTPFacet.sol#L85-L102 https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Facets/NXTPFacet.sol#L150-L171

Vulnerability details

Impact

There is a reenterancy vulnerability in functions which call Swapper._executeSwap() which would allow the attacker to change their postSwapBalance.

The functions following similar logic to that seen in GenericSwapFacet.swapTokensGeneric().

uint256 receivingAssetIdBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId); // Swap _executeSwaps(_lifiData, _swapData); uint256 postSwapBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId) - receivingAssetIdBalance; LibAsset.transferAsset(_lifiData.receivingAssetId, payable(msg.sender), postSwapBalance);

This logic records the balance before and after the _executeSwaps() function. The difference is then transferred to the msg.sender.

The issue occurs since it is possible for an attacker to reenter this function during _executeSwaps(), that is because execute swap makes numerous external calls, such as to the AMM, or to untrusted ERC20 token addresses.

If a function is called such as WithdrawFacet.withdraw() this will impact the calculations of postSwapBalance which will account for the funds transferred out during withdrawal. Furthermore, any functions which transfers funds into the contract will also be counted in the postSwapBalance calculations.

Vulnerable Functions:

  • GenericSwapFacet.swapTokensGeneric()
  • CBridgeFacet.swapAndStartBridgeTokensViaCBridge()
  • AnyswapFacet.swapAndStartBridgeTokensViaAnyswap()
  • HopFacet.swapAndStartBridgeTokensViaHop()
  • NXTPFacet.swapAndStartBridgeTokensViaNXTP()
  • NXTPFacet.swapAndCompleteBridgeTokensViaNXTP()

Proof of Concept

GenericSwapFacet.swapTokensGeneric()

function swapTokensGeneric(LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData) public payable { uint256 receivingAssetIdBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId); // Swap _executeSwaps(_lifiData, _swapData); uint256 postSwapBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId) - receivingAssetIdBalance; LibAsset.transferAsset(_lifiData.receivingAssetId, payable(msg.sender), postSwapBalance);

CBridgeFacet.swapAndStartBridgeTokensViaCBridge()

function swapAndStartBridgeTokensViaCBridge( LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData, CBridgeData memory _cBridgeData ) public payable { if (_cBridgeData.token != address(0)) { uint256 _fromTokenBalance = LibAsset.getOwnBalance(_cBridgeData.token); // Swap _executeSwaps(_lifiData, _swapData); uint256 _postSwapBalance = LibAsset.getOwnBalance(_cBridgeData.token) - _fromTokenBalance; require(_postSwapBalance > 0, "ERR_INVALID_AMOUNT"); _cBridgeData.amount = _postSwapBalance; } else { uint256 _fromBalance = address(this).balance; // Swap _executeSwaps(_lifiData, _swapData); uint256 _postSwapBalance = address(this).balance - _fromBalance; require(_postSwapBalance > 0, "ERR_INVALID_AMOUNT"); _cBridgeData.amount = _postSwapBalance; } _startBridge(_cBridgeData);

AnyswapFacet.swapAndStartBridgeTokensViaAnyswap()

function swapAndStartBridgeTokensViaAnyswap( LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData, AnyswapData memory _anyswapData ) public payable { address underlyingToken = IAnyswapToken(_anyswapData.token).underlying(); if (_anyswapData.token != address(0) && underlyingToken != IAnyswapRouter(_anyswapData.router).wNATIVE()) { if (underlyingToken == address(0)) { underlyingToken = _anyswapData.token; } uint256 _fromTokenBalance = LibAsset.getOwnBalance(underlyingToken); // Swap _executeSwaps(_lifiData, _swapData); uint256 _postSwapBalance = LibAsset.getOwnBalance(underlyingToken) - _fromTokenBalance; require(_postSwapBalance > 0, "ERR_INVALID_AMOUNT"); _anyswapData.amount = _postSwapBalance; } else { uint256 _fromBalance = address(this).balance; // Swap _executeSwaps(_lifiData, _swapData); require(address(this).balance - _fromBalance >= _anyswapData.amount, "ERR_INVALID_AMOUNT"); uint256 _postSwapBalance = address(this).balance - _fromBalance; require(_postSwapBalance > 0, "ERR_INVALID_AMOUNT"); _anyswapData.amount = _postSwapBalance; } _startBridge(_anyswapData);

HopFacet.swapAndStartBridgeTokensViaHop()

function swapAndStartBridgeTokensViaHop( LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData, HopData memory _hopData ) public payable { address sendingAssetId = _bridge(_hopData.asset).token; uint256 _sendingAssetIdBalance = LibAsset.getOwnBalance(sendingAssetId); // Swap _executeSwaps(_lifiData, _swapData); uint256 _postSwapBalance = LibAsset.getOwnBalance(sendingAssetId) - _sendingAssetIdBalance; require(_postSwapBalance > 0, "ERR_INVALID_AMOUNT"); _hopData.amount = _postSwapBalance; _startBridge(_hopData);

NXTPFacet.swapAndStartBridgeTokensViaNXTP()

function swapAndStartBridgeTokensViaNXTP( LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData, ITransactionManager.PrepareArgs memory _nxtpData ) public payable { address sendingAssetId = _nxtpData.invariantData.sendingAssetId; uint256 _sendingAssetIdBalance = LibAsset.getOwnBalance(sendingAssetId); // Swap _executeSwaps(_lifiData, _swapData); uint256 _postSwapBalance = LibAsset.getOwnBalance(sendingAssetId) - _sendingAssetIdBalance; require(_postSwapBalance > 0, "ERR_INVALID_AMOUNT"); _nxtpData.amount = _postSwapBalance; _startBridge(_lifiData.transactionId, _nxtpData);

NXTPFacet.swapAndCompleteBridgeTokensViaNXTP()

function swapAndCompleteBridgeTokensViaNXTP( LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData, address finalAssetId, address receiver ) public payable { uint256 startingBalance = LibAsset.getOwnBalance(finalAssetId); // Swap _executeSwaps(_lifiData, _swapData); uint256 postSwapBalance = LibAsset.getOwnBalance(finalAssetId); uint256 finalBalance; if (postSwapBalance > startingBalance) { finalBalance = postSwapBalance - startingBalance; LibAsset.transferAsset(finalAssetId, payable(receiver), finalBalance); } emit LiFiTransferCompleted(_lifiData.transactionId, finalAssetId, receiver, finalBalance, block.timestamp); }

Consider adding a reentrancy guard over every function which may send or receive tokens. It may be easiest too add this guard over the fallback() function however that could prevent view functions from being called (since it would perform storage operations).

Ensure the same slot is used to store the reentrancy guard so all required functions are covered by a single guard.

#0 - H3xept

2022-04-04T14:50:50Z

Bridge functions are vulnerable aswell.

#1 - H3xept

2022-04-11T12:27:00Z

Fixed in lifinance/lifi-contracts@703919f74d8b750e3bcf7a84bdcb4d742bc8d45a

#2 - gzeoneth

2022-04-16T16:46:29Z

Sponsor confirmed with fix. While the reentrancy is valid there are no exploit, keeping this and all duplicates as Med Risk.

Findings Information

🌟 Selected for report: hake

Also found by: Jujic, WatchPug, catchup, danb, defsec, kirk-baird, nedodn, shenwilly, sorrynotsorry

Labels

bug
duplicate
2 (Med Risk)
sponsor acknowledged

Awards

196.5762 USDC - $196.58

External Links

Lines of code

https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Facets/DiamondCutFacet.sol#L14-L22

Vulnerability details

Impact

The owner of the LiFiDiamond contract may set additional facets through the function diamondCut() allowing them to rug pull the protocol by making arbitrary external calls.

Each facet is a smart contract address which may be delegate called (as is the design of the protocol). Since the owner can add new facets and there are no restrictions on what the bytecode is of these new facets. The owner may then later delegate call their new facet. If their new facet is malicious it could transfer all native or other tokens from the contract to the attacker. Furthermore, this attack could be used to spend all allowance of users in the protocol. Since allowances are required to be set by user to use the protocol there is expected to reasonable amount of allowances for the owner to spend.

Proof of Concept

DiamondCutFacet.sol allows adding of new facets through the function LibDiamond.diamondCut() though it may only be called by the owner.

function diamondCut( FacetCut[] calldata _diamondCut, address _init, bytes calldata _calldata ) external override { LibDiamond.enforceIsContractOwner(); LibDiamond.diamondCut(_diamondCut, _init, _calldata); }

LiFiDiamond fallback function allows delegatecall to any facet.

fallback() external payable { LibDiamond.DiamondStorage storage ds; bytes32 position = LibDiamond.DIAMOND_STORAGE_POSITION; // get diamond storage // solhint-disable-next-line no-inline-assembly assembly { ds.slot := position } // get facet from function selector address facet = ds.selectorToFacetAndPosition[msg.sig].facetAddress; require(facet != address(0), "Diamond: Function does not exist"); // Execute external function from facet using delegatecall and return any value. // solhint-disable-next-line no-inline-assembly assembly { // copy function selector and any arguments calldatacopy(0, 0, calldatasize()) // execute function call using the facet let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0) // get any return value returndatacopy(0, 0, returndatasize()) // return any return value or error back to the caller switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } }

Ensure that the owner is a Timelocked DAO rather than an EOA or multisig to prevent the owner from being able to rug pull the protocol.

#0 - H3xept

2022-04-12T09:30:49Z

The bridges/swaps ecosystem is continually changing. Our mission is to allow seamless UX and provide users with new bridging and swapping routes as fast as possible. This comes at the cost of having some degree of centralization. We chose the Diamond standard to be able to constantly add new bridges and update the existing ones as they improve and update. We agree with the increased safety a DAO/Multisign mechanism and will provide them in the future. Timelocks are currently not planned, as we want to be able to react fast if we have to disable bridges for security reasons (e.g. if the underlying bridge is being exploited)

#1 - H3xept

2022-04-12T09:34:04Z

Duplicate of #65

Awards

113.8424 USDC - $113.84

Labels

bug
sponsor disputed
QA (Quality Assurance)

External Links

Lines of code

https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Facets/NXTPFacet.sol#L150-L171

Vulnerability details

Impact

The function NXTPFacet.swapAndCompleteBridgeTokensViaNXTP() will continue without reverting if the postSwapBalance <= startingBalance.

If the swap results in the balance of the contract decreasing (due to reentrancy bugs or misconfigured _swapData) then transaction will continue without reverting.

This is an issue since there has been a decrease in tokens. This means either the user or the contract is losing tokens.

Proof of Concept

NXTPFacet.swapAndCompleteBridgeTokensViaNXTP()

function swapAndCompleteBridgeTokensViaNXTP( LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData, address finalAssetId, address receiver ) public payable { uint256 startingBalance = LibAsset.getOwnBalance(finalAssetId); // Swap _executeSwaps(_lifiData, _swapData); uint256 postSwapBalance = LibAsset.getOwnBalance(finalAssetId); uint256 finalBalance; if (postSwapBalance > startingBalance) { finalBalance = postSwapBalance - startingBalance; LibAsset.transferAsset(finalAssetId, payable(receiver), finalBalance); } emit LiFiTransferCompleted(_lifiData.transactionId, finalAssetId, receiver, finalBalance, block.timestamp); }

This issue may be resolved by ensuring the balance of the contract has increased as seen in the following code. Note there is no benefit to the user if the balance remains the same and so we may use a strict inequality.

function swapAndCompleteBridgeTokensViaNXTP( LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData, address finalAssetId, address receiver ) public payable { uint256 startingBalance = LibAsset.getOwnBalance(finalAssetId); // Swap _executeSwaps(_lifiData, _swapData); uint256 postSwapBalance = LibAsset.getOwnBalance(finalAssetId); uint256 finalBalance; require(postSwapBalance > startingBalance); finalBalance = postSwapBalance - startingBalance; LibAsset.transferAsset(finalAssetId, payable(receiver), finalBalance); emit LiFiTransferCompleted(_lifiData.transactionId, finalAssetId, receiver, finalBalance, block.timestamp); }

#0 - maxklenk

2022-04-05T08:47:51Z

Thanks for pointing out that there may no token be transferred from the contract to the user, if the swaps are configured like this. We are aware of that and therefore only trigger a transfer if the desired tokens are present in the contract. We still allow this to be able to support swaps which send funds directly to the user.

#1 - gzeoneth

2022-04-16T17:59:10Z

Downgrading to Low/QA. Treating as warden's QA Report.

#2 - JeeberC4

2022-04-17T04:23:22Z

Preserving original title: Users May Lose Funds in NXTPFacet.swapAndCompleteBridgeTokensViaNXTP() if the Swap Does Not Increase The Balance

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