For the complete documentation index, see llms.txt. This page is also available as Markdown.

76425 bc medium eth getproof response is not bound to l2tol1messagepasser allowing a substituted bridge storage root to be accepted

Submitted on May 4th 2026 at 11:35:57 UTC by @ZeroExRes for Audit Comp | Base Azul

  • Report ID: #76425

  • Report Type: Blockchain/DLT

  • Report severity: Medium

  • Target: https://github.com/base/base/tree/v0.8.0-rc.28

  • Impacts:

    • Direct loss to Base or users ≥ 10% of funds held within Bridge.

Description

Brief/Intro

The challenger asks the L2 RPC for an eth_getProof of L2_TO_L1_MESSAGE_PASSER, but it never checks that the proof returned is actually for that address. A malicious or faulty RPC can return a valid proof for some other account under the same L2 state root. The challenger then uses that account’s storage_hash as the message-passer storage root, which can make an invalid output root look valid. In the bridge path, that substituted root can contain forged withdrawal storage.

Vulnerability Details

In crates/proof/challenge/src/validator.rs, the challenger requests the correct account:

let account_result =
    self.l2_provider.get_proof(Predeploys::L2_TO_L1_MESSAGE_PASSER, rpc_hash).await?;

But verification is delegated to AccountProofVerifier:

Inside crates/proof/challenge/src/verify.rs, the verifier derives the trie key from the RPC response itself:

So the proof is valid for response.address, not necessarily for Predeploys::L2_TO_L1_MESSAGE_PASSER.

There is no check like:

After that, the challenger uses:

That is the bug. The caller asked for the message-passer account, but the verifier accepts whatever account the RPC says it proved.

The same bug applies to intermediate root validation as well: validate_intermediate_roots calls compute_output_root for each checkpoint, so a substituted proof can affect every root the challenger validates, not only the final root claim.

Impact Details

Direct loss to Base or users >= 10% of funds held within Bridge.

OptimismPortal2.proveWithdrawalTransaction checks that the supplied messagePasserStorageRoot hashes into the dispute game’s root claim, then checks the withdrawal proof against that root. It does not independently prove that the root belongs to the canonical L2ToL1MessagePasser account.

So if a bad output root using a substituted storage root is proposed, and challengers rely on this vulnerable proof verification, the proposal can avoid dispute. A forged withdrawal stored under the substituted account’s storage root can then satisfy the portal’s withdrawal proof check.

The validator already treats buggy or compromised RPC data as an explicit threat model: compute_output_root_with_hash recomputes the block hash from the consensus header and rejects mismatches, with an in-code comment saying this guards against compromised or buggy RPC nodes. The account proof path is part of the same RPC response surface, but it does not apply the same binding check. The issue is therefore an inconsistent defense: the header is bound to its expected hash, while the account proof is not bound to the requested message-passer address.

References

  • crates/proof/challenge/src/validator.rs

  • crates/proof/challenge/src/verify.rs

Proof of Concept

Make these changes for PoC. The test builds an L2 state with two accounts, the real message passer and a substituted attacker account, feeds the validator a valid proof for the substituted account, and asserts that validate_final_root returns is_valid = true for an output root computed from the attacker account's storage hash.

Run:

Output:

Was this helpful?