One prior-knowledge h2c connection multiplexes N eth_getLogs past an L4 per-connection cap on go-ethereum's JSON-RPC port
go-ethereum's JSON-RPC HTTP port terminates cleartext HTTP/2 (h2c) by prior knowledge — a client can send the raw HTTP/2 preface with no TLS and no Upgrade handshake and the node speaks h2 — and HTTP/2 multiplexes many requests as independent streams over one TCP connection. An operator who fronts that port with an L4 / TCP-passthrough edge and rate-limits connections per source IP (nginx stream limit_conn, or a cloud L4 load balancer) counts a single connection while being blind to the requests multiplexed inside it, so one h2c connection carrying N eth_getLogs requests pulls N amplified responses through the single permitted connection — an amplification-multiplexing bypass of the connection cap.
Why this matters
Rate-limiting an exposed RPC endpoint by connections per IP is a common, cheap edge control — it is what an L4 load balancer or an nginx stream block gives you without terminating TLS or parsing HTTP. The bypass matters because HTTP/2's whole point is to carry many requests on one connection, and an L4 edge cannot see them: it forwards the TCP byte stream — the h2 preface and every multiplexed stream inside — opaquely to the origin, and its only lever is the count of connections. So the naïve assumption — "we cap connections per IP, so one IP can't flood us" — does not hold once the origin speaks h2c: one permitted connection becomes an arbitrary number of concurrent requests. The control is not useless (it does stop N separate connections), it is simply enforcing at the wrong layer for a multiplexed transport. Note the honest boundary: this defeats a connection cap, not an L7 per-request rate limit — an edge that terminates h2 and counts requests still sees and limits each stream.
How the attack works
- Entry point: go-ethereum's JSON-RPC HTTP transport (
--http) on its port (:8545in the lab), reached through an L4/TCP-passthrough edge that forwards the connection opaquely. - Preconditions: the RPC port is exposed behind an L4 edge whose only control is a per-connection cap (e.g. nginx
streamlimit_conn); the origin accepts prior-knowledge h2c (no TLS, noUpgradeneeded). No authentication is required. - Processing path: geth's JSON-RPC HTTP server (
node/rpcstack.go,rpc/http.go) is served by Go'snet/http, whose h2c-capable handler accepts a prior-knowledge HTTP/2 preface on the cleartext listener. HTTP/2 (RFC 9113) then multiplexes concurrent streams over the one connection up toSETTINGS_MAX_CONCURRENT_STREAMS. The attacker opens one connection and issues Neth_getLogscalls as N streams; the L4 edge forwards the bytes and counts one connection. - Boundary crossed: a connection-count control (L4
limit_conn), not an L7 per-request rate limit. Eacheth_getLogsinside is itself a response-amplifying read (the subject of a separate finding, NR-2026-023), so the multiplex delivers N amplified responses for one connection slot. - Resulting behaviour: the connection cap never triggers on the multiplexed tunnel while the origin does N requests' worth of scan, serialise and egress — measured at 20 streams / ~39 MB on a single connection (below).
Affected systems
- Severity: MEDIUM — availability / bandwidth-and-CPU cost only; no funds, no consensus break, no authentication bypass. The harm is per-node egress + serialisation load on an exposed RPC endpoint whose only edge control counts connections.
- Chain: Ethereum.
- Implementation: go-ethereum, tested at 1.17.4, with the JSON-RPC HTTP transport exposed behind an L4/TCP-passthrough edge.
- Required edge: an L4 / TCP-passthrough front whose only control is a per-connection cap — measured with nginx
streamlimit_conn 1per source IP; a cloud L4 load balancer with per-connection limits is the same shape. - Not affected: an edge that terminates HTTP/2 and rate-limits per request (L7) — in testing, an h2-terminating edge with a per-request limit rejected 17 of 20 streams (
503), so the multiplex does not defeat request-level limiting. A loopback-bound RPC, or a port not exposed on a routable interface, is also out of scope. The behaviour is a property of the RPC server terminating cleartext h2c plus HTTP/2 multiplexing, not a version-specific regression; no affected version range is established beyond the tested 1.17.4.
Evidence
- Disclosed by NullRabbit: advisory NR-2026-053 ("h2c stream-multiplexing bypasses an L4 connection cap on the JSON-RPC port", published 2026-07-15) is the canonical record; it cites geth's
node/rpcstack.go/rpc/http.goand carries the measurements below. No CVE is assigned. - Publicly documented: go-ethereum's JSON-RPC HTTP server is served by Go's
net/http, whose h2c handler accepts a prior-knowledge HTTP/2 preface on the cleartext listener; HTTP/2 stream multiplexing is RFC 9113. The bypass is the interaction of that with a connection-granularity edge control, not a documented feature. - Independently reproduced by NullRabbit (lab): a reproducer (primitive
eth_geth_h2c_multiplex_connlimit_bypass, familyrate_limiter_bypass,source_class: original) runs a self-owned go-ethereum 1.17.4--devnode behind an nginxstreamL4 edge (limit_conn 1per source IP). The captured packet trace ships in the public datasetNullRabbit/nr-bundles-public(corpuscrp_12a57e6811944e4a); it contains the two successful multiplexed h2c connections — one direct to the origin, one through the L4 edge — each carrying all 20 streams on a single connection. - Measured (self-owned lab): one prior-knowledge h2c connection multiplexing 20
eth_getLogsstreams completed 20/20, pulling tens of megabytes of response through the single connection — an independent read of the shipped pcap measures ~38–39 MB of TCP payload per multiplexed connection (~1.9 MB/stream), while the advisory reports the response volume as ~43.9 MB. The same run rejected 16 of 20 separate connections from one IP (limit_conn), the advisory/manifest control confirming the cap is real and is bypassed only by the multiplexed tunnel (that rejection set is a reported result, not part of this packet capture). Through an L7 (h2-terminating) edge with a per-request rate limit, 17 of 20 streams were rejected (503, per the advisory). These figures are from a self-owned lab node, not a production measurement.
Operator actions
- Rate-limit at L7 (per request), not only L4 (per connection). A terminating reverse proxy that counts requests sees each multiplexed stream; a connection cap does not.
- Bound HTTP/2 multiplexing at the edge or origin with a low
SETTINGS_MAX_CONCURRENT_STREAMS, and cap theeth_getLogsresult size / block range as well (the NR-2026-023 mitigation) so each permitted stream is bounded. - Do not expose the JSON-RPC port raw behind an L4 / TCP passthrough — keep it on loopback or behind an authenticating, request-aware gateway.
- Exposure check: if your RPC edge is an L4 load balancer or nginx
streamwith a per-connection cap, confirm whether the origin answers a prior-knowledge h2c preface and whether a single connection can carry multiple concurrenteth_getLogsstreams. These are configuration-level mitigations; no vendor patch is asserted here.
Canonical references
- NullRabbit advisory: NR-2026-053 — Ethereum (go-ethereum): h2c stream-multiplexing bypasses an L4 connection cap on the JSON-RPC port (published 2026-07-15;
source_class: original, out-of-scope publish-track, no CVE assigned). - NRDAX technique: NRDAX-T0396 — HTTP/2 Multiplexing Rate-Limit Bypass (family
rate_limiter_bypass); theethereum/ go-ethereum instanceeth_geth_h2c_multiplex_connlimit_bypass. - Evidence bundle (Hugging Face): NullRabbit/nr-bundles-public — primitive
eth_geth_h2c_multiplex_connlimit_bypass(rate_limiter_bypass, corpuscrp_12a57e6811944e4a, captured pcap). - Upstream: go-ethereum JSON-RPC documentation; the RPC HTTP server (
node/rpcstack.go,rpc/http.go, Gonet/httph2c) cited by the advisory; HTTP/2 stream multiplexing per RFC 9113.
Related attacks
The per-stream payload here is eth_getLogs, whose own response amplification is a separate NullRabbit finding — NR-2026-023 (eth_getlogs_response_amp, NRDAX-T0329), which NR-2026-053 explicitly cross-references: the multiplex tunnel is what lets N of those amplified reads ride one connection the L4 edge counts as one. Separately, a companion go-ethereum finding, eth_geth_h2h1_empty_authority_vhost_bypass (advisory NR-2026-054, NRDAX-T0110), also turns on an HTTP/2 edge-vs-origin disagreement — there over Host/:authority identity (an h2→h1 downgrade that bypasses geth's --http.vhosts allowlist), here over connection-vs-stream accounting. That companion is a different family (auth_bypass, not this finding's rate_limiter_bypass), and it is its record — not NR-2026-053 — that places it on NullRabbit's rigs/h2-diff downgrade-differential set; NR-2026-053 does not itself reference it. No relationship is claimed beyond the advisory's own NR-2026-023 cross-reference and this same-surface companion link.
Track attacks affecting go-ethereum and Ethereum RPC infrastructure.
Analysis plus reproducer. No weaponised proof-of-concept code.
Related Posts
Bypassing go-ethereum's --http.vhosts host allowlist with an empty Host, forged by an HAProxy HTTP/2→1.1 downgrade
An HTTP/2 request with an empty :authority, downgraded to HTTP/1.1 by an HAProxy front, reaches go-ethereum with an empty Host header that its --http.vhosts allowlist accepts (200) though it rejects any concrete disallowed host (403) — a bypass of geth's anti-DNS-rebinding Host filter by an attacker with raw-HTTP/2 reach to the edge.
How an exposed debug_traceCall lets one unauthenticated request burn seconds of go-ethereum CPU
debug_traceCall re-runs an eth_call-style message opcode-by-opcode with a full EVM tracer attached, so a single small request converts into up to seconds of single-goroutine CPU on the target — and with no per-source rate or concurrency cap, an unauthenticated caller drives many such traces in parallel against an exposed go-ethereum node.
How a wide-range eth_getLogs query turns a few hundred bytes into an unbounded response on exposed go-ethereum RPC nodes
eth_getLogs returns every log matching a filter across the requested block span in one JSON-RPC response, so a tiny wide-range or broad-topic request forces an exposed go-ethereum node to scan the range and serialise an unbounded matched-log set — a response-amplification DoS payable by an unauthenticated caller.
