<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Kn0t</title>
        <link>https://paragraph.com/@knot</link>
        <description>undefined</description>
        <lastBuildDate>Sun, 05 Jul 2026 17:47:09 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <image>
            <title>Kn0t</title>
            <url>https://storage.googleapis.com/papyrus_images/0c2e6adc8994f7da9adc6e7d8909be7ce3ab71274868fc68603c60517aa0521d.jpg</url>
            <link>https://paragraph.com/@knot</link>
        </image>
        <copyright>All rights reserved</copyright>
        <item>
            <title><![CDATA[The Map Protocol Bridge Exploit: Unraveling the Mystery Behind the One Quadrillion MAPO Minting]]></title>
            <link>https://paragraph.com/@knot/the-map-protocol-bridge-exploit-unraveling-the-mystery-behind-the-one-quadrillion-mapo-minting</link>
            <guid>NOGmfiBxRk7KHIy4SOmp</guid>
            <pubDate>Thu, 21 May 2026 07:26:38 GMT</pubDate>
            <description><![CDATA[SummaryOn 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 va...]]></description>
            <content:encoded><![CDATA[<h2 id="h-summary" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">Summary</h2><p>On 20 May 2026, an attacker minted <strong>1,000,000,000,000,000 MAPO</strong> — one quadrillion tokens, roughly 4.8 million times the legitimate circulating supply — directly into their own wallet by exploiting the cross-chain message <em>retry</em> path of the Map Protocol bridge (Butter Network) on Ethereum.</p><p>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: <strong>using </strong><code>abi.encodePacked</code><strong> over several consecutive variable-length fields as the preimage of an authentication hash.</strong></p><p><code>abi.encodePacked</code> 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.</p><p>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.</p><hr><h2 id="h-the-two-transactions" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">The two transactions</h2><p>The attack is a two-step sequence: plant, then trigger.</p><table><colgroup><col><col><col><col></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Role</p></th><th colspan="1" rowspan="1"><p>Transaction hash</p></th><th colspan="1" rowspan="1"><p>Block</p></th><th colspan="1" rowspan="1"><p>Purpose</p></th></tr><tr><td colspan="1" rowspan="1"><p><strong>Decoy</strong></p></td><td colspan="1" rowspan="1"><p><code>0xfe9b130aeeabdef616a5ed4b100944b33f10d685b892564e4a8622c080656de8</code></p></td><td colspan="1" rowspan="1"><p>25137016</p></td><td colspan="1" rowspan="1"><p>A genuine cross-chain message, deliberately made to fail, so the bridge stores it for retry</p></td></tr><tr><td colspan="1" rowspan="1"><p><strong>Exploit</strong></p></td><td colspan="1" rowspan="1"><p><code>0x31e56b4737649e0acdb0ebb4eca44d16aeca25f60c022cbde85f092bde27664a</code></p></td><td colspan="1" rowspan="1"><p>25137572</p></td><td colspan="1" rowspan="1"><p>A <code>retryMessageIn</code> call whose re-sliced calldata collides with the decoy's stored hash</p></td></tr></tbody></table><p>Roughly 90 minutes — 556 blocks — separated the two.</p><p><strong>Key addresses</strong></p><ul><li><p>Butter Bridge V3.1 (proxy): <code>0x0000317Bec33Af037b5fAb2028f52d14658F6A56</code></p></li><li><p>Bridge implementation at exploit time: <code>0x12bfb3b58ad02a0df40ee7186d26266c52d0109c</code></p></li><li><p>MAPO token: <code>0x66d79b8f60ec93bfce0b56f5ac14a2714e509a99</code></p></li><li><p>Attacker wallet: <code>0x40592025392bd7d7463711c6e82ed34241b64279</code></p></li><li><p>Shared order ID: <code>0xf2fbaa8a33bc05e0454299f2d43ed99fdb5cf024770484bbb598ace5e0c7d4a4</code></p></li></ul><hr><h2 id="h-background-how-bridge-message-retries-work" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">Background: how bridge message retries work</h2><p>When the Map Protocol bridge receives a verified cross-chain message, it delivers it by calling the recipient contract's <code>mapoExecute</code> 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.</p><p>To save storage, the bridge does not keep the whole message. It stores a single 32-byte hash:</p><pre data-type="codeBlock" text="// _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
)));
"><code><span class="hljs-comment">// _storeMessageData — writes the retry commitment</span>
orderList[_inEvent.orderId] <span class="hljs-operator">=</span> <span class="hljs-keyword">uint256</span>(<span class="hljs-built_in">keccak256</span>(<span class="hljs-built_in">abi</span>.<span class="hljs-built_in">encodePacked</span>(
    _inEvent.messageType,   <span class="hljs-comment">// uint8   — fixed width</span>
    _inEvent.fromChain,     <span class="hljs-comment">// uint256 — fixed width</span>
    _inEvent.toChain,       <span class="hljs-comment">// uint256 — fixed width</span>
    _inEvent.token,         <span class="hljs-comment">// address — fixed width</span>
    _inEvent.amount,        <span class="hljs-comment">// uint256 — fixed width</span>
    _inEvent.gasLimit,      <span class="hljs-comment">// uint256 — fixed width</span>
    _initiator,             <span class="hljs-comment">// bytes   — VARIABLE LENGTH</span>
    _inEvent.from,          <span class="hljs-comment">// bytes   — VARIABLE LENGTH</span>
    _inEvent.to,            <span class="hljs-comment">// bytes   — VARIABLE LENGTH</span>
    _inEvent.swapData       <span class="hljs-comment">// bytes   — VARIABLE LENGTH</span>
)));
</code></pre><p>On retry, the caller resubmits the message data, and the bridge re-derives the hash and compares it:</p><pre data-type="codeBlock" text="// _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();
"><code><span class="hljs-comment">// _getStoredMessage — checks the retry commitment</span>
<span class="hljs-keyword">bytes32</span> retryHash <span class="hljs-operator">=</span> <span class="hljs-built_in">keccak256</span>(<span class="hljs-built_in">abi</span>.<span class="hljs-built_in">encodePacked</span>(
    inEvent.messageType, inEvent.fromChain, inEvent.toChain,
    _token, _amount, inEvent.gasLimit,
    initiator,        <span class="hljs-comment">// bytes — VARIABLE LENGTH</span>
    _fromAddress,     <span class="hljs-comment">// bytes — VARIABLE LENGTH</span>
    inEvent.to,       <span class="hljs-comment">// bytes — VARIABLE LENGTH</span>
    inEvent.swapData  <span class="hljs-comment">// bytes — VARIABLE LENGTH</span>
));
<span class="hljs-keyword">if</span> (<span class="hljs-keyword">uint256</span>(retryHash) <span class="hljs-operator">!</span><span class="hljs-operator">=</span> orderList[_orderId]) <span class="hljs-keyword">revert</span> retry_verify_fail();
</code></pre><p>Two design decisions, each defensible alone, combine into disaster:</p><ol><li><p><strong>The retry path performs no light-client re-verification.</strong> The original cross-chain proof was checked once, at first delivery. On retry, the hash comparison is the <em>only</em> security gate.</p></li><li><p><strong>The hash is built with </strong><code>abi.encodePacked</code><strong> over four consecutive </strong><code>bytes</code><strong> fields.</strong> Unlike <code>abi.encode</code>, <code>abi.encodePacked</code> writes raw concatenated bytes with no length prefixes. Given the output, you cannot recover where one field ended and the next began.</p></li></ol><hr><h2 id="h-the-vulnerability" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">The vulnerability</h2><p>For dynamic values, <code>abi.encodePacked(a, b)</code> is simply <code>a</code> followed by <code>b</code>. So:</p><pre data-type="codeBlock" text="encodePacked(&quot;RED&quot;, &quot;FOX&quot;)  ==  encodePacked(&quot;REDF&quot;, &quot;OX&quot;)  ==  &quot;REDFOX&quot;
"><code>encodePacked(<span class="hljs-string">"RED"</span>, <span class="hljs-string">"FOX"</span>)  <span class="hljs-operator">=</span><span class="hljs-operator">=</span>  encodePacked(<span class="hljs-string">"REDF"</span>, <span class="hljs-string">"OX"</span>)  <span class="hljs-operator">=</span><span class="hljs-operator">=</span>  <span class="hljs-string">"REDFOX"</span>
</code></pre><p>All three yield the same bytes, and therefore the same <code>keccak256</code>. The hash authenticates the <strong>concatenation</strong> of the four dynamic fields — not the fields themselves.</p><p>The consequence: anyone who can register one commitment can later satisfy the retry check with <strong>any message whose four dynamic fields concatenate to the same total byte string</strong> — no matter where the field boundaries fall. Sliding those boundaries changes what each field <em>means</em> while the hash stays <em>fixed</em>.</p><p>The six fixed-width fields (<code>messageType</code>, <code>fromChain</code>, <code>toChain</code>, <code>token</code>, <code>amount</code>, <code>gasLimit</code>) are safe — they occupy a known number of bytes and cannot be reinterpreted. The whole attack lives in the four-field dynamic tail.</p><hr><h2 id="h-the-attack-step-by-step" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">The attack, step by step</h2><h3 id="h-step-1-plant-the-decoy" class="text-2xl font-header !mt-6 !mb-4 first:!mt-0 first:!mb-0">Step 1 — Plant the decoy</h3><p>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.</p><p>The attacker set the message's delivery target (<code>to</code>) to the <strong>bridge's own address</strong>. Delivery to that address fails, so the bridge recorded the failure (<code>reason = "NotContract"</code>) and ran <code>_storeMessageData</code>, writing the commitment hash into storage.</p><p>The critical move: the attacker <strong>planted the MAPO token address, plus a relay address, inside the decoy's </strong><code>swapData</code><strong> field</strong>, followed by an ABI-encoded mint payload. Within the decoy's own field layout, those bytes were just opaque <code>swapData</code> content. They were positioned for the next step.</p><p>The decoy wrote this value into the bridge's <code>orderList</code> storage:</p><pre data-type="codeBlock" text="orderList[0xf2fbaa8a…d4a4] = 0xf6e32d1bade0d41d54a9ab057a5df5cb87be81130156d5cc06251a06496caf75
"><code>orderList<span class="hljs-selector-attr">[0xf2fbaa8a…d4a4]</span> = <span class="hljs-number">0</span>xf6e32d1bade0d41d54a9ab057a5df5cb87be81130156d5cc06251a06496caf75
</code></pre><h3 id="h-step-2-retry-with-re-sliced-fields" class="text-2xl font-header !mt-6 !mb-4 first:!mt-0 first:!mb-0">Step 2 — Retry with re-sliced fields</h3><p>The attacker then called <code>retryMessageIn</code>. The calldata supplied the <strong>same total dynamic byte content</strong> as the decoy, but declared <strong>different field lengths</strong> — so the bridge's decoder cut the bytes at different boundaries.</p><p>The fixed prefix is identical for both transactions. The 452-byte dynamic tail is the <strong>same sequence of bytes</strong>, parsed two ways:</p><pre data-type="codeBlock" text="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]
"><code>The committed dynamic blob (452 bytes):

  2475…1c30 2475…1c30 2475…1c30 │ 1ad1a4…6167 │ 66d79b…9a99 │ 1de78e…<span class="hljs-section">[mint payload]</span>
  └───────── 60 bytes ────────┘   └── 20 ──┘    └── 20 ──┘     └──── 352 bytes ────┘


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

   <span class="hljs-attr">initiator</span> = <span class="hljs-number">0</span>x2475…<span class="hljs-number">1</span>c30
   <span class="hljs-attr">from</span>      = <span class="hljs-number">0</span>x2475…<span class="hljs-number">1</span>c30
   <span class="hljs-attr">to</span>        = <span class="hljs-number">0</span>x2475…<span class="hljs-number">1</span>c30        ← the BRIDGE itself<span class="hljs-comment">; delivery fails, message is stored</span>
   <span class="hljs-attr">swapData</span>  = <span class="hljs-number">1</span>ad1a4…<span class="hljs-number">6167</span> ‖ <span class="hljs-number">66</span>d79b…<span class="hljs-number">9</span>a99 ‖ <span class="hljs-number">1</span>de78e…[mint payload]
                                  ↑ MAPO token address hidden inside swapData


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

   <span class="hljs-attr">initiator</span> = <span class="hljs-number">0</span>x2475…<span class="hljs-number">1</span>c30 <span class="hljs-number">2475</span>…<span class="hljs-number">1</span>c30 <span class="hljs-number">2475</span>…<span class="hljs-number">1</span>c30   ← padded to <span class="hljs-number">60</span> bytes
   <span class="hljs-attr">from</span>      = <span class="hljs-number">0</span>x1ad1a4…<span class="hljs-number">6167</span>
   <span class="hljs-attr">to</span>        = <span class="hljs-number">0</span>x66d79b…<span class="hljs-number">9</span>a99      ← the MAPO TOKEN<span class="hljs-comment">; bridge will call mapoExecute on it</span>
   <span class="hljs-attr">swapData</span>  = <span class="hljs-number">1</span>de78e…[mint payload]
</code></pre><p>The attacker grew <code>initiator</code> from 20 to 60 bytes. That 40-byte expansion slides the <code>to</code> window forward by exactly 40 bytes — off the harmless bridge address and onto <code>0x66d79b…9a99</code>, the MAPO token, which had been pre-planted inside the decoy's <code>swapData</code>. The <code>swapData</code> 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.</p><p>Same bytes. <code>abi.encodePacked</code> records no boundaries. Both splits produce the identical hash — <code>0xf6e32d1b…caf75</code> — so <code>_getStoredMessage</code>'s only check, <code>retryHash != orderList[_orderId]</code>, passes.</p><h3 id="h-step-3-the-mint" class="text-2xl font-header !mt-6 !mb-4 first:!mt-0 first:!mb-0">Step 3 — The mint</h3><p>With the retry check bypassed, the bridge proceeded to deliver the message. For a <code>MESSAGE</code>-type message, it runs its hardcoded delivery call:</p><pre data-type="codeBlock" text="IMapoExecutor(to).mapoExecute{gas: gasLimit}(fromChain, toChain, from, orderId, swapData);
"><code>IMapoExecutor(to).mapoExecute{<span class="hljs-built_in">gas</span>: gasLimit}(fromChain, toChain, <span class="hljs-keyword">from</span>, orderId, swapData);
</code></pre><p><code>to</code> was now the MAPO token. MAPO is a bridge-native token: it exposes a <code>mapoExecute</code> 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 <code>MAPO.mapoExecute</code>, forwarding the 352-byte mint payload. Decoded, that payload carried:</p><ul><li><p>an amount of <strong>10¹⁵ × 10¹⁸</strong> — one quadrillion MAPO, in wei</p></li><li><p>a recipient of <code>0x40592025392bd7d7463711c6e82ed34241b64279</code> — the attacker</p></li></ul><p>MAPO minted one quadrillion tokens to the attacker. The bridge then marked the order consumed, flipping its <code>orderList</code> slot from the stored hash to <code>0x01</code>.</p><p>The attacker never called a mint function. They pointed the bridge's own trusted hand at it.</p><hr><h2 id="h-what-each-message-actually-asked-for" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">What each message actually asked for</h2><p>The clearest way to see the exploit is to read both messages as instructions to the bridge.</p><p><strong>The decoy said:</strong></p><pre data-type="codeBlock" text="deliver a generic MESSAGE
  to       = the bridge contract
  swapData = (opaque blob)

result: delivery fails → bridge stores hash(message) for retry
"><code>deliver a generic MESSAGE
  <span class="hljs-attr">to</span>       = the bridge contract
  <span class="hljs-attr">swapData</span> = (opaque blob)

result: delivery fails → bridge stores hash(message) for retry
</code></pre><p>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.</p><p><strong>The exploit said:</strong></p><pre data-type="codeBlock" text="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
"><code>retry the stored message
  <span class="hljs-keyword">to</span>       = the MAPO token contract
  swapData = mint <span class="hljs-number">10</span>^<span class="hljs-number">15</span> MAPO <span class="hljs-keyword">to</span> the attacker

<span class="hljs-symbol">result:</span> hash matches the decoy<span class="hljs-comment">'s → check passes</span>
        → bridge calls MAPO.mapoExecute(mint payload)
        → MAPO mints <span class="hljs-number">10</span>^<span class="hljs-number">15</span> <span class="hljs-keyword">to</span> the attacker
</code></pre><p>Side by side:</p><pre data-type="codeBlock" text="                DECOY                          EXPLOIT
  to       →    &quot;the bridge&quot;              →     &quot;the MAPO token&quot;
  swapData →    opaque blob               →     &quot;mint 10^15 to attacker&quot;
  goal     →    fail, get a hash stored   →     pass the hash check, mint
  ──────────────────────────────────────────────────────────────────────
  hash     →    0xf6e32d1b…caf75          ==    0xf6e32d1b…caf75
"><code>                DECOY                          EXPLOIT
  to       →    <span class="hljs-string">"the bridge"</span>              →     <span class="hljs-string">"the MAPO token"</span>
  swapData →    opaque blob               →     <span class="hljs-string">"mint 10^15 to attacker"</span>
  goal     →    fail, get a <span class="hljs-built_in">hash</span> stored   →     <span class="hljs-keyword">pass</span> the <span class="hljs-built_in">hash</span> check, mint
  ──────────────────────────────────────────────────────────────────────
  <span class="hljs-built_in">hash</span>     →    <span class="hljs-number">0xf6e32d1b</span>…caf75          ==    <span class="hljs-number">0xf6e32d1b</span>…caf75
</code></pre><p>The attacker authored the decoy <em>backwards</em> from the exploit. They first decided what they wanted — <code>to</code> = MAPO, <code>swapData</code> = 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.</p><hr><h2 id="h-root-cause" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">Root cause</h2><blockquote><p>The Map Protocol bridge authenticated retried cross-chain messages with <code>keccak256(abi.encodePacked(initiator, from, to, swapData))</code> — four adjacent variable-length <code>bytes</code> fields. Because <code>abi.encodePacked</code> emits no length delimiters, the field boundaries were never part of the hash. The attacker registered a benign message whose <code>swapData</code> 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 <code>to</code> pointing at the MAPO token and <code>swapData</code> 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 <code>MAPO.mapoExecute</code> minted one quadrillion MAPO.</p></blockquote><p>It is the textbook <code>abi.encodePacked</code> collision hazard — explicitly warned against in the Solidity documentation — applied to a security-critical authentication path.</p><hr><h2 id="h-the-fix" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">The fix</h2><p>The minimal and correct fix is to use <code>abi.encode</code> instead of <code>abi.encodePacked</code> in <strong>both</strong> <code>_storeMessageData</code> and <code>_getStoredMessage</code>:</p><pre data-type="codeBlock" text="// 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
));
"><code><span class="hljs-comment">// VULNERABLE — no length delimiters; field boundaries are not committed</span>
<span class="hljs-built_in">keccak256</span>(<span class="hljs-built_in">abi</span>.<span class="hljs-built_in">encodePacked</span>(
    messageType, fromChain, toChain, token, amount, gasLimit,
    initiator, <span class="hljs-keyword">from</span>, to, swapData
));

<span class="hljs-comment">// FIXED — abi.encode length-prefixes every dynamic field, so the</span>
<span class="hljs-comment">// boundaries become part of the preimage; no re-slice can collide</span>
<span class="hljs-built_in">keccak256</span>(<span class="hljs-built_in">abi</span>.<span class="hljs-built_in">encode</span>(
    messageType, fromChain, toChain, token, amount, gasLimit,
    initiator, <span class="hljs-keyword">from</span>, to, swapData
));
</code></pre><p>Both functions must change together — the writer and the reader have to agree on the encoding.</p><h3 id="h-defense-in-depth" class="text-2xl font-header !mt-6 !mb-4 first:!mt-0 first:!mb-0">Defense in depth</h3><p>The encoding fix closes the specific hole. Several further measures would have contained the damage:</p><ul><li><p><strong>Re-verify on retry, or bind the retry to the original verified message.</strong> 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.</p></li><li><p><strong>Cap mintable-token issuance.</strong> 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.</p></li><li><p><strong>Validate the delivery target.</strong> A <code>to</code> address equal to the bridge itself, or to a core system token, is a strong anomaly and could be rejected outright.</p></li><li><p><strong>Forbid </strong><code>abi.encodePacked</code><strong> with adjacent dynamic arguments.</strong> This belongs in every smart-contract linter and review checklist for any hashing or signing preimage.</p></li></ul><hr><h2 id="h-timeline" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">Timeline</h2><table><colgroup><col><col></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Block</p></th><th colspan="1" rowspan="1"><p>Event</p></th></tr><tr><td colspan="1" rowspan="1"><p>25137016</p></td><td colspan="1" rowspan="1"><p>Decoy transaction stores commitment <code>0xf6e32d1b…caf75</code></p></td></tr><tr><td colspan="1" rowspan="1"><p>25137572</p></td><td colspan="1" rowspan="1"><p>Exploit transaction retries with the colliding split; one quadrillion MAPO minted</p></td></tr></tbody></table><hr><h2 id="h-lessons" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">Lessons</h2><ol><li><p><code>abi.encodePacked</code><strong> is not a serialization format.</strong> 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.</p></li><li><p><strong>A cheap "we already verified this" shortcut is only as strong as the shortcut.</strong> 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.</p></li><li><p><strong>Mintable cross-chain tokens need a supply invariant.</strong> Unbounded mint authority turns any message-forgery bug into an unbounded-loss bug. A cap turns a catastrophe into an incident.</p></li><li><p><strong>Severity is a chain of links.</strong> 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.</p></li></ol><br>]]></content:encoded>
            <author>knot@newsletter.paragraph.com (Kn0t)</author>
        </item>
    </channel>
</rss>