Platform: Code4rena
Start Date: 08/06/2022
Pot Size: $115,000 USDC
Total HM: 26
Participants: 72
Period: 11 days
Judge: leastwood
Total Solo HM: 14
Id: 132
League: ETH
Rank: 6/72
Findings: 3
Award: $3,918.68
🌟 Selected for report: 2
🚀 Solo Findings: 1
🌟 Selected for report: Chom
Also found by: csanuragjain
1169.1572 USDC - $1,169.16
https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/facets/BridgeFacet.sol#L856-L877 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/helpers/Executor.sol#L142-L144 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/helpers/Executor.sol#L160-L166 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/helpers/Executor.sol#L194-L213
_handleExecuteTransaction may not working correctly on fee-on-transfer tokens. As duplicated fee is applied to fee on transfer token when executing a arbitrary call message passing request. Moreover, the Executor contract increase allowance on that token for that target contract in full amount without any fee, this may open a vulnerability to steal dust fund in the contract
Moreover, failure is trying to send full amount without any fee which is not possible because fee is already applied one time for example 100 Safemoon -> 90 Safemoon but trying to transfer 100 safemoon to _recovery address. Obviously not possible since we only have 90 Safemoon. This will revert and always revert causing loss of fund in all case of fee-on-transfer tokens.
A message to transfer 100 Safemoon with some contract call payload has been submitted by a relayer to _handleExecuteTransaction function on BridgeFacet these lines hit.
// execute calldata w/funds AssetLogic.transferAssetFromContract(_asset, address(s.executor), _amount); (bool success, bytes memory returnData) = s.executor.execute( IExecutor.ExecutorArgs( _transferId, _amount, _args.params.to, _args.params.recovery, _asset, _reconciled ? LibCrossDomainProperty.formatDomainAndSenderBytes(_args.params.originDomain, _args.originSender) : LibCrossDomainProperty.EMPTY_BYTES, _args.params.callData ) );
Noticed that 100 Safemoon is transferred to executor before contract execution. Now executor has 90 Safemoon due to 10% fee on transfer.
if (!isNative) { SafeERC20Upgradeable.safeIncreaseAllowance(IERC20Upgradeable(_args.assetId), _args.to, _args.amount); }
Next, we increase allowance of target contract to transfer 100 more Safemoon.
// Try to execute the callData // the low level call will return `false` if its execution reverts (success, returnData) = ExcessivelySafeCall.excessivelySafeCall( _args.to, gas, isNative ? _args.amount : 0, MAX_COPY, _args.callData );
After that, it call target contract
// Try to execute the callData // the low level call will return `false` if its execution reverts (success, returnData) = ExcessivelySafeCall.excessivelySafeCall( _args.to, gas, isNative ? _args.amount : 0, MAX_COPY, _args.callData );
Target contract tried to pull 100 Safemoon from Executor but now Executor only has 90 Safemoon, so contract call failed. Moving on to the failure handle.
function _handleFailure( bool isNative, bool hasIncreased, address _assetId, address payable _to, address payable _recovery, uint256 _amount ) private { if (!isNative) { // Decrease allowance if (hasIncreased) { SafeERC20Upgradeable.safeDecreaseAllowance(IERC20Upgradeable(_assetId), _to, _amount); } // Transfer funds SafeERC20Upgradeable.safeTransfer(IERC20Upgradeable(_assetId), _recovery, _amount); } else { // Transfer funds AddressUpgradeable.sendValue(_recovery, _amount); } }
isNative = false because of Safemoon
Trying to transfer 100 Safemoon to _recovery while only having 90 Safemoon in the contract. Thus failure handle is reverted.
Manual
You should approve one step only. Avoid an extra token transfer.
#0 - LayneHaber
2022-06-25T18:36:09Z
Not sure I agree with the mitigation (perhaps a better alternative would be to transfer the balance of the executor), but understand the problems!
#1 - LayneHaber
2022-06-26T19:33:27Z
Upon further reflection, choosing to remove support for fee on transfer tokens. The problems are much deeper than just issues on execute
-- the minted token will not have fees on transfers (uses vanilla ERC20 implementation), and i doubt the StableSwap
implementations are taking these into account as well. Changing label to "acknowledged"!
#2 - LayneHaber
2022-06-27T15:26:28Z
#3 - 0xleastwood
2022-08-12T22:33:46Z
The destination chain will indeed be unable to handle bridge transfers involving fee-on-transfer tokens. Although, its worth adding that this is only made possible because handleIncomingAsset
and swapToLocalAssetIfNeeded
in xcall
do not fail when the provided asset has some fee-on-transfer behaviour.
Because _args.amount
is not overridden with the actual amount
transferred in from handleIncomingAsset
, routers on the destination chain will attempt to provide liquidity for the amount transferred by the user + the fee. Furthermore, when the transfer has been fully bridged, routers who fronted the liquidity will receive less funds than expected. However, I don't actually think this issue warrants high
severity, mainly because the bridge transfer should actually execute successfully.
The only issue is that if routers front liquidity, they are exposing themselves to receiving slightly less funds due to the fee upon reconciliation. Hence, only value is being leaked and I think medium
severity makes more sense.
🌟 Selected for report: Chom
2598.127 USDC - $2,598.13
https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/helpers/Executor.sol#L113-L192 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/helpers/Executor.sol#L194-L213
Current implementation of arbitrary call execute failure handler may break some use case for example NFT Bridge.
In the case of NFT Bridge, NFT may be lost forever.
This is likely to be happened in the case of out of gas.
Relayer receive the message to unlock BAYC on ETH chain. Relayer call execute on BridgeFacet which then call execute in Executor internally. Continue to these lines:
// Ensure there is enough gas to handle failures uint256 gas = gasleft() - FAILURE_GAS; // Try to execute the callData // the low level call will return `false` if its execution reverts (success, returnData) = ExcessivelySafeCall.excessivelySafeCall( _args.to, gas, isNative ? _args.amount : 0, MAX_COPY, _args.callData ); // Unset properties properties = LibCrossDomainProperty.EMPTY_BYTES; // Unset amount amnt = 0; // Handle failure cases if (!success) { _handleFailure(isNative, true, _args.assetId, payable(_args.to), payable(_args.recovery), _args.amount); }
Unfortunately, an enormous NFT project has just started minting their NFT when the relayer perform the execution. Causing gas price to increase by 20x.
As a result, gasLimit is 20x lesser than what we have calculated causing ExcessivelySafeCall.excessivelySafeCall to fail because of out of gas. We fall into _handleFailure function. Notice that NFT is not unlocked yet because target contract has failed to being executed.
function _handleFailure( bool isNative, bool hasIncreased, address _assetId, address payable _to, address payable _recovery, uint256 _amount ) private { if (!isNative) { // Decrease allowance if (hasIncreased) { SafeERC20Upgradeable.safeDecreaseAllowance(IERC20Upgradeable(_assetId), _to, _amount); } // Transfer funds SafeERC20Upgradeable.safeTransfer(IERC20Upgradeable(_assetId), _recovery, _amount); } else { // Transfer funds AddressUpgradeable.sendValue(_recovery, _amount); } }
_handleFailure just sent dummy fund in the NFT bridge process to the useless fallback address (Useless in NFT bridge case as it doesn't involve any cross chain swapping / token transferring).
Finally, transferId will be marked as used (or reconciled). This transferId cannot be used anymore.
Recall that BAYC hasn't been unlocked yet as target contract has failed to being executed.
And we cannot reuse this transferId to retry anymore in the future as it is marked as used
As a result, BAYC is locked forever as target contract call never success anymore since transferId has been marked as used
Manual
You should mix axelar style and connext style of error handling together.
Fund shouldn't be sent to fallback address immediately. Instead, leave an option for user to choose whether they want to recall the failed transaction manually or they want to transfer the fund to the fallback address.
#0 - LayneHaber
2022-06-27T14:59:41Z
Acknowledge that this should be an issue, but think that the UX of having to submit multiple destination chain transactions is not great (and causes problems with fees / assuming the user has gas on the destination domain). I think this type of thing needs to be handled by the integrator themselves
#1 - Chomtana
2022-08-11T03:38:02Z
I have reviewed this once again and found another case that Current implementation of arbitrary call execute failure handler may break some use case for example NFT bridge.
The idea for this to happened is simply do anything to set success
to false. If NFT is failed to mint on the destination chain and revert (Likely due to bug in the contract), it cause the same consequence.
It would be better to revert and allow it to be executed again in the future by simply revert in case of arbitrary call failed. If the destination contract is upgradable, it can be fixed but if not it is out of luck. But current implementation is out of luck in all case.
Anyway, the sudden gas price increase problem can't be handled accurately as its may happened very fast. (5 gwei 2 minutes ago, 50 gwei when the NFT unlock as a big NFT project is minting). No one will spare that much gas while locking NFT in the source chain 2 minutes ago.
#2 - 0xleastwood
2022-08-12T22:54:33Z
Gas limit does not change deterministically. If you make a call which provides X amount of gas at Y price, if gas price changes, it will not affect any pending calls. The issue only arises if the relayer intentionally provides insufficient gas at any price.
#3 - 0xleastwood
2022-08-12T22:59:10Z
The recovery address is configured when the bridge transfer is initiated, if this or the executed calldata fails to be used correctly, I think the onus is on the sender. However, there are some legitimate concerns of a bridge relayer intentionally making the callback fail. Because funds aren't at direct risk, medium
severity would make more sense.
#4 - Chomtana
2022-08-17T05:30:06Z
The recovery address only recover ERC20 tokens managed by the Connext bridge system. Other custom made bridging solution that utilize the arbitrary message passing never utilize that recovery address passed.
For example, if A lock "multiple BAYC NFT" on the Ethereum chain along with 1 WETH sent into Connext Amarok. On the destination chain, if executed calldata fails or gas limit is miscalculated (both intentionally and non-intentionally in case ether.js returns too low gas limit, it has happened many times on the complex contracts in 2021 not sure it is fixed or not), only 1 WETH is sent to the recovery address and not "multiple BAYC NFT". "Multiple BAYC NFT" will be locked on the Ethereum chain indefinitely without any chance to unlock or recovery it in the source chain. So, funds which are "multiple BAYC NFT" are at direct risk. BAYC floor price is currently more than 70 ETH. If 3 BAYC lost due to a single error, total 200+ ETH will be lost.
Here is an example case that might become common if NFT is not out of hyped but sadly the hype in NFT is currently down. It is "NFT buying frontrunning" problem.
Since Connext bridge requires 2-3 minutes between that times somebody may frontrun buying NFT without using bridge. Then the execution will be fail since NFT has been sold to buyer B. It only refund 10 ETH but not 3 NFT sent.
Perfect mitigation is not found yet. There is an ongoing debate about this one at https://github.com/code-423n4/2022-07-axelar-findings/issues/97
#5 - 0xleastwood
2022-08-17T09:54:37Z
Fully agree with your take on this. But I still stand by my decision on keeping this as a medium
risk issue. Mainly because funds are not at direct risk and when they are at risk, certain assumptions must be made for this to hold true. Again, the onus seems to be on the implementer's end.
The important thing is to not integrate with Connext with the guarantee that calldata will be successfully executed on the destination contract. This should be clearly documented and I think allowing the bridge user to re-execute calldata upon failure is a potentially useful suggestion, but this doesn't come with its pitfalls too. Added complexity could expand Connext's attack surface.
#6 - 0xleastwood
2022-08-17T10:01:39Z
In response to your proposed mitigation(s):
🌟 Selected for report: BowTiedWardens
Also found by: 0x1f8b, 0x29A, 0x52, 0xNazgul, 0xNineDec, 0xf15ers, 0xkatana, 0xmint, Chom, ElKu, Funen, IllIllI, JMukesh, Jujic, Kaiziron, Lambda, MiloTruck, Ruhum, SmartSek, SooYa, TerrierLover, TomJ, WatchPug, Waze, _Adam, asutorufos, auditor0517, bardamu, c3phas, catchup, cccz, ch13fd357r0y3r, cloudjunky, cmichel, cryptphi, csanuragjain, defsec, fatherOfBlocks, hansfriese, hyh, jayjonah8, joestakey, k, kenta, obtarian, oyc_109, robee, sach1r0, shenwilly, simon135, slywaters, sorrynotsorry, tintin, unforgiven, xiaoming90, zzzitron
151.3887 USDC - $151.39
https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/helpers/Executor.sol#L143 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/facets/PortalFacet.sol#L185 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/libraries/AssetLogic.sol#L288 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/facets/BridgeFacet.sol#L1029 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/helpers/Executor.sol#L205 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/facets/BridgeFacet.sol#L1043 https://github.com/code-423n4/2022-06-connext/blob/4dd6149748b635f95460d4c3924c7e3fb6716967/contracts/contracts/core/connext/libraries/AssetLogic.sol#L347
safeApprove, safeIncreaseAllowance and safeDecreaseAllowance will be failed if allowance is not zero.
This lead to broken logic in swapping (and other actions) these special tokens such as USDT.
According to SafeERC20 implementation https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/utils/SafeERC20.sol
function safeApprove( IERC20 token, address spender, uint256 value ) internal { // safeApprove should only be called when setting an initial allowance, // or when resetting it to zero. To increase and decrease it, use // 'safeIncreaseAllowance' and 'safeDecreaseAllowance' require( (value == 0) || (token.allowance(address(this), spender) == 0), "SafeERC20: approve from non-zero to non-zero allowance" ); _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value)); } function safeIncreaseAllowance( IERC20 token, address spender, uint256 value ) internal { uint256 newAllowance = token.allowance(address(this), spender) + value; _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, newAllowance)); } function safeDecreaseAllowance( IERC20 token, address spender, uint256 value ) internal { unchecked { uint256 oldAllowance = token.allowance(address(this), spender); require(oldAllowance >= value, "SafeERC20: decreased allowance below zero"); uint256 newAllowance = oldAllowance - value; _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, newAllowance)); } }
You will see that both safeIncreaseAllowance and safeDecreaseAllowance still use token.approve.selector as same as safeApprove. Except that safeApprove require approval to be zero.
But sadly, USDT also require approval to be zero in all case when calling token.approve.selector
as safeIncreaseAllowance and safeDecreaseAllowance are also calling token.approve.selector
. They are required to have zero allowance before call.
function approve(address _spender, uint _value) public onlyPayloadSize(2 * 32) { // To change the approve amount you first have to reduce the addresses` // allowance to zero by calling `approve(_spender, 0)` if it is not // already 0 to mitigate the race condition described here: // https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 require(!((_value != 0) && (allowed[msg.sender][_spender] != 0))); allowed[msg.sender][_spender] = _value; Approval(msg.sender, _spender, _value); }
From your note
// Edge case with some tokens: Example USDT in ETH Mainnet, after the backUnbacked call there could be a remaining allowance if not the whole amount is pulled by aave. // Later, if we try to increase the allowance it will fail. USDT demands if allowance is not 0, it has to be set to 0 first. // TODO: Should we call approve(0) and approve(totalRepayAmount) instead? or with a try catch to not affect gas on all cases? // Example: https://github.com/aave/aave-v3-periphery/blob/ca184e5278bcbc10d28c3dbbc604041d7cfac50b/contracts/adapters/paraswap/ParaSwapRepayAdapter.sol#L138-L140 SafeERC20.safeIncreaseAllowance(IERC20(adopted), s.aavePool, totalRepayAmount);
This mean after backUnbacked call there may be dust token (non-zero allowance). If this case happened, subsequence swap will be failed as safeIncreaseAllowance is always revert due to non-zero allowance.
Manual
You should call approve(0) and approve(totalRepayAmount) instead? or with a try catch to not affect gas on all cases
// Better version of approve to prevent revert function safeApproveNew( IERC20 token, address spender, uint256 value ) internal { try token.approve(spender, value) returns (bool) {} catch { _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, 0)); _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value)); } }
You can safely use this function in your contract to approve token seamlessly without extra gas cost.
#0 - ecmendenhall
2022-06-20T15:31:40Z
Duplicate of #154
#1 - jakekidd
2022-06-24T23:39:52Z
#2 - 0xleastwood
2022-08-12T22:08:14Z
Again, I wouldn't consider these to be detailed enough in terms of protocol impact. Downgrading to QA
.