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: 2/72
Findings: 6
Award: $16,616.39
🌟 Selected for report: 4
🚀 Solo Findings: 3
🌟 Selected for report: unforgiven
https://github.com/code-423n4/2022-06-connext/blob/b4532655071566b33c41eac46e75be29b4a381ed/contracts/contracts/core/connext/facets/BridgeFacet.sol#L526-L616 https://github.com/code-423n4/2022-06-connext/blob/b4532655071566b33c41eac46e75be29b4a381ed/contracts/contracts/core/connext/facets/BridgeFacet.sol#L753-L803 https://github.com/code-423n4/2022-06-connext/blob/b4532655071566b33c41eac46e75be29b4a381ed/contracts/contracts/core/connext/facets/BridgeFacet.sol#L398-L428 https://github.com/code-423n4/2022-06-connext/blob/b4532655071566b33c41eac46e75be29b4a381ed/contracts/contracts/core/connext/facets/BridgeFacet.sol#L345-L351
routers pay for transaction in destination domain then nomad messages come and routers get paid again. but the amount routers pay in execute()
are what transaction sender signed and the amount routers receive is what nomad sends and handles in _reconcile()
but this two amount can be different because of slippage and swap that happens in xcall()
because the amount sent in nomad message is the result of swapToLocalAssetIfNeeded()
.
So it's possible for routers to lose funds if some slippage happens in that swap.
This is xcall()
code:
function xcall(XCallArgs calldata _args) external payable whenNotPaused nonReentrant returns (bytes32) { // Sanity checks. { // Correct origin domain. if (_args.params.originDomain != s.domain) { revert BridgeFacet__xcall_wrongDomain(); } // Recipient is defined. if (_args.params.to == address(0)) { revert BridgeFacet__xcall_emptyTo(); } // If callback address is not set, callback fee should be 0. if (_args.params.callback == address(0) && _args.params.callbackFee > 0) { revert BridgeFacet__xcall_nonZeroCallbackFeeForCallback(); } // Callback is contract if supplied. if (_args.params.callback != address(0) && !Address.isContract(_args.params.callback)) { revert BridgeFacet__xcall_callbackNotAContract(); } } bytes32 transferId; bytes memory message; XCalledEventArgs memory eventArgs; { // Get the remote BridgeRouter address; revert if not found. bytes32 remote = _mustHaveRemote(_args.params.destinationDomain); // Get the true transacting asset ID (using wrapper instead of native, if applicable). address transactingAssetId = _args.transactingAssetId == address(0) ? address(s.wrapper) : _args.transactingAssetId; // Check that the asset is supported -- can be either adopted or local. ConnextMessage.TokenId memory canonical = s.adoptedToCanonical[transactingAssetId]; if (canonical.id == bytes32(0)) { // Here, the asset is *not* the adopted asset. The only other valid option // is for this asset to be the local asset (i.e. transferring madEth on optimism) // NOTE: it *cannot* be the canonical asset. the canonical asset is only used on // the canonical domain, where it is *also* the adopted asset. if (s.tokenRegistry.isLocalOrigin(transactingAssetId)) { // revert, using a token of local origin that is not registered as adopted revert BridgeFacet__xcall_notSupportedAsset(); } (uint32 canonicalDomain, bytes32 canonicalId) = s.tokenRegistry.getTokenId(transactingAssetId); canonical = ConnextMessage.TokenId(canonicalDomain, canonicalId); } transferId = _getTransferId(_args, canonical); s.nonce += 1; // Store the relayer fee s.relayerFees[transferId] = _args.params.relayerFee; // Transfer funds of transacting asset to the contract from the user. // NOTE: Will wrap any native asset transferred to wrapped-native automatically. (, uint256 amount) = AssetLogic.handleIncomingAsset( _args.transactingAssetId, _args.amount, _args.params.relayerFee + _args.params.callbackFee ); // Swap to the local asset from adopted if applicable. (uint256 bridgedAmt, address bridged) = AssetLogic.swapToLocalAssetIfNeeded( canonical, transactingAssetId, amount, _args.params.slippageTol ); // Transfer callback fee to PromiseRouter if set if (_args.params.callbackFee != 0) { s.promiseRouter.initCallbackFee{value: _args.params.callbackFee}(transferId); } message = _formatMessage(_args, bridged, transferId, bridgedAmt); s.xAppConnectionManager.home().dispatch(_args.params.destinationDomain, remote, message); // Format arguments for XCalled event that will be emitted below. eventArgs = XCalledEventArgs({ transactingAssetId: transactingAssetId, amount: amount, bridgedAmt: bridgedAmt, bridged: bridged }); } // emit event emit XCalled(transferId, _args, eventArgs, s.nonce - 1, message, msg.sender); return transferId; }
As you can see it swaps what user sent to LoccalAsset
which the amount is bridgedAmt
and then send value of bridgedAmt
to nomad bridge message = _formatMessage(_args, bridged, transferId, bridgedAmt)
.
but the amount user signed in _args.amount
is different and that what user sends to contract.
the reasons that bridgedAmt
could be different than _args.amount
is:
1- deflationary tokens in transferring from user.
2- slippage in swap to local token.
This is _reconcile()
code:
function _reconcile(uint32 _origin, bytes memory _message) internal { // Parse tokenId and action from the message. bytes29 msg_ = _message.ref(0).mustBeMessage(); bytes29 tokenId = msg_.tokenId(); bytes29 action = msg_.action(); // Assert that the action is valid. if (!action.isTransfer()) { revert BridgeFacet__reconcile_invalidAction(); } // Load the transferId. bytes32 transferId = action.transferId(); // Ensure the transaction has not already been handled (i.e. previously reconciled). if (s.reconciledTransfers[transferId]) { revert BridgeFacet__reconcile_alreadyReconciled(); } // NOTE: `tokenId` and `amount` must be in plaintext in the message so funds can *only* be minted by // `handle`. They are both used in the generation of the `transferId` so routers must provide them // correctly to be reimbursed. // Get the appropriate local token contract for the given tokenId on this chain. // NOTE: If the token is of remote origin and there is no existing representation token contract, // the TokenRegistry will deploy a new one. address token = s.tokenRegistry.ensureLocalToken(tokenId.domain(), tokenId.id()); // Load amount once. uint256 amount = action.amnt(); // Mint tokens if the asset is of remote origin (i.e. is representational). // NOTE: If the asset IS of local origin (meaning it's canonical), then the tokens will already be held // in escrow in this contract (from previous `xcall`s). if (!s.tokenRegistry.isLocalOrigin(token)) { IBridgeToken(token).mint(address(this), amount); // Update the recorded `detailsHash` for the token (name, symbol, decimals). // TODO: do we need to keep this bytes32 details = action.detailsHash(); IBridgeToken(token).setDetailsHash(details); } // Mark the transfer as reconciled. s.reconciledTransfers[transferId] = true; // If the transfer was executed using fast-liquidity provided by routers, then this value would be set // to the participating routers. // NOTE: If the transfer was not executed using fast-liquidity, then the funds will be reserved for // execution (i.e. funds will be delivered to the transfer's recipient in a subsequent `execute` call). address[] memory routers = s.routedTransfers[transferId]; // If fast transfer was made using portal liquidity, we need to repay // FIXME: routers can repay any-amount out-of-band using the `repayAavePortal` method // or by interacting with the aave contracts directly uint256 portalTransferAmount = s.portalDebt[transferId] + s.portalFeeDebt[transferId]; uint256 toDistribute = amount; uint256 pathLen = routers.length; if (portalTransferAmount != 0) { // ensure a router took on credit risk if (pathLen != 1) revert BridgeFacet__reconcile_noPortalRouter(); toDistribute = _reconcileProcessPortal(amount, token, routers[0], transferId); } if (pathLen != 0) { // fast liquidity path // Credit each router that provided liquidity their due 'share' of the asset. uint256 routerAmt = toDistribute / pathLen; for (uint256 i; i < pathLen; ) { s.routerBalances[routers[i]][token] += routerAmt; unchecked { i++; } } } emit Reconciled(transferId, _origin, routers, token, amount, msg.sender); }
As you can see it uses amount in message to calculate what router should receive.
This is _handleExecuteLiquidity()
code which is used in execute()
:
function _handleExecuteLiquidity( bytes32 _transferId, bool _isFast, ExecuteArgs calldata _args ) private returns (uint256, address) { uint256 toSwap = _args.amount; // If this is a fast liquidity path, we should handle deducting from applicable routers' liquidity. // If this is a slow liquidity path, the transfer must have been reconciled (if we've reached this point), // and the funds would have been custodied in this contract. The exact custodied amount is untracked in state // (since the amount is hashed in the transfer ID itself) - thus, no updates are required. if (_isFast) { uint256 pathLen = _args.routers.length; // Calculate amount that routers will provide with the fast-liquidity fee deducted. toSwap = _getFastTransferAmount(_args.amount, s.LIQUIDITY_FEE_NUMERATOR, s.LIQUIDITY_FEE_DENOMINATOR); // Save the addressess of all routers providing liquidity for this transfer. s.routedTransfers[_transferId] = _args.routers; // If router does not have enough liquidity, try to use Aave Portals. // only one router should be responsible for taking on this credit risk, and it should only // deal with transfers expecting adopted assets (to avoid introducing runtime slippage) if ( !_args.params.receiveLocal && pathLen == 1 && s.routerBalances[_args.routers[0]][_args.local] < toSwap && s.aavePool != address(0) ) { if (!s.routerPermissionInfo.approvedForPortalRouters[_args.routers[0]]) revert BridgeFacet__execute_notApprovedForPortals(); // Portal provides the adopted asset so we early return here return _executePortalTransfer(_transferId, toSwap, _args.local, _args.routers[0]); } else { // for each router, assert they are approved, and deduct liquidity uint256 routerAmount = toSwap / pathLen; for (uint256 i; i < pathLen; ) { // decrement routers liquidity s.routerBalances[_args.routers[i]][_args.local] -= routerAmount; unchecked { i++; } } } }
As you can see it uses the amount defined in ExecuteArgs
to see how much routers should pay.
because of this two issue (deflationary tokens and swap slippage) attacker could fool protocol to spend more than what he transferred to protocol. this could be seen as two bug so.
VIM
update spending amount based on (deflationary tokens and swap slippage)
#0 - LayneHaber
2022-06-25T17:54:18Z
I think there is a misunderstanding here -- the user takes on the slippage risk both into and out of the local assets, and the router has consistent returns on what was bridged.
On xcall
, the user swaps the amount put in for the local asset. This incurs some slippage, and only the amount of the local asset is bridged directly. It is the bridged amount that the router should supply liquidity for, and take fees on. Once the router supplies liquidity in execute
(bridged amount minus the fees), then it is swapped for the local asset and sent to the user. The user may get some different amount here, but it is the user who is impacted by this slippage. On handle, the router is credited the bridged amount.
However, there was a separate bug where the transferId
was generated with the wrong amount
on execute, so that could be where the confusion is coming from.
#1 - 0xleastwood
2022-08-13T21:21:16Z
I actually agree with the warden here, it seems that they're right about the issue but they just failed to mention the main reason why its an issue is because transferId
is calculated using _args.amount
which does not necessarily equal bridgedAmt
due to slippage. Therefore, routers may end up fronting too much liquidity and receive considerably less when the bridge transfer is eventually reconciled. This seems rather severe as the user will receive the full transfer amount without slippage. This could be abused to drain routers on low liquidity tokens.
#2 - LayneHaber
2022-08-17T15:06:12Z
Right -- I agree that the problems outlined here would be the true consequences for a mismatched transferId
. If the question is to take the action outlined here -- specifically to keep this open and downgrade #227 as a QA -- that would work with me.
#3 - liveactionllama
2022-10-17T19:04:34Z
Per conversation with @LayneHaber - related mitigation link here: https://github.com/connext/nxtp/commit/f41a156b55a01837c8f57a77e52781f74406e1cd
🌟 Selected for report: xiaoming90
Also found by: unforgiven
There are some security levels for router, like setting owner and recipient and when removeRouter()
is called this values set to 0x0
and router address become vulnerable. contract should transfer router balance to recipient before removing it.
This is removeRouter()
code:
/** * @notice Used to remove routers that can transact crosschain * @param router Router address to remove */ function removeRouter(address router) external onlyOwner { // Sanity check: not empty if (router == address(0)) revert RoutersFacet__removeRouter_routerEmpty(); // Sanity check: needs removal if (!s.routerPermissionInfo.approvedRouters[router]) revert RoutersFacet__removeRouter_notAdded(); // Update mapping s.routerPermissionInfo.approvedRouters[router] = false; // Emit event emit RouterRemoved(router, msg.sender); // Remove router owner address _owner = s.routerPermissionInfo.routerOwners[router]; if (_owner != address(0)) { emit RouterOwnerAccepted(router, _owner, address(0)); // delete routerOwners[router]; s.routerPermissionInfo.routerOwners[router] = address(0); } // Remove router recipient address _recipient = s.routerPermissionInfo.routerRecipients[router]; if (_recipient != address(0)) { emit RouterRecipientSet(router, _recipient, address(0)); // delete routerRecipients[router]; s.routerPermissionInfo.routerRecipients[router] = address(0); } // Clear any proposed ownership changes s.routerPermissionInfo.proposedRouterOwners[router] = address(0); s.routerPermissionInfo.proposedRouterTimestamp[router] = 0; }
As you can see there is no check for router balances and owner
can set all the security variables for router address to 0x0
and router balance would be in danger.
There may be other functionalities that could be broke by just removing router without checking them.
VIM
check some states before removing router.
#0 - jakekidd
2022-06-26T18:15:41Z
🌟 Selected for report: unforgiven
Also found by: xiaoming90
1169.1572 USDC - $1,169.16
https://github.com/code-423n4/2022-06-connext/blob/b4532655071566b33c41eac46e75be29b4a381ed/contracts/contracts/core/connext/facets/BridgeFacet.sol#L753-L803 https://github.com/code-423n4/2022-06-connext/blob/b4532655071566b33c41eac46e75be29b4a381ed/contracts/contracts/core/connext/facets/BridgeFacet.sol#L526-L616
variable routerBalances
suppose to keep track of routers balance in contract and routers
can withdraw their balance from contract. but because of division rounding error in _handleExecuteLiquidity()
and _reconcile()
contract uses more of its tokens than it subtract from router's balance. this can lead to fund lose.
This is _handleExecuteLiquidity()
code:
/** * @notice Execute liquidity process used when calling `execute` * @dev Need this to prevent stack too deep */ function _handleExecuteLiquidity( bytes32 _transferId, bool _isFast, ExecuteArgs calldata _args ) private returns (uint256, address) { uint256 toSwap = _args.amount; // If this is a fast liquidity path, we should handle deducting from applicable routers' liquidity. // If this is a slow liquidity path, the transfer must have been reconciled (if we've reached this point), // and the funds would have been custodied in this contract. The exact custodied amount is untracked in state // (since the amount is hashed in the transfer ID itself) - thus, no updates are required. if (_isFast) { uint256 pathLen = _args.routers.length; // Calculate amount that routers will provide with the fast-liquidity fee deducted. toSwap = _getFastTransferAmount(_args.amount, s.LIQUIDITY_FEE_NUMERATOR, s.LIQUIDITY_FEE_DENOMINATOR); // Save the addressess of all routers providing liquidity for this transfer. s.routedTransfers[_transferId] = _args.routers; // If router does not have enough liquidity, try to use Aave Portals. // only one router should be responsible for taking on this credit risk, and it should only // deal with transfers expecting adopted assets (to avoid introducing runtime slippage) if ( !_args.params.receiveLocal && pathLen == 1 && s.routerBalances[_args.routers[0]][_args.local] < toSwap && s.aavePool != address(0) ) { if (!s.routerPermissionInfo.approvedForPortalRouters[_args.routers[0]]) revert BridgeFacet__execute_notApprovedForPortals(); // Portal provides the adopted asset so we early return here return _executePortalTransfer(_transferId, toSwap, _args.local, _args.routers[0]); } else { // for each router, assert they are approved, and deduct liquidity uint256 routerAmount = toSwap / pathLen; for (uint256 i; i < pathLen; ) { // decrement routers liquidity s.routerBalances[_args.routers[i]][_args.local] -= routerAmount; unchecked { i++; } } } }
As you can see in last part it uses uint256 routerAmount = toSwap / pathLen
to calculate amount to decrease from router's balance but because of division rounding error contract using toSwap
amount of token buy total value it decrease from router's balances are lower than toSwap
.
Of course in _reconcile()
contract add some amount to router's balances again with division rounding error, but because the amounts are different in this two functions (in _handleExecuteLiquidity()
we subtract fee and in _reconcile()
we don't subtract fee) so the division rounding error would be different for them and in the end sum of total balances of routers would not be equal to contract balance. this bug could be more serious for tokens with low precision and higher price.
VIM
perform better calculations.
#0 - LayneHaber
2022-06-25T18:24:26Z
The maximum fund loss in this case is capped by the maximum number of routers allowed in a transfer (this will generally be below 10, meaning maximum loss from one of these errors is 18 wei units of the token -- assuming it hit max on both execute and reconcile).
I understand the rounding error exists, but even with tokens at a precision of 6 with a high price the maximum rounding error is small (i.e. 100K coin, 6 decimals, this amounts to $1.8). At this level of impact, this should amount to a value leak.
#1 - 0xleastwood
2022-08-15T00:43:16Z
I'm gonna downgrade this to QA
because the value leak is mostly negligible and extremely hard to avoid. You could sweep the remaining balance and delegate that to the last router, but this is unfair to other routers.
#2 - 0xleastwood
2022-08-15T00:46:31Z
I think a good solution would be to make the last router pay the swept balance and then be reconciled this amount once the bridge transfer is complete.
#3 - 0xleastwood
2022-08-15T08:26:03Z
Actually, upon thinking about this more, I think there is potential for the system to not work as intended under certain parts of the transfer flow.
If the bridge transfer amount is 10 DAI
and the liquidity must be provided three routers, each router provides 3 DAI
respectively. If the user opted to receive the local asset than 10 DAI
will be directly sent out to them. Therefore, until the transfer has been reconciled, the protocol will essentially take on temporary bad debt which won't be resolved until the bridge transfer has been reconciled. This may limit withdrawals for other routers during this period. For these reasons, I think medium
severity makes more sense:)
🌟 Selected for report: unforgiven
2598.127 USDC - $2,598.13
process()
in PromiseRouter
is used for process stored callback function and anyone calls it gets callbackFee
and it calls callback()
function of callbackAddress
. but attacker set a callbackAddress
that reverts on callback()
and cause process()
caller griefing. attacker can perform this buy front running or complex logic.
This is process()
code:
/** * @notice Process stored callback function * @param transferId The transferId to process */ function process(bytes32 transferId, bytes calldata _message) public nonReentrant { // parse out the return data and callback address from message bytes32 messageHash = messageHashes[transferId]; if (messageHash == bytes32(0)) revert PromiseRouter__process_invalidTransferId(); bytes29 _msg = _message.ref(0).mustBePromiseCallback(); if (messageHash != _msg.keccak()) revert PromiseRouter__process_invalidMessage(); // enforce relayer is whitelisted by calling local connext contract if (!connext.approvedRelayers(msg.sender)) revert PromiseRouter__process_notApprovedRelayer(); address callbackAddress = _msg.callbackAddress(); if (!AddressUpgradeable.isContract(callbackAddress)) revert PromiseRouter__process_notContractCallback(); uint256 callbackFee = callbackFees[transferId]; // remove message delete messageHashes[transferId]; // remove callback fees callbackFees[transferId] = 0; // execute callback ICallback(callbackAddress).callback(transferId, _msg.returnSuccess(), _msg.returnData()); emit CallbackExecuted(transferId, msg.sender); // Should transfer the stored relayer fee to the msg.sender if (callbackFee > 0) { AddressUpgradeable.sendValue(payable(msg.sender), callbackFee); } }
As you can see it calls ICallback(callbackAddress).callback(transferId, _msg.returnSuccess(), _msg.returnData());
and if that call reverts then whole transaction would revert. so attacker can setup callbackAddress
that revert and the caller of process()
wouldn't get any fee and lose gas.
VIM
change the code so it won't revert if call to callbackAddress
reverts.
#0 - jakekidd
2022-06-25T02:33:30Z
If the callback would revert, normally it won't be called.
However, the attacker (griefer) could potentially frontrun the relayer's callback transaction (which is already submitted / in the mempool) and update the state of their callback contract in such a way to cause this subsequent callback to fail. Why would they do that? Nothing is gained, only losses are incurred on both sides.
This seems like it should be invalid, but it also seems like we should be doing a try/catch on principle though. Perhaps the issue is misrepresented here - it's not so much an attack vector as it's 'best practice' / QA issue.
Let's de-escalate to QA issue. Confirming, but would be great to get a second look from @LayneHaber on this.
EDIT: Changed my mind, think the risk level is appropriate.
#1 - LayneHaber
2022-06-30T11:42:02Z
If you change the message, the following check(s) would fail (since the hash is put onchain when the message is propagated by nomad):
bytes32 messageHash = messageHashes[transferId]; if (messageHash == bytes32(0)) revert PromiseRouter__process_invalidTransferId(); bytes29 _msg = _message.ref(0).mustBePromiseCallback(); if (messageHash != _msg.keccak()) revert PromiseRouter__process_invalidMessage();
An attacker cannot falsify this message because sending messages on the router is restricted via the onlyConnext
modifier, so I don't think changing the callbackAddress
is a valid attack strategy.
If we extend this concern to any failing callback
, then any revert would not be executed. In most cases, this would be caught in offchain simulations meaning the relayer would not submit the transaction (and then nobody could process the callback). This means that integrators must handle failures within their code. This is a common practice when writing crosschain handlers (i.e. nomad handle
should not revert as the message cannot be reprocessed, functions called via execute
should handle errors unless sending funds to a recovery address is okay, etc.).
Don't think this qualifies as a value leak, but because it is potentially unprocessable will leave the severity at 2 and move the label to "acknowledged"
#2 - 0xleastwood
2022-08-15T08:54:19Z
I guess this issue has the same concerns as #220. Even if the onus is on the caller of process
to simulate the call beforehand, it seems likely that the transaction could be front-run and prove to be poor user experience for relayers.
I don't think its realistic that relayers would call this function without first simulating the transaction to see if the callback fails, but I guess the bridge user could set the callback address up as a honey pot such that simulated transactions are successful.
It probably makes sense to remove the main culprit of the issue by using a try/catch statement for the ICallback(callbackAddress).callback(transferId, _msg.returnSuccess(), _msg.returnData());
call.
🌟 Selected for report: unforgiven
2598.127 USDC - $2,598.13
when code swaps tokens it should specify slippage but in reimburseLiquidityFees()
code contract calls tokenExchange.swapExactIn()
without slippage and it's possible to perform sandwich attack and make contract to swap on bad exchange rates and there is MEV.
This is reimburseLiquidityFees()
code in SponserVault
:
/** * @notice Performs liquidity fee reimbursement. * @dev Uses the token exchange or liquidity deposited in this contract. * The `_receiver` address is only used for emitting in the event. * @param _token The address of the token * @param _liquidityFee The liquidity fee amount * @param _receiver The address of the receiver * @return Sponsored liquidity fee amount */ function reimburseLiquidityFees( address _token, uint256 _liquidityFee, address _receiver ) external override onlyConnext returns (uint256) { uint256 sponsoredFee; if (address(tokenExchanges[_token]) != address(0)) { uint256 currentBalance = address(this).balance; ITokenExchange tokenExchange = tokenExchanges[_token]; uint256 amountIn = tokenExchange.getInGivenExpectedOut(_token, _liquidityFee); amountIn = currentBalance >= amountIn ? amountIn : currentBalance; // sponsored fee may end being less than _liquidityFee due to slippage sponsoredFee = tokenExchange.swapExactIn{value: amountIn}(_token, msg.sender); } else { uint256 balance = IERC20(_token).balanceOf(address(this)); sponsoredFee = balance < _liquidityFee ? balance : _liquidityFee; // some ERC20 do not allow to transfer 0 amount if (sponsoredFee > 0) { IERC20(_token).safeTransfer(msg.sender, sponsoredFee); } } emit ReimburseLiquidityFees(_token, sponsoredFee, _receiver); return sponsoredFee; }
As you can see there is no slippage defined when calling swapExactIn()
can that swap could happen in any exchange rate. it's possible to perform sandwich attack and do large swap before and after the transaction and make users lose funds. and it's also MEV opportunity.
VIM
specify slippage when calling swap tokens.
#0 - jakekidd
2022-06-24T21:17:01Z
This is absolutely correct, but unfortunately there is no apparent viable on-chain solution. If we revert because slippage is too high, behavior would be inconsistent and UX would be bad, to say the least. If we instead choose to not sponsor because the slippage is too high, then sponsorship could be griefed severely (i.e. 'if we can't get at least X, then give the user nothing' is not a valid sponsorship policy).
Some things that make a sandwich attack less feasible however:
This is a good entrypoint for a future feature that gives sponsors better options, such as only sponsoring transfers for specific assets that they supply (i.e. so no swap is required).
cc @LayneHaber for thoughts. Because this essentially boils down to a feature request on behalf of sponsors - not a bug - going to close.
#1 - jakekidd
2022-06-24T21:27:04Z
Reopening and leaving this as acknowledged.
#2 - 0xleastwood
2022-08-15T08:46:20Z
Seeing as I can't verify if tokenExchange.swapExactIn
is indeed vulnerable to slippage, I will just side with the sponsor/warden on this one.
🌟 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
421.3856 USDC - $421.39
https://github.com/code-423n4/2022-06-connext/blob/b4532655071566b33c41eac46e75be29b4a381ed/contracts/contracts/core/connext/helpers/LPToken.sol#L13-L27 https://github.com/code-423n4/2022-06-connext/blob/b4532655071566b33c41eac46e75be29b4a381ed/contracts/contracts/core/connext/helpers/StableSwap.sol#L31-L116
In proxy pattern if the initialization contract don't get initialized then attacker can initialize it and get access to it's admin level functionalities and with logics like self-destruct it can harm the protocol. it's safer to initialize all the implementation contracts too.
There is no constructor in contracts to set the state of implementation contract to initialized
in BridgeToken
, LPToken
, StableSwap
, TokenRegistry
, PromiseRouter
and others..
attacker can call initialize()
method for implementation contract and take control of it and if there were some logics like self-destruct or ... attacker can use it harm protocol.
VIM
add constructor and set implementation contract state to initialized
#0 - LayneHaber
2022-06-24T17:11:44Z
These contracts all have the following modifier attached to the initialize
functions:
modifier initializer() { bool isTopLevelCall = _setInitializedVersion(1); if (isTopLevelCall) { _initializing = true; } _; if (isTopLevelCall) { _initializing = false; emit Initialized(1); } }
These functions then do update an internal variable marking whether the contract has been initialized to true
. The constructors were dropped according to the best practice of writing upgradeable contracts outlined here
#1 - 0xleastwood
2022-08-02T04:35:12Z
While the warden's issue has some truth to it, this would only be a concern if the user who initialized the implementation contract could arbitrarily delegatecall
and self-destruct the contract. Because there is no way to directly exploit this, I think it would be fair to downgrade this to QA
.
#2 - LayneHaber
2022-08-19T18:51:42Z
see comment here from previously merged issue!