In-message blocking state is possible due to incorrect implementation of non blocking pattern. The issue arises because the lzReceive() does not use a try-catch with an ExcessivelySafeCall library to call _blockingLzReceive() in function lzReceive(). This would cause the message channel to be blocked since the gas sent can be intentionally or unintentionally set to a low value.
These are the following impacts:
There would be permanent DOS until the team clears the payload by calling retryPayload() on the endpoint contract on Arbitrum.
Any claim() calls during the DOS period would cause permanent loss of rewards for users. These claim() calls can be forced by an attacker since it allows anyone to claim the tokens on behalf of a user.
There are two root causes to this issue:
The gas amount supplied can be arbitrary. This is against what LayerZero recommends i.e. using estimateFees() to obtain the gas value to be used.
The lzReceive() function does not implement a try-catch and a library like ExcessivelySafeCall to minimize the gas used to call _blockingLzReceive().
Here is the whole process:
Execution path from Distribution.sol to Endpoint:
claim() => sendMintMessage() => send()
Execution path from Endpoint to L2MessageReceiver.sol:
receivePayload() => lzReceive() => _blockingLzReceive()
First we'll take a look at the claim() function, which allows the attacker to pass in an arbitrary amount of gas. (Note: The attacker is making a tx for himself as user_ here)
On Line 174, we can see that the msg.value is neither checked nor estimateFees() is used anywhere in the function.
The function sendMintMessage() calls the send() function on the Endpoint contract on Ethereum with the gas provided for the destination call.
LayerZero relays the call to Arbitrum and calls the receivePayload() function on the Endpoint contract on Arbitrum. The receivePayload() calls the lzReceive() function on the L2MessageReceiver.sol contract. The following occurs in lzReceive():
On Line 38, the function checks if the msg.sender is the endpoint contract. We assume this is true.
On Line 40 above, an internal call is made to the function _blockingLzReceive(). But since the attacker did not send enough gas, the call would revert due to OOG exception, which would then caught by the catch block in the receivePayload() function and be stored in the storedPayload mapping as seen below.
Due to this now, the message channel is blocked since the check below in the receivePayload() function would cause a revert (due to a stored payload existing) whenever a claim() call arrives from Ethereum to Arbitrum.
This stored payload can only be cleared by either calling retryPayload() manually or forceResumeReceive() from the L2MessageReceiver contract. Since the L2MessageReceiver does not have an implementation to call the forceResumeReceive() function, the only option left is the former one.
The attacker is in a win-win situation now since if retryPayload() is used, he will get his MOR tokens. But if retryPayload() has not been used yet, all claim() function calls arriving from Ethereum to Arbitrum would revert due to the check mentioned in step 5. Since the calls would revert on destination, the state changes on the source chain remain the same since the LayerZero relayer is the one transmitting the calls and has no ability as such to "revert" transactions that were successfully completed on the source chain.
Due to this, the pending rewards in the claim() function on the source chain is set to 0 and the rewards are lost.
An additional point to note over here is that, the claim() function call needs to arrive while the message channel is blocked. Since the claim() function allows anyone to pass in _user and claim for the user, the attacker could intentionally call this claim() function to target addresses that have higher rewards. This would then lead to permanent loss of rewards for those addresses due to the issue mentioned in step 7 above.
On Line 171, we can see that pending rewards for the user is set to 0.
Through this, we can see that an attacker can not only block the message channel but also cause permanent loss of rewards for users by calling the claim() function intentionally.
Manual Review, Similar issue
Implement estimateFees() in the claim() function and check if the msg.value provided is enough.
Use a try-catch block in lzReceive() with the ExcessivelySafeCall library to minimize the gas used to call _blockingLzReceive().
Additionally, only limit the user_ themselves to call the claim() function instead of anyone.
Additional solutions:
Implement an access controlled function in L2MessageReceiver.sol to call forceResumeReceive() on the endpoint contract. This would force eject any payload without executing it and clear the blocked channel.
Implement a NonBlockingLZApp provided by LayerZero that allows messages to flow regardless of error (which will all be stored on the destination to be dealt with anytime - see here).
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.