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 whenleaf_index == subtree boundaryCalculateRoot(...)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:
| Step | Normal governance flow | Attack flow |
|---|---|---|
| Proof validation | A 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 root | The attacker abuses the MMR index bug so the forged leaf is ignored, and the stored overlayRoot itself is accepted as the proof result |
| Trust boundary | HandlerV1 only dispatches an authentic request that was really committed on the source chain | HandlerV1 dispatches a forged request that merely satisfies the buggy acceptance predicates |
| Gateway authorization | TokenGateway receives a real Hyperbridge-originated governance message and applies an intended admin action | TokenGateway receives a forged request with source = host.hyperbridge() and treats it as trusted governance |
| Asset administration | Bridged DOT admin remains under legitimate bridge governance control | DOT admin is reassigned to the attacker helper |
| Economic outcome | No unexpected minting; supply changes only through intended bridge-controlled actions | The 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:
Valid to
HandlerV1- The attacker passed the real Ethereum host
0x792A6236AF69787C40cf76B69B4C8c7b28c4Ca20as thehostargument. host.frozen()returnedfalse.host.challengePeriod()returned0, so the delay gate was effectively disabled for this path.- The forged request used
dest = "EVM-1", andhost.host()also returned"EVM-1", so the destination check passed. - The forged request used
timeoutTimestamp = 0; inMessage.timeout(...), zero means “no timeout”, so the timeout check passed automatically. host.requestReceipts(commitment)returned0x0, 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, andmultiproof = [0x466dddba7e9a84a0f2632b59be71b8bd489e3334a1314a61253f8b827c9d3a36]. HandlerV1converts the forged request intoMmrLeaf(leaf.kIndex, leaf.index, commitment)and callsMerkleMountainRange.VerifyProof(...).- For
leafCount = 1, the MMR code creates a single subtree with boundary1. BecauseleavesForSubtree(...)breaks whenleafIndex <= leaves[p].leaf_index, a forgedleaf.index = 1causes 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 setproof[0]equal to the host’s storedoverlayRoot, 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.”
- The attacker passed the real Ethereum host
Valid to
TokenGateway- After
HandlerV1dispatched the request,TokenGateway.onAccept(...)decoded the first byte of the body as action0x04, i.e.ChangeAssetAdmin. - For this governance/admin path,
TokenGatewaydoes not use theauthenticate(request)modifier that checks known gateway instances. That stricter check is only used on incoming-asset paths. - Instead,
handleChangeAssetAdmin(...)only checksrequest.source.equals(IIsmpHost(_params.host).hyperbridge()). - The forged request set
source = "POLKADOT-3367", andhost.hyperbridge()returned the same"POLKADOT-3367", so this authorization check also passed. - The remainder of the body decoded cleanly to
assetId = 0x9bd00430e53a5999c7c603cfc04cbdaf68bdbc180f300e4a2067937f57a0534fandnewAdmin = 0x31a165a956842ab783098641db25c7a9067ca9ab, whichTokenGatewaythen executed.
- After
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
overlayRootwas already stored on-chain, - set the forged leaf index so the buggy MMR verifier would ignore the leaf,
- pass the stored
overlayRootback 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
- The attacker EOA
0xC513E4f5D7a93A1Dd5B7C4D9f6cC2F52d2F1F8E7deploysExploitMasterand, from its constructor path, deploysExploitHelper. ExploitHelper.run()submits a forgedhandlePostRequests(...)batch toHandlerV1using the Hyperbridge host0x792A6236AF69787C40cf76B69B4C8c7b28c4Ca20as the host argument.HandlerV1accepts the batch, verifies the supplied proof against the stored overlay root, and dispatches the included request intoTokenGateway.onAccept(...).TokenGatewayinterprets the first byte ofrequest.bodyasChangeAssetAdmin, decodes the payload, and callsDOT.changeAdmin(attackerHelper).- With admin rights now moved to the helper, the helper mints
1,000,000,000DOT to itself, approves the Odos router, and swaps the entire minted balance through Odos / Uniswap v4 infrastructure. - The swap returns
108.206143512481490001ETH to the helper, which forwards the ETH toExploitMaster, andExploitMasterforwards the ETH to the attacker EOA.
Detailed Call Trace
The following call flow is derived directly from trace_callTracer.json:
0xC513...F8E7->0x518AB393...8f26viaCREATE0x518AB393...8f26->0x31a165a9...a9ABviaCREATE0x518AB393...8f26->0x31a165a9...a9ABviaCALLrun()(0xc0406226)0x31a165a9...a9AB->0x6c84eDd2...6D64viaCALLhandlePostRequests(...)(0x9d38eb35)0x6c84eDd2...6D64->0x792A6236...Ca20viaSTATICCALLstateMachineCommitmentUpdateTime((uint256,uint256))(0x1a880a93)0x6c84eDd2...6D64->0x792A6236...Ca20viaSTATICCALLchallengePeriod()(0xf3f480d9)0x6c84eDd2...6D64->0x792A6236...Ca20viaSTATICCALLrequestReceipts(bytes32)(0x19667a3e)0x6c84eDd2...6D64->0x792A6236...Ca20viaSTATICCALLstateMachineCommitment((uint256,uint256))(0xa70a8c47)0x6c84eDd2...6D64->0x792A6236...Ca20viaCALLdispatchIncoming((bytes,bytes,uint64,bytes,bytes,uint64,bytes),address)(0xb85e6fbb)0x792A6236...Ca20->0xFd413e3A...B6dEviaCALLonAccept(((bytes,bytes,uint64,bytes,bytes,uint64,bytes),address))(0x0fee32ce)0xFd413e3A...B6dE->0x792A6236...Ca20viaSTATICCALLhyperbridge()(0x005e763e)0xFd413e3A...B6dE->0x8d010bf9...90b8viaCALLchangeAdmin(address)(0x8f283970)0x31a165a9...a9AB->0x8d010bf9...90b8viaCALLmint(address,uint256)(0x40c10f19)0x31a165a9...a9AB->0x8d010bf9...90b8viaCALLapprove(address,uint256)(0x095ea7b3)0x31a165a9...a9AB->0x0D05A7d3...0D05viaCALLswap((address,uint256,address,address,uint256,uint256,address),bytes,address,(uint64,uint64,address))(0x30f80b4c)0x0D05A7d3...0D05->0x365084b0...b5b8viaCALLexecutePath(bytes,uint256[],address)(0xcb70e273)0x365084b0...b5b8->0x000000000004444c5dc75cb358380d2e3de08a90viaCALLunlock(bytes)(0x48c89491)0x000000000004444c5dc75cb358380d2e3de08a90->0x365084b0...b5b8viaCALLunlockCallback(bytes)(0x91dd7346)0x365084b0...b5b8->0x1c4404A6...76ffviaDELEGATECALLunlockCallback(bytes)(0x91dd7346)- Downstream Uniswap v4 pool-manager calls execute
swap,take,sync,transfer, andsettle, then ETH is paid back to the helper. 0x31a165a9...a9AB->0x518AB393...8f26via plain ETHCALL(108.206143512481490001ETH)0x518AB393...8f26->0xC513...F8E7via plain ETHCALL(108.206143512481490001ETH)
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.jsonshowsstatus = 0x1and8logs for the exploit transaction.- The
handlePostRequests(...)calldata decodes to host0x792A6236AF69787C40cf76B69B4C8c7b28c4Ca20, proof height(3367, 9775932), and a single request whosebodybegins with0x04, theChangeAssetAdminaction. - Decoding that body yields
assetId = 0x9bd00430e53a5999c7c603cfc04cbdaf68bdbc180f300e4a2067937f57a0534fandnewAdmin = 0x31a165a956842ab783098641db25c7a9067ca9ab; at block24868295,TokenGateway.erc6160(assetId)returns DOT at0x8d010bf9C26881788b4e6bf5Fd1bdC358c8F90b8. - The Hyperbridge host returns the bytes string
POLKADOT-3367fromhyperbridge(), matching the forged request’ssourcefield. - Receipt log
0fromTokenGatewayuses topic0x82e0cebbbfcea0d10cf649041c20143e863ed85b7e3427ac67cf58dd502426ee, which isAssetAdminChanged(address,address). - Receipt log
2is the DOTTransfermint from0x0to the helper for1_000_000_000DOT; log3is the DOTApprovalfor the Odos router. trace_prestateTracer.jsonshows DOT total supply storage slot0x3moving from356,466.04153486015tokens to1,000,356,466.0415348, exactly a1,000,000,000DOT 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 Odosswap((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