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 attack is a two-step sequence: plant, then trigger.
Role | Transaction hash | Block | Purpose |
|---|---|---|---|
Decoy |
| 25137016 | A genuine cross-chain message, deliberately made to fail, so the bridge stores it for retry |
Exploit |
| 25137572 | A |
Roughly 90 minutes — 556 blocks — separated the two.
Key addresses
Butter Bridge V3.1 (proxy):
0x0000317Bec33Af037b5fAb2028f52d14658F6A56Bridge implementation at exploit time:
0x12bfb3b58ad02a0df40ee7186d26266c52d0109cMAPO token:
0x66d79b8f60ec93bfce0b56f5ac14a2714e509a99Attacker wallet:
0x40592025392bd7d7463711c6e82ed34241b64279Shared order ID:
0xf2fbaa8a33bc05e0454299f2d43ed99fdb5cf024770484bbb598ace5e0c7d4a4
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:
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.
The hash is built with
abi.encodePackedover four consecutivebytesfields. Unlikeabi.encode,abi.encodePackedwrites raw concatenated bytes with no length prefixes. Given the output, you cannot recover where one field ended and the next began.
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 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
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.
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.
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.
The Map Protocol bridge authenticated retried cross-chain messages with
keccak256(abi.encodePacked(initiator, from, to, swapData))— four adjacent variable-lengthbytesfields. Becauseabi.encodePackedemits no length delimiters, the field boundaries were never part of the hash. The attacker registered a benign message whoseswapDatasecretly contained the MAPO token address and a mint payload, then "retried" it with re-declared field lengths that reparsed the identical byte string withtopointing at the MAPO token andswapDatareduced 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 toMAPO.mapoExecuteminted 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 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.
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
toaddress equal to the bridge itself, or to a core system token, is a strong anomaly and could be rejected outright.Forbid
abi.encodePackedwith adjacent dynamic arguments. This belongs in every smart-contract linter and review checklist for any hashing or signing preimage.
Block | Event |
|---|---|
25137016 | Decoy transaction stores commitment |
25137572 | Exploit transaction retries with the colliding split; one quadrillion MAPO minted |
abi.encodePackedis 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.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.
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.
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.
