Platform: Code4rena
Start Date: 22/09/2023
Pot Size: $100,000 USDC
Total HM: 15
Participants: 175
Period: 14 days
Judge: alcueca
Total Solo HM: 4
Id: 287
League: ETH
Rank: 14/175
Findings: 4
Award: $1,278.56
🌟 Selected for report: 0
🚀 Solo Findings: 0
1165.9616 USDC - $1,165.96
addGlobalToken
is used to add a global token to a branch.
The flow as follows:
1 => CoreBranchRouter.addGlobalToken
2 => Send Cross-Chain request (System Response/Request) with FuncId 0x01 (Notice that is uses normal callOut though. Anyway, let's continue) CoreBranchRouter: L51-L55
3 => Root receives the request to lzReceiveNonBlocking
and calls RootBridgeAgentExecutor.executeNoDeposit
RootBridgeAgent: L482-L489
4 => RootBridgeAgentExecutor calls CoreRootRouter.execute
CoreRootRouter: L336-L343
5 => CoreRootRouter calls _addGlobalToken
internally where it only checks if the GlobalToken is unrecognized and the token was not already added
if (!IPort(rootPortAddress).isGlobalAddress(_globalAddress)) { revert UnrecognizedGlobalToken(); } // Verify that it does not exist if (IPort(rootPortAddress).isGlobalToken(_globalAddress, _dstChainId)) { revert TokenAlreadyAdded(); }
6 => Send Cross-Chain request (System Response/Request) with FuncId 0x01 to the branch chain CoreRootRouter: L434-L440
7 => Branch receives the request to lzReceiveNonBlocking
and calls BranchBridgeAgentExecutor.executeWithSettlement
8 => BranchBridgeAgentExecutor calls CoreBranchRouter.executeNoSettlement
CoreBranchRouter: L53-L57
9 => CoreBranchRouter calls _receiveAddGlobalToken
internally
CoreBranchRouter: L88-L99
10 => _receiveAddGlobalToken
calls IBridgeAgent.callOutSystem
with FuncId (0x03)
CoreBranchRouter: L181-L185
11 => IBridgeAgent.callOutSystem
send Cross-Chain request with FuncId 0x00
BranchBridgeAgent: L188-L192
12 => Root receives the request to lzReceiveNonBlocking
and calls RootBridgeAgentExecutor.executeSystemRequest
RootBridgeAgent: L755
13 => RootBridgeAgentExecutor calls CoreRootRouter.executeResponse
RootBridgeAgentExecutor: L56
14 => CoreRootRouter.executeResponse
calls _setLocalToken
Internally since FundId is 0x03 (check number 10 above)
15 => Notice that _setLocalToken
only verifies if the token already added then it calls IPort(rootPortAddress).setLocalAddress
if (IPort(rootPortAddress).isLocalToken(_localAddress, _dstChainId)) revert TokenAlreadyAdded();
16 => RootPort.setLocalAddress
only checks if local address is not zero. After that, it assigns the global token address to the local and the other way around
if (_localAddress == address(0)) revert InvalidLocalAddress(); getGlobalTokenFromLocal[_localAddress][_srcChainId] = _globalAddress; getLocalTokenFromGlobal[_globalAddress][_srcChainId] = _localAddress;
From the above flow, we see that we can link the same global token to many local tokens which is a big issue that could cause many unpredicted behaviours in the whole protocol. This is because the code doesn't check if a global token was already linked to a local token for a certain chain, it just overwrites the values regardless if they were set before or not.
getGlobalTokenFromLocal[_localAddress][_srcChainId]
getLocalTokenFromGlobal[_globalAddress][_srcChainId]
This also makes it possible to link a wrong existing global token (i.e. belongs to a different underlying token) to a local token.
Please check above as the issue explained with the flow step by step.
Manual analysis
At the moment, any one can call addGlobalToken, I suggest this function should be not permission-less. However, if this is required, then consider adding the proper validation to prevent overwriting old linked local tokens into those.
getGlobalTokenFromLocal[_localAddress][_srcChainId] getLocalTokenFromGlobal[_globalAddress][_srcChainId]
Invalid Validation
#0 - c4-pre-sort
2023-10-10T07:36:01Z
0xA5DF marked the issue as duplicate of #822
#1 - c4-pre-sort
2023-10-10T07:37:11Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T08:28:22Z
alcueca changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-10-26T08:31:13Z
This previously downgraded issue has been upgraded by alcueca
#4 - alcueca
2023-10-26T08:32:32Z
See #822
#5 - c4-judge
2023-10-26T08:40:03Z
alcueca changed the severity to QA (Quality Assurance)
#6 - c4-judge
2023-10-26T08:41:06Z
alcueca marked the issue as grade-a
#7 - c4-judge
2023-11-04T05:54:16Z
This previously downgraded issue has been upgraded by alcueca
#8 - c4-judge
2023-11-04T05:54:37Z
alcueca marked the issue as duplicate of #610
#9 - c4-judge
2023-11-07T13:53:15Z
alcueca marked the issue as satisfactory
🌟 Selected for report: nobody2018
Also found by: 0xStalin, 0xnev, 3docSec, Arz, Koolex, Kow, OMEN, gumgumzum, minhtrng, perseverancesuccess, rvierdiiev, windhustler
46.9091 USDC - $46.91
When sending a message with deposit (i.e. callOutAndBridge) from chain A to chain B, the message will go through the root chain. If rootBridgeAgent.lzReceiveNonBlocking
fails for any reason, the airdropped native token in rootBridgeAgent will be kept and not refunded to the user. The airdrop caps out at values that are not negligible. Some examples of these values:
Check Relayer Adapter Parameters
This airdropped value should be returned to the user since it wasn't spent at all. To undertsnad this better, the airdropped value is meant to send another cross-chain request to the destination chain (e.g. chain B). However, if lzReceiveNonBlocking
fails in the root chain, the request is never made and the funds are stuck in the root.
In case that the airdrop value is big enough, anyone (e.g. a stealer) is tempted to send a successful deposit to sweep this value and get it as a refund. This is because:
address(this).balance
is passed.(bool success,) = bridgeAgentExecutorAddress.call{value: address(this).balance}(_calldata); // check if address(this).balance has impact . we need tto understand how LZ airdrops the rootAgentAddress
// assert the user has attached enough native token for this address require(totalNativeFee <= msg.value, "LayerZero: not enough native for fees"); // refund if they send too much uint amount = msg.value.sub(totalNativeFee); if (amount > 0) { (bool success, ) = _refundAddress.call{value: amount}(""); require(success, "LayerZero: failed to refund"); }
So (at least) the old balance of root chain will be sent to the stealer. This is applicable to other types of cross-chain requests.
Note: A similiar issue exists for BranchBridgeAgent as well. I didn't submit it as a separate one since I though it is gonna be merged by the judge.
BranchBridgeAgent.callOutAndBridge
, we pass GasParamsfunction callOutAndBridge( address payable _refundee, bytes calldata _params, DepositInput memory _dParams, GasParams calldata _gParams ) external payable override lock { //Cache Deposit Nonce
GasParams has gasLimit and remoteBranchExecutionGas.
gasLimit is used by LZ to call lzReceive
on root chain
remoteBranchExecutionGas is sent to root chain before calling lzReceive
function validateTransactionProofV2(uint16 _srcChainId, address _dstAddress, uint _gasLimit, bytes32 _blockHash, bytes calldata _transactionProof, address payable _to) external payable onlyApproved nonReentrant { (bool sent, ) = _to.call{value: msg.value}(""); require(sent, "Relayer: failed to send ether"); uln.validateTransactionProof(_srcChainId, _dstAddress, _gasLimit, _blockHash, _transactionProof); }
Here is how airdropping works explained in LZ docs Airdrop by adapterParams
Manual analysis
Two approaches:
(Recommended) Better options is, to track this value, the use can claim it from the root chain if he/she would like to. However, when making the request, please pass remoteBranchExecutionGas
instead of address(this).balance
.
(Not Recommended) If the request fails in the root chain, refund the user the balance of the contract after rootBridgeAgent.lzReceiveNonBlocking
call. Please consider a buffer gas for this since we don't want lzReceive
to fail at all. For why, check the other issues that I've submitted in this regard.
Other
#0 - c4-pre-sort
2023-10-10T12:16:11Z
0xA5DF marked the issue as duplicate of #887
#1 - c4-pre-sort
2023-10-15T06:50:27Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-25T09:45:00Z
alcueca changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-25T09:45:15Z
alcueca marked the issue as satisfactory
#4 - c4-judge
2023-10-25T09:52:00Z
alcueca marked the issue as partial-50
#5 - c4-judge
2023-11-03T10:53:05Z
alcueca marked the issue as duplicate of #518
🌟 Selected for report: LokiThe5th
Also found by: 0xadrii, 33BYTEZZZ, 3docSec, Bauchibred, DevABDee, Koolex, Kow, Limbooo, QiuhaoLi, Tendency, ast3ros, ihtishamsudo, kodyvim, lsaudit, neumo, peakbolt, windhustler
40.0102 USDC - $40.01
Note:skip this if you have a good understanding of how LayerZero works
LayerZero delivers the messages in the same order they were sent. In case a message delivery fails, it will block the messaging channel.
LayerZero provides ordered delivery of messages from a given sender to a destination chain, i.e. srcUA-> dstChain. In other words, the message order nonce is shared by all dstUA on the same dstChain. That's why a STORED message blocks the message pathway from srcUA to all dstUA on the same destination chain.
// block if any message blocking StoredPayload storage sp = storedPayload[_srcChainId][_srcAddress]; require(sp.payloadHash == bytes32(0), "LayerZero: in message blocking"); try ILayerZeroReceiver(_dstAddress).lzReceive{gas: _gasLimit}(_srcChainId, _srcAddress, _nonce, _payload) { // success, do nothing, end of the message delivery } catch (bytes memory reason) { // revert nonce if any uncaught errors/exceptions if the ua chooses the blocking mode storedPayload[_srcChainId][_srcAddress] = StoredPayload(uint64(_payload.length), _dstAddress, keccak256(_payload)); emit PayloadStored(_srcChainId, _srcAddress, _dstAddress, _nonce, _payload, reason); }
To prevent this from happening, UAs should implement a non-blocking pattern in the contract code. So that whenever lzReceive
method is called by LayerZero endpoint, it should always succeed. This way we guarantee messaging channel will never be blocked.
Maia implements the non-blocking pattern in RootBridgeAgent as suggested by LayerZero.
function lzReceive(uint16 _srcChainId, bytes calldata _srcAddress, uint64, bytes calldata _payload) public { (bool success,) = address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector(this.lzReceiveNonBlocking.selector, msg.sender, _srcChainId, _srcAddress, _payload) ); if (!success) if (msg.sender == getBranchBridgeAgent[localChainId]) revert ExecutionFailure(); }
As seen lzReceive
doesn't care if the lzReceiveNonBlocking
call fails (unless it is a local call which is irrelevant here). This way, lzReceive
should always succeed so that messaging channel is never blocked. However, the implementation doesn't take into consideration that when performing an external call only 63/64 of the available gas is passed and 1/64 is kept for after the call is done (check EIP-150 for further details).
The worst case that lzReceiveNonBlocking
run out of gas and only 1/64 is left. Notice we still have few Opcodes to be executed after the call. If the 1/64 left gas isn't enough to execute it, lzReceive
will fail by out of gas error, ultimately blocking the messaging channel.
An attacker can exploit this by passing a gas limit (e.g. 152000) that cause this issue eventually blocking depositing flow (Obviously all types of it) from any branch chain (except Arb) to the root chain.
Note: the issue is applicable only on RootBridgeAgent contract because BranchBridgeAgent's lzReceive
doesn't have any additional code after lzReceiveNonBlocking
call. However, Opcodes after the call happening in excessivelySafeCall
that's different from the regular call should be considered.
According to ExcessivelySafeCall,
The main difference with between
address.call()
call andaddress.excessivelySafeCall()
is that a regular solidity call will automatically copy bytes to memory without consideration of gas.
Since there is an additional logic that limits the max copied returned data to 150 bytes (e.g. the if condition), it should be considered.
When you run the test file below, you should get the following output:
[PASS] test_C1_deposit_flow_broken() (gas: 242817) Logs: Case #1: (1/64 rule) Gas Limit: 152000 Deposit Flow is broken! storedPayload.payloadHash => 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 [PASS] test_C1_deposit_flow_not_broken() (gas: 199729) Logs: Case #1: (1/64 rule) Gas Limit: 152000 Deposit Flow is functional storedPayload.payloadHash => 0x0000000000000000000000000000000000000000000000000000000000000000 Test result: ok. 2 passed; 0 failed; finished in 16.88ms
We have two tests:
test_C1_deposit_flow_broken()
This demonstrates how the attacker can break the deposit flow. storedPayload will have a non zero value in payloadHash.
A STORED message will block the delivery of any future message from srcUA to all dstUA
Check message-state for further details.
test_C1_deposit_flow_not_broken()
This demonstrates that if we remove the code after lzReceiveNonBlocking
call, then the issue won't occur.
storedPayload will have a zero value in payloadHash.
Note: in the coded PoC below, some comments are added for further clarity.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; // PoC => Maia OmniChain: Attacker can break the deposit functionality from any branch chain (except Arb) to the root import {Test} from "forge-std/Test.sol"; import "forge-std/console.sol"; import {DSTest} from "ds-test/test.sol"; library ExcessivelySafeCall { uint256 constant LOW_28_MASK = 0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff; /// @notice Use when you _really_ really _really_ don't trust the called /// contract. This prevents the called contract from causing reversion of /// the caller in as many ways as we can. /// @dev The main difference between this and a solidity low-level call is /// that we limit the number of bytes that the callee can cause to be /// copied to caller memory. This prevents stupid things like malicious /// contracts returning 10,000,000 bytes causing a local OOG when copying /// to memory. /// @param _target The address to call /// @param _gas The amount of gas to forward to the remote contract /// @param _maxCopy The maximum number of bytes of returndata to copy /// to memory. /// @param _calldata The data to send to the remote contract /// @return success and returndata, as `.call()`. Returndata is capped to /// `_maxCopy` bytes. function excessivelySafeCall( address _target, uint256 _gas, uint16 _maxCopy, bytes memory _calldata ) internal returns (bool, bytes memory) { // set up for assembly call uint256 _toCopy; bool _success; bytes memory _returnData = new bytes(_maxCopy); // dispatch message to recipient // by assembly calling "handle" function // we call via assembly to avoid memcopying a very large returndata // returned by a malicious contract assembly { _success := call( _gas, // gas _target, // recipient 0, // ether value add(_calldata, 0x20), // inloc mload(_calldata), // inlen 0, // outloc 0 // outlen ) // limit our copy to 256 bytes _toCopy := returndatasize() if gt(_toCopy, _maxCopy) { _toCopy := _maxCopy } // Store the length of the copied bytes mstore(_returnData, _toCopy) // copy the bytes from returndata[0:_toCopy] returndatacopy(add(_returnData, 0x20), 0, _toCopy) } return (_success, _returnData); } /// @notice Use when you _really_ really _really_ don't trust the called /// contract. This prevents the called contract from causing reversion of /// the caller in as many ways as we can. /// @dev The main difference between this and a solidity low-level call is /// that we limit the number of bytes that the callee can cause to be /// copied to caller memory. This prevents stupid things like malicious /// contracts returning 10,000,000 bytes causing a local OOG when copying /// to memory. /// @param _target The address to call /// @param _gas The amount of gas to forward to the remote contract /// @param _maxCopy The maximum number of bytes of returndata to copy /// to memory. /// @param _calldata The data to send to the remote contract /// @return success and returndata, as `.call()`. Returndata is capped to /// `_maxCopy` bytes. function excessivelySafeStaticCall( address _target, uint256 _gas, uint16 _maxCopy, bytes memory _calldata ) internal view returns (bool, bytes memory) { // set up for assembly call uint256 _toCopy; bool _success; bytes memory _returnData = new bytes(_maxCopy); // dispatch message to recipient // by assembly calling "handle" function // we call via assembly to avoid memcopying a very large returndata // returned by a malicious contract assembly { _success := staticcall( _gas, // gas _target, // recipient add(_calldata, 0x20), // inloc mload(_calldata), // inlen 0, // outloc 0 // outlen ) // limit our copy to 256 bytes _toCopy := returndatasize() if gt(_toCopy, _maxCopy) { _toCopy := _maxCopy } // Store the length of the copied bytes mstore(_returnData, _toCopy) // copy the bytes from returndata[0:_toCopy] returndatacopy(add(_returnData, 0x20), 0, _toCopy) } return (_success, _returnData); } /** * @notice Swaps function selectors in encoded contract calls * @dev Allows reuse of encoded calldata for functions with identical * argument types but different names. It simply swaps out the first 4 bytes * for the new selector. This function modifies memory in place, and should * only be used with caution. * @param _newSelector The new 4-byte selector * @param _buf The encoded contract args */ function swapSelector( bytes4 _newSelector, bytes memory _buf ) internal pure { require(_buf.length >= 4); uint256 _mask = LOW_28_MASK; assembly { // load the first word of let _word := mload(add(_buf, 0x20)) // mask out the top 4 bytes // /x _word := and(_word, _mask) _word := or(_newSelector, _word) mstore(add(_buf, 0x20), _word) } } } interface ILayerZeroReceiver { // @notice LayerZero endpoint will invoke this function to deliver the message on the destination // @param _srcChainId - the source endpoint identifier // @param _srcAddress - the source sending contract address from the source chain // @param _nonce - the ordered message nonce // @param _payload - the signed payload is the UA bytes has encoded to be sent function lzReceive( uint16 _srcChainId, bytes calldata _srcAddress, uint64 _nonce, bytes calldata _payload ) external; } contract BridgeAgentConstants { // Settlement / Deposit Execution Status uint8 internal constant STATUS_READY = 0; uint8 internal constant STATUS_DONE = 1; uint8 internal constant STATUS_RETRIEVE = 2; // Settlement / Deposit Redeeem Status uint8 internal constant STATUS_FAILED = 1; uint8 internal constant STATUS_SUCCESS = 0; // Payload Encoding / Decoding uint256 internal constant PARAMS_START = 1; uint256 internal constant PARAMS_START_SIGNED = 21; uint256 internal constant PARAMS_TKN_START = 5; uint256 internal constant PARAMS_TKN_START_SIGNED = 25; uint256 internal constant PARAMS_ENTRY_SIZE = 32; uint256 internal constant PARAMS_ADDRESS_SIZE = 20; uint256 internal constant PARAMS_TKN_SET_SIZE = 109; uint256 internal constant PARAMS_TKN_SET_SIZE_MULTIPLE = 128; uint256 internal constant ADDRESS_END_OFFSET = 12; uint256 internal constant PARAMS_AMT_OFFSET = 64; uint256 internal constant PARAMS_DEPOSIT_OFFSET = 96; uint256 internal constant PARAMS_END_OFFSET = 6; uint256 internal constant PARAMS_END_SIGNED_OFFSET = 26; uint256 internal constant PARAMS_SETTLEMENT_OFFSET = 129; // Deposit / Settlement Multiple Max uint256 internal constant MAX_TOKENS_LENGTH = 255; } // LZ EndPoint (Mock only relevant code copied) // https://github.com/LayerZero-Labs/LayerZero/blob/main/contracts/Endpoint.sol#L100-L125 contract Endpoint { struct StoredPayload { uint64 payloadLength; address dstAddress; bytes32 payloadHash; } // inboundNonce = [srcChainId][srcAddress]. mapping(uint16 => mapping(bytes => uint64)) public inboundNonce; // outboundNonce = [dstChainId][srcAddress]. mapping(uint16 => mapping(address => uint64)) public outboundNonce; // storedPayload = [srcChainId][srcAddress] mapping(uint16 => mapping(bytes => StoredPayload)) public storedPayload; function receivePayload( uint16 _srcChainId, bytes calldata _srcAddress, address _dstAddress, uint64 _nonce, uint _gasLimit, bytes calldata _payload ) public { try ILayerZeroReceiver(_dstAddress).lzReceive{gas: _gasLimit}( _srcChainId, _srcAddress, _nonce, _payload ) { // success, do nothing, end of the message delivery } catch (bytes memory reason) { // revert nonce if any uncaught errors/exceptions if the ua chooses the blocking mode storedPayload[_srcChainId][_srcAddress] = StoredPayload( uint64(_payload.length), _dstAddress, keccak256(_payload) ); // emit PayloadStored(_srcChainId, _srcAddress, _dstAddress, _nonce, _payload, reason); } } function readStoredPayload( uint16 _srcChainId, bytes memory _srcAddress ) public view returns (StoredPayload memory) { return storedPayload[_srcChainId][_srcAddress]; } } contract RootBridgeAgentBaseMock is BridgeAgentConstants { /*/////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ event LogExecute(uint256 indexed depositNonce, uint256 indexed srcChainId); event LogFallback( uint256 indexed settlementNonce, uint256 indexed dstChainId ); /*/////////////////////////////////////////////////////////////// ONLY REQUIRED ERRORS FOR POC //////////////////////////////////////////////////////////////*/ error ExecutionFailure(); error LayerZeroUnauthorizedEndpoint(); error LayerZeroUnauthorizedCaller(); /*/////////////////////////////////////////////////////////////// ROOT BRIDGE AGENT STATE //////////////////////////////////////////////////////////////*/ /// @notice Local Chain Id uint16 public immutable localChainId; /// @notice Bridge Agent Factory Address. address public immutable factoryAddress; /// @notice Local Core Root Router Address address public immutable localRouterAddress; /// @notice Local Port Address where funds deposited from this chain are stored. address public immutable localPortAddress; /// @notice Local Layer Zero Endpoint Address for cross-chain communication. address public immutable lzEndpointAddress; /// @notice Address of Root Bridge Agent Executor. address public immutable bridgeAgentExecutorAddress; /*/////////////////////////////////////////////////////////////// BRANCH BRIDGE AGENTS STATE //////////////////////////////////////////////////////////////*/ /// @notice Chain -> Branch Bridge Agent Address. For N chains, each Root Bridge Agent Address has M =< N Branch Bridge Agent Address. mapping(uint256 chainId => address branchBridgeAgent) public getBranchBridgeAgent; /// @notice Message Path for each connected Branch Bridge Agent as bytes for Layzer Zero interaction = localAddress + destinationAddress abi.encodePacked() mapping(uint256 chainId => bytes branchBridgeAgentPath) public getBranchBridgeAgentPath; /// @notice If true, bridge agent manager has allowed for a new given branch bridge agent to be synced/added. mapping(uint256 chainId => bool allowed) public isBranchBridgeAgentAllowed; /*/////////////////////////////////////////////////////////////// SETTLEMENTS STATE //////////////////////////////////////////////////////////////*/ /// @notice Deposit nonce used for identifying transaction. uint32 public settlementNonce; // /// @notice Mapping from Settlement nonce to Settlement Struct. // mapping(uint256 nonce => Settlement settlementInfo) public getSettlement; /*/////////////////////////////////////////////////////////////// EXECUTOR STATE //////////////////////////////////////////////////////////////*/ /// @notice If true, bridge agent has already served a request with this nonce from a given chain. Chain -> Nonce -> Bool mapping(uint256 chainId => mapping(uint256 nonce => uint256 state)) public executionState; /*/////////////////////////////////////////////////////////////// REENTRANCY STATE //////////////////////////////////////////////////////////////*/ /// @notice Re-entrancy lock modifier state. uint256 internal _unlocked = 1; // State Vars for the sake of the PoC uint256[] public uselessArray; constructor(address _lzEndpointAddress) { lzEndpointAddress = _lzEndpointAddress; } // set initial values for testing only function setSrcChainAndAddress( uint16 _srcChain, address _srcAddress ) public { getBranchBridgeAgent[_srcChain] = _srcAddress; } modifier requiresEndpoint( address _endpoint, uint16 _srcChain, bytes calldata _srcAddress ) virtual { if (msg.sender != address(this)) revert LayerZeroUnauthorizedEndpoint(); if (_endpoint != getBranchBridgeAgent[localChainId]) { if (_endpoint != lzEndpointAddress) revert LayerZeroUnauthorizedEndpoint(); if (_srcAddress.length != 40) revert LayerZeroUnauthorizedCaller(); if ( getBranchBridgeAgent[_srcChain] != address(uint160(bytes20(_srcAddress[PARAMS_ADDRESS_SIZE:]))) ) { revert LayerZeroUnauthorizedCaller(); } } _; } function lzReceiveNonBlocking( address _endpoint, uint16 _srcChainId, bytes calldata _srcAddress, bytes calldata _payload ) public /*override*/ requiresEndpoint(_endpoint, _srcChainId, _srcAddress) { // Deposit Nonce uint32 nonce; // Some code here (e.g. loop) to spend all the gas uint256 uselessSum = 0; for (uint256 i = 0; i < 10000; i++) { uselessSum += i; uselessArray.push(uselessSum); } emit LogExecute(nonce, _srcChainId); } fallback() external payable {} receive() external payable {} } contract RootBridgeAgentMockV1 is RootBridgeAgentBaseMock { using ExcessivelySafeCall for address; constructor( address _lzEndpointAddress ) RootBridgeAgentBaseMock(_lzEndpointAddress) {} function lzReceive( uint16 _srcChainId, bytes calldata _srcAddress, uint64, bytes calldata _payload ) public { (bool success, ) = address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector( this.lzReceiveNonBlocking.selector, msg.sender, _srcChainId, _srcAddress, _payload ) ); // Can you make this reaches "out of gas" here so lzReceive fails ? Yes! if (!success) if (msg.sender == getBranchBridgeAgent[localChainId]) revert ExecutionFailure(); } } contract RootBridgeAgentMockV2 is RootBridgeAgentBaseMock { using ExcessivelySafeCall for address; constructor( address _lzEndpointAddress ) RootBridgeAgentBaseMock(_lzEndpointAddress) {} function lzReceive( uint16 _srcChainId, bytes calldata _srcAddress, uint64, bytes calldata _payload ) public { (bool success, ) = address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector( this.lzReceiveNonBlocking.selector, msg.sender, _srcChainId, _srcAddress, _payload ) ); // This is commented out here to show the issue // if (!success) // if (msg.sender == getBranchBridgeAgent[localChainId]) // revert ExecutionFailure(); } } contract BreakDepositTest is DSTest, Test { RootBridgeAgentMockV1 rootBridgeAgentV1; RootBridgeAgentMockV2 rootBridgeAgentV2; Endpoint lzEndpoint; address branchBridgeAgent = vm.addr(8); // any address to construct srcAddress below function setUp() public { lzEndpoint = new Endpoint(); rootBridgeAgentV1 = new RootBridgeAgentMockV1(address(lzEndpoint)); rootBridgeAgentV2 = new RootBridgeAgentMockV2(address(lzEndpoint)); } /** Case #1: (1/64 rule) **/ // Attacker can pass a specific gas limit uitlizing 1/64 rule to break the deposit flow from Branch to Root function test_C1_deposit_flow_broken() public { uint16 _srcChainId = 1; rootBridgeAgentV1.setSrcChainAndAddress( _srcChainId, address(branchBridgeAgent) ); bytes memory _srcAddress = abi.encodePacked( address(rootBridgeAgentV1), address(branchBridgeAgent) ); uint64 _nonce = 0; bytes memory _payload; uint256 _gasLimit = 152_000; lzEndpoint.receivePayload{gas: 1 ether}( // Give big amount of gas for this, we don't want this to fail _srcChainId, _srcAddress, address(rootBridgeAgentV1), _nonce, _gasLimit, _payload ); Endpoint.StoredPayload memory storedPayload = lzEndpoint .readStoredPayload(_srcChainId, _srcAddress); console.log("Case #1: (1/64 rule)"); console.log("Gas Limit: %d", _gasLimit); console.log("Deposit Flow is broken!"); console.log("storedPayload.payloadHash =>"); console.logBytes32(storedPayload.payloadHash); assertTrue(storedPayload.payloadHash != bytes32(0)); } // When removing the condition after lzReceiveNonBlocking call, the issue is no longer there. All other code is the same // This is to prove that the LZ message above failed because of not enough gas for it // RootBridgeAgentMockV2 is used function test_C1_deposit_flow_not_broken() public { uint16 _srcChainId = 1; rootBridgeAgentV2.setSrcChainAndAddress( _srcChainId, address(branchBridgeAgent) ); bytes memory _srcAddress = abi.encodePacked( address(rootBridgeAgentV2), address(branchBridgeAgent) ); uint64 _nonce = 0; bytes memory _payload; uint256 _gasLimit = 152_000; lzEndpoint.receivePayload{gas: 1 ether}( // Give big amount of gas for this, we don't want this to fail _srcChainId, _srcAddress, address(rootBridgeAgentV2), _nonce, _gasLimit, _payload ); Endpoint.StoredPayload memory storedPayload = lzEndpoint .readStoredPayload(_srcChainId, _srcAddress); console.log("Case #1: (1/64 rule)"); console.log("Gas Limit: %d", _gasLimit); console.log("Deposit Flow is functional"); console.log("storedPayload.payloadHash =>"); console.logBytes32(storedPayload.payloadHash); assertTrue(storedPayload.payloadHash == bytes32(0)); } }
Manual analysis
Instead of passing all gasleft()
to lzReceiveNonBlocking
call, pass gasleft()-XOfGas
in which XOfGas is enough gas to execute the Opcodes after lzReceiveNonBlocking
call. Please consider Opcodes after the actual call happening in excessivelySafeCall
that's different from the regular call. In case you need a coded example, I will be happy to provide it.
DoS
#0 - c4-pre-sort
2023-10-13T09:55:59Z
0xA5DF marked the issue as duplicate of #430
#1 - c4-pre-sort
2023-10-13T09:56:05Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T10:37:39Z
alcueca marked the issue as not a duplicate
#3 - alcueca
2023-10-26T10:38:15Z
Great report, duplicate of #399, in a way. The system can recover, so Medium.
#4 - c4-judge
2023-10-26T10:38:28Z
alcueca marked the issue as duplicate of #399
#5 - c4-judge
2023-10-26T10:38:37Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-10-26T10:39:24Z
alcueca changed the severity to QA (Quality Assurance)
#7 - c4-judge
2023-10-27T10:18:21Z
This previously downgraded issue has been upgraded by alcueca
#8 - c4-judge
2023-10-27T10:18:51Z
alcueca marked the issue as satisfactory
🌟 Selected for report: LokiThe5th
Also found by: 0xadrii, 33BYTEZZZ, 3docSec, Bauchibred, DevABDee, Koolex, Kow, Limbooo, QiuhaoLi, Tendency, ast3ros, ihtishamsudo, kodyvim, lsaudit, neumo, peakbolt, windhustler
40.0102 USDC - $40.01
Note:skip this if you have a good understanding of how LayerZero works
=> Adapter Parameters
When sending a message through LayerZero, the default gas limit is 200000. However, the message sender can tell LayerZero to use a custom gas limit.
From LayerZero docs
Abstract: Every transaction costs a certain amount of gas. Since LayerZero delivers the destination transaction when a message is sent it must pay for that destination gas. A default of 200,000 gas is priced into the call for simplicity
LayerZero Adapter Parameters doc
=> Delivering Messages
LayerZero delivers the messages in the same order they were sent. In case a message delivery fails, it will block the messaging channel.
LayerZero provides ordered delivery of messages from a given sender to a destination chain, i.e. srcUA-> dstChain. In other words, the message order nonce is shared by all dstUA on the same dstChain. That's why a STORED message blocks the message pathway from srcUA to all dstUA on the same destination chain.
// block if any message blocking StoredPayload storage sp = storedPayload[_srcChainId][_srcAddress]; require(sp.payloadHash == bytes32(0), "LayerZero: in message blocking"); try ILayerZeroReceiver(_dstAddress).lzReceive{gas: _gasLimit}(_srcChainId, _srcAddress, _nonce, _payload) { // success, do nothing, end of the message delivery } catch (bytes memory reason) { // revert nonce if any uncaught errors/exceptions if the ua chooses the blocking mode storedPayload[_srcChainId][_srcAddress] = StoredPayload(uint64(_payload.length), _dstAddress, keccak256(_payload)); emit PayloadStored(_srcChainId, _srcAddress, _dstAddress, _nonce, _payload, reason); }
To prevent this from happening, UAs should implement a non-blocking pattern in the contract code. So that whenever lzReceive
method is called by LayerZero endpoint, it should always succeed. This way we guarantee messaging channel will never be blocked.
The non-blocking pattern in BranchBridgeAgent is implemented as follows:
function lzReceive(uint16, bytes calldata _srcAddress, uint64, bytes calldata _payload) public override { address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector(this.lzReceiveNonBlocking.selector, msg.sender, _srcAddress, _payload) ); } }
As seen lzReceive
doesn't care if the lzReceiveNonBlocking
call fails. This way, lzReceive
should always succeed so that messaging channel is never blocked. Obviously, if we can make lzReceive
fail, we cause blocking the messaging channel. On the surface, this seems not possible. However, since Maia is utilizing Adapter Parameters (which allows a custom gas limit to be passed), there is a way to cause lzReceive
failure. This can be done by passing too low gas limit that's not enough to exceute Opcodes that happen to be before the low level call. Yes, there are Opcodes that are not taken into account before the low level call.
Let's have a look at the code again
address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector(this.lzReceiveNonBlocking.selector, msg.sender, _srcAddress, _payload) );
We see that excessivelySafeCall (from ExcessivelySafeCall library) is used to make the low level call (This is to protect from gas griefing attacks which could cause lzReceive
failure, not our topic now). So the question is, what are the Opcodes that are executed before the low level call? Here is some examples:
gasleft()
abi.encodeWithSelector
From this we know that if we pass gas limit that's not enough to cover those, lzReceive
will fail by out of gas error, ultimately blocking the messaging channel. An attacker can exploit this to block the settlement flow eventually from the root chain to any branch chain (except Arb since it is local).
Note: the issue is applicable also on RootBridgeAgent contract since it has the same implementation of the call. So deposit flow could be also blocked. In other words, all messaging channels from any chain to any chain through LZ can be completely blocked.
When you run the test file below, you should get the following output:
[PASS] test_C1_settelment_flow_broken_too_little_gas() (gas: 108231) Logs: Case #1: too little gas Gas Limit: 11000 Settlement Flow is broken when too little gas! storedPayload.payloadHash => 0x7baaf1e67936132a7e216cb3251a719c814e578d5d01f2307cd2334a0fb130d0 Test result: ok. 1 passed; 0 failed; finished in 1.62ms
test_C1_settelment_flow_broken_too_little_gas()
test demonstrates how the attacker can break the settlement flow by passing too low gas limit (e.g. 11K).
storedPayload will have a non zero value in payloadHash.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; // PoC => Maia OmniChain: Attacker can break the settlement functionality from the root chain to any branch chain (except Arb) import {Test} from "forge-std/Test.sol"; import "forge-std/console.sol"; import {DSTest} from "ds-test/test.sol"; library ExcessivelySafeCall { uint256 constant LOW_28_MASK = 0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff; /// @notice Use when you _really_ really _really_ don't trust the called /// contract. This prevents the called contract from causing reversion of /// the caller in as many ways as we can. /// @dev The main difference between this and a solidity low-level call is /// that we limit the number of bytes that the callee can cause to be /// copied to caller memory. This prevents stupid things like malicious /// contracts returning 10,000,000 bytes causing a local OOG when copying /// to memory. /// @param _target The address to call /// @param _gas The amount of gas to forward to the remote contract /// @param _maxCopy The maximum number of bytes of returndata to copy /// to memory. /// @param _calldata The data to send to the remote contract /// @return success and returndata, as `.call()`. Returndata is capped to /// `_maxCopy` bytes. function excessivelySafeCall( address _target, uint256 _gas, uint16 _maxCopy, bytes memory _calldata ) internal returns (bool, bytes memory) { // set up for assembly call uint256 _toCopy; bool _success; bytes memory _returnData = new bytes(_maxCopy); // dispatch message to recipient // by assembly calling "handle" function // we call via assembly to avoid memcopying a very large returndata // returned by a malicious contract assembly { _success := call( _gas, // gas _target, // recipient 0, // ether value add(_calldata, 0x20), // inloc mload(_calldata), // inlen 0, // outloc 0 // outlen ) // limit our copy to 256 bytes _toCopy := returndatasize() if gt(_toCopy, _maxCopy) { _toCopy := _maxCopy } // Store the length of the copied bytes mstore(_returnData, _toCopy) // copy the bytes from returndata[0:_toCopy] returndatacopy(add(_returnData, 0x20), 0, _toCopy) } return (_success, _returnData); } /// @notice Use when you _really_ really _really_ don't trust the called /// contract. This prevents the called contract from causing reversion of /// the caller in as many ways as we can. /// @dev The main difference between this and a solidity low-level call is /// that we limit the number of bytes that the callee can cause to be /// copied to caller memory. This prevents stupid things like malicious /// contracts returning 10,000,000 bytes causing a local OOG when copying /// to memory. /// @param _target The address to call /// @param _gas The amount of gas to forward to the remote contract /// @param _maxCopy The maximum number of bytes of returndata to copy /// to memory. /// @param _calldata The data to send to the remote contract /// @return success and returndata, as `.call()`. Returndata is capped to /// `_maxCopy` bytes. function excessivelySafeStaticCall( address _target, uint256 _gas, uint16 _maxCopy, bytes memory _calldata ) internal view returns (bool, bytes memory) { // set up for assembly call uint256 _toCopy; bool _success; bytes memory _returnData = new bytes(_maxCopy); // dispatch message to recipient // by assembly calling "handle" function // we call via assembly to avoid memcopying a very large returndata // returned by a malicious contract assembly { _success := staticcall( _gas, // gas _target, // recipient add(_calldata, 0x20), // inloc mload(_calldata), // inlen 0, // outloc 0 // outlen ) // limit our copy to 256 bytes _toCopy := returndatasize() if gt(_toCopy, _maxCopy) { _toCopy := _maxCopy } // Store the length of the copied bytes mstore(_returnData, _toCopy) // copy the bytes from returndata[0:_toCopy] returndatacopy(add(_returnData, 0x20), 0, _toCopy) } return (_success, _returnData); } /** * @notice Swaps function selectors in encoded contract calls * @dev Allows reuse of encoded calldata for functions with identical * argument types but different names. It simply swaps out the first 4 bytes * for the new selector. This function modifies memory in place, and should * only be used with caution. * @param _newSelector The new 4-byte selector * @param _buf The encoded contract args */ function swapSelector( bytes4 _newSelector, bytes memory _buf ) internal pure { require(_buf.length >= 4); uint256 _mask = LOW_28_MASK; assembly { // load the first word of let _word := mload(add(_buf, 0x20)) // mask out the top 4 bytes // /x _word := and(_word, _mask) _word := or(_newSelector, _word) mstore(add(_buf, 0x20), _word) } } } interface ILayerZeroReceiver { // @notice LayerZero endpoint will invoke this function to deliver the message on the destination // @param _srcChainId - the source endpoint identifier // @param _srcAddress - the source sending contract address from the source chain // @param _nonce - the ordered message nonce // @param _payload - the signed payload is the UA bytes has encoded to be sent function lzReceive( uint16 _srcChainId, bytes calldata _srcAddress, uint64 _nonce, bytes calldata _payload ) external; } contract BridgeAgentConstants { // Settlement / Deposit Execution Status uint8 internal constant STATUS_READY = 0; uint8 internal constant STATUS_DONE = 1; uint8 internal constant STATUS_RETRIEVE = 2; // Settlement / Deposit Redeeem Status uint8 internal constant STATUS_FAILED = 1; uint8 internal constant STATUS_SUCCESS = 0; // Payload Encoding / Decoding uint256 internal constant PARAMS_START = 1; uint256 internal constant PARAMS_START_SIGNED = 21; uint256 internal constant PARAMS_TKN_START = 5; uint256 internal constant PARAMS_TKN_START_SIGNED = 25; uint256 internal constant PARAMS_ENTRY_SIZE = 32; uint256 internal constant PARAMS_ADDRESS_SIZE = 20; uint256 internal constant PARAMS_TKN_SET_SIZE = 109; uint256 internal constant PARAMS_TKN_SET_SIZE_MULTIPLE = 128; uint256 internal constant ADDRESS_END_OFFSET = 12; uint256 internal constant PARAMS_AMT_OFFSET = 64; uint256 internal constant PARAMS_DEPOSIT_OFFSET = 96; uint256 internal constant PARAMS_END_OFFSET = 6; uint256 internal constant PARAMS_END_SIGNED_OFFSET = 26; uint256 internal constant PARAMS_SETTLEMENT_OFFSET = 129; // Deposit / Settlement Multiple Max uint256 internal constant MAX_TOKENS_LENGTH = 255; } // LZ EndPoint (Mock only relevant code copied) // https://github.com/LayerZero-Labs/LayerZero/blob/main/contracts/Endpoint.sol#L100-L125 contract Endpoint { struct StoredPayload { uint64 payloadLength; address dstAddress; bytes32 payloadHash; } // inboundNonce = [srcChainId][srcAddress]. mapping(uint16 => mapping(bytes => uint64)) public inboundNonce; // outboundNonce = [dstChainId][srcAddress]. mapping(uint16 => mapping(address => uint64)) public outboundNonce; // storedPayload = [srcChainId][srcAddress] mapping(uint16 => mapping(bytes => StoredPayload)) public storedPayload; function receivePayload( uint16 _srcChainId, bytes calldata _srcAddress, address _dstAddress, uint64 _nonce, uint _gasLimit, bytes calldata _payload ) public { try ILayerZeroReceiver(_dstAddress).lzReceive{gas: _gasLimit}( _srcChainId, _srcAddress, _nonce, _payload ) { // success, do nothing, end of the message delivery } catch (bytes memory reason) { // revert nonce if any uncaught errors/exceptions if the ua chooses the blocking mode storedPayload[_srcChainId][_srcAddress] = StoredPayload( uint64(_payload.length), _dstAddress, keccak256(_payload) ); // emit PayloadStored(_srcChainId, _srcAddress, _dstAddress, _nonce, _payload, reason); } } function readStoredPayload( uint16 _srcChainId, bytes memory _srcAddress ) public view returns (StoredPayload memory) { return storedPayload[_srcChainId][_srcAddress]; } } contract BranchBridgeAgentMock is BridgeAgentConstants { using ExcessivelySafeCall for address; /*/////////////////////////////////////////////////////////////// ONLY REQUIRED ERRORS FOR POC //////////////////////////////////////////////////////////////*/ error LayerZeroUnauthorizedEndpoint(); error LayerZeroUnauthorizedCaller(); /// @notice Address for Bridge Agent who processes requests submitted for the Root Router Address /// where cross-chain requests are executed in the Root Chain. address public immutable rootBridgeAgentAddress; /// @notice Local Layerzero Endpoint Address where cross-chain requests are sent to the Root Chain Router. address public immutable lzEndpointAddress; // State Vars for the sake of the PoC uint256[] public uselessArray; constructor(address _lzEndpointAddress, address _rootBridgeAgent) { lzEndpointAddress = _lzEndpointAddress; rootBridgeAgentAddress = _rootBridgeAgent; } /// @notice Modifier verifies the caller is the Layerzero Enpoint or Local Branch Bridge Agent. modifier requiresEndpoint(address _endpoint, bytes calldata _srcAddress) { _requiresEndpoint(_endpoint, _srcAddress); _; } /// @notice Internal function for caller verification. To be overwritten in `ArbitrumBranchBridgeAgent'. function _requiresEndpoint( address _endpoint, bytes calldata _srcAddress ) internal view virtual { //Verify Endpoint if (msg.sender != address(this)) revert LayerZeroUnauthorizedEndpoint(); if (_endpoint != lzEndpointAddress) revert LayerZeroUnauthorizedEndpoint(); //Verify Remote Caller if (_srcAddress.length != 40) revert LayerZeroUnauthorizedCaller(); if ( rootBridgeAgentAddress != address(uint160(bytes20(_srcAddress[20:]))) ) revert LayerZeroUnauthorizedCaller(); } function lzReceive( uint16, bytes calldata _srcAddress, uint64, bytes calldata _payload ) public /*override*/ { address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector( this.lzReceiveNonBlocking.selector, msg.sender, _srcAddress, _payload ) ); } function lzReceiveNonBlocking( address _endpoint, bytes calldata _srcAddress, bytes calldata _payload ) public /*override*/ requiresEndpoint(_endpoint, _srcAddress) { uint32 nonce; // Some code here (e.g. loop) to spend all the gas, so we run out of gas uint256 uselessSum = 0; for (uint256 i = 0; i < 10000; i++) { uselessSum += i; uselessArray.push(uselessSum); } // emit LogExecute(nonce, _srcChainId); } fallback() external payable {} receive() external payable {} } contract BreakSettelmentTest is DSTest, Test { BranchBridgeAgentMock branchBridgeAgent; Endpoint lzEndpoint; address rootBridgeAgent = vm.addr(8); // any address to construct srcAddress below function setUp() public { lzEndpoint = new Endpoint(); branchBridgeAgent = new BranchBridgeAgentMock( address(lzEndpoint), address(rootBridgeAgent) ); } /** Case #1: too little gas **/ // Attacker can pass too little gas to break the settelment flow from Root to Branch // in our PoC, gasLimit that is equal or lower than 11_968 will cause the issue // RootBridgeAgentMockV2 is used function test_C1_settelment_flow_broken_too_little_gas() public { uint16 _srcChainId = 1; bytes memory _srcAddress = abi.encodePacked( address(branchBridgeAgent), address(rootBridgeAgent) ); uint64 _nonce = 0; // random payload bytes memory _payload = new bytes(10000); // 10000 is the maximum allowed in LZ // 11_000 gas limit is used as an example uint256 _gasLimit = 11_000; // in our PoC, gasLimit that is equal or lower than 11_968 will cause the issue lzEndpoint.receivePayload{gas: 1 ether}( // Give big amount of gas for this, we don't want this to fail _srcChainId, _srcAddress, address(branchBridgeAgent), _nonce, _gasLimit, _payload ); Endpoint.StoredPayload memory storedPayload = lzEndpoint .readStoredPayload(_srcChainId, _srcAddress); console.log("Case #1: too little gas"); console.log("Gas Limit: %d", _gasLimit); console.log("Settelment Flow is broken when too little gas!"); console.log("storedPayload.payloadHash =>"); console.logBytes32(storedPayload.payloadHash); assertTrue(storedPayload.payloadHash != bytes32(0)); } }
Manual analysis
When sending a message from any chain, enforce a minimum gas limit to be passed (remote execution gas as well). This minimum should be enough to execute the Opcodes before the actual call of lzReceiveNonBlocking
done. This way we protect lzReceive
from failure.
DoS
#0 - c4-pre-sort
2023-10-11T07:26:38Z
0xA5DF marked the issue as duplicate of #785
#1 - c4-pre-sort
2023-10-11T07:26:46Z
0xA5DF marked the issue as high quality report
#2 - c4-judge
2023-10-22T04:55:09Z
alcueca changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-22T05:11:17Z
alcueca marked the issue as satisfactory
🌟 Selected for report: LokiThe5th
Also found by: 0xadrii, 33BYTEZZZ, 3docSec, Bauchibred, DevABDee, Koolex, Kow, Limbooo, QiuhaoLi, Tendency, ast3ros, ihtishamsudo, kodyvim, lsaudit, neumo, peakbolt, windhustler
40.0102 USDC - $40.01
https://github.com/code-423n4/2023-09-maia/blob/c0dc3550e0754571b82d7bfd8f0282ac8fa5e42f/src/RootBridgeAgent.sol#L32 https://github.com/code-423n4/2023-09-maia/blob/c0dc3550e0754571b82d7bfd8f0282ac8fa5e42f/src/BranchBridgeAgent.sol#L45
The User Application (LZReceiver) should implement the ILayerZeroUserApplicationConfig interface which includes the forceResumeReceive function. This is very important as in the worst case, it can allow the owner to unblock the queue of messages if something unexpected/unpredicted occurs.
This is crucially important to have especially in emergency situations. I've reported 2 issues related to blocking messaging channel of LZ. This explains why it is highly recommended by LayerZero. Please check https://layerzero.gitbook.io/docs/evm-guides/best-practice
Please check RootBridgeAgent and BranchBridgeAgent. There is no Implementation of ILayerZeroUserApplicationConfig.
Manual analysis
Implement forceResumeReceive to as a last resort in case something happens.
Other
#0 - c4-pre-sort
2023-10-09T16:10:25Z
0xA5DF marked the issue as primary issue
#1 - c4-pre-sort
2023-10-09T16:10:38Z
0xA5DF marked the issue as sufficient quality report
#2 - 0xA5DF
2023-10-09T16:11:13Z
Improvements are usually just QA, but will leave open for sponsor to comment and for judge to see if there's a case to make an exception here
#3 - 0xA5DF
2023-10-11T12:35:23Z
#760 talks about using a non-blocking config, duping due to similarity
#4 - c4-sponsor
2023-10-17T20:20:39Z
0xBugsy (sponsor) disputed
#5 - 0xBugsy
2023-10-17T20:22:36Z
This is intended. Our system is a centralization minimal approach to a non blocking lzApp. Despite this not being possible in production in theory would be a nice addition due to abundance of caution.
#6 - c4-sponsor
2023-10-17T20:22:42Z
0xBugsy (sponsor) confirmed
#7 - c4-sponsor
2023-10-17T20:22:47Z
0xBugsy marked the issue as disagree with severity
#8 - alcueca
2023-10-22T04:44:37Z
Medium is justified, see #399
#9 - c4-judge
2023-10-22T04:47:13Z
alcueca marked the issue as satisfactory
#10 - c4-judge
2023-10-22T04:47:18Z
alcueca marked the issue as partial-50
#11 - alcueca
2023-10-22T04:47:53Z
Misses the attack angle via underfunded transactions that makes it a valid DoS
#12 - c4-judge
2023-10-22T04:56:35Z
alcueca marked the issue as duplicate of #399
#13 - c4-judge
2023-10-22T05:08:21Z
alcueca marked the issue as satisfactory
#14 - c4-judge
2023-11-03T17:07:20Z
alcueca marked the issue as partial-50
🌟 Selected for report: MrPotatoMagic
Also found by: 0xAadi, 0xDING99YA, 0xDemon, 0xRstStn, 0xSmartContract, 0xStriker, 0xWaitress, 0xbrett8571, 0xfuje, 0xsagetony, 0xsurena, 33BYTEZZZ, 3docSec, 7ashraf, ABA, ABAIKUNANBAEV, Aamir, Audinarey, Bauchibred, Black_Box_DD, Daniel526, DanielArmstrong, DanielTan_MetaTrust, Dinesh11G, Eurovickk, Franklin, Inspecktor, John, Jorgect, Joshuajee, K42, Kek, Koolex, LokiThe5th, MIQUINHO, Myd, NoTechBG, QiuhaoLi, SanketKogekar, Sathish9098, Sentry, Soul22, SovaSlava, Stormreckson, Tendency, Topmark, Udsen, V1235816, Viktor_Cortess, Viraz, Yanchuan, ZdravkoHr, Zims, albahaca, albertwh1te, alexweb3, alexxander, ast3ros, audityourcontracts, bareli, bin2chen, bronze_pickaxe, c0pp3rscr3w3r, cartlex_, castle_chain, chaduke, debo, ether_sky, gumgumzum, imare, its_basu, jaraxxus, jasonxiale, josephdara, kodyvim, ladboy233, lanrebayode77, lsaudit, mert_eren, minhtrng, n1punp, nadin, niroh, nmirchev8, orion, peakbolt, perseverancesuccess, pfapostol, ptsanev, rvierdiiev, saneryee, shaflow2, te_aut, terrancrypt, twcctop, unsafesol, ustas, versiyonbir, windhustler, yongskiws, zhaojie, ziyou-
25.6785 USDC - $25.68
adding global token in CoreRootRouter will revert if destChainId is greater than than 65535. This because the destChainId was encoded as uint256 then it is decoded as uint16.
On CoreBranchRouter the addGlobalToken
method is building the payload data and passing params to send it later to CoreBranchRouter CoreBranchRouter: L43
function addGlobalToken(address _globalAddress, uint256 _dstChainId, GasParams[3] calldata _gParams) external payable { // Encode Call Data bytes memory params = abi.encode(msg.sender, _globalAddress, _dstChainId, [_gParams[1], _gParams[2]]); // Pack FuncId bytes memory payload = abi.encodePacked(bytes1(0x01), params); // Send Cross-Chain request (System Response/Request) IBridgeAgent(localBridgeAgentAddress).callOut{value: msg.value}(payable(msg.sender), payload, _gParams[0]); }
Here is where the params are encoded CoreBranchRouter: L48-L51
bytes memory params = abi.encode(msg.sender, _globalAddress, _dstChainId, [_gParams[1], _gParams[2]]); // Pack FuncId bytes memory payload = abi.encodePacked(bytes1(0x01), params);
Notice that _dstChainId is uint256
The issue occurs when the function of CoreRootRouter decodes the values of the payload data L332
function execute(bytes calldata _encodedData, uint16) external payable override requiresExecutor { // Parse funcId bytes1 funcId = _encodedData[0]; /// FUNC ID: 1 (_addGlobalToken) if (funcId == 0x01) { (address refundee, address globalAddress, uint16 dstChainId, GasParams[2] memory gasParams) = abi.decode(_encodedData[1:], (address, address, uint16, GasParams[2])); _addGlobalToken(refundee, globalAddress, dstChainId, gasParams); /// Unrecognized Function Selector } else { revert UnrecognizedFunctionId(); } }
As you see at CoreRootRouter: L338-L339 the decoded types on CoreRootRouter do not match with addGlobalToken
method on CoreBranchRouter:
(address refundee, address globalAddress, uint16 dstChainId, GasParams[2] memory gasParams) = abi.decode(_encodedData[1:], (address, address, uint16, GasParams[2]));
Since the dstChainId is decoded as uint16, if the value is greater than 65535, it will revert.
Manual Review
Decoding types should match the ones when encoding.
Other
#0 - c4-pre-sort
2023-10-09T15:54:44Z
0xA5DF marked the issue as low quality report
#1 - 0xA5DF
2023-10-09T15:55:42Z
if destChainId is greater than than 65535
Any example of such a chain? LZ are using unit16
for dest chains ids too
#2 - alcueca
2023-10-23T05:11:50Z
There are a few chains with chain id over 2**16-1
https://chainlist.org/?chain=300&search=
Still, the impact to users is minimum, so QA.
#3 - c4-judge
2023-10-23T05:13:16Z
alcueca changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-10-23T05:13:22Z
alcueca marked the issue as grade-a