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_proof → storage_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 = 15RPC_DEFAULT_MAX_RESPONSE_SIZE_MB = 160RPC_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):
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
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.
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.
Memory amplification. ~32 MiB of transient per in-flight request observed; scales linearly with N and with trie depth on the target contract.
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?