The Cosmos-SDK gRPC server never sets MaxConcurrentStreams, and a fork already fixed it
The Cosmos-SDK gRPC server is constructed with grpc.NewServer and no grpc.MaxConcurrentStreams option, so grpc-go falls back to an effectively unbounded concurrent-stream limit and advertises no cap to the client, letting a remote client hold streams that pin about 7 KB of server state each, reaching 7.6 GB of gaiad RSS at the top of the tested sweep with no plateau observed.
Why this matters
The interesting part is not that a limit is missing. It is that the limit is missing upstream while a widely-used fork already sets it. Sei's server/grpc/server.go:19 passes grpc.MaxConcurrentStreams(100) at the equivalent construction site. Upstream Cosmos-SDK does not. The gap between them is roughly two lines, and everything built on upstream inherits the version without the cap.
That makes this a default-inheritance problem rather than a bug in any one chain. If you run a Cosmos-SDK chain and expose the gRPC port, you have this unless you or your fork put a cap somewhere.
There is also a signalling failure that makes the surface worse than a missing limit alone. Because the option is unset, the server never emits SETTINGS_MAX_CONCURRENT_STREAMS in its HTTP/2 SETTINGS frame. A cooperative, well-behaved client therefore sees no limit either, and has no way to discover one exists. The protocol's own mechanism for advertising the boundary is silent.
The harm is per-validator downtime with a slashing tail, not a chain halt. On a multi-validator chain, quorum keeps producing blocks while an affected validator cycles through OOM and restart.
How the attack works
Entry point. The Cosmos-SDK gRPC port, default 9090.
Preconditions. Network reachability to that port. No credentials, and no completed request: opening a stream is sufficient.
Processing path. In server/grpc/server.go, the grpc.NewServer(...) construction site passes codec and message-size options but no grpc.MaxConcurrentStreams. (The exact line number drifts between SDK releases, so the construction site is the durable reference rather than a line range.) Two consequences follow from that single omission:
- grpc-go's default maximum concurrent streams is
math.MaxUint32, effectively unbounded, so the server accepts arbitrarily many simultaneously-open streams on one connection. - grpc-go also skips emitting
SETTINGS_MAX_CONCURRENT_STREAMS, so the client is told nothing about a limit.
Resource boundary crossed. Each accepted stream allocates per-stream server state on open: route lookup, request context, and gas meter. That state is retained for as long as the stream is open. The attacker multiplexes many streams over a single TCP connection and simply holds them, never sending a body. Stream count converts directly into pinned server memory.
Resulting behaviour. RSS grows with held-stream count until the process is OOM-killed by the kernel, or by the operator's cgroup if MemoryMax is set. With the standard Restart=on-failure, that is roughly a 5-second downtime per kill cycle. Sustained, the accumulated missed blocks are what create slashing exposure.
Affected systems
- Chain: Cosmos, and Cosmos-SDK-based chains generally.
- Implementation:
cosmos/cosmos-sdk, atserver/grpc/server.go. Measured against gaiad v22.x. - Component: the gRPC server, default port
9090. - Exposure condition: the gRPC port reachable by an attacker, without a
MaxConcurrentStreamscap or an external per-source stream or connection limit. - Not affected: Sei's fork, which sets
grpc.MaxConcurrentStreams(100)at the equivalent call site.
Note the scope carefully. The measurement is against gaiad; the source trace is against upstream Cosmos-SDK. A given SDK-based chain inherits the defect only if its own fork has not added a cap.
Evidence
Publicly documented. None. NullRabbit original finding, no CVE.
Independently reproduced by NullRabbit. The published corpus reproducer captures the wire signature, many HTTP/2 streams multiplexed on one connection to a gRPC endpoint, via the shared HTTP/2 multiplex capture template. Sixteen attack captures span a 512 to 2048 stream-count sweep at saturating posture. NRDAX records this instance at fidelity: lab, discovery_origin: original-research. The advisory is explicit that it stands on the source trace and the measurement rather than on the reproducer's transport shim.
Measured under controlled conditions. On gaiad v22.x over loopback:
About 7 KB pinned per held stream. The underlying sweep:
| Connections | Streams each | Peak RSS |
|---|---|---|
| 1 | 1,000 | 305 MB |
| 1 | 10,000 | 358 MB |
| 1 | 100,000 | 962 MB |
| 5 | 100,000 | 3,896 MB |
| 10 | 100,000 | 7,618 MB |
The marginal cost between the 1,000- and 100,000-stream single-connection rows works out at about 6.6 KB per stream, which is where the ~7 KB figure comes from.
Two honest qualifications on this data, both of which cut against a stronger reading:
- 7.6 GB is the top of the tested matrix, not a demonstrated ceiling. RSS grew monotonically across every row with no plateau. Testing stopped at 10 connections of 100,000 streams; nothing here shows where, or whether, it levels off. Earlier internal notes described a ~7-8 GB per-source ceiling attributable to a grpc-go internal limit around 1M streams. That plateau does not appear in the raw sweep, and this brief does not claim it.
- The sweep varied connection count, not distinct source IPs. Scaling across separate attacker hosts is a mechanism inference from the per-connection allocation, not something these runs measured.
For a 32 GiB Gaia validator with a ~4 GB normal working set, the tested 7.6 GB figure is already enough to matter on a host with no cgroup cap. How far beyond it an attacker can push is untested.
Measured and falsified. This is not a transaction front-running or asymmetric-degradation finding, and that was tested rather than assumed. During the attack, with the validator pinned at 7-9 GB RSS, transaction submission succeeded 20 out of 20 via the CometBFT RPC path (port 26657) and 15 out of 15 via LCD/REST (port 1317). Those paths use separate server pools that the gRPC stream flood does not bleed into.
Inferred but not reproduced. The slashing tail is a documented consequence chain rather than an observed outcome: Cosmos Hub jails at 500 missed blocks in a 10,000-block window, which at 6-second blocks is about 50 minutes consecutive, and jailing carries a 0.01% bonded-stake slash. Whether a given attack sustains long enough to reach that threshold was not measured.
Operator actions
Chain-side, and the preferred fix:
- Add
grpc.MaxConcurrentStreams(N)to the gRPC server construction inserver/grpc/server.go. Sei's fork uses100. This is the two-line change, and it also restores theSETTINGS_MAX_CONCURRENT_STREAMSsignal to clients.
Operator-side, already standard at institutional validators:
- Do not expose gRPC on the consensus validator. Use a sentry architecture.
- Per-source connection and stream limits at an L7 proxy (
limit_connin HAProxy or nginx), oriptables connlimit. - Cap process memory with a cgroup
MemoryMax, paired withRestart=on-failurefor fast recovery. This converts an uncontrolled OOM into a bounded restart cycle; it limits the damage rather than preventing the attack.
The realistic blast radius is operators exposing gRPC without any per-source limiting.
Canonical references
- NRDAX technique: NRDAX-T0064 (
endpoint-concurrency-cap-exhaustion), instancecosmos_grpc_stream_flood - NullRabbit advisory: NR-2026-018, published 2026-07-06
- Evidence bundle: primitive
cosmos_grpc_stream_floodin the public datasetNullRabbit/nr-bundles-public - Source trace and measurement:
chains/cosmos/findings/C11-disclosure-note.md - Upstream:
cosmos/cosmos-sdk,server/grpc/server.go; the contrasting Sei fork atserver/grpc/server.go:19
Related attacks
NRDAX-T0064 (endpoint-concurrency-cap-exhaustion) covers the same missing-cap shape across implementations. The Kaspa instance is covered in the rusty-kaspa gRPC brief, and the Internet Computer's XNet endpoint carries a related concurrency-cap defect in the opposite direction, where the cap exists but is set to 4.
The advisory names this the Cosmos-side sibling of the IOTA StreamCheckpoints held-stream finding. That sibling is iota_grpc_stream_cap_dos (NR-2026-009), which shares NRDAX-T0064 and has no brief yet. It should not be confused with the separate, already-briefed IOTA gRPC HTTP/2 stream OOM, which is a different primitive (iota_grpc_h2_multiplex_oom, NR-2026-003) in the memory-amplification family. The two Cosmos availability findings published as NR-2026-007 and NR-2026-008 are on the CometBFT consensus and handshake surfaces, not this gRPC one; the CometBFT handshake burn has its own brief.
Track attacks affecting Cosmos-SDK chains in NRDAX.
Analysis plus reproducer. No weaponised proof-of-concept code.
Related Posts
rusty-kaspa gRPC pre-auth HTTP/2 stream flood causing inbound-peering exhaustion and eviction
A pre-auth HTTP/2 stream flood on the rusty-kaspa gRPC P2P endpoint causes inbound-peering exhaustion via eviction and admission denial.
How CometBFT's SecretConnection handshake spends CPU before authenticating the peer
CometBFT completes the full SecretConnection STS handshake — an X25519 Diffie–Hellman exchange and an Ed25519 signature verification — for any connecting peer before checking whether that peer's node-ID is allowlisted, so an unauthenticated remote source can spend the node's asymmetric-crypto budget at will.
A reth message that decodes cleanly never trips the misbehaviour counter, and that was the problem
Before reth PR #23718, eth-wire Transactions and PooledTransactions decoded into an unbounded Vec before any validation, so a well-formed 10 MB message could allocate 436 MB during decode without ever incrementing the peer-misbehaviour counter that only reacts to decode errors.
