75535 bc insight mpt trie node decoder panics on empty leaf or extension path breaking fault proof liveness
Submitted on Apr 29th 2026 at 17:10:00 UTC by @coffee_boi for Audit Comp | Base Azul
Report ID: #75535
Report Type: Blockchain/DLT
Report severity: Insight
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
Summary
TrieNode::decode in base-proof-mpt panics with an index out of bounds when it is asked to decode a leaf or extension node whose RLP encoded path is empty. The decoder reads the first nibble of the path with path[0] before checking that the path has any bytes.
This is reachable through the proof client's normal trie-node oracle boundary when a malformed preimage is returned for a requested node hash. The production host has a separate validation gap in the HintType::L2StateNode path: it stores whatever debug_dbGet(hash) returns under the requested keccak preimage key without checking that keccak256(preimage) == hash. Because off-chain TEE/ZK proving consumes the populated host witness directly, a poisoned or incorrect L2 RPC response can deliver malformed trie bytes to the proof program without requiring a keccak collision.
Because this code runs inside the fault proof program, a panic during proof execution prevents the honest challenger from producing a proof for the disputed transition. A persistent malformed preimage for a required trie node can stall the off-chain proof path on the honest side.
Description
The vulnerable function is try_decode_leaf_or_extension_payload in crates/proof/mpt/src/node.rs:
Bytes::decode accepts an empty RLP string. When that happens, path is a zero length slice. The very next line indexes path[0], which is unguarded and panics. The same problem is repeated at path[1..] in the two unpack_path_to_nibbles calls, where slicing past the end of an empty buffer also panics.
The decoder must never panic on attacker shaped input. The expected behaviour for any malformed RLP node, including one with an empty path, is a clean Err(TrieNodeError::InvalidNodeType) so that the caller can decide what to do.
The smallest input that triggers the bug is the three byte RLP value 0xc28080, which encodes the two item list [ empty_path, empty_value ]. Storing this preimage under its keccak256 hash is sufficient to crash any code path that asks the preimage oracle to materialise that hash as a trie node.
The decoder is called from the proof client's oracle backed L2 chain provider:
The proof program is the program that the honest challenger runs to generate a fault proof for a disputed game. If that program panics, the proof never completes, and the challenger has nothing to submit on chain.
Host preimage validation gap
The host should only insert keccak preimages after verifying that the returned bytes match the requested hash. The L2StateNode hint handler does not do that:
By contrast, the account proof paths compute the hash from each returned proof node before inserting it:
This means the host KV store is not universally hash-bound. For L2StateNode, the proof program can request an honest trie node hash H, while a faulty or adversarial RPC returns 0xc28080; the host records that malformed value under the preimage key for H, and OracleL2ChainProvider::trie_node_by_hash later decodes it as if it were the committed node.
This does not require finding bytes whose keccak hash is H. It requires control of, or corruption in, the host's L2 RPC/preimage delivery path for debug_dbGet.
Likelihood Explanation
The bug itself is unconditional. Any code path that asks TrieNode::decode to materialise a node whose path is empty will panic.
To reach that code path in production, the attacker needs the honest proof program to load a preimage whose value is a malformed leaf or extension node. The honest challenger does not generate this preimage on its own. Concretely, this requires one of:
A malicious proposer or upstream component injects a reference to the malformed node hash into a witness, account proof, storage proof, header field, or other structure that the proof program follows. When the proof program walks the structure, it eventually requests
keccak256(0xc28080)from the oracle, the oracle returns0xc28080, and the decoder panics.A poisoned preimage bundle is shipped with the dispute artifacts that the honest challenger consumes.
An L2 RPC or oracle proxy in the challenger's stack returns
0xc28080for a legitimately requested state node hashH. This is currently accepted byHintType::L2StateNodebecause the host does not check thatkeccak256(0xc28080) == H.
In a healthy network, canonical L2 state should not contain this node, and an honest RPC should not serve it. The realistic likelihood depends on how trusted the witness and preimage path is end to end. The proof system's value is exactly that it should not have to trust those inputs. As long as a malformed node can survive into the proof program's preimage requests, the panic is reachable.
The malformed node fits in three bytes. The challenge is delivery, not construction. Without the L2StateNode validation gap, delivery would require a canonical trie reference to the malformed node hash or a poisoned preimage bundle. With the gap, a bad debug_dbGet response for any legitimately requested state node hash is enough.
For these reasons the likelihood is best described as conditional but not exotic. It does not require key compromise, sequencer privileges, or special network position. It requires the attacker to control or influence one piece of witness or preimage data.
Impact Explanation
The proof system is the load bearing component that turns disputed L2 state transitions into a verified outcome on L1. Liveness of that component is what guarantees that an invalid proposal can be challenged in time. If the honest challenger cannot finish a proof for a disputed transition, the dispute timer keeps running on the malicious side without an answer.
When the decoder panics inside the proof program:
TEE proof generation fails with the panic message.
The driver schedules the ZK fallback. If the ZK path also cannot produce a proof, no proof is submitted.
The challenger's pending entry stays in
AwaitingProofand the retry counter increments. The dispute does not advance on the honest side.A persistent malformed preimage for the required state path keeps reproducing the panic. The honest side never produces a submit ready proof for that game.
In the FPVM or MIPS style execution setting that the proof program targets, an equivalent abort halts the program instead of producing a clean invalid node result. The proof contract on L1 cannot finalize a verdict from a halted program.
The end state of a successful exploit is that a malicious proposer wins a dispute by default, because the challenger cannot complete the proof in time. That maps directly to bridge fund loss when the disputed game governs withdrawals or output proposals. Even without an immediate fund loss, the soundness of the dispute system is broken for the duration that the malformed reference is reachable.
Recommendation
Reject empty paths before any indexing in try_decode_leaf_or_extension_payload:
This converts the malformed input into a normal decode error and lets the caller handle it the same way it handles any other invalid node.
Recommended companion changes:
Verify
keccak256(preimage) == hashbefore inserting anyHintType::L2StateNoderesult underPreimageKey::new_keccak256(*hash). Return a host error on mismatch.Audit every other indexing site in
crates/proof/mpt/src/node.rsand replace directpath[i]andpath[i..]access with checkedgetandget(i..)calls that map toTrieNodeError::InvalidNodeTypeonNone. The same shape of bug is likely to recur in other branches of the decoder.Add a fuzz target for
TrieNode::decodein the proof workspace. The decoder is on the boundary between attacker controlled bytes and proof execution. Fuzzing it undercargo fuzzor a property based harness will surface other panics of this class.Treat any panic in proof execution as a correctness bug, not an availability bug. The proof program should always produce a structured error for malformed inputs.
The PoCs above can be kept as regression tests once the fix is in place. The first two should switch from #[should_panic] to asserting that decoding returns a TrieNodeError::InvalidNodeType. The challenger level test should be replaced by one that asserts the proof program reports a clean decode error rather than panicking.
Proof of Concept
Three layered PoCs are included, each one running closer to the real attack surface than the last. All three are committed in the working tree and pass under cargo test. File and line references for the as-checked-in versions:
PoC 1:
crates/proof/mpt/src/node.rs:717(test_decode_leaf_or_extension_empty_path_panics)PoC 2:
crates/proof/proof/src/l2/chain_provider.rs:328(malformed_trie_preimage_panics_through_l2_provider)PoC 3:
crates/proof/challenge/tests/driver.rs:640(test_step_invalid_game_mpt_decode_failure_leaves_no_ready_proof)
The full reproduction command set:
All three return ok on the current working tree. The first two land on the same panic site at crates/proof/mpt/src/node.rs:442:28 with the message index out of bounds: the len is 0 but the index is 0. The third demonstrates that when the panic surfaces as a TEE failure and the ZK fallback also fails, the challenger driver retains the pending entry in AwaitingProof with retry_count = 1.
Panic through the proof client oracle boundary
This shows that the panic is reachable through the same oracle backed provider that the proof program uses, not only through a direct unit call. In the minimal version, the malformed preimage is keyed by its own keccak256 hash so that an oracle can serve it for any witness that references that hash.
The production L2StateNode validation gap makes the boundary more dangerous: the malformed bytes do not need to be stored under keccak256(0xc28080). If debug_dbGet(H) returns 0xc28080, the host stores 0xc28080 under the requested preimage key for H.
Test source at crates/proof/proof/src/l2/chain_provider.rs:328:
Run:
Observed:
Liveness consequence at the challenger
When the prover panics during proof generation, the challenger's TEE path returns a failure. The driver falls back to ZK. If that path also fails or has not been provisioned, the challenger has no proof ready to submit and the dispute stalls on the honest side.
Test source at crates/proof/challenge/tests/driver.rs:640:
Run:
Observed:
The honest challenger ends a step still holding a not ready entry. Repeated steps continue to fail in the same way as long as the malformed preimage is required.
Was this helpful?