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: 11/59
Findings: 8
Award: $2,306.50
π Selected for report: 1
π Solo Findings: 0
π 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
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);
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; emit AssetSwapped( transactionId, _swapData.callTo, _swapData.sendingAssetId, _swapData.receivingAssetId, fromAmount, toAmount, block.timestamp ); }
The Swapper
allow arbitrary _swapData
(0x style), this makes it possible for a attacker to steal the funds in the contract.
Based on the context, we beleive it's possible that the contract can hold funds.
The funds can be the refunds of failed orders, or fee rebates from bridging or dex aggregators, etc.
See also the permissioned WithdrawFacet
.
Given:
The attacker can submit a swapTokensGeneric()
with USDT
as receivingAssetId
with the following SwapData[]
:
As a result, the attacker will receive ~100 USDT with 0 USDC paid.
Given that Swapper
is a standlone module that can be and should be deployed as a standalone contract, we suggest spin it off from the diamond so that it can no longer access the funds in the diamond contract.
#0 - H3xept
2022-04-12T08:39:08Z
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).
π Selected for report: kirk-baird
Also found by: TomFrenchBlockchain, VAD37, WatchPug, hyh, rayn, wuwe1
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 ); }
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]); } }
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; emit AssetSwapped( transactionId, _swapData.callTo, _swapData.sendingAssetId, _swapData.receivingAssetId, fromAmount, toAmount, block.timestamp ); }
In the AnyswapFacet.sol
, _anyswapData.router
is from the caller's calldata, which can really be any contract, including a fake Anyswap router contract, as long as it complies to the interfaces used.
And in _startBridge
, it will grant infinite approval for the _anyswapData.token
to the _anyswapData.router
.
This makes it possible for a attacker to steal all the funds from the contract.
Which we explained in [WP-H6], the diamond contract may be holding some funds for various of reasons.
Given:
The attacker can submit a swapTokensGeneric()
with 0.1 ETH
as receivingAssetId
with the following SwapData[]
:
1. Swap 0.1 ETH to USDC;
2. Swap 0.1 ETH to USDC.
The attacker will receive ~2,000 USDC. 1,000 of which is the 0.1 ETH stolen from the contract.
{value: msg.value}
when LibAsset.isNativeAsset(fromAssetId)
: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); } uint256 callValue = msg.value; if (!LibAsset.isNativeAsset(fromAssetId)) { callValue = 0; LibAsset.approveERC20(IERC20(fromAssetId), _swapData.approveTo, fromAmount); } // solhint-disable-next-line avoid-low-level-calls (bool success, bytes memory res) = _swapData.callTo.call{ value: callValue }(_swapData.callData); if (!success) { string memory reason = LibUtil.getRevertMsg(res); revert(reason); }
#0 - H3xept
2022-04-11T09:22:26Z
Duplicate of #86
function _startBridge(CBridgeData memory _cBridgeData) internal { Storage storage s = getStorage(); address bridge = _bridge(); // Do CBridge stuff require(s.cBridgeChainId != _cBridgeData.dstChainId, "Cannot bridge to the same network."); if (LibAsset.isNativeAsset(_cBridgeData.token)) { ICBridge(bridge).sendNative( _cBridgeData.receiver, _cBridgeData.amount, _cBridgeData.dstChainId, _cBridgeData.nonce, _cBridgeData.maxSlippage ); } else { ...
/** * @notice Send a cross-chain transfer via the liquidity pool-based bridge using the native token. * @param _receiver The address of the receiver. * @param _amount The amount of the transfer. * @param _dstChainId The destination chain ID. * @param _nonce A unique number. Can be timestamp in practice. * @param _maxSlippage The max slippage accepted, given as percentage in point (pip). Eg. 5000 means 0.5%. * Must be greater than minimalMaxSlippage. Receiver is guaranteed to receive at least (100% - max slippage percentage) * amount or the * transfer can be refunded. */ function sendNative( address _receiver, uint256 _amount, uint64 _dstChainId, uint64 _nonce, uint32 _maxSlippage ) external payable nonReentrant whenNotPaused { require(msg.value == _amount, "Amount mismatch"); require(nativeWrap != address(0), "Native wrap not set"); bytes32 transferId = _send(_receiver, nativeWrap, _amount, _dstChainId, _nonce, _maxSlippage); IWETH(nativeWrap).deposit{value: _amount}(); emit Send(transferId, msg.sender, _receiver, nativeWrap, _amount, _dstChainId, _nonce, _maxSlippage); }
ICBridge(bridge).sendNative()
should be ICBridge(bridge).sendNative{ value: msg.value }()
, and the current implementation will simply revert on the cBridge's side with the "Amount mismatch" error.
Change to: ICBridge(bridge).sendNative{ value: msg.value }()
#0 - H3xept
2022-04-11T11:00:15Z
Duplicate of #35
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; } } } }
Given:
A
, B
, C
are all whitelisted DEXs;B
and C
are found to have security issues and they are to be removed from the whitelist.batchRemoveDex()
to remove B
and C
.The transaction was successful. However, only B
was removed, C
is still on the whitelist.
The users who continues to use C
can suffer from fund loss.
Change to:
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); break; } } } }
#0 - H3xept
2022-04-11T10:41:35Z
Duplicate of #34
π Selected for report: WatchPug
Also found by: VAD37, catchup, csanuragjain, rayn
665.807 USDC - $665.81
function _startBridge(AnyswapData memory _anyswapData) internal { // Check chain id require(block.chainid != _anyswapData.toChainId, "Cannot bridge to the same network."); 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 {
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); } }
In the AnyswapFacet.sol
, _anyswapData.router
is from the caller's calldata, which can really be any contract, including a fake Anyswap router contract, as long as it complies to the interfaces used.
And in _startBridge
, it will grant infinite approval for the _anyswapData.token
to the _anyswapData.router
.
This makes it possible for a attacker to steal all the funds from the contract.
Which we explained in [WP-H6], the diamond contract may be holding some funds for various of reasons.
Given:
startBridgeTokensViaAnyswap()
with a FAKE _anyswapData.router
.transferFrom()
and take all the funds, including the 100 USDC in the contract anytime._anyswapData.router
rather than trusting user's inputs;approve()
for the amount that required for the current transaction instead of infinite approval.#0 - H3xept
2022-04-12T09:51:25Z
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-12T10:12:52Z
Duplicate of #130
#2 - gzeoneth
2022-04-16T17:01:04Z
Warden highlighted the vulnerability in AnyswapFacet
which would allow attacker to grant approval to arbitrary contract.
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: Jujic, WatchPug, catchup, danb, defsec, kirk-baird, nedodn, shenwilly, sorrynotsorry
196.5762 USDC - $196.58
Use of Upgradeable Proxy Contract Structure (The Diamond Structure) allows the logic of the contract to be arbitrarily changed.
This allows the proxy admin to perform malicious actions e.g., taking funds from users' wallets up to the allowance limit.
This action can be performed by the malicious/compromised proxy admin without any restriction.
Considering that the main purpose of this particular contract is for bridging tokens with multiple bridge operators, we believe it's not necessary for the diamond contract to hold users' allowances.
Given:
USDC
approve()
and swapAndStartBridgeTokensViaAnyswap()
with 1e8 USDC
;approve()
and swapAndStartBridgeTokensViaAnyswap()
with 5e8 USDC
;A smart contract being structured as an upgradeable contract alone is not usually considered as a high severity risk. But given the severe impact (funds in users' wallets got stolen), we mark it as a High
severity issue.
Consider using the non-upgradeable Router
contract to hold user's allowances instead. Then call transferFrom
to pull funds from users' wallets to the the diamond contract and prcess the business login with the diamond contract.
#0 - H3xept
2022-04-12T09:29:37Z
The bridges/swaps ecosystem is continually changing. 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.
#1 - H3xept
2022-04-12T09:33:27Z
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
114.8562 USDC - $114.86
HopFacet#startBridgeTokensViaHop()
sendingAssetId
with non-native asset, msg.value is not enforced to be 0, makes it possible for users to loss the msg.value if they send anyfunction 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" ); } _startBridge(_hopData); emit LiFiTransferStarted( _lifiData.transactionId, _lifiData.integrator, _lifiData.referrer, _lifiData.sendingAssetId, _lifiData.receivingAssetId, _lifiData.receiver, _lifiData.amount, _lifiData.destinationChainId, block.timestamp ); }
function _startBridge(HopData memory _hopData) internal { Storage storage s = getStorage(); address sendingAssetId = _bridge(_hopData.asset).token; address bridge; if (s.hopChainId == 1) { bridge = _bridge(_hopData.asset).bridge; } else { bridge = _bridge(_hopData.asset).ammWrapper; } // Do HOP stuff require(s.hopChainId != _hopData.chainId, "Cannot bridge to the same network."); // Give Hop approval to bridge tokens LibAsset.approveERC20(IERC20(sendingAssetId), bridge, _hopData.amount); uint256 value = LibAsset.isNativeAsset(address(sendingAssetId)) ? _hopData.amount : 0; if (s.hopChainId == 1) { // Ethereum L1 IHopBridge(bridge).sendToL2{ value: value }( _hopData.chainId, _hopData.recipient, _hopData.amount, _hopData.destinationAmountOutMin, _hopData.destinationDeadline, address(0), 0 ); } else { // L2 // solhint-disable-next-line check-send-result IHopBridge(bridge).swapAndSend{ value: value }( _hopData.chainId, _hopData.recipient, _hopData.amount, _hopData.bonderFee, _hopData.amountOutMin, _hopData.deadline, _hopData.destinationAmountOutMin, _hopData.destinationDeadline ); } }
Change to:
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 { require(msg.value == 0, "ERR_INVALID_AMOUNT"); uint256 _sendingAssetIdBalance = LibAsset.getOwnBalance(sendingAssetId); LibAsset.transferFromERC20(sendingAssetId, msg.sender, address(this), _hopData.amount); require( LibAsset.getOwnBalance(sendingAssetId) - _sendingAssetIdBalance == _hopData.amount, "ERR_INVALID_AMOUNT" ); } _startBridge(_hopData); emit LiFiTransferStarted( _lifiData.transactionId, _lifiData.integrator, _lifiData.referrer, _lifiData.sendingAssetId, _lifiData.receivingAssetId, _lifiData.receiver, _lifiData.amount, _lifiData.destinationChainId, block.timestamp ); }
Contracts use assert() instead of require() in multiple places.
Per to Solidityβs documentation:
"Assert should only be used to test for internal errors, and to check invariants. Properly functioning code should never create a Panic, not even on invalid external input. If this happens, then there is a bug in your contract which you should fix. Language analysis tools can evaluate your contract to identify the conditions and function calls which will cause a Panic.β
assert(_amount <= self.balance);
assert(_amount <= assetBalance);
Change to require()
.
import { LibDiamond } from "../Libraries/LibDiamond.sol";
import { LibDiamond } from "../Libraries/LibDiamond.sol";
#0 - H3xept
2022-04-11T12:48:14Z
Duplicate of https://github.com/code-423n4/2022-03-lifinance-findings/issues/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
63.9343 USDC - $63.93
[S]: Suggested optimation, save a decent amount of gas without compromising readability;
[M]: Minor optimation, the amount of gas saved is minor, change when you see fit;
[N]: Non-preferred, the amount of gas saved is at cost of readability, only apply when gas saving is a top priority.
++i
is more efficient than i++
Using ++i
is more gas efficient than i++
, especially in a loop.
For example:
for (uint256 i; i < _dexs.length; i++) {
for (uint256 i; i < s.dexs.length; i++) {
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; } } }
for (uint256 i; i < numFacets; i++) {
for (uint8 i; i < _tokens.length; i++) {
for (uint8 i; i < _swapData.length; i++) {
Reading array length at each iteration of the loop takes 6 gas (3 for mload and 3 to place memory_offset) in the stack.
Caching the array length in the stack saves around 3 gas per iteration.
Instances include:
for (uint8 i; i < _swapData.length; i++)
#0 - H3xept
2022-04-08T14:39:18Z
Duplicate of #44
#1 - H3xept
2022-04-11T12:05:14Z
We internally decided to avoid previx increments for now.