The Map Protocol Bridge Exploit: Unraveling the Mystery Behind the One Quadrillion MAPO Minting

Uncovering the Secrets of the One Quadrillion MAPO Minting: A Deep Dive into the Map Protocol Bridge Exploit

Summary

On 20 May 2026, an attacker minted 1,000,000,000,000,000 MAPO — one quadrillion tokens, roughly 4.8 million times the legitimate circulating supply — directly into their own wallet by exploiting the cross-chain message retry path of the Map Protocol bridge (Butter Network) on Ethereum.

There was no stolen key, no broken light client, and no bug in the MAPO token. The entire incident traces to a single, well-documented Solidity pitfall: using abi.encodePacked over several consecutive variable-length fields as the preimage of an authentication hash.

abi.encodePacked glues its arguments together with no length markers. Once two or more dynamic fields sit side by side, the boundaries between them vanish from the output — and therefore from the hash. Two genuinely different messages can collapse to the same hash.

The attacker turned that ambiguity into a forgery. They registered one harmless message with the bridge, then "retried" it as an entirely different message — one that redirected the bridge's call onto the MAPO token contract and carried a mint instruction — all while the authenticating hash stayed identical.


The two transactions

The attack is a two-step sequence: plant, then trigger.

Role

Transaction hash

Block

Purpose

Decoy

0xfe9b130aeeabdef616a5ed4b100944b33f10d685b892564e4a8622c080656de8

25137016

A genuine cross-chain message, deliberately made to fail, so the bridge stores it for retry

Exploit

0x31e56b4737649e0acdb0ebb4eca44d16aeca25f60c022cbde85f092bde27664a

25137572

A retryMessageIn call whose re-sliced calldata collides with the decoy's stored hash

Roughly 90 minutes — 556 blocks — separated the two.

Key addresses

  • Butter Bridge V3.1 (proxy): 0x0000317Bec33Af037b5fAb2028f52d14658F6A56

  • Bridge implementation at exploit time: 0x12bfb3b58ad02a0df40ee7186d26266c52d0109c

  • MAPO token: 0x66d79b8f60ec93bfce0b56f5ac14a2714e509a99

  • Attacker wallet: 0x40592025392bd7d7463711c6e82ed34241b64279

  • Shared order ID: 0xf2fbaa8a33bc05e0454299f2d43ed99fdb5cf024770484bbb598ace5e0c7d4a4


Background: how bridge message retries work

When the Map Protocol bridge receives a verified cross-chain message, it delivers it by calling the recipient contract's mapoExecute handler. If that delivery fails — for instance because the target address has no contract code — the bridge does not discard the message. It keeps a record so the message can be retried later.

To save storage, the bridge does not keep the whole message. It stores a single 32-byte hash:

// _storeMessageData — writes the retry commitment
orderList[_inEvent.orderId] = uint256(keccak256(abi.encodePacked(
    _inEvent.messageType,   // uint8   — fixed width
    _inEvent.fromChain,     // uint256 — fixed width
    _inEvent.toChain,       // uint256 — fixed width
    _inEvent.token,         // address — fixed width
    _inEvent.amount,        // uint256 — fixed width
    _inEvent.gasLimit,      // uint256 — fixed width
    _initiator,             // bytes   — VARIABLE LENGTH
    _inEvent.from,          // bytes   — VARIABLE LENGTH
    _inEvent.to,            // bytes   — VARIABLE LENGTH
    _inEvent.swapData       // bytes   — VARIABLE LENGTH
)));

On retry, the caller resubmits the message data, and the bridge re-derives the hash and compares it:

// _getStoredMessage — checks the retry commitment
bytes32 retryHash = keccak256(abi.encodePacked(
    inEvent.messageType, inEvent.fromChain, inEvent.toChain,
    _token, _amount, inEvent.gasLimit,
    initiator,        // bytes — VARIABLE LENGTH
    _fromAddress,     // bytes — VARIABLE LENGTH
    inEvent.to,       // bytes — VARIABLE LENGTH
    inEvent.swapData  // bytes — VARIABLE LENGTH
));
if (uint256(retryHash) != orderList[_orderId]) revert retry_verify_fail();

Two design decisions, each defensible alone, combine into disaster:

  1. The retry path performs no light-client re-verification. The original cross-chain proof was checked once, at first delivery. On retry, the hash comparison is the only security gate.

  2. The hash is built with abi.encodePacked over four consecutive bytes fields. Unlike abi.encode, abi.encodePacked writes raw concatenated bytes with no length prefixes. Given the output, you cannot recover where one field ended and the next began.


The vulnerability

For dynamic values, abi.encodePacked(a, b) is simply a followed by b. So:

encodePacked("RED", "FOX")  ==  encodePacked("REDF", "OX")  ==  "REDFOX"

All three yield the same bytes, and therefore the same keccak256. The hash authenticates the concatenation of the four dynamic fields — not the fields themselves.

The consequence: anyone who can register one commitment can later satisfy the retry check with any message whose four dynamic fields concatenate to the same total byte string — no matter where the field boundaries fall. Sliding those boundaries changes what each field means while the hash stays fixed.

The six fixed-width fields (messageType, fromChain, toChain, token, amount, gasLimit) are safe — they occupy a known number of bytes and cannot be reinterpreted. The whole attack lives in the four-field dynamic tail.


The attack, step by step

Step 1 — Plant the decoy

The attacker originated a real cross-chain message from the Map relay chain (chain ID 22776) to Ethereum (chain ID 1). Being genuine, it passed full light-client verification on arrival.

The attacker set the message's delivery target (to) to the bridge's own address. Delivery to that address fails, so the bridge recorded the failure (reason = "NotContract") and ran _storeMessageData, writing the commitment hash into storage.

The critical move: the attacker planted the MAPO token address, plus a relay address, inside the decoy's swapData field, followed by an ABI-encoded mint payload. Within the decoy's own field layout, those bytes were just opaque swapData content. They were positioned for the next step.

The decoy wrote this value into the bridge's orderList storage:

orderList[0xf2fbaa8a…d4a4] = 0xf6e32d1bade0d41d54a9ab057a5df5cb87be81130156d5cc06251a06496caf75

Step 2 — Retry with re-sliced fields

The attacker then called retryMessageIn. The calldata supplied the same total dynamic byte content as the decoy, but declared different field lengths — so the bridge's decoder cut the bytes at different boundaries.

The fixed prefix is identical for both transactions. The 452-byte dynamic tail is the same sequence of bytes, parsed two ways:

The committed dynamic blob (452 bytes):

  2475…1c30 2475…1c30 2475…1c30 │ 1ad1a4…6167 │ 66d79b…9a99 │ 1de78e…[mint payload]
  └───────── 60 bytes ────────┘   └── 20 ──┘    └── 20 ──┘     └──── 352 bytes ────┘


DECOY split — _storeMessageData     initiator(20) │ from(20) │ to(20) │ swapData(392)

   initiator = 0x2475…1c30
   from      = 0x2475…1c30
   to        = 0x2475…1c30        ← the BRIDGE itself; delivery fails, message is stored
   swapData  = 1ad1a4…6167 ‖ 66d79b…9a99 ‖ 1de78e…[mint payload]
                                  ↑ MAPO token address hidden inside swapData


EXPLOIT split — _getStoredMessage   initiator(60) │ from(20) │ to(20) │ swapData(352)

   initiator = 0x2475…1c30 2475…1c30 2475…1c30   ← padded to 60 bytes
   from      = 0x1ad1a4…6167
   to        = 0x66d79b…9a99      ← the MAPO TOKEN; bridge will call mapoExecute on it
   swapData  = 1de78e…[mint payload]

The attacker grew initiator from 20 to 60 bytes. That 40-byte expansion slides the to window forward by exactly 40 bytes — off the harmless bridge address and onto 0x66d79b…9a99, the MAPO token, which had been pre-planted inside the decoy's swapData. The swapData field shrinks correspondingly, from 392 down to 352 bytes, shedding the two addresses now absorbed by the shifted boundaries and leaving exactly the mint payload.

Same bytes. abi.encodePacked records no boundaries. Both splits produce the identical hash — 0xf6e32d1b…caf75 — so _getStoredMessage's only check, retryHash != orderList[_orderId], passes.

Step 3 — The mint

With the retry check bypassed, the bridge proceeded to deliver the message. For a MESSAGE-type message, it runs its hardcoded delivery call:

IMapoExecutor(to).mapoExecute{gas: gasLimit}(fromChain, toChain, from, orderId, swapData);

to was now the MAPO token. MAPO is a bridge-native token: it exposes a mapoExecute handler precisely so the bridge can mint tokens for arriving cross-chain transfers — it mints when, and because, its trusted bridge calls it. The bridge dutifully called MAPO.mapoExecute, forwarding the 352-byte mint payload. Decoded, that payload carried:

  • an amount of 10¹⁵ × 10¹⁸ — one quadrillion MAPO, in wei

  • a recipient of 0x40592025392bd7d7463711c6e82ed34241b64279 — the attacker

MAPO minted one quadrillion tokens to the attacker. The bridge then marked the order consumed, flipping its orderList slot from the stored hash to 0x01.

The attacker never called a mint function. They pointed the bridge's own trusted hand at it.


What each message actually asked for

The clearest way to see the exploit is to read both messages as instructions to the bridge.

The decoy said:

deliver a generic MESSAGE
  to       = the bridge contract
  swapData = (opaque blob)

result: delivery fails → bridge stores hash(message) for retry

The decoy did not want value. Its only goal was to get a hash on file — and to smuggle the MAPO token address into a stored field.

The exploit said:

retry the stored message
  to       = the MAPO token contract
  swapData = mint 10^15 MAPO to the attacker

result: hash matches the decoy's → check passes
        → bridge calls MAPO.mapoExecute(mint payload)
        → MAPO mints 10^15 to the attacker

Side by side:

                DECOY                          EXPLOIT
  to       →    "the bridge"              →     "the MAPO token"
  swapData →    opaque blob               →     "mint 10^15 to attacker"
  goal     →    fail, get a hash stored   →     pass the hash check, mint
  ──────────────────────────────────────────────────────────────────────
  hash     →    0xf6e32d1b…caf75          ==    0xf6e32d1b…caf75

The attacker authored the decoy backwards from the exploit. They first decided what they wanted — to = MAPO, swapData = mint payload — worked out the exact byte string that produces, then built a harmless-looking decoy whose fields concatenate to that very same string. Plant the hash with one reading of the bytes; cash it with the other.


Root cause

The Map Protocol bridge authenticated retried cross-chain messages with keccak256(abi.encodePacked(initiator, from, to, swapData)) — four adjacent variable-length bytes fields. Because abi.encodePacked emits no length delimiters, the field boundaries were never part of the hash. The attacker registered a benign message whose swapData secretly contained the MAPO token address and a mint payload, then "retried" it with re-declared field lengths that reparsed the identical byte string with to pointing at the MAPO token and swapData reduced to the mint payload — producing the same hash. Because the retry path's only check was that hash, the forged message was accepted, and the bridge's trusted call to MAPO.mapoExecute minted one quadrillion MAPO.

It is the textbook abi.encodePacked collision hazard — explicitly warned against in the Solidity documentation — applied to a security-critical authentication path.


The fix

The minimal and correct fix is to use abi.encode instead of abi.encodePacked in both _storeMessageData and _getStoredMessage:

// VULNERABLE — no length delimiters; field boundaries are not committed
keccak256(abi.encodePacked(
    messageType, fromChain, toChain, token, amount, gasLimit,
    initiator, from, to, swapData
));

// FIXED — abi.encode length-prefixes every dynamic field, so the
// boundaries become part of the preimage; no re-slice can collide
keccak256(abi.encode(
    messageType, fromChain, toChain, token, amount, gasLimit,
    initiator, from, to, swapData
));

Both functions must change together — the writer and the reader have to agree on the encoding.

Defense in depth

The encoding fix closes the specific hole. Several further measures would have contained the damage:

  • Re-verify on retry, or bind the retry to the original verified message. The retry path trusted a hash as a stand-in for light-client verification. Binding the retry to the original verified message digest removes the entire "forge a matching preimage" class of attack.

  • Cap mintable-token issuance. Mintable bridge tokens reported effectively unlimited capacity. A per-message or rate-limited mint ceiling, or an invariant tying minted supply to locked collateral, would have turned a catastrophic mint into a bounded one.

  • Validate the delivery target. A to address equal to the bridge itself, or to a core system token, is a strong anomaly and could be rejected outright.

  • Forbid abi.encodePacked with adjacent dynamic arguments. This belongs in every smart-contract linter and review checklist for any hashing or signing preimage.


Timeline

Block

Event

25137016

Decoy transaction stores commitment 0xf6e32d1b…caf75

25137572

Exploit transaction retries with the colliding split; one quadrillion MAPO minted


Lessons

  1. abi.encodePacked is not a serialization format. It is a concatenation primitive. The moment two or more dynamic fields sit next to each other, the result is ambiguous — and using it as a hash or signature preimage turns that ambiguity into a forgery primitive.

  2. A cheap "we already verified this" shortcut is only as strong as the shortcut. The retry path swapped a cryptographic proof for a hash comparison. The hash was weaker than the proof it replaced, and that gap was the whole vulnerability.

  3. Mintable cross-chain tokens need a supply invariant. Unbounded mint authority turns any message-forgery bug into an unbounded-loss bug. A cap turns a catastrophe into an incident.

  4. Severity is a chain of links. The collision was the entry point; the missing retry re-verification made the collision sufficient; the uncapped mintable token set the loss to "infinite." Breaking any single link would have changed the outcome — which is exactly why all of them deserve fixing.