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
Rank: 5/59
Findings: 8
Award: $5,507.65
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: 0xDjango
Also found by: hake, kirk-baird, rayn, shenwilly
2219.3568 USDC - $2,219.36
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()
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
🌟 Selected for report: kirk-baird
665.807 USDC - $665.81
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/AnyswapFacet.sol#L35-L53
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()
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.
🌟 Selected for report: hake
Also found by: Kenshin, Ruhum, VAD37, WatchPug, csanuragjain, hickuphh3, hyh, kirk-baird, obront, pmerkleplant, rayn, shw, tintin, wuwe1
77.3842 USDC - $77.38
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
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.
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
🌟 Selected for report: kirk-baird
Also found by: TomFrenchBlockchain, VAD37, WatchPug, hyh, rayn, wuwe1
385.2169 USDC - $385.22
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
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
.
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.
🌟 Selected for report: hyh
Also found by: danb, kirk-baird, pmerkleplant
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibSwap.sol#L29-L46
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.
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
🌟 Selected for report: kirk-baird
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
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()
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.
🌟 Selected for report: hake
Also found by: Jujic, WatchPug, catchup, danb, defsec, kirk-baird, nedodn, shenwilly, sorrynotsorry
196.5762 USDC - $196.58
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.
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
🌟 Selected for report: hake
Also found by: 0v3rf10w, 0xDjango, 0xkatana, BouSalman, CertoraInc, Dravee, Hawkeye, IllIllI, JMukesh, Jujic, Kenshin, PPrieditis, Picodes, PranavG, Ruhum, SolidityScan, VAD37, WatchPug, aga7hokakological, catchup, csanuragjain, cthulhu_cult, defsec, dimitri, hickuphh3, hubble, hyh, kenta, kirk-baird, obront, peritoflores, rayn, robee, saian, samruna, shenwilly, shw, sorrynotsorry, tchkvsky, teryanarmen, ych18
113.8424 USDC - $113.84
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.
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