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: 6/59
Findings: 6
Award: $4,591.30
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: 0xDjango
Also found by: hake, kirk-baird, rayn, shenwilly
LibSwap.swap swapTokensGeneric
Users could input incongruent values for _lifiData
and _swapData
leading to a swap no being processed correctly and users not getting any of the expected _lifiData.receivingAssetId
.
It can also damage reputation because LiFi could use this to scam their users through a front end by changing inputs deliberatly.
swapTokensGeneric
but accidentally inputs two different addresses for _lifiData.receivingAssetId
(DAI) and _swapData.receivingAssetId
(ADA).function swapTokensGeneric(LiFiData memory _lifiData, LibSwap.SwapData[] calldata _swapData)
LibSwap.swap
uses _swapData.receivingAssetId
(ADA) to make the swap, however GenericSwapFacet.swapTokensGeneric.postSwapBalance
uses _lifiData.receivingAssetId
(DAI) to calculate the amount to be transferred based on its balance before the swap.uint256 toAmount = LibAsset.getOwnBalance(_swapData.receivingAssetId);
vs
uint256 postSwapBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId) - receivingAssetIdBalance;
LibAsset.transferAsset(_lifiData.receivingAssetId, payable(msg.sender), postSwapBalance);
Implement a check to make sure _lifiData
and _swapData
values are the same.
#0 - H3xept
2022-04-11T10:22:22Z
Duplicate of #137
#1 - gzeoneth
2022-04-16T17:46:02Z
Agree with sponsor this does not pose a functional risk. Changing to Low/QA.
#2 - gzeoneth
2022-04-16T18:52:58Z
Consider with warden's QA Report #68
#3 - gzeoneth
2022-05-11T17:16:10Z
This should be a duplicate of https://github.com/code-423n4/2022-03-lifinance-findings/issues/75 instead, I made a mistake during the deduplication process.
🌟 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
LibSwap.swap GenericSwapFacet.sol
Remaining or unaccounted ERC20 balance could be freely taken through swapTokensGenerics
and swap
.
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); } toAmount = LibAsset.getOwnBalance(_swapData.receivingAssetId) - toAmount;
Given:
There has been a deposit to LiFi of a non-native ERC20 that makes LibAsset.getOwnBalance(fromAssetId)
a desirable amount.
Attacker calls swapTokensGeneric
with a _swapData.fromAmount
value just below LibAsset.getOwnBalance(fromAssetId)
.
First if
statement in swap
is skipped (no funds are tranferred to LiFis contract).
if (!LibAsset.isNativeAsset(fromAssetId) && LibAsset.getOwnBalance(fromAssetId) < fromAmount) { LibAsset.transferFromERC20(fromAssetId, msg.sender, address(this), fromAmount); }
LibAsset.getOwnBalance(_lifiData.receivingAssetId)
postSwapBalance
and transfered to attacker.Ensure funds are always subtracted from users account in swap
, even if LiFi has enough balance to do the swap.
#0 - H3xept
2022-04-12T08:37:45Z
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 - H3xept
2022-04-12T08:40:04Z
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).
#2 - gzeoneth
2022-04-16T16:09:24Z
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.
#3 - ezynda3
2022-05-03T10:09:16Z
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: kirk-baird
LibSwap.swap: L29-46 LibAsset.approveERC20
If a whitelisted dex gets compromised or becomes ill intentioned it can withdraw all funds in LiFi via approveERC20
and/or a reentrancy attack through the LibSwap.swap
function.
By the time the dex is blacklisted it might be too late, as there are no inbuilt boundaries or mechanisms to mitigate an attack.
This means that LiFi is only as secure as the dexs it allows/approves.
The call in line 42 (bool success, bytes memory res) = _swapData.callTo.call{ value: msg.value }(_swapData.callData);
has no reentrancy guard an could be used as a reentrancy vector if any approved callTo
turned malicious.
The LibAsset.approveERC20
function allows any approveTo
address to withdraw as much as they want from LiFi with no limits or boundaries as we can see in the last three lines of code below.
function approveERC20( IERC20 assetId, address spender, uint256 amount ) internal { if (isNativeAsset(address(assetId))) return; uint256 allowance = assetId.allowance(address(this), spender); if (allowance < amount) { if (allowance > 0) SafeERC20.safeApprove(IERC20(assetId), spender, 0); SafeERC20.safeApprove(IERC20(assetId), spender, MAX_INT); } }
Manual Review
Implement a reentrancy guard at swapAndStartBridgeTokensViaNXTP
and similar functions. I recommend using ReentrancyGuard
from OpenZeppelin.
Set allowance
dynamically equal to the amount to be transfered/swapped instead of an unlimited amount.
Implement checks for making sure _swapData
and _nxtpData
are congruent with each other.
#0 - H3xept
2022-04-11T12:25:57Z
Duplicate of #109
🌟 Selected for report: hake
Also found by: Jujic, WatchPug, catchup, danb, defsec, kirk-baird, nedodn, shenwilly, sorrynotsorry
196.5762 USDC - $196.58
DiamondCutFacet.sol WithdrawFacet.sol DexManagerFacet.sol
contractOwner
has complete freedom to change any functionality and withdraw/rug all assets. Even if well intended the project could still be called out resulting in a damaged reputation like in this example
https://twitter.com/RugDocIO/status/1411732108029181960
Recommend implementing extra safeguards such as:
contractOwner
private key leaks.#0 - H3xept
2022-04-12T09:24:31Z
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 choose the Dimond standard to be able to constantly add new bridges and update the existing ones as they progress as well.
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:33:17Z
Duplicate of #65
#2 - gzeoneth
2022-04-16T17:20:14Z
Valid submission as user will approve fund to the contract.
🌟 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
1110.8314 USDC - $1,110.83
initNXTP
can be initialized multiple times.function initNXTP(ITransactionManager _txMgrAddr) external { Storage storage s = getStorage(); LibDiamond.enforceIsContractOwner(); s.nxtpTxManager = _txMgrAddr; }
The function initNXTP
has init in its name, suggesting that it should only be called once to intiliaze the nxtpTxManager
. However, it can be called multiple times to overwrite the address.
Recommend setting the address in a constructor
or reverting if address is already set. Third option would be changing the name from initNXTP
to something like setNXTP
to better align function names with their functionality.
Note: Same issue is present in initHop
and initCbridge
.
_nxtpData.amount
and msg.value
in startBridgeTokensViaNXTP
.function startBridgeTokensViaNXTP(LiFiData memory _lifiData, ITransactionManager.PrepareArgs memory _nxtpData) public payable { // Ensure sender has enough to complete the bridge transaction address sendingAssetId = _nxtpData.invariantData.sendingAssetId; if (sendingAssetId == address(0)) require(msg.value == _nxtpData.amount, "ERR_INVALID_AMOUNT"); else { uint256 _sendingAssetIdBalance = LibAsset.getOwnBalance(sendingAssetId); LibAsset.transferFromERC20(sendingAssetId, msg.sender, address(this), _nxtpData.amount); require( LibAsset.getOwnBalance(sendingAssetId) - _sendingAssetIdBalance == _nxtpData.amount, "ERR_INVALID_AMOUNT" ); }
Lack of zero value check on both _nxtpData.amount
and msg.value
allow bridge to be wastefully started.
Recommend implementing a require
function such as(line 50):
require(msg.value > 0 || _nxtpData.amount > 0, "Amount must be greater than zero!");
Implementing events here would increase transparency and trust.
addDex
, batchAddDex
, removeDex
and batchRemoveDex
.Zero address check would prevent adding harmful DEX contracts by mistake. If zero address DEXs are whitelisted, users could burn their tokens on accident.
transfer
in WithdrawFacet.sol
Boolean return value for transfer
is not checked.
assert(_amount <= self.balance); payable(sendTo).transfer(_amount); } else {
I recommend implementing call
instead:
(bool success, ) = sendTo.call.value(_amount)(""); require(success, "Transfer failed.");
swapTokensGeneric
incorrect as to spec.* @param _lifiData data used purely for tracking and analytics
The above statement in the code snippet is incorrect because _lifiData
is used to calculate the amount to be transfered back to user after swap.
uint256 postSwapBalance = LibAsset.getOwnBalance(_lifiData.receivingAssetId) - receivingAssetIdBalance; LibAsset.transferAsset(_lifiData.receivingAssetId, payable(msg.sender), postSwapBalance);
I recommend either exchanging the use of _lifiData
for _swapData
or changing the comments.
initNXTP
and initHop
emit no event.NXTPFacet.sol: L33-37
HopFacet.sol: L40-52
Emitting an event with the initialization of nxtpTxManager
and initHop
can increase the protocols transparency and trust. CbridgeFacet.initCbridge
emits an event, so keeping it consistent is also good practice.
Fix typo.
startBridgeTokensViaCBridge
has same functionality but deviates from conventions set in startBridgeTokensViaHop
and startBridgeTokensViaNXT
By comparing startBridgeTokensViaCBridge
to startBridgeTokensViaHop
and startBridgeTokensViaNXT
we can see that the first deviates from the other two, despite containing the same functionality. This hurts readbility. Please implement startBridgeTokensViaCBridge
in the same manner the other startBridge
functions have been implemented. Differences can be seen below.
startBridgeTokensViaCBridge
:
function startBridgeTokensViaCBridge(LiFiData memory _lifiData, CBridgeData calldata _cBridgeData) public payable { if (_cBridgeData.token != address(0)) { uint256 _fromTokenBalance = LibAsset.getOwnBalance(_cBridgeData.token); LibAsset.transferFromERC20(_cBridgeData.token, msg.sender, address(this), _cBridgeData.amount); require( LibAsset.getOwnBalance(_cBridgeData.token) - _fromTokenBalance == _cBridgeData.amount, "ERR_INVALID_AMOUNT" ); } else { require(msg.value >= _cBridgeData.amount, "ERR_INVALID_AMOUNT"); }
startBridgeTokensViaHop
:
function startBridgeTokensViaHop(LiFiData memory _lifiData, HopData calldata _hopData) public payable { address sendingAssetId = _bridge(_hopData.asset).token; if (sendingAssetId == address(0)) require(msg.value == _hopData.amount, "ERR_INVALID_AMOUNT"); else { uint256 _sendingAssetIdBalance = LibAsset.getOwnBalance(sendingAssetId); LibAsset.transferFromERC20(sendingAssetId, msg.sender, address(this), _hopData.amount); require( LibAsset.getOwnBalance(sendingAssetId) - _sendingAssetIdBalance == _hopData.amount, "ERR_INVALID_AMOUNT" ); }
DiamondLoupeFacet.sol
.Please delete code snippet below as it serves no purpose.
// Diamond Loupe Functions //////////////////////////////////////////////////////////////////// /// These functions are expected to be called frequently by tools. // // struct Facet { // address facetAddress; // bytes4[] functionSelectors; // }
DiamondLoupeFacet.sol
contract.The function supportsInterface
uses an unamed return, while the rest of the contract uses named returns. Please keep it consistent within contracts and accross the project if possible to improve readibility.
function supportsInterface(bytes4 _interfaceId) external view override returns (bool) { LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage(); return ds.supportedInterfaces[_interfaceId]; }
#0 - H3xept
2022-04-08T07:59:39Z
#1 - H3xept
2022-04-08T08:03:21Z
#2 - H3xept
2022-04-08T08:07:16Z
#3 - H3xept
2022-04-08T15:29:36Z
Duplicate of #14
#4 - H3xept
2022-04-11T12:13:28Z
Duplicate of #174
#5 - liveactionllama
2022-04-28T06:11:26Z
Per discussion with judge ( @gzeoneth ), Low#3 -> Non Critical
Downgraded issues from this warden that were also considered with their QA Report: #61 #64 #67 -> Low Risk #62 -> Non Critical
#6 - gzeoneth
2022-05-11T17:17:16Z
Low#6: Function swapTokensGeneric incorrect as to spec. related to #67 submitted by the same warden and duplicate of #75
🌟 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
62.4172 USDC - $62.42
initNXTP
.Storage storage s = getStorage(); LibDiamond.enforceIsContractOwner(); s.nxtpTxManager = _txMgrAddr; }
Storage storage s = getStorage();
reads from storage even though the function can revert at LibDiamond.enforceIsContractOwner();
causing unnecessary usage of gas.
Recommend implementing LibDiamond.enforceIsContractOwner();
prior to Storage storage s = getStorage();
Example:
LibDiamond.enforceIsContractOwner(); Storage storage s = getStorage(); s.nxtpTxManager = _txMgrAddr; }
else
statement in swapAndStartBridgeTokensViaCBridge
The use of the else
statement below is not necessary and can be avoided by implementing swapAndStartBridgeTokensViaCBridge
in the same manner swapAndStartBridgeTokensViaHop
or swapAndStartBridgeTokensViaNXTP
have been implemented. Reference for swapAndStartBridgeTokensViaHop
below.
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; }
swapAndStartBridgeTokensViaHop
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;
Note: Same issue present in AnyswapFacet.swapAndStartBridgeTokensViaAnyswap
L74-108
#0 - H3xept
2022-04-04T08:01:13Z
#1 - H3xept
2022-04-04T08:02:18Z