Hyperbridge Forged Proof DOT Mint on Ethereum

On April 13, 2026 at 03:55:23 UTC, a helper contract deployed by the attacker used Hyperbridge’s Ethereum-side ISMP message path to deliver a forged governance-style PostRequest into TokenGateway. The exploit is best classified as an access-control failure at the proof-validation boundary: HandlerV1 accepted a malicious cross-chain request as authentic, and the downstream gateway treated it as trusted governance. That request reassigned admin rights on the bridged DOT token to the attacker helper, which immediately minted 1,000,000,000 DOT and dumped it through Odos / Uniswap v4 infrastructure for 108.206143512481490001 ETH. Using incident-time market pricing, that is roughly $237K, while the on-chain loss is deterministically measured as 108.206143512481490001 ETH.

Root Cause

Vulnerable Contract

The primary trust boundary that failed in this transaction is HandlerV1 at 0x6c84eDd2A018b1fe2Fc93a56066B5C60dA4E6D64. It is not a proxy, and verified source is available in src/modules/HandlerV1.sol.

The privileged sink reached after that failure is TokenGateway at 0xFd413e3AFe560182C4471F4d143A96d3e259B6dE, also verified in src/modules/TokenGateway.sol. Once the malicious request crossed the handler boundary, TokenGateway executed the ChangeAssetAdmin action and handed DOT admin privileges to the attacker helper.

Vulnerable Function

The proof-validation entrypoint is handlePostRequests(IIsmpHost host, PostRequestMessage calldata request) in src/modules/HandlerV1.sol. Its runtime selector is 0x9d38eb35, which matches the expanded ABI signature handlePostRequests(address,(((uint256,uint256),bytes32[],uint256),((bytes,bytes,uint64,bytes,bytes,uint64,bytes),uint256,uint256)[])).

The privileged sink reached from that entrypoint is handleChangeAssetAdmin(PostRequest calldata request) in src/modules/TokenGateway.sol. It is reached through onAccept(((bytes,bytes,uint64,bytes,bytes,uint64,bytes),address)) (0x0fee32ce) and ultimately calls changeAdmin(address) on the ERC6160 DOT token (0x8f283970).

Vulnerable Code

The actual root cause is the MMR verifier bug below. The two exact failure points are:

  • leavesForSubtree(...) excludes a leaf when leaf_index == subtree boundary
  • CalculateRoot(...) then accepts the next proof node as the subtree root even though no leaf hash was incorporated

MMR verifier bug in MerkleMountainRange:

function CalculateRoot(
    bytes32[] memory proof,
    MmrLeaf[] memory leaves,
    uint256 leafCount
) internal pure returns (bytes32) {
    uint256[] memory subtrees = subtreeHeights(leafCount);
    Iterator memory peakRoots = Iterator(0, new bytes32[](subtrees.length));
    Iterator memory proofIter = Iterator(0, proof);

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

        MmrLeaf[] memory subtreeLeaves = new MmrLeaf[](0);
        if (leaves.length > 0) {
            (subtreeLeaves, leaves) = leavesForSubtree(leaves, current_subtree);
        }

        if (subtreeLeaves.length == 0) {
            push(peakRoots, next(proofIter)); // <-- VULNERABILITY: a proof node becomes the peak root even though no leaf hash was checked
        } else if (subtreeLeaves.length == 1 && height == 0) {
            push(peakRoots, subtreeLeaves[0].hash);
        } else {
            push(peakRoots, CalculateSubtreeRoot(subtreeLeaves, proofIter, height));
        }
    }
}

function leavesForSubtree(
    MmrLeaf[] memory leaves,
    uint256 leafIndex
) internal pure returns (MmrLeaf[] memory, MmrLeaf[] memory) {
    uint256 p;
    uint256 length = leaves.length;
    for (; p < length; p++) {
        if (leafIndex <= leaves[p].leaf_index) { // <-- VULNERABILITY: `==` drops an out-of-range forged leaf at the subtree boundary
            break;
        }
    }
    ...
}

HandlerV1 is the trust boundary that feeds attacker-controlled indices into that buggy verifier:

function handlePostRequests(IIsmpHost host, PostRequestMessage calldata request) external notFrozen(host) {
    uint256 timestamp = block.timestamp;
    uint256 delay = timestamp - host.stateMachineCommitmentUpdateTime(request.proof.height);
    uint256 challengePeriod = host.challengePeriod();
    if (challengePeriod != 0 && challengePeriod > delay) revert ChallengePeriodNotElapsed();

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

    for (uint256 i = 0; i < requestsLen; ++i) {
        PostRequestLeaf memory leaf = request.requests[i];
        if (!leaf.request.dest.equals(host.host())) revert InvalidMessageDestination();
        if (timestamp >= leaf.request.timeout()) revert MessageTimedOut();
        bytes32 commitment = leaf.request.hash();
        if (host.requestReceipts(commitment) != address(0)) revert DuplicateMessage();

        leaves[i] = MmrLeaf(leaf.kIndex, leaf.index, commitment); // <-- untrusted `index` / `kIndex` are copied directly into verifier input
    }

    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(); // <-- library bug turns the forged leaf/proof tuple into an accepted request here

    for (uint256 i = 0; i < requestsLen; ++i) {
        PostRequestLeaf memory leaf = request.requests[i];
        host.dispatchIncoming(leaf.request, _msgSender()); // <-- once accepted, the forged request is delivered to privileged downstream modules
    }
}

TokenGateway is not where the proof bug lives, but it is the first privileged sink that turns the verifier bypass into protocol compromise:

function handleChangeAssetAdmin(PostRequest calldata request) internal {
    if (!request.source.equals(IIsmpHost(_params.host).hyperbridge())) revert UnauthorizedAction();
    // <-- only `request.source == hyperbridge()` is checked here; no additional gateway-instance authentication applies

    ChangeAssetAdmin memory asset = abi.decode(request.body[1:], (ChangeAssetAdmin));
    address erc6160Address = _erc6160s[asset.assetId];

    if (asset.newAdmin == address(0)) revert ZeroAddress();
    if (erc6160Address == address(0)) revert UnknownAsset();

    IERC6160Ext20(erc6160Address).changeAdmin(asset.newAdmin); // <-- attacker-controlled admin transfer
}

ERC6160Ext20 is the final privilege-amplification sink: once admin is reassigned, unlimited minting follows immediately.

function changeAdmin(address newAdmin) public {
    if (!_isRoleAdmin(MINTER_ROLE) || !_isRoleAdmin(BURNER_ROLE)) revert NotRoleAdmin();

    delete _rolesAdmin[MINTER_ROLE][_msgSender()];
    delete _rolesAdmin[BURNER_ROLE][_msgSender()];

    if (newAdmin == address(0)) {
        return;
    }

    _rolesAdmin[MINTER_ROLE][newAdmin] = true; // <-- attacker becomes MINTER_ROLE admin
    _rolesAdmin[BURNER_ROLE][newAdmin] = true;
}

function mint(address _to, uint256 _amount) public {
    if (!_isRoleAdmin(MINTER_ROLE) && !hasRole(MINTER_ROLE, _msgSender())) revert PermissionDenied();
    super._mint(_to, _amount); // <-- newly assigned admin can mint immediately
}

Why It’s Vulnerable

Expected behavior: only an authentic Hyperbridge-governance message, cryptographically bound to a valid remote state commitment, should ever reach TokenGateway.handleChangeAssetAdmin. After that, only the legitimate bridge admin should be able to move MINTER/BURNER admin rights on the bridged DOT asset.

Actual behavior: in this transaction, handlePostRequests accepted a PostRequestMessage whose single leaf decoded to source = "POLKADOT-3367", dest = "EVM-1", to = 0xFd413e3AFe560182C4471F4d143A96d3e259B6dE, action = 0x04 (ChangeAssetAdmin), assetId = 0x9bd00430e53a5999c7c603cfc04cbdaf68bdbc180f300e4a2067937f57a0534f, and newAdmin = 0x31a165a956842ab783098641db25c7a9067ca9ab. At block 24868295, TokenGateway.erc6160(assetId) resolves that asset ID to DOT at 0x8d010bf9C26881788b4e6bf5Fd1bdC358c8F90b8. Once the forged request was accepted, TokenGateway only checked that request.source matched host.hyperbridge() and then called DOT.changeAdmin(attackerHelper). Unlike the incoming-asset paths, this governance/admin path is not gated by the authenticate(request) modifier that checks a known gateway instance.

Why that matters: ERC6160Ext20.changeAdmin makes the new admin an admin of MINTER_ROLE, and mint() explicitly allows any MINTER admin to mint without a separate grant. The trace proves the exact sequence: handlePostRequests succeeded, dispatchIncoming invoked onAccept, onAccept called changeAdmin, and the attacker helper then immediately called mint(address,uint256) for 1_000_000_000 ether. In other words, the proof-validation boundary failed open, and the first privileged sink behind it was catastrophic because it transferred mint authority on a live bridged asset.

Normal flow vs Attack flow:

StepNormal governance flowAttack flow
Proof validationA genuine cross-chain governance message is cryptographically bound to a real request commitment and passes VerifyProof(...) because the leaf is actually included in the committed overlay rootThe attacker abuses the MMR index bug so the forged leaf is ignored, and the stored overlayRoot itself is accepted as the proof result
Trust boundaryHandlerV1 only dispatches an authentic request that was really committed on the source chainHandlerV1 dispatches a forged request that merely satisfies the buggy acceptance predicates
Gateway authorizationTokenGateway receives a real Hyperbridge-originated governance message and applies an intended admin actionTokenGateway receives a forged request with source = host.hyperbridge() and treats it as trusted governance
Asset administrationBridged DOT admin remains under legitimate bridge governance controlDOT admin is reassigned to the attacker helper
Economic outcomeNo unexpected minting; supply changes only through intended bridge-controlled actionsThe attacker helper mints 1,000,000,000 DOT and dumps it for 108.206143512481490001 ETH

How the Forged Request Was Accepted

There are two separate “validity” layers in this exploit, and the report should have spelled both out more explicitly:

  1. Valid to HandlerV1

    • The attacker passed the real Ethereum host 0x792A6236AF69787C40cf76B69B4C8c7b28c4Ca20 as the host argument.
    • host.frozen() returned false.
    • host.challengePeriod() returned 0, so the delay gate was effectively disabled for this path.
    • The forged request used dest = "EVM-1", and host.host() also returned "EVM-1", so the destination check passed.
    • The forged request used timeoutTimestamp = 0; in Message.timeout(...), zero means “no timeout”, so the timeout check passed automatically.
    • host.requestReceipts(commitment) returned 0x0, so the request was not considered a replay.
    • The crucial verifier bug is in the MMR library, not in some hidden off-chain cryptography step. The calldata supplied proof.height = (3367, 9775932), leafCount = 1, index = 1, kIndex = 0, and multiproof = [0x466dddba7e9a84a0f2632b59be71b8bd489e3334a1314a61253f8b827c9d3a36].
    • HandlerV1 converts the forged request into MmrLeaf(leaf.kIndex, leaf.index, commitment) and calls MerkleMountainRange.VerifyProof(...).
    • For leafCount = 1, the MMR code creates a single subtree with boundary 1. Because leavesForSubtree(...) breaks when leafIndex <= leaves[p].leaf_index, a forged leaf.index = 1 causes that only leaf to be excluded from the subtree entirely.
    • Once subtreeLeaves.length == 0, the library does not verify the forged leaf hash. It simply consumes the next proof element and uses that as the peak root. In this transaction, the attacker set proof[0] equal to the host’s stored overlayRoot, so the computed root trivially matched the expected root.
    • In other words, the attacker did not find a real inclusion proof for the forged message. They exploited an index-handling bug so the verifier ignored the message leaf and accepted the stored root itself as the “proof.”
  2. Valid to TokenGateway

    • After HandlerV1 dispatched the request, TokenGateway.onAccept(...) decoded the first byte of the body as action 0x04, i.e. ChangeAssetAdmin.
    • For this governance/admin path, TokenGateway does not use the authenticate(request) modifier that checks known gateway instances. That stricter check is only used on incoming-asset paths.
    • Instead, handleChangeAssetAdmin(...) only checks request.source.equals(IIsmpHost(_params.host).hyperbridge()).
    • The forged request set source = "POLKADOT-3367", and host.hyperbridge() returned the same "POLKADOT-3367", so this authorization check also passed.
    • The remainder of the body decoded cleanly to assetId = 0x9bd00430e53a5999c7c603cfc04cbdaf68bdbc180f300e4a2067937f57a0534f and newAdmin = 0x31a165a956842ab783098641db25c7a9067ca9ab, which TokenGateway then executed.

So, in concrete terms, the attacker did not need to satisfy some hidden admin check, and they did not need a cryptographically correct proof of inclusion for the forged request. They only needed to:

  • query a height whose overlayRoot was already stored on-chain,
  • set the forged leaf index so the buggy MMR verifier would ignore the leaf,
  • pass the stored overlayRoot back as the single proof node, and
  • set the message fields so the downstream governance path recognized it as Hyperbridge-originated.

One important limit of the transaction-level evidence: this tx proves which predicates were satisfied on-chain, but it does not identify the first party who discovered the bug or every off-chain tool the attacker used. What Ethereum does prove is enough to explain the exploit mechanically: the attacker targeted an already-stored state commitment height and supplied a forged leaf/proof tuple that the buggy verifier accepted because it never actually bound the forged request commitment into the computed root.

Attack Execution

High-Level Flow

  1. The attacker EOA 0xC513E4f5D7a93A1Dd5B7C4D9f6cC2F52d2F1F8E7 deploys ExploitMaster and, from its constructor path, deploys ExploitHelper.
  2. ExploitHelper.run() submits a forged handlePostRequests(...) batch to HandlerV1 using the Hyperbridge host 0x792A6236AF69787C40cf76B69B4C8c7b28c4Ca20 as the host argument.
  3. HandlerV1 accepts the batch, verifies the supplied proof against the stored overlay root, and dispatches the included request into TokenGateway.onAccept(...).
  4. TokenGateway interprets the first byte of request.body as ChangeAssetAdmin, decodes the payload, and calls DOT.changeAdmin(attackerHelper).
  5. With admin rights now moved to the helper, the helper mints 1,000,000,000 DOT to itself, approves the Odos router, and swaps the entire minted balance through Odos / Uniswap v4 infrastructure.
  6. The swap returns 108.206143512481490001 ETH to the helper, which forwards the ETH to ExploitMaster, and ExploitMaster forwards the ETH to the attacker EOA.

Detailed Call Trace

The following call flow is derived directly from trace_callTracer.json:

  1. 0xC513...F8E7 -> 0x518AB393...8f26 via CREATE
  2. 0x518AB393...8f26 -> 0x31a165a9...a9AB via CREATE
  3. 0x518AB393...8f26 -> 0x31a165a9...a9AB via CALL run() (0xc0406226)
  4. 0x31a165a9...a9AB -> 0x6c84eDd2...6D64 via CALL handlePostRequests(...) (0x9d38eb35)
  5. 0x6c84eDd2...6D64 -> 0x792A6236...Ca20 via STATICCALL stateMachineCommitmentUpdateTime((uint256,uint256)) (0x1a880a93)
  6. 0x6c84eDd2...6D64 -> 0x792A6236...Ca20 via STATICCALL challengePeriod() (0xf3f480d9)
  7. 0x6c84eDd2...6D64 -> 0x792A6236...Ca20 via STATICCALL requestReceipts(bytes32) (0x19667a3e)
  8. 0x6c84eDd2...6D64 -> 0x792A6236...Ca20 via STATICCALL stateMachineCommitment((uint256,uint256)) (0xa70a8c47)
  9. 0x6c84eDd2...6D64 -> 0x792A6236...Ca20 via CALL dispatchIncoming((bytes,bytes,uint64,bytes,bytes,uint64,bytes),address) (0xb85e6fbb)
  10. 0x792A6236...Ca20 -> 0xFd413e3A...B6dE via CALL onAccept(((bytes,bytes,uint64,bytes,bytes,uint64,bytes),address)) (0x0fee32ce)
  11. 0xFd413e3A...B6dE -> 0x792A6236...Ca20 via STATICCALL hyperbridge() (0x005e763e)
  12. 0xFd413e3A...B6dE -> 0x8d010bf9...90b8 via CALL changeAdmin(address) (0x8f283970)
  13. 0x31a165a9...a9AB -> 0x8d010bf9...90b8 via CALL mint(address,uint256) (0x40c10f19)
  14. 0x31a165a9...a9AB -> 0x8d010bf9...90b8 via CALL approve(address,uint256) (0x095ea7b3)
  15. 0x31a165a9...a9AB -> 0x0D05A7d3...0D05 via CALL swap((address,uint256,address,address,uint256,uint256,address),bytes,address,(uint64,uint64,address)) (0x30f80b4c)
  16. 0x0D05A7d3...0D05 -> 0x365084b0...b5b8 via CALL executePath(bytes,uint256[],address) (0xcb70e273)
  17. 0x365084b0...b5b8 -> 0x000000000004444c5dc75cb358380d2e3de08a90 via CALL unlock(bytes) (0x48c89491)
  18. 0x000000000004444c5dc75cb358380d2e3de08a90 -> 0x365084b0...b5b8 via CALL unlockCallback(bytes) (0x91dd7346)
  19. 0x365084b0...b5b8 -> 0x1c4404A6...76ff via DELEGATECALL unlockCallback(bytes) (0x91dd7346)
  20. Downstream Uniswap v4 pool-manager calls execute swap, take, sync, transfer, and settle, then ETH is paid back to the helper.
  21. 0x31a165a9...a9AB -> 0x518AB393...8f26 via plain ETH CALL (108.206143512481490001 ETH)
  22. 0x518AB393...8f26 -> 0xC513...F8E7 via plain ETH CALL (108.206143512481490001 ETH)

Financial Impact

The deterministic on-chain profit is 108.206143512481490001 ETH, as computed in funds_flow.json. The helper minted exactly 1,000,000,000 DOT (1e27 raw units with 18 decimals), approved the Odos router, and routed the full amount into swap liquidity. Receipt logs show three DOT Transfer events: mint to the helper, helper to the swap executor, and executor into the Uniswap v4 pool manager path.

The immediate economic loss fell on liquidity counterparties in the Odos / Uniswap v4 route that bought attacker-minted DOT for ETH. Separately, Hyperbridge lost control over the Ethereum-side bridged DOT asset, whose integrity depends on trusted bridge administration. The DOT token’s total supply rose from 356,466.04153486015 to 1,000,356,466.0415348, so the attacker minted roughly 2805.3x the pre-attack supply before selling.

After gas, the attacker still cleared approximately 108.20580483152307 ETH. The direct gas cost for the exploit transaction was only 0.000338680958418962 ETH. Even if the protocol remained online for other assets, the DOT bridge instance was no longer trustworthy once admin and mint authority moved to the attacker helper.

Evidence

  • receipt.json shows status = 0x1 and 8 logs for the exploit transaction.
  • The handlePostRequests(...) calldata decodes to host 0x792A6236AF69787C40cf76B69B4C8c7b28c4Ca20, proof height (3367, 9775932), and a single request whose body begins with 0x04, the ChangeAssetAdmin action.
  • Decoding that body yields assetId = 0x9bd00430e53a5999c7c603cfc04cbdaf68bdbc180f300e4a2067937f57a0534f and newAdmin = 0x31a165a956842ab783098641db25c7a9067ca9ab; at block 24868295, TokenGateway.erc6160(assetId) returns DOT at 0x8d010bf9C26881788b4e6bf5Fd1bdC358c8F90b8.
  • The Hyperbridge host returns the bytes string POLKADOT-3367 from hyperbridge(), matching the forged request’s source field.
  • Receipt log 0 from TokenGateway uses topic 0x82e0cebbbfcea0d10cf649041c20143e863ed85b7e3427ac67cf58dd502426ee, which is AssetAdminChanged(address,address).
  • Receipt log 2 is the DOT Transfer mint from 0x0 to the helper for 1_000_000_000 DOT; log 3 is the DOT Approval for the Odos router.
  • trace_prestateTracer.json shows DOT total supply storage slot 0x3 moving from 356,466.04153486015 tokens to 1,000,356,466.0415348, exactly a 1,000,000,000 DOT increase.
  • Selector checks used in this report: run() -> 0xc0406226, handlePostRequests(address,(((uint256,uint256),bytes32[],uint256),((bytes,bytes,uint64,bytes,bytes,uint64,bytes),uint256,uint256)[])) -> 0x9d38eb35, dispatchIncoming((bytes,bytes,uint64,bytes,bytes,uint64,bytes),address) -> 0xb85e6fbb, onAccept(((bytes,bytes,uint64,bytes,bytes,uint64,bytes),address)) -> 0x0fee32ce, changeAdmin(address) -> 0x8f283970, mint(address,uint256) -> 0x40c10f19, and Odos swap((address,uint256,address,address,uint256,uint256,address),bytes,address,(uint64,uint64,address)) -> 0x30f80b4c.