Bypassing go-ethereum's --http.vhosts host allowlist with an empty Host, forged by an HAProxy HTTP/2→1.1 downgrade
A go-ethereum node that gates its JSON-RPC by Host header — the --http.vhosts allowlist that ships as geth's DNS-rebinding protection — accepts a request whose Host is empty (HTTP 200) while correctly rejecting the same request carrying a concrete, disallowed hostname (HTTP 403). When such a node sits behind an HAProxy HTTP/2 front that downgrades to HTTP/1.1, an attacker can produce that empty Host on demand: an HTTP/2 request with an empty :authority pseudo-header is normalised by HAProxy into an HTTP/1.1 request with an empty Host, which slips past the allowlist and reaches the RPC — a bypass of the Host-based access control the operator is relying on.
Why this matters
--http.vhosts is go-ethereum's documented protection against DNS rebinding: it restricts which Host values may reach the HTTP JSON-RPC, so a node is not driven by a request carrying an attacker-chosen hostname. Operators also lean on it as a plain Host allowlist in front of an RPC that is otherwise unauthenticated. The bypass matters because it defeats that control on a specific but ordinary deployment shape — geth behind a TLS-terminating HAProxy that speaks HTTP/2 to clients and HTTP/1.1 to the backend — without the operator changing anything.
The caveat that bounds it, stated plainly: the empty :authority cannot come from a browser (browsers always populate :authority), so this is not the classic browser-driven rebinding. It needs an attacker with a raw HTTP/2 client that can reach the front. Within that model, the naïve assumption — "the allowlist rejects bad hostnames, so we're covered" — is wrong in a specific way: the allowlist rejects the bad hostname but waves through the absent one.
How the attack works
- Entry point: the TLS HTTP/2 front (here HAProxy 3.3.6) terminating client connections in front of go-ethereum's HTTP JSON-RPC (
--http) on:8545. - Preconditions: geth's HTTP RPC is reachable only through the proxy; geth runs a
--http.vhostsallowlist that does not include the attacker's chosen hostname (so a direct, honestly-labelled attempt is refused); and the front performs an HTTP/2→HTTP/1.1 downgrade that maps the request's:authorityonto the HTTP/1.1Host. - Processing path: the attacker sends an HTTP/2 request with
:authorityset to the empty string alongside a separatehost: evil.exampleheader. HAProxy downgrades the stream to HTTP/1.1 and forwards an emptyHostto geth, ignoring the suppliedhost:header. geth's virtual-host check (node/rpcstack.go,virtualHostHandler) compares the incomingHostagainst the--http.vhostsallowlist and treats the empty value as an implicitly trusted default rather than an explicit deny, so the request is served. - Boundary crossed: the Host-based access-control / anti-DNS-rebinding trust boundary. The allowlist comparator is handed an input it does not model — absent identity — and fails open.
- Resulting behaviour: the control returns HTTP 200 for the empty-Host request, while the same request sent honestly as
:authority=evil.example(a concrete disallowed host) returns HTTP 403. The attacker reaches the JSON-RPC endpoint the Host filter was meant to keep them off.
Affected systems
- Severity: MEDIUM — a Host-allowlist / anti-DNS-rebinding access-control bypass; no funds, no consensus impact, no memory-safety issue. Reaching the RPC still exposes only whatever the enabled namespaces serve.
- Chain: Ethereum.
- Implementation making the trust decision: go-ethereum, tested at 1.17.4, relying on its
--http.vhostsHost allowlist. - Required edge: an HTTP/2→HTTP/1.1 downgrading front that forwards an empty
:authorityas an emptyHost— observed with HAProxy 3.3.6 (a TLS HTTP/2 front). - Not affected the same way: a front that refuses or normalises the empty
:authorityrather than forwarding it. Advisory NR-2026-054 scopes the bypass as HAProxy-specific and records that nginxGOAWAYs and Envoy rejects the empty:authority, so those fronts are not affected — a scoping claim from the finding, not reproduced in this bundle's capture (which contains only the HAProxy control/attack exchanges). geth bound to loopback with no proxy, or a deployment that does not rely on--http.vhostsfor access control, is outside this bypass. This is a composition of a front-end downgrade quirk with a geth Host-check behaviour, not a version-specific geth regression; no affected version range is established beyond the tested 1.17.4.
Evidence
- Disclosed by NullRabbit: advisory NR-2026-054 ("h2→h1 empty-
:authoritydowngrade bypasses geth's--http.vhostsHost allowlist", published 2026-07-15) is the canonical record; it cites geth'svirtualHostHandler(node/rpcstack.go) and the HAProxy downgrade, and carries the measurements below. No CVE is assigned. - Publicly documented: go-ethereum documents
--http.vhostsas the virtual-host / DNS-rebinding protection for its HTTP JSON-RPC server. What is exercised here is how that check treats an empty Host — an interaction, not a documented feature. - Independently reproduced by NullRabbit (lab): a reproducer (primitive
eth_geth_h2h1_empty_authority_vhost_bypass, familyauth_bypass,source_class: original) runs a self-owned localnet — real go-ethereum 1.17.4 behind a real HAProxy 3.3.6 TLS HTTP/2 front — and drives the empty-:authorityrequest against it. The captured packet trace ships in the public datasetNullRabbit/nr-bundles-public(corpuscrp_3083e2525b7743c4). - Observed under controlled conditions: the control request (
:authority=evil.example, a disallowed host) returns HTTP 403 ("invalid host specified"); the attack request (empty:authority+host: evil.example) returns HTTP 200. The advisory records the fuller behaviour that isolates the defect —Host: localhost→ 200,Host: 127.0.0.1→ 200, an emptyHost→ 200,Host: evil.example→ 403, and noHostat all → 400 — so it is the empty-but-present Host, specifically, that is treated as allowed. That differential, captured in the bundle's pcap, is the entire result — a binary allowlist bypass. - Measured: not applicable — this is an access-control bypass, not an amplification or resource-exhaustion finding, so there is no throughput or resource multiplier to report.
Operator actions
- Do not treat
--http.vhostsas the only Host gate behind a downgrading proxy. Enforce the Host /:authoritypolicy at the edge as well: configure the HAProxy front to reject requests with an empty or missing:authority/Hostrather than forwarding them, so the empty value never reaches geth. - Prefer a front that rejects the malformed request outright. Fronts that
GOAWAYor400an empty:authoritydo not produce the bypassing input in the first place; the advisory scopes nginx and Envoy fronts as unaffected for this reason. - Keep the RPC unreachable except through the intended path — bind
--httpto loopback or an internal interface and require the proxy, and enable only the namespaces an application needs, so a Host-filter bypass yields the least surface. - Exposure check: if you run geth behind an HTTP/2-terminating proxy and rely on
--http.vhosts(or a proxy Host allowlist) for access control, test how the stack handles an empty:authority/Host— send one through the front and confirm it is rejected, not answered with 200. These are configuration-level mitigations; no vendor patch is asserted here.
Canonical references
- NullRabbit advisory: NR-2026-054 — Ethereum (go-ethereum): h2→h1 empty-
:authoritydowngrade bypasses geth's--http.vhostsHost allowlist (published 2026-07-15;source_class: original, out-of-scope publish-track, no CVE assigned). - NRDAX technique: NRDAX-T0110 — HTTP Header Spoofing Trust Bypass (class
network-rpc); this brief is theethereum/ go-ethereum instanceeth_geth_h2h1_empty_authority_vhost_bypass. - Evidence bundle (Hugging Face): NullRabbit/nr-bundles-public — primitive
eth_geth_h2h1_empty_authority_vhost_bypass(auth_bypass, corpuscrp_3083e2525b7743c4, captured pcap). - Upstream: go-ethereum JSON-RPC documentation — the HTTP server and its
--http.vhostsvirtual-host / DNS-rebinding protection; geth handlernode/rpcstack.go(virtualHostHandler), cited by the advisory. - No CVE: no CVE is assigned to this finding. The CVE references attached to the broader NRDAX technique class belong to other software, not to this geth/HAProxy instance.
Related attacks
This instance was found on the same HTTP/2 downgrade-differential rig as a companion go-ethereum finding, eth_geth_h2c_multiplex_connlimit_bypass — an HTTP/2 cleartext (h2c) stream-multiplexing bypass of a per-connection rate limit. Both exploit a disagreement between how an edge and go-ethereum interpret the same HTTP/2 request: one over the Host/:authority identity, the other over connection-vs-stream accounting. NRDAX groups this instance under the chain-agnostic technique NRDAX-T0110 (HTTP Header Spoofing Trust Bypass), whose registry entry also catalogues known public header-trust CVEs in other software as coverage references; those are separate implementations and are not asserted to be the same defect. No relationship is claimed beyond the shared rig, the auth_bypass family, and that technique id.
Track attacks affecting go-ethereum and Ethereum RPC infrastructure.
Analysis plus reproducer. No weaponised proof-of-concept code.
Related Posts
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 port terminates cleartext HTTP/2 (h2c) by prior knowledge, so one TCP connection can multiplex N eth_getLogs streams past an L4 edge that only caps connections per IP (nginx stream limit_conn) — measured 20/20 streams and ~39 MB pulled through a single connection the edge counts as one, an amplification-multiplexing bypass of a connection cap, not of an L7 request rate limit.
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.
