How unbounded HTTP/2 concurrent streams let one TCP connection OOM-kill an IOTA node's public gRPC server
An IOTA node's public gRPC server accepts an unbounded number of concurrent HTTP/2 streams on a single TCP connection. One client that opens a single connection and multiplexes around 200 concurrent GetTransactions calls, each requesting a large batch, drives the server's resident memory from a few hundred MB to multiple GB in around 10 seconds — until the kernel OOM-killer terminates the node process. No large bandwidth is required, and there is no idle eviction: one source IP, one burst, node down, restart required.
This is an availability issue only — no memory corruption, no funds or consensus impact.
Why this matters
Anyone running an IOTA node with the public gRPC API enabled is exposed — including the IOTA Foundation's own grpc.mainnet.iota.cafe endpoint and third-party RPC providers. The consequence is not degraded service but the whole node process dying: gRPC and JSON-RPC both go down together, and if the node is a validator, consensus participation stops until an operator restarts it. Because the underlying iota-localnet/iota-node binary is what gets OOM-killed, this is process-level, not method-level, unavailability.
A naïve rate limit does not catch this. The attack is a single burst of roughly 200 requests on one connection, then the attacker just holds the streams open — there is no request-rate spike to detect, and the server keeps buffering per-stream response state regardless of how slowly (or never) the client reads it back. An HTTP/2 concurrent-stream cap at the edge (for example, a CDN's own post-CVE-2023-44487 default) only forces the attacker onto two or three TCP connections instead of one; it does not bound the uncapped backend, so the kill still lands.
How the attack works
- Entry point. The IOTA node's public gRPC
LedgerService, specifically the unaryGetTransactionscall (/iota.grpc.v1.ledger_service.LedgerService/GetTransactions). Remote, unauthenticated, reachable over a single TCP connection. - Preconditions. Any IOTA node exposing the public gRPC API whose tonic
Serverbuilder does not set.max_concurrent_streams(N). In that state the server's HTTP/2 SETTINGS frame advertisesmax_concurrent_streams = u32::MAX— effectively unlimited — to any connecting client. - Processing path. The tonic server is constructed at
iota-grpc-server/src/server.rs:185viatonic::transport::Server::builder()with no.max_concurrent_streams(N)call added, so the HTTP/2 layer inherits an unbounded per-connection concurrent-stream count. The node does have a concurrency cap —default_grpc_api_max_concurrent_stream_subscribers = 1024(iota-config/src/node.rs:341-347) — but it is scoped only to theStreamCheckpointsserver-streaming subscription; it does not apply toGetTransactions,GetObjects, orGetCheckpoint, the unary calls this attack multiplexes. - Resource boundary crossed. Server-side memory. Each in-flight
GetTransactionscall accumulates its response state (serialized batch data) in memory independent of the client's receive rate — there is no receive-rate backpressure bounding retained memory, so concurrency alone, not bandwidth, is the multiplier. - Resulting behaviour. Memory grows roughly linearly with the number of concurrent open streams — measured at approximately 100 MB resident memory per concurrent stream — with no ceiling but the host's available RAM, ending in a kernel OOM-kill of the node process. The node recovers only via operator restart; if the burst continues, it is killed again.
Affected systems
- Chain: IOTA.
- Implementation: IOTA node (
iota-node; upstream repositoryiotaledger/iota). - Component / method: public gRPC
LedgerService, unaryGetTransactions(and the other unary ledger reads —GetObjects,GetCheckpoint— which share the same uncapped concurrency). - Version tested. The reproduction was run against a fresh-genesis IOTA node at version
v1.23.2, on a 4 vCPU / 8 GB RAM Ubuntu 24.04 lab host. The underlying gap — no.max_concurrent_streams(N)set on the tonic server builder — is a configuration default in the server code rather than a version-pinned regression; this brief makes no claim about which other IOTA node releases do or do not carry the same default, only about the version measured. - Vendor response. IOTA's security contact is
[email protected]. IOTA does not run a public paid bug-bounty program (the legacy program is defunct), and node-availability/DoS is not a paid-impact category in any case, so this finding is out of scope for any bounty. There is no embargo: the finding rests on NullRabbit's own measurement against a self-owned lab node, and it is published as an operator advisory rather than a coordinated disclosure.
Evidence
- Publicly documented. None. There is no external CVE or GHSA reference for this finding — it is original NullRabbit research.
- Independently reproduced by NullRabbit. A code-level reproducer accompanies NR-2026-003 (
reproducers/): it opens one TCP connection to a local gRPC endpoint, multiplexes N concurrentGetTransactionscalls, and samples the node process's RSS once per second. It targets only a self-owned, locally-run node — it is not a turnkey tool and does not reference or target any public or mainnet endpoint. - Measured under controlled conditions. On an 8 GB lab node: RSS climbed from 472 MB to 7.6 GB in approximately 10 seconds, then the kernel OOM-killer terminated the process (
dmesg:oom-kill ... anon-rss:7798928kB). The trigger was roughly 200 concurrentGetTransactionsstreams on one TCP connection, each stream requesting a batch of 500 digests. The measured slope was approximately 100 MB RSS per concurrent stream. - Inferred but not separately reproduced. The advisory extrapolates the measured ~100 MB/stream slope to other host sizes: roughly 70–80 concurrent streams would exhaust an 8 GB host, roughly 150 a 16 GB host, and roughly 300 a 32 GB host. Only the 8 GB case was directly measured to a kill; the 16 GB and 32 GB figures are linear extrapolations from the measured slope, not independently reproduced OOM events on hosts of those sizes.
Operator actions
- Server-side fix (the one that closes it). Bound the tonic gRPC server with
Server::builder().max_concurrent_streams(N), for exampleN = 64. This caps per-connection concurrent streams at the transport layer, so RSS plateaus instead of climbing without bound. - Edge/CDN controls reduce but do not remove exposure. An HTTP/2 concurrent-stream cap at the edge forces the attacker onto two or three TCP connections instead of one; it does not save an uncapped backend, so it is not a substitute for the server-side fix.
- Exposure check. An operator can check their own node's HTTP/2 SETTINGS frame on the public gRPC port: if
max_concurrent_streamsis advertised asu32::MAX(effectively unlimited) rather than a bounded value, the tonic server builder has not been configured with.max_concurrent_streams(N)and the node is exposed as described above.
Canonical references
- NRDAX technique: NRDAX-T0097 — grpc-h2-multiplexing-oom
- NullRabbit advisory: NR-2026-003 — IOTA node gRPC OOM via unbounded HTTP/2 concurrent streams
- Evidence bundle (Hugging Face): NullRabbit/nr-bundles-public — primitive
iota_grpc_h2_multiplex_oom - Reproducer:
NR-2026-003/reproducers/ - Upstream:
iotaledger/iota(no CVE/GHSA has been filed for this finding); vendor security contact[email protected].
Related attacks
- NRDAX-T0064 — endpoint-concurrency-cap-exhaustion on IOTA — advisory NR-2026-009, primitive
iota_grpc_stream_cap_dos. The same public gRPC surface, but a different mechanism and resource: a single IP fills theStreamCheckpointssubscriber cap (1024, process-global) and denies all other legitimate subscribers, rather than exhausting host memory. - NRDAX-T0329 — unbounded-rpc-response-amplification on IOTA — advisory NR-2026-031, primitive
iota_f10_grpc_batch_amp. Response-size amplification on the sameGetObjects/GetTransactionsunary reads, on a single stream rather than via concurrency; the two mechanisms compound when combined on one connection.
Track attacks affecting IOTA
New IOTA node findings are catalogued as they are reproduced. Track attacks affecting IOTA on NRDAX and in NullRabbit research.
Analysis plus reproducer. No weaponised proof-of-concept code.
Related Posts
How unauthenticated simulateTransaction requests saturate an Agave RPC node's executor pool
Agave runs the simulateTransaction handler synchronously on its shared Tokio executor threads, so unauthenticated, gas-free simulate requests pin those workers and add queue latency across the validator's entire JSON-RPC tier — not just the simulate path.
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.
How an unauthenticated TLS half-open flood pins rippled's memory and crashes it under low file-descriptor limits
rippled's inbound TLS listeners on the peer port (51235) and the RPC-HTTPS port (5006) accept a partial TLS handshake with no deadline and no per-IP half-open cap, so a single source IP can pin server memory indefinitely and, at a default file-descriptor limit, crash the process.
