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: 44/175
Findings: 3
Award: $79.32
π Selected for report: 0
π Solo Findings: 0
π Selected for report: 0xTheC0der
Also found by: 0x180db, 0xDING99YA, 0xRstStn, 0xTiwa, 0xWaitress, 0xblackskull, 0xfuje, 3docSec, Aamir, Black_Box_DD, HChang26, Hama, Inspecktor, John_Femi, Jorgect, Kek, KingNFT, Kow, Limbooo, MIQUINHO, MrPotatoMagic, NoTechBG, Noro, Pessimistic, QiuhaoLi, SovaSlava, SpicyMeatball, T1MOH, TangYuanShen, Vagner, Viktor_Cortess, Yanchuan, _eperezok, alexweb3, alexxander, ast3ros, ayden, bin2chen, blutorque, btk, ciphermarco, ether_sky, gumgumzum, gztttt, hals, imare, its_basu, joaovwfreire, josephdara, klau5, kodyvim, ladboy233, marqymarq10, mert_eren, minhtrng, n1punp, nobody2018, oada, orion, peakbolt, peritoflores, perseverancesuccess, pfapostol, rvierdiiev, stuxy, tank, unsafesol, ustas, windhustler, zambody, zzzitron
0.1127 USDC - $0.11
The VirtualAccount
contract allows users to manage assets and perform interactions remotely while maintaining an encapsulated user balance. The contract includes a payableCall
function that aggregates and executes a batch of calls to external contracts. Unfortunately, this function lacks proper access control checks, allowing any external user or contract to invoke it without authorization. However, it's important to note that the function does enforce proper validation of msg.value
to prevent unauthorized Ether withdrawals.
The lack of access control in the payableCall
function of the VirtualAccount
contract poses a high-security risk, as it allows unauthorized users to perform actions that may result in financial losses and unintended behavior. To address this vulnerability, it is crucial to implement proper access control checks to ensure that only authorized entities can use the function. Additionally, applying the existing requiresApprovedCaller
modifier to payableCall
would provide a consistent access control mechanism across all contract functions.
This vulnerability poses a significant security risk as it allows unauthorized users to call the payableCall
function, potentially resulting in the following adverse consequences:
Unauthorized Token Withdrawals: Attackers can exploit the lack of access control to call payableCall
and withdraw ERC20 tokens or ERC721 tokens from the VirtualAccount
without proper authorization.
Potential Financial Loss: If the function performs actions that involve transferring tokens to external contracts, an unauthorized caller could drain the contract of assets, leading to a financial loss for the VirtualAccount
and its users.
Attacks Over Cross-Chain: Some of contracts trusted VirtualAccount
to preform some cross-chain procedures like this validation in RootBridgeAgent
, where it can be used to extend some different attacks that would potentiality leads to a financial loss on other chains.
Below is a simplified demonstrating how an unauthorized user can exploit the lack of access control in the payableCall
function to withdraw ERC20 tokens from the VirtualAccount
:
// Contract representing an unauthorized user contract UnauthorizedUser { function exploitPayableCall(IVirtualAccount virtualAccount, address token, uint256 amount) external { // Define a malicious PayableCall IVirtualAccount.PayableCall[] memory maliciousCall = new IVirtualAccount.PayableCall[](1); maliciousCall[0] = IVirtualAccount.PayableCall({ target: address(token), callData: abi.encodeWithSignature("transfer(address, uint256)", msg.sender, amount), value: 0 // No value sent }); // Execute the malicious PayableCall (bool success, ) = address(virtualAccount).call{value: 0}(abi.encodeWithSignature("payableCall(PayableCall[])", maliciousCall)); // Check if the call was successful (it should not be) require(!success, "Exploit failed"); } }
The UnauthorizedUser
contract attempts to execute a malicious PayableCall
to the payableCall
function of the VirtualAccount
. Since there are no access control checks in place, the unauthorized user can attempt this exploit easily for all VirtualAccount
s in the system.
To address this vulnerability and enhance the security of the VirtualAccount
contract, it is strongly recommended to implement proper access control checks within the payableCall
function. Access control should ensure that only authorized users or contracts can execute this function. Consider the following steps:
Implement a modifier or access control checks within the payableCall
function to verify that the caller is authorized to use the function.
Define a list of authorized users or contracts that are allowed to call payableCall
.
Restrict access to the function based on the ownership of the VirtualAccount
or any other designated access control mechanism.
Additionally, it's important to note that the VirtualAccount
contract already implements access control checks using the requiresApprovedCaller
modifier in other interaction functions, such as withdrawNative
, withdrawERC20
, and call
. Therefore, it is recommended to apply the same access control pattern to the payableCall
function to maintain consistency and ensure that unauthorized users cannot exploit this vulnerability.
Access Control
#0 - c4-pre-sort
2023-10-08T14:03:35Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:37:05Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:29:06Z
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/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L829 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L921 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L776
Many function in the Maia Protocol's contracts allow users to send a cross-chain messages with specified gas parameters without any limitation. Where they eventually will be sent through these functions:
RootBridgeAgent._performCall
RootBridgeAgent._performRetrySettlementCall
BranchBridgeAgent._performCall
A potential vulnerability has been identified in multiple functions that using _perform*
functions, due to the lack of proper gas validation and enforcement. This vulnerability allows any caller to specify GasParams
(used to build LayerZero's Relayer adapterParams
) for the cross-chain call, including a potentially low gas value. Without proper gas validation, this could lead to scenarios where cross-chain calls fail due to insufficient gas, potentially causing denial-of-service (DoS) situations or blockage of the message pathway.
BranchBridgeAgent
contract
callOutAndBridge
callOutAndBridgeMultiple
callOutSigned
callOutSignedAndBridge
callOutSignedAndBridgeMultiple
retryDeposit
retrieveDeposit
retrySettlement
- 2 level cross-chain messagesBaseBranchRouter
callOut
RootBridgeAgent
contract
retrySettlement
retrieveSettlement
CoreBranchRouter
addGlobalToken
- 3 level cross-chain messagesaddLocalToken
CoreRootRouter
addBranchToBridgeAgent
- 2 level cross-chain messagestoggleBranchBridgeAgentFactory
removeBranchBridgeAgent
...
Some of these functions controlled by a trusted entities but this issue could happens unintentionally, and It's important to note that multi level cross-chain calls is allowed! while this vulnerability can be occurs in very complex chain of cross-chain calls.
In scenarios where a LayerZero cross-chain call fails due to low gas, it could potentially block the message pathway, preventing the normal flow of messages channel between two chains.
When the gas limit specified in the try
block (inside LayerZero Endpoint) extremely low and cannot cover the gas requirements of the internal calls within lzReceive
, It's important to note that while excessivelySafeCall
can help prevent reverts due to out-of-gas conditions caused by low-level calls, it cannot overcome extremely low gas limits set at the transaction level. To ensure the successful execution of desired operations, the gas limit should be appropriately set.
Additionally, If the gas limit provided in the try
block is insufficient to even enter the lzReceive
function, the transaction will revert before the internal call inside lzReceive
reaches excessivelySafeCall
.
Therefore, if an attacker sets an extremely low gas limit in the try
block, and that limit is inadequate to enter the lzReceive
function, the error of out-of-gas will be caught before the internal call inside lzReceive
reaches the excessivelySafeCall
, leading to a failed transaction.
Attacker's Intent:
Low Gas Specification:
adapterParams
, specifying an exceptionally low gas value, for instance, 30,000 gas units.Cross-Chain Call:
Failure to Execute:
lzReceive
, lead to storing the payload inΒ StoredPayload
.Impact:
NonBlockingLzApp
architecture.This test will verify the vulnerability by simulating two scenarios:
Low Gas Attack: It will test the scenario where an attacker specifies an extremely low gas value, causing a cross-chain call to fail due to insufficient gas. This test aims to demonstrate how this vulnerability can lead to a denial-of-service (DoS) situation or blockage of the message pathway.
Normal Gas: It will test the scenario with an appropriate gas value to ensure that the cross-chain call executes successfully, preventing a DoS situation. This test serves as a control to show the expected behavior when using the correct gas parameters.
These test cases aim to demonstrate the impact of gas limitations on the Maia Protocol's message pathway and highlight the importance of proper gas validation and enforcement.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "forge-std/Test.sol"; import {ExcessivelySafeCall} from "lib/ExcessivelySafeCall.sol"; contract EndpointMock { struct StoredPayload { uint64 payloadLength; address dstAddress; bytes32 payloadHash; } // storedPayload = [srcChainId][srcAddress] mapping(uint16 => mapping(bytes => StoredPayload)) public storedPayload; event PayloadStored(uint16 srcChainId, bytes srcAddress, address dstAddress, uint64 nonce, bytes payload, bytes reason); function receivePayload(uint16 _srcChainId, bytes calldata _srcAddress, address _dstAddress, uint64 _nonce, uint _gasLimit, bytes calldata _payload) external { try LzReceiverMock(_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); } } } contract LzReceiverMock { using ExcessivelySafeCall for address; uint256 nonce; 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) ); } function lzReceiveNonBlocking( address _endpoint, uint16 _srcChainId, bytes calldata _srcAddress, bytes calldata _payload ) public { require(_srcChainId == 0, "reverting test"); _endpoint; _srcAddress; _payload; nonce++; } } contract LowGasTest is Test { EndpointMock endpointMock; LzReceiverMock lzReceiver; event PayloadStored(uint16 srcChainId, bytes srcAddress, address dstAddress, uint64 nonce, bytes payload, bytes reason); constructor() { endpointMock = new EndpointMock(); lzReceiver = new LzReceiverMock(); } function test_PoC_LowGasStoreThePayload() public { bytes memory _payload = bytes("dummy"); bytes memory _srcAddress = bytes("_srcAddress"); vm.expectEmit(true, true, false, true); emit PayloadStored(0, _srcAddress, address(lzReceiver), 0, _payload, bytes("")); endpointMock.receivePayload(0, _srcAddress, address(lzReceiver), 0, 10, _payload); // Call lzReceiver with low gas (, address payloadSrcAddress,) = endpointMock.storedPayload(0, _srcAddress); assertEq(payloadSrcAddress, address(lzReceiver)); } function test_PoC_RightGasDoseNotStorePayloadWhenReverts() public { // chainId = 1 to make the lzReceive reverts endpointMock.receivePayload(1, bytes("_srcAddress"), address(lzReceiver), 0, 10000, bytes("dummy")); } }
consider Implementing gas validation and enforcement mechanisms within the function. Ensure that the gas provided by the caller is above a minimum threshold, which should cover the worst-case gas consumption scenario for the cross-chain call. it should hit (bool success,) = address(this).excessivelySafeCall(...
when receiving cross-chain messages.
Make sure to test the amount of gas required for the execution on the destination. Use custom adapter parameters and specify minimum destination gas for each cross-chain path when the default amount of gas (
200,000
) is not enough. This requires whoever calls the send function to provide the adapter params with a destinationgas >= amount
set in theminDstGasLookup
for that chain. So that your users don't run into failed messages on the destination. It makes it a smoother end-to-end experience for all.
Also this snippet can be used to implement the check from LayerZero implementation of LzApp
function _checkGasLimit(uint16 _dstChainId, uint16 _type, bytes memory _adapterParams, uint _extraGas) internal view virtual { uint providedGasLimit = _getGasLimit(_adapterParams); uint minGasLimit = minDstGasLookup[_dstChainId][_type] + _extraGas; require(minGasLimit > 0, "LzApp: minGasLimit not set"); require(providedGasLimit >= minGasLimit, "LzApp: gas limit is too low"); } function _getGasLimit(bytes memory _adapterParams) internal pure virtual returns (uint gasLimit) { require(_adapterParams.length >= 34, "LzApp: invalid adapterParams"); assembly { gasLimit := mload(add(_adapterParams, 34)) } }
It would be easier to implement the check right before the send
call, Also it will prevent the multi level cross-chain calls and break any call that will end up in StoredPayload
.
DoS
#0 - c4-pre-sort
2023-10-07T13:17:33Z
0xA5DF marked the issue as primary issue
#1 - c4-pre-sort
2023-10-07T13:18:16Z
0xA5DF marked the issue as sufficient quality report
#2 - 0xA5DF
2023-10-12T14:38:33Z
Notice #528 claims this can be used to steal the airdropped gas from the messages sent while blocked
#3 - c4-sponsor
2023-10-16T17:47:51Z
0xLightt (sponsor) confirmed
#4 - c4-judge
2023-10-22T04:52:04Z
alcueca marked issue #399 as primary and marked this issue as a duplicate of 399
#5 - c4-judge
2023-10-22T04:55:09Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-10-22T04:58:19Z
alcueca marked the issue as satisfactory
π Selected for report: 3docSec
Also found by: 0xStalin, 0xadrii, KingNFT, Limbooo, T1MOH, Tendency, ZdravkoHr, ciphermarco, jasonxiale, lsaudit, minhtrng, rvierdiiev, wangxx2026
39.2026 USDC - $39.20
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L1212 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L943
We discovered a remote address verification issue due to inappropriate extraction of the remote address from the source chain. This issue affects the Maia protocol's interaction with LayerZero and could lead to cross-chain message delivery failures.
LayerZero defines a Trusted Remote as a 40-byte object used to identify another contract from which a user application contract (UA) will receive messages. The Trusted Remote consists of the concatenated bytes of the REMOTE contract address and the LOCAL contract address. This is the same as the one named bytes calldata _srcAddress
in lzReceive
parameters.
Array slices in Solidity are views on a contiguous portion of an array, represented as x[start:end]
. Both start
and end
are optional, defaulting to 0 and the length of the array, respectively. Array slices do not have a type name and are implicitly convertible to arrays of their underlying type. Index access within a slice is relative to the start of the slice.
The source address verification issue arises when extracting the source contract's address from the _srcAddress
parameter within the Maia protocol's LayerZero-related contracts. This parameter represents the Trusted Remote of the source User Application contract (UA), but the verification process assumes the last 20 bytes of _srcAddress
correspond to the source contract's address. However, this assumption is not accurate, potentially leading to incorrect address extraction. according to LayerZero contracts, this is how the node is encoding the _srcAddress
before calling the lzReceive
on the destination contract:
bytes memory pathData = abi.encodePacked(_packet.srcAddress, _packet.dstAddress);
In the Root Bridge Agent contract lzReceive
, which is responsible for receiving messages on the destination chain. The _srcAddress
parameter is passed to this function and is expected to represent the source UA's Trusted Remote. However, the verification process in requiresEndpoint
takes the last 20 bytes _srcAddress[20:]
as the source contract's address, assuming that the first 20 bytes are the local contract's address. (PARAMS_ADDRESS_SIZE
is equal to 20)
if (_srcAddress.length != 40) revert LayerZeroUnauthorizedCaller(); if (getBranchBridgeAgent[_srcChain] != address(uint160(bytes20(_srcAddress[PARAMS_ADDRESS_SIZE:])))) { revert LayerZeroUnauthorizedCaller(); }
However, this verification process may result in extracting the local contract's address rather than the source contract's address. Also, the same happens in the Branch Bridge Agent.
The impact of this issue is primarily related to cross-chain message delivery. When the source address verification is incorrect, it can result in the failure of cross-chain message delivery. This means that messages may not be successfully transmitted from the source UA to the destination UA, leading to message loss or disruption in communication between blockchains.
Consider correcting desire address extraction. Modify the source address extraction process to accurately identify the source contract's address. Utilize address slicing to extract the correct remote address from _srcAddress
, specifically the first 20 bytes.
Invalid Validation
#0 - c4-pre-sort
2023-10-10T06:18:44Z
0xA5DF marked the issue as duplicate of #439
#1 - c4-pre-sort
2023-10-10T06:19:57Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T09:42:57Z
alcueca marked the issue as satisfactory
#3 - alcueca
2023-10-26T09:53:04Z
There are very high effort submissions in this duplicate group. All others are getting 50% so that the few very high effort ones get double rewards.
#4 - c4-judge
2023-10-26T09:53:09Z
alcueca marked the issue as partial-50