Summary

A critical oversight in a widely used open-source Solidity library allowed malicious actors to submit forged proofs of exclusion. The vulnerability affected cross-chain protocols that bridge state root hashes from Ethereum Layer 1 (L1) to Layer 2 (L2), relying on this library to verify state proofs.
An attacker could manipulate the L2's view of the L1 state, convincing the protocol that a specific storage slot on L1 was uninitialized (i.e., absent from the trie) and therefore had a value of 0. This opened the door to falsifying critical data such as oracle prices and governance voting power, creating high risk of financial loss.
Fortunately, all known affected protocols have now patched the issue. In this article, we share our technical deep dive into the vulnerability.
Background
What are Merkle-Patricia Tries?

A Merkle-Patricia Trie (MPT) is a core data structure in Ethereum, used to efficiently store and verify key-value pairs with cryptographic guarantees. MPTs underpin Ethereum's world state, the full mapping of accounts and smart contract storage, and are central to how the protocol verifies and propagates state.
In essence, MPTs combine the principles of Merkle trees with Patricia tries to allow for efficient lookup, insertion, and compact proofs of inclusion or exclusion. For a comprehensive explanation, we recommend Ethereum's official documentation. Below, we provide a concise and simplified overview, assuming some familiarity with Merkle trees.
In an MPT, a value v is stored at the node corresponding to its key k. Unlike binary trees, Ethereum's MPT uses a branching factor of 16, meaning it follows nibbles (4-bit chunks, i.e., hexadecimal characters) of the key to traverse the trie. Each nibble indicates which of the 16 possible paths (0-f) to take at a given node.
The primary node types are:
- Branch nodes: contain 17 fields, the first 16 are for child pointers (or their hashes), and the 17th optionally stores a value at that point in the trie.
- Leaf nodes: store the remaining key fragment and the corresponding value, representing terminal paths in the trie.
- Extension nodes: optimize the trie by collapsing sequences of single-child paths into a single node containing a shared key fragment (we omit them here for brevity).

State Trie and Storage Trie
In Ethereum, Merkle-Patricia Tries (MPTs) are a foundational data structure. For the purpose of this analysis, we focus on two critical instances: the State trie and Storage Tries.
The State Trie: Ethereum's Global State
The State Trie represents Ethereum's world state, a snapshot of all account data. Each key in the State Trie is the Keccak-256 hash of an account address, making all keys exactly 64 nibbles long (32 bytes). Values are stored only in the leaf nodes, not in intermediates.
Each leaf node holds an account record consisting of:
- Nonce: the number of transactions sent from the account.
- Balance: the account's ETH balance.
- Storage Root: the root hash of the account's Storage Trie.
- Code Hash: the hash of the contract's bytecode.
Storage Tries: Smart Contract Storage
Storage Tries are where Ethereum smart contracts store their data. Each storage slot index s is hashed with Keccak-256, and the resulting 32-byte value is used as the key. The corresponding value is the 32-byte word stored in that slot.
How Does Proof Verification Work in MPTs?
With an understanding of how State and Storage Tries organize Ethereum's data, we can now examine the cryptographic mechanisms that allow anyone to prove specific values exist (or don't exist) within these tries.
Verifying a Merkle-Patricia Trie (MPT) proof is conceptually similar to verifying a proof in a standard Merkle tree: the goal is to reconstruct the root hash from the proof and compare it to a known "ground-truth" root hash (the trusted state anchor). If they match, the proof is valid.
However, MPTs introduce additional complexity due to their branching structure and support for both proof of inclusion and proof of exclusion.
Proof of Inclusion
To prove that a node (i.e. a key-value pair) exists in the trie:
- The proof must contain the full path of nodes from the root to the target node.
- Each node includes hashes of all possible children (16 entries for a branch node), allowing the verifier to reconstruct each step of the trie.
- The key being proven is essential, as its nibbles define the path direction at each level.
- If all nodes along the path exist and are correctly hashed, the final reconstructed root can be compared against the trusted root hash.
This confirms that the specified key is present in the trie, and the corresponding value is authentic.
Proof of Exclusion
MPTs also allow for efficient proofs of exclusion, demonstrating that a specific key is not present in the trie.
In this case:
- The prover supplies a partial path through the trie, extending only as far as the deepest existing node before a "dead end" is reached.
- This dead end occurs when the child node expected at a given nibble position does not exist (typically represented as a 0 or empty value in the node's children array).
- The verifier checks that the path terminates exactly where the claimed key would continue, and that no further child exists at that position.
This convincingly shows that the key does not exist in the trie, as it cannot be reached without stepping into a non-existent branch.
Why Verify State Proofs On-Chain?
Ethereum Layer 2 (L2) solutions aim to scale throughput and reduce gas costs, without compromising the security guarantees of Layer 1 (L1). To achieve this, many protocols offload user-facing interactions to L2s, while keeping their core state and logic anchored on Ethereum mainnet (L1).
This setup introduces a challenge: how can L2 contracts trust and interact with data that lives on L1, without constantly bridging between the two layers? Frequent bridging is expensive and slow, making it impractical for real-time or high-frequency use cases.
The solution is to bridge a single source of truth, typically the block hash of a recent L1 block to the L2. This block hash acts as a cryptographic trust anchor. It includes the L1 block header, which contains:
- The State Trie root hash (representing the entire Ethereum world state).
- And transitively, the Storage Trie root hash for every smart contract.
With just this one trust anchor on the L2, users can generate verifiable proofs of any smart contract's storage slot value on L1. This enables L2 smart contracts to independently verify critical on-chain data from L1, such as:
- Oracle prices
- Voting power balances
- Whitelists or allowlists
- Any other stateful variable stored on L1
Users interacting with the L2 simply attach a state proof (based on the bridged L1 block hash) when submitting a transaction. The L2 contract uses this proof to validate the claimed L1 state, without needing to call back to L1.
The Vulnerable Solidity Library
At the core of the bug was a Solidity library responsible for verifying Merkle-Patricia Trie (MPT) proofs, specifically, to determine whether a key exists or is absent in Ethereum's state trie. The library was widely used in cross-chain messaging and oracle protocols that verify L1 state on L2.
Originally forked from a little-maintained open-source implementation, the library had not been rigorously audited or updated in years. It exposes a function that processes a proof (as an array of serialized MPT nodes), a key, and a known root hash, returning either the matching leaf node value, an empty value for non-inclusion, or reverting for invalid proofs.
The main verification logic is a for loop that traverses the proof path node by node, starting from the top. At each step, it performs the following:
- Verifies that the hash of the current node matches the expected one (starting from the trusted root).
- Matches the current key nibble with the child indicated in the parent node.
- Advances both the proof path and the key nibble sequence in sync.
Depending on whether the traversal ends at a leaf, a branch, or a dead-end, the function concludes:
- Valid inclusion: reached a leaf or branch node and fully consumed both the key and the proof.
- Valid exclusion: path ends early, or hits a 0 child pointer mid-branch.
- Invalid proof: any mismatch in hashes or unexpected structure causes a revert.
The Bug: Silent Exit from the Verification Loop
.png)
A subtle control flow flaw in the verification logic allowed attackers to submit forged proofs of exclusion, proofs that passed all validation while incorrectly claiming that a key was absent from the trie.
What Went Wrong
The library's for loop iterates through the nodes of the provided proof path, advancing both:
- the node path, and
- the key (nibble by nibble)
The assumption was that every valid proof would either:
- reach a leaf or branch node with a fully matched key (proof of inclusion),
- reach a dead end or unmatched node (proof of exclusion),
- or trigger a revert on invalidity or malformed input.
However, this assumption was incorrect. The loop could exit naturally, without ever hitting an explicit return or revert if all of the following conditions were met:
- The key was not fully consumed, and
- No dead end was encountered, and
- No leaf node was reached, and
- No hash mismatches or other invalid states were detected.
In this case, the loop simply runs out of nodes to process and falls through, reaching the implicit function return:
Since the function had a named return value of type bytes, it returns an uninitialized (empty) byte array, which, by convention, is interpreted as a valid proof of exclusion.
Exploiting the Bug
To trigger this vulnerability, an attacker could:
Take a valid proof of inclusion for an existing node, and truncate the proof path, while leaving the key unchanged.
Because the truncated path still hashes correctly at every step, the verifier sees no reason to reject it. But since the proof ends prematurely and skips any explicit return condition, it quietly exits the loop and returns an empty byte array, falsely proving that the node does not exist.
Real-World Consequences
In protocols using this library to validate the L1 storage state on L2, an attacker could falsely convince the L2 verifier that a storage slot was unset (i.e., had a value of 0), even if it contained a non-zero value. This opens the door to critical exploits such as:
- Falsifying oracle prices
- Forging voting power
- Bypassing access controls or whitelists
This bug illustrates how even minor control-flow oversights in smart contract logic can have devastating security implications.
How to Fix
The issue arises when the loop over the proof nodes completes without hitting any return or revert, causing the function to fall through and return an uninitialized (empty) byte array, falsely signaling a valid proof of exclusion.
The fix is straightforward: add an explicit revert at the end of the function. This ensures that if none of the expected conditions are met (e.g. a valid inclusion or exclusion), the proof is treated as invalid rather than silently accepted.
Here's a minimal patch:
This guarantees that every code path leads to a deliberate outcome: either a valid inclusion, a valid exclusion, or an explicit rejection. No more silent fallthroughs.
Timeline and Credits
This vulnerability was discovered by Roman from Curve Finance (@agureevroman) on March 11, 2025. Upon discovering the issue, Roman immediately reached out to ChainSecurity for independent verification and assistance with coordinated disclosure.
Disclosure Timeline
- March 11, 2025: Roman from Curve Finance discovers the vulnerability and contacts ChainSecurity
- March 11, 2025: ChainSecurity confirms the vulnerability the same night
- March 12-14, 2025: Comprehensive search conducted across deployed contracts to identify all affected protocols
- March 14, 2025: Initial disclosure to Frax Finance
- March 15, 2025: Disclosure expanded to include Aave and StakeDAO
- March 15-31, 2025: Protocols given time to implement fixes and upgrades at their own pace
- March 31, 2025: Broader notification sent to users of the vulnerable library and similar implementations that were not found to be directly affected but could be at risk
We thank Roman for the responsible disclosure of this critical vulnerability and all affected protocols for their swift response in implementing the necessary fixes.
About us
ChainSecurity’s mission is to build trust within the blockchain ecosystem, to allow this emerging technology to reach its potential among established organizations, governments, and blockchain companies alike.
If you have questions, don’t hesitate to reach out to contact@chainsecurity.com for general requests including requests for audits, and for questions about this or other vulnerabilities. Also, visit us at chainsecurity.com.