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

76299 bc low eth getproof accepts an unbounded vec jsonstoragekey letting a single public rpc request walk the state trie tens of thousands of times and amplify response payload by 100x

Submitted on May 3rd 2026 at 18:48:59 UTC by @coffee_boi for Audit Comp | Base Azul

  • Report ID: #76299

  • Report Type: Blockchain/DLT

  • Report severity: Low

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

  • Impacts:

    • RPC API crash affecting programs with greater than or equal to 25% of the market capitalization on top of the respective layer

Description

Summary

EthApiExtServer::get_proof in crates/execution/rpc/src/eth/proofs.rs is the override for the public eth_getProof method when the proofs-history ExEx is enabled. It accepts the storage-key list as keys: Vec<JsonStorageKey> with no length validation. The handler then performs one MPT proof walk per key against a historical state provider built on top of the BaseProofsStorage overlay, and serialises every entry into the EIP1186AccountProofResponse.

The default jsonrpsee server body cap is 10 MiB. A JsonStorageKey serialises to ~70 bytes (a quoted 32-byte hex string plus separator), so a single request body can carry on the order of 140,000 storage keys. With the proofs-history ExEx mounted, each of those keys triggers an independent storage-trie traversal plus a per-key Merkle proof allocation in the response. Geth, reth-mainnet, and most production-tuned eth_getProof paths cap this list at a few hundred (geth uses 1000; many providers cap at 100).

A single attacker request can therefore cause:

  • O(100k) MPT proof walks against the historical overlay, each touching the proofs-history MDBX store.

  • A response payload many megabytes in size — each storage entry expands to a JSON object with a hex key, hex value, and a hex proof array, easily 1-2 KiB per entry; 140k entries produce a ~150-300 MiB response that the server must hold in memory before serialisation respects max_response_body_size.

Held under sustained traffic, this is enough to push the public-facing execution RPC into latency spikes and OOM, taking out the endpoint that production dapps depend on.

Description

Root cause

crates/execution/rpc/src/eth/proofs.rs:25-30:

crates/execution/rpc/src/eth/proofs.rs:58-79:

There is no if keys.len() > N guard, no per-method body cap, and no semaphore on this endpoint. keys.iter().map(...).collect() allocates a second Vec<B256> of the same length, and proof(...) constructs a per-key StorageProof { proof: Vec<Bytes>, .. } which is then re-zipped with keys inside into_eip1186_response, producing yet another allocation.

Reachable from the public RPC

crates/execution/node/src/proof_history.rs:74-86:

replace_configured swaps the override into the same RpcModule collection that serves the public eth_* namespace. Any client reaching the node's HTTP/WS RPC port can call eth_getProof and hit this handler. No auth gate.

State provider path

crates/execution/rpc/src/state.rs:33-72 confirms the proof walks against BaseProofsStateProviderRef, which is an overlay over the historical preimage store backed by MDBX. Each proof() call invokes StateProofProvider::proof which calls into Proof::account_proofstorage_multiproof.

Amplification math (empirically confirmed)

Configured caps in force on Base nodes (verified — reth/crates/node/core/src/args/rpc_server.rs, inherited unchanged):

  • RPC_DEFAULT_MAX_REQUEST_SIZE_MB = 15

  • RPC_DEFAULT_MAX_RESPONSE_SIZE_MB = 160

  • RPC_DEFAULT_MAX_CONNECTIONS = 500

So request bodies up to ~210k storage keys are accepted, and responses up to 160 MiB are returned without rejection. The "10 MiB cap" assumption in early analysis was wrong — the actual cap is 16× larger.

Measured against a live devnet (base-client on http://localhost:8545, ~33h uptime, target 0x4200000000000000000000000000000000000015 (L1Block predeploy), upstream eth_getProof path — same Proof::account_proof code as the override):

keys
request KiB
response bytes
wall time
per-key resp

1

0.2

3,860

0.05 s

100

7.0

81,224

0.05 s

812 B

1,000

68.5

782,455

0.12 s

782 B

5,000

341.9

3,909,508

0.43 s

781 B

10,000

683.7

7,816,208

1.04 s

781 B

25,000

1,709.1

19,536,526

3.89 s

781 B

50,000

3,418.1

39,060,632

12.33 s

781 B

100,000

6,836.1

78,154,163

43.95 s

781 B

Per-target proof bytes is constant (~781 B/key on this contract — its storage trie is shallow because only ~3 slots are populated). Wall time and response bytes both scale linearly in N. A separate trie-level test against a contract with 10,000 populated slots measured 1,643 B/key; real Base contracts (USDC, OpenSea bridges, bridge accounting) have deeper storage tries and would amplify further.

A single 100k-key request: 6.7 MiB request → 78 MB response → 44 s of CPU. Within the 15 MiB request cap, an attacker can request up to ~210k keys per call (~164 MB response, ~92 s of CPU at this trie depth).

Concurrency burst (measured on the same devnet)

8 parallel 25k-key requests:

Linear extrapolation to the configured ceiling (RPC_DEFAULT_MAX_CONNECTIONS = 500, max-N = 210k): ~32 GiB transient memory and several CPU-hours per attacker round if every connection saturates. Even a 50-connection burst pins ~4 GiB transient and ~37 minutes of cumulative CPU.

Why this is more than a slow query

  1. Response cap is 160 MiB, not 10 MiB. Even at the legitimate ceiling, a single request returns a 78 MB body; nothing in the stack rejects it.

  2. CPU cost outpaces wall-time SLAs. 44 s of CPU per single 100k-key request makes a low-rate sustained attack (a handful of connections) sufficient to keep the public-facing execution RPC at 100 % CPU indefinitely.

  3. Memory amplification. ~32 MiB of transient per in-flight request observed; scales linearly with N and with trie depth on the target contract.

  4. MDBX read amplification on the proofs-history overlay. When the override is mounted, the per-walk trie cursors hit the preimage store with on-demand lookups. The walk itself is single-pass, but the per-request cost is still substantially higher than against the in-DB trie tables of the upstream path.

Recommendation

Cap keys.len() at the handler entry, before the Vec<B256> allocation:

1000 matches geth's cap; lower (e.g., 256) is also reasonable. The cap should be applied before any allocation work — i.e., before the keys.iter().map(...).collect() call.

Proof of Concept

Two PoCs were run; both produced the empirical numbers tabulated above.

1. Live devnet (HTTP path)

attacks/proof_dos_devnet.sh (in repo). Sends bodies via --data-binary @file to avoid argv limits, escalates key count, records HTTP status, response bytes, wall time:

Concurrency burst — 8 parallel 25k-key requests pinned ~7 cores and added ~257 MiB transient RSS to the base-client container while remaining in the ceiling.

2. Trie-level PoC (direct state_provider.proof() call)

crates/execution/trie/tests/proof_scaling.rs (in repo). Populates real MDBX-backed reth state with one contract holding 10,000 storage slots, then calls state.proof(Default::default(), TARGET, &slots) — the same call the override makes at crates/execution/rpc/src/eth/proofs.rs:75 — for slot counts spanning 1 to 100,000:

The subtree_uniq column (deduplicated trie-node bytes touched by the walk) plateaus at ~758 KiB — confirming the walk is single-pass. The storage_subtree column (sum of per-target proof bytes in the response) grows linearly in N because each StorageProof.proof field copies the full path. This is the source of the O(N) response amplification.

Run with:

Was this helpful?