Discovering a vulnerability in Hyperlane's RateLimited ISM

Summary

Hyperlane is a cross-chain messaging infrastructure that allows on-chain communication between a source and destination chain. It introduced the sovereign security model, where applications can choose their security models based on their preferences. This model is now widely adopted into multiple other cross-chain messaging protocols. ISMs (Interchain Security Modules) make this sovereign security model possible. I won’t dive deep into its specifics. One such ISM is the rate-limited ISM, which is being used by Hyperlane’s warp routes. This ISM works differently from the other ISM, where it maintains its very own state. Other ISMs like aggregation / multi-sig ISMs won’t allow any state modifications during the verify() call. This very functionality intrigued me to explore further and earthed a HIGH vulnerability issue, where the Hyperplane’s warp routes that use this very ISM can be DoSed temporarily and force re-deployment of new ISM without much capital at risk.

After reporting the issue on October 2nd, 2024, the team promptly acknowledged it on October 3rd, 2024, and a fix was merged by October 7th, 2024.

Discovery

I uncovered the vulnerability while working on my warp route assignment during my interview with Hyperlane. Usually, the verify() the function of most of the ISMs will be read-only, but here it was declared external without the view visibility identifier. This made me more curious, so I explored the states it was modifying and quickly found the issue: a valid message can be used to consume all the rate limits, thereby DoSing users of the RateLimitedISM. Then, the team swiftly responded to the finding without expecting any bounty. The team discussed the nature of the bug, acknowledged it, and fixed it. Later, they offered me a bounty for the submission, which I had never expected in the first place.

The Vulnerability

The core functionality of the rate-limited ISM is rate-limiting. It can help enforce sender-based limits while using the warp routes (as an additional security measure).

The verify() function of this ISM is to verify if the message ID has already been used and if it has been delivered to the mailbox (checking its validity).The validateMessageOnce modifier helps validate if the message ID is verified only once, thereby consuming the limits only once. Later, the _isDelivered() function call checks the mailbox to see if the message has been delivered via the mailbox hyperlane.

function verify(
  bytes calldata,
  bytes calldata _message
) external
  validateMessageOnce(_message)
  returns (bool)
{
  require(_isDelivered(_message.id()), "InvalidDeliveredMessage");
  uint256 newAmount = _message.body().amount();
  validateAndConsumeFilledLevel(newAmount);

  return true;
}

However, this function does not check to ensure that the payload is intended for a particular recipient/ism, leading to DoS using a random message ID. A malicious user can encode a message that consumes the entire sender limit and send it to a random receiver. Later, he can use the same message and call the verify() method on the targeted ISM to consume all the limits and DoS temporarily. Since this attack is not cost-intensive, in most L2s, it’ll take only a few dollars to DoS ISM for several weeks. L1s can cost the attacker a few hundred dollars to DoS the ISM.

Impact

  • Temporary DoS - The attacker can target any RateLimitedISM of interest and DoS them by consuming all limits occasionally.

  • Permanent DoS—In some cases, the attacker could also cause a permanent DoS by spending more funds, but the attack cost is multiplied, so it is unlikely.

Though the RateLimitedISM is not a standalone ISM and will be used with other ISMs, DoSing one could be enough to prevent message delivery in a few cases as the rate-limiting will avoid further action of the warp routes.

Remediation

The Hyperlane team responded with a suitable fix by introducing recipient validations to mitigate the issue. Each message in Hyperlane is encoded with the recipient address, and this is used to validate the verify() message.

function verify(
        bytes calldata,
        bytes calldata _message
    )
        external
        onlyRecipient(_message)
        validateMessageOnce(_message)
        returns (bool)
    {
        require(_isDelivered(_message.id()), "InvalidDeliveredMessage");

        uint256 newAmount = _message.body().amount();
        validateAndConsumeFilledLevel(newAmount);

        return true;
    }
Subscribe to Sujith Somraaj
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.