MorpheusAI

MorpheusAI
Foundry
22,500 USDC
View results
Submission Details
Severity: low
Valid

In message blocking can occur due to incorrect non-blocking implementation

Impact

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:

  1. There would be permanent DOS until the team clears the payload by calling retryPayload() on the endpoint contract on Arbitrum.

  2. 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:

  1. 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.

  2. The lzReceive() function does not implement a try-catch and a library like ExcessivelySafeCall to minimize the gas used to call _blockingLzReceive().

Vulnerability Details

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()

  1. 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.

File: Distribution.sol
154: function claim(uint256 poolId_, address user_) external payable poolExists(poolId_) {
155: Pool storage pool = pools[poolId_];
156: PoolData storage poolData = poolsData[poolId_];
157: UserData storage userData = usersData[user_][poolId_];
158:
159: require(block.timestamp > pool.payoutStart + pool.claimLockPeriod, "DS: pool claim is locked");
160:
161: uint256 currentPoolRate_ = _getCurrentPoolRate(poolId_);
162: uint256 pendingRewards_ = _getCurrentUserReward(currentPoolRate_, userData);
163: require(pendingRewards_ > 0, "DS: nothing to claim");
164:
165: // Update pool data
166: poolData.lastUpdate = uint128(block.timestamp);
167: poolData.rate = currentPoolRate_;
168:
169: // Update user data
170: userData.rate = currentPoolRate_;
171: userData.pendingRewards = 0;
172:
173: // Transfer rewards
174: L1Sender(l1Sender).sendMintMessage{value: msg.value}(user_, pendingRewards_, _msgSender());
175:
176: emit UserClaimed(poolId_, user_, pendingRewards_);
177: }
  1. The function sendMintMessage() calls the send() function on the Endpoint contract on Ethereum with the gas provided for the destination call.

File: L1Sender.sol
125: function sendMintMessage(address user_, uint256 amount_, address refundTo_) external payable onlyDistribution {
126: RewardTokenConfig storage config = rewardTokenConfig;
127:
128: bytes memory receiverAndSenderAddresses_ = abi.encodePacked(config.receiver, address(this));
129: bytes memory payload_ = abi.encode(user_, amount_);
130:
131:
132: ILayerZeroEndpoint(config.gateway).send{value: msg.value}(
133: config.receiverChainId, // communicator LayerZero chainId
134: receiverAndSenderAddresses_, // send to this address to the communicator
135: payload_, // bytes payload
136: payable(refundTo_), // refund address
137: address(0x0), // future parameter
138: bytes("") // adapterParams (see "Advanced Features")
139: );
140: }
  1. 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.

File: L2MessageReceiver.sol
32: function lzReceive(
33: uint16 senderChainId_,
34: bytes memory senderAndReceiverAddresses_,
35: uint64 nonce_,
36: bytes memory payload_
37: ) external {
38: require(_msgSender() == config.gateway, "L2MR: invalid gateway");
39:
40: _blockingLzReceive(senderChainId_, senderAndReceiverAddresses_, nonce_, payload_);
41: }
  1. 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.

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);
}
  1. 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.

// block if any message blocking
StoredPayload storage sp = storedPayload[_srcChainId][_srcAddress];
require(sp.payloadHash == bytes32(0), "LayerZero: in message blocking");
  1. 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.

function retryPayload(uint16 _srcChainId, bytes calldata _srcAddress, bytes calldata _payload) external override receiveNonReentrant {
StoredPayload storage sp = storedPayload[_srcChainId][_srcAddress];
require(sp.payloadHash != bytes32(0), "LayerZero: no stored payload");
require(_payload.length == sp.payloadLength && keccak256(_payload) == sp.payloadHash, "LayerZero: invalid payload");
address dstAddress = sp.dstAddress;
// empty the storedPayload
sp.payloadLength = 0;
sp.dstAddress = address(0);
sp.payloadHash = bytes32(0);
uint64 nonce = inboundNonce[_srcChainId][_srcAddress];
ILayerZeroReceiver(dstAddress).lzReceive(_srcChainId, _srcAddress, nonce, _payload);
emit PayloadCleared(_srcChainId, _srcAddress, nonce, dstAddress);
}
  1. 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.

  2. 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.

File: Distribution.sol
154: function claim(uint256 poolId_, address user_) external payable poolExists(poolId_) {
155: Pool storage pool = pools[poolId_];
156: PoolData storage poolData = poolsData[poolId_];
157: UserData storage userData = usersData[user_][poolId_];
158:
159: require(block.timestamp > pool.payoutStart + pool.claimLockPeriod, "DS: pool claim is locked");
160:
161: uint256 currentPoolRate_ = _getCurrentPoolRate(poolId_);
162: uint256 pendingRewards_ = _getCurrentUserReward(currentPoolRate_, userData);
163: require(pendingRewards_ > 0, "DS: nothing to claim");
164:
165: // Update pool data
166: poolData.lastUpdate = uint128(block.timestamp);
167: poolData.rate = currentPoolRate_;
168:
169: // Update user data
170: userData.rate = currentPoolRate_;
171: userData.pendingRewards = 0;
172:
173: // Transfer rewards
174: L1Sender(l1Sender).sendMintMessage{value: msg.value}(user_, pendingRewards_, _msgSender());
175:
176: emit UserClaimed(poolId_, user_, pendingRewards_);
177: }

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.

Tools Used

Manual Review, Similar issue

Recommendations

  1. Implement estimateFees() in the claim() function and check if the msg.value provided is enough.

  2. Use a try-catch block in lzReceive() with the ExcessivelySafeCall library to minimize the gas used to call _blockingLzReceive().

  3. Additionally, only limit the user_ themselves to call the claim() function instead of anyone.

Additional solutions:

  1. 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.

  2. 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).

Updates

Lead Judging Commences

inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

LayerZero Integration: `sendMintMessage` doesn't verify the `msg.value` sent by the user facilitating failed transactions.

mrpotatomagic Submitter
over 1 year ago
inallhonesty Lead Judge
over 1 year ago
mrpotatomagic Submitter
over 1 year ago
mrpotatomagic Submitter
over 1 year ago
inallhonesty Lead Judge
over 1 year ago
inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

LayerZero Integration: `sendMintMessage` doesn't verify the `msg.value` sent by the user facilitating failed transactions.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.