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: 1/59
Findings: 6
Award: $7,983.73
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: 0xDjango
Also found by: hake, kirk-baird, rayn, shenwilly
In the swapTokensGeneric()
function, an arbitrary number of swaps can be performed from and to various tokens. However, the final balance that is sent to the user relies on _lifiData.receivingAssetId
which has no use in the swapping functionality. LifiData is claimed to be used purely for analytical reasons per the comments to this function. If this value is input incorrectly, the swapped tokens will simply sit in the contract and be lost to the user.
Imagine a call to swapTokensGeneric()
with the following parameters (excluding unnecessary parameters for this example):
Single SwapData array:
Since the receivingAssetId
from SwapData
does not match the receivingAssetId
from LifiData
, the final funds will not be sent to the user after the swap is complete, based on the following lines of code:
uint256 receivingAssetIdBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId); _executeSwaps(_lifiData, _swapData); uint256 postSwapBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId) - receivingAssetIdBalance; LibAsset.transferAsset(_lifiData.receivingAssetId, payable(msg.sender), postSwapBalance);
Lines 1, 3, and 4 reference LifiData.receivingAssetId
and handle the transfer of funds following the swaps. Line 2 performs the swap, referencing SwapData.receivingAssetId
as can be seen in the executeSwaps()
function definition:
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]); } }
Manual Review.
I recommend adding a check that _lifiData.receivingAssetId
equals the receivingAssetId
of the last index of the SwapData array, or simply use the receivingAssetId
of the last index of the SwapData array for sending the final tokens to the user.
#0 - H3xept
2022-04-05T15:02:46Z
Fixed in lifinance/lifi-contracts@52aa2b8ea3bc51de3e60784c00201e29103fe250
#1 - gzeoneth
2022-04-16T16:36:38Z
Sponsor confirmed with fix.
🌟 Selected for report: 0xDjango
Also found by: GeekyLumberjack, pmerkleplant
https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Facets/GenericSwapFacet.sol#L23-L30 https://github.com/code-423n4/2022-03-lifinance/blob/699c2305fcfb6fe8862b75b26d1d8a2f46a551e6/src/Libraries/LibSwap.sol#L48
Every function that stems from the GenericSwapFacet
lacks checks to ensure that some tokens have been returned via the swaps. In LibSwap.sol
in the swap()
function, the swap call is sent to the target DEX. A return of success is required, otherwise the operation will revert.
Each "inner" swap via LibSwap.sol
lacks output checks and also the "outer" swapTokensGeneric()
via GenericSwapFacet.sol
lacks a final check as well.
There is a possibility that the calldata is accidently populated with a function in the target router that is not actually performing any swapping functionality, getAmountsOut()
for example. The function will return true, but no new returned tokens will be present in the contract. Meanwhile, the contract has already received the user's fromTokens
directly.
Manual Review
This would be a potential use case of using function signature whitelists as opposed to contract address whitelists, as noted as a possibility by the LiFi team.
Otherwise, the following require
statement in swapTokensGeneric()
would ensure that at least a single token was received:
require(LibAsset.getOwnBalance(_swapData.receivingAssetId) - toAmount) > 0, "No tokens received")
#0 - H3xept
2022-04-11T11:47:42Z
Fixed in lifinance/lifi-contracts@3a42484dda8bafcfd122c8aa3b61d3766d545bf9
#1 - gzeoneth
2022-04-16T16:38:03Z
Sponsor confirmed with fix.
In the swap()
function in LibSwap.sol
, the SwapData attributes determine many of the initial logical steps, but do not have any bearing on the actual calldata that's passed via the call()
function.
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);
The first several lines of this function transfer the _swapData.fromAssetId
to the contract in the amount of _swapData.fromAmount
, and subsequently approves _swapdata.approveTo
for the same amount. The issue is that these values may not be what is being passed to the DEX contract via the _swapData.callData
in the call()
function.
The most severe issue as a result of a discrepancy that I can foresee is if swapData.fromAmount
is larger than what is passed in via the calldata, the full swapData.fromAmount
is transferred to the contract, then the swap is performed via the value in the calldata. The leftover amount will sit in the contract, available to whoever next swaps TO the original fromAssetId
.
Manual review
You have mentioned the idea of whitelisting function signatures instead of DEX addresses. If you do so, if it's feasible to obtain the contracts' ABIs to decode the calldata input, you can verifiably ensure that the values used in the internal logic will match what is sent to the DEXs.
Otherwise, you can add a step to "sweep" the remainder balance (if any) following swaps to ensure that the user retains maximum token value.
#0 - H3xept
2022-04-12T10:35:33Z
Duplicate of #33
Only one DEX removed while performing batchRemoveDEX()
in DexManagerFacet.sol
, potentially leading to many whitelisted DEXs previously thought to have been removed.
Given the following code to remove multiple DEXs from the whitelist:
function batchRemoveDex(address[] calldata _dexs) external { LibDiamond.enforceIsContractOwner(); for (uint256 i; i < _dexs.length; i++) { if (s.dexWhitelist[_dexs[i]] == false) { continue; } s.dexWhitelist[_dexs[i]] = false; for (uint256 j; j < s.dexs.length; j++) { if (s.dexs[j] == _dexs[i]) { _removeDex(j); return; } } } }
The batchRemoveDEX()
function contains nested for loops. The inner for loop unfortunately uses return
instead of break
, causing the execution of the function to stop the moment it removes the first DEX. This is potentially serious as the team will believe they've removed multiple DEXs from being able to be called from the facets, meanwhile the DEXs can still receive said calls.
Manual review.
Change return
to break
.
#0 - H3xept
2022-04-11T10:40:12Z
Duplicate of #34
🌟 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
167.0761 USDC - $167.08
Most or all contracts include a floating pragma ^0.8.7. It is recommended that the compiler version be locked to a single version to avoid contracts deployed with different versions that may contain various compiler bugs.
It is recommended to employ a transfer/accept ownership transfer pattern. This will avoid accidently transferring ownership to an incorrect address, as the accept function must be called by the pending owner for the transfer to be completed.
The comments specify:
_lifiData data used purely for tracking and analytics
The function uses the _lifiData attributes such as transactionId for logging, but this value can be arbitrary by the msg.sender.
The swap function contains logic that the fromAmount must be transferred to LibSwap.sol if the contract balance is less than the fromAmount. Given the possiblity that the contract already contains some number of tokens, the entire from amount will still be transferred to the contract, and subsequently swapped, leaving the same remainder tokens as before. Given the functionality that checks if the contract balance is equal to or larger than the from amount in the first place, it would make sense to only send the difference between the fromAmount and the contract balance.
OpenZeppelin has marked this function as deprecated and recommends using safeIncreaseAllowance. https://github.com/OpenZeppelin/openzeppelin-contracts/blob/566a774222707e424896c0c390a84dc3c13bdcb2/contracts/token/ERC20/utils/SafeERC20.sol#L38-L43
#0 - H3xept
2022-04-07T09:39:46Z
We internally decided to tackle this once the audit resolves
#1 - H3xept
2022-04-07T09:40:17Z
Fixed in lifinance/lifi-contracts@221fe883f705635feebb2af64a028f30d05afbf8
#2 - H3xept
2022-04-07T11:18:43Z
Fixed in lifinance/lifi-contracts@b4c3fab52f13194dc6c5e4d06819f8228b866035
#3 - H3xept
2022-04-07T11:24:01Z
We now send tokens back to the user after swaps
#4 - ezynda3
2022-04-07T11:25:29Z
We now send tokens back to the user after swaps so we should not do this fix.
#5 - H3xept
2022-04-11T11:23:04Z
Duplicate of #82
#6 - H3xept
2022-04-11T12:22:18Z
Duplicate of #143
🌟 Selected for report: Dravee
Also found by: 0v3rf10w, 0xDjango, 0xNazgul, 0xkatana, ACai, CertoraInc, FSchmoede, Funen, Hawkeye, IllIllI, Jujic, Kenshin, PPrieditis, Picodes, SolidityScan, TerrierLover, Tomio, WatchPug, catchup, csanuragjain, defsec, dimitri, hake, hickuphh3, kenta, minhquanym, obront, peritoflores, rayn, rfa, robee, saian, samruna, tchkvsky, teryanarmen, ych18
61.5429 USDC - $61.54
Same conditions repeated in separate ifs, can be reduced to nested ifs, saving a repeated clause.
Update code:
if (!LibAsset.isNativeAsset(fromAssetId)) { if (LibAsset.getOwnBalance(fromAssetId) < fromAmount) { LibAsset.transferFromERC20(fromAssetId, msg.sender, address(this), fromAmount); } LibAsset.approveERC20(IERC20(fromAssetId), _swapData.approveTo, fromAmount); }
#0 - H3xept
2022-04-04T08:10:35Z
Tackled in previous commit.
#1 - H3xept
2022-04-08T15:21:17Z
Duplicate of #39