In July 2024, blockchain security firm Hexens disclosed a critical vulnerability chain in the Polygon Plasma bridge, the bridge that held all locked POL between Ethereum and Polygon PoS. The bugs allowed an attacker to forge a proof that any arbitrary event had happened on the Polygon chain, including a Withdraw event for the full bridge balance. One transaction. No prerequisites. $800 million in POL at risk.

Polygon fixed it. No funds were lost. The full technical writeup is on the Hexens research page.

I've been going through the Hexens disclosure and the on-chain code to understand how this worked, and it's one of the more creative exploit chains I've seen. It combines an early-stopping bug in a Merkle Patricia Trie verifier with an out-of-bounds read in an RLP parser, then uses Solidity's memory management behavior to control the contents of unallocated memory. Each bug alone is insufficient. Chained together, they give you full control over what the bridge thinks happened on Polygon.

Here's how.

Background: How the Polygon Plasma Bridge Verifies Withdrawals

The Polygon Plasma bridge works on a proof-based model. When a user wants to withdraw POL from Polygon back to Ethereum, they burn their tokens on Polygon, which emits a Withdraw event. Then on Ethereum, they submit a proof that this event actually happened. The bridge verifies the proof and releases the tokens from escrow.

The proof has two layers. First, the user proves that a specific transaction receipt (containing the Withdraw event) exists in a block's receipt trie using a Merkle Patricia Trie (MPT) proof. Second, they prove that the block itself is part of a checkpoint submitted by Polygon validators to the RootChain contract on Ethereum.

The critical code lives in the ERC20PredicateBurnOnly contract. Its startExitWithBurntTokens function parses the user-provided payload, extracts the transaction receipt, verifies the MPT proof through the WithdrawManager, then reads the log from the receipt to get the withdrawal details: who's withdrawing, which token, how much.

The order of operations matters here, and we'll come back to why.

Bug #1: Early Stopping in the MPT Verifier

The first bug is in the MerklePatriciaProof library that Polygon used for verifying receipts against a receipt root hash.

In an MPT, data is stored at leaf nodes. To prove data exists, you provide the nodes along the path from the root to the leaf. The path is derived from the RLP-encoded transaction index. For example, transaction 273 gets path 8–2–0–1–1–1.

The trie has two types of path-compressing nodes: extension nodes and leaf nodes. They're distinguished by a prefix nibble (0/1 for extension, 2/3 for leaf). This distinction is critical because you should only be able to prove data by reaching a leaf node.

But the MerklePatriciaProof library didn't enforce this. If the provided proof path was shorter than the full path to a leaf, the verifier would happily stop at an extension node. The extension node contains two things: a path fragment and a 32-byte hash of the next node. When stopping early, the verifier would compare the value parameter against the hash inside the extension node.

Since the value parameter is freely chosen by the caller (it's normally the RLP-encoded receipt), an attacker could set value to be the extension node's internal hash, provide a truncated proof path, and the verification would pass.

The result: instead of proving a real transaction receipt, you can "prove" a 32-byte hash. That hash is just whatever happens to be inside the extension node at that point in the trie.

By itself, this isn't exploitable. A 32-byte hash can't be parsed as a valid transaction receipt. Which brings us to the second bug.

Bug #2: Out-of-Bounds Read in the RLP Parser

The Polygon contracts used an external Solidity-RLP library for parsing RLP-encoded data. The library represents parsed items as RLPItem structs: just a memory pointer and a length.

The toList function parses an RLPItem into a list of child items by reading each child's encoded length, creating an RLPItem for it, and advancing the pointer. The problem: it trusts the encoded length of each child element without checking whether it overflows past the parent's boundary.

for (uint256 i = 0; i < items; i++) {
dataLen = _itemLength(memPtr);
result[i] = RLPItem(dataLen, memPtr);
memPtr = memPtr + dataLen;
}

The last child element can have an encoded length that pushes its boundary past the end of the parent item. The RLPItem gets created pointing to memory beyond the original data. It reads whatever is there.

So now we have: a way to make the bridge accept a 32-byte hash as a "receipt" (bug #1), and a parser that can be tricked into reading memory beyond that 32 bytes (bug #2). The question becomes: can you find a hash that parses correctly, and can you control what's in the memory it reads into?

Finding the Right Hash

The 32-byte hash needs to parse as a valid-enough receipt structure. Not a real receipt, just enough to get the RLP parser through the required fields until it reaches the log list, at which point the out-of-bounds read takes over.

The Hexens research breaks down exactly what each byte of the hash needs to satisfy:

The first byte can be anything below 0xc0 (it gets popped as a non-list indicator). The next bytes need to encode a valid list header. Then three small elements (the first three receipt fields, all ignored by the bridge code). Then a long-list header for the log array, with a length large enough to push parsing into unallocated memory. Then a long-string element that acts as a "buffer," pushing the second log entry (the one the attacker actually controls) into controllable memory.

The Hexens team wrote a script to check hashes against these constraints and calculated that the complexity was low enough to find one by scanning Polygon's chain history. They looked for blocks with transaction counts between 273 and 512 (which produce extension nodes at a predictable path), computed the extension node hashes from the receipt tries, and checked each one.

They found a working hash in block 17,074,251 after searching only a few tens of thousands of blocks:

8cf8a384e97b4bf8c814e0be6e1c3573d267ffdf9b8ea8546ba5b5b9e5f2a205

The research page includes a byte-by-byte breakdown of how this hash parses. It's elegant in a terrifying way: each byte happens to satisfy the structural requirements just enough to keep the parser moving forward until it overflows into unallocated memory.

Controlling Unallocated Memory

Having a hash that makes the parser read out-of-bounds is useless if the memory it reads is garbage. The attacker needs to control what's in that memory region.

This is where the order of operations in ERC20PredicateBurnOnly becomes critical. The exit function does things in this order:

  1. Parse the user's payload into a receipt struct (reads from calldata into memory)
  2. Call the WithdrawManager to verify the MPT proof (external call)
  3. Parse the log from the receipt struct (reads from memory)

Step 2 is the key. When Solidity makes an external call, it copies the calldata into memory for the CALL opcode. After the call returns, Solidity decrements the free memory pointer to "deallocate" that memory. But it doesn't zero it out. The memory is dirty: it still contains the calldata bytes.

So the attacker includes their forged log data at the end of their payload, after all the legitimate proof data plus a calculated buffer of zero bytes for alignment. The external call in step 2 loads this entire payload into memory. After the call, that memory is "unallocated" but still contains the attacker's data. In step 3, when the RLP parser follows the out-of-bounds pointer from the hash, it lands exactly in this dirty memory region, where the attacker's forged log is waiting.

The forged log contains whatever the attacker wants: the Withdraw event signature, the POL token address, the attacker's address as receiver, and the full bridge balance as the withdrawal amount.

The Full Chain

Putting it all together:

  1. Find a block on Polygon with an extension node hash that passes the parsing requirements (done once, reusable forever)
  2. Build an exit payload that includes the real checkpoint proof and block proof (these are legitimate) but replaces the transaction receipt with the extension node hash and truncates the MPT proof path
  3. Append a zero-byte buffer plus the forged Withdraw log at the end of the payload
  4. Call startExitWithBurntTokens on the ERC20PredicateBurnOnly contract
  5. The MPT verifier accepts the hash as the "receipt" (bug #1)
  6. The external call to WithdrawManager loads the full payload into memory, then "deallocates" it
  7. The RLP parser reads the hash as a receipt, overflows into the dirty memory (bug #2), and parses the forged log
  8. The bridge processes the withdrawal for the full $800M balance

One transaction. No prerequisites. No interaction with Polygon chain required beyond the initial hash search.

Why This Matters

This exploit chain is a textbook example of how individually non-critical bugs become critical when they interact.

The MPT early-stopping bug alone lets you prove a hash. Useless. The RLP out-of-bounds read alone lets the parser overshoot into adjacent memory. Useless unless you can control that memory. The Solidity memory deallocation behavior alone is a known quirk. Not a vulnerability by itself.

Chain them together and you can forge arbitrary withdrawal proofs against an $800M bridge.

The Hexens security research team also used Glider by Hexens, their smart contract analysis engine, to scan all verified contracts on Ethereum and other chains for projects using the same vulnerable RLP library. They found one additional project with the same vulnerability, which was separately disclosed and fixed. Other projects used the library differently, making the bug unexploitable in their context.

This kind of cross-project blast radius analysis is what separates a good disclosure from a great one. Finding the bug in Polygon is one thing. Proactively scanning the ecosystem for other affected projects before going public is another level of thoroughness.

The Fixes

Polygon applied two fixes:

  • First, a total length check was added to the toList function in the RLPReader library, preventing child items from overflowing past the parent boundary.
  • Second, prefix checks were added to the MerklePatriciaProof library to properly distinguish between extension and leaf nodes, preventing early stopping on extension nodes.

The RLP fix was also merged upstream into the Solidity-RLP library.

Takeaways

A few things stand out about this vulnerability:

Memory safety in Solidity is not guaranteed. Solidity abstracts away memory management, but libraries that work directly with memory pointers (like RLP parsers) can introduce memory corruption bugs that feel more like C exploits than smart contract vulnerabilities.

Proof verification libraries are high-value targets. Any code that validates proofs (MPT, Merkle, ZK) is a critical trust boundary. A bug here doesn't just cause data corruption. It lets an attacker fabricate reality from the bridge's perspective.

The interaction between Solidity's ABI encoding and manual memory parsing creates a dangerous attack surface. The fact that external calls leave dirty memory that can later be read by libraries operating below Solidity's abstraction level is a class of vulnerability that deserves more attention.

Hexens, a top smart contract auditor specializing in bridge security and EVM internals, found and disclosed this before anyone lost money. The Polygon team fixed it quickly. The system worked. But $800 million was one transaction away from being drained, and the bug had been live since the bridge's deployment.

Full technical analysis with exploit construction details, hash hunting scripts, and proof-of-concept code: https://hexens.io/research/polygon-bridge-forging-transaction-proofs