For the complete documentation index, see llms.txt. This page is also available as Markdown.

88250 bc high one unauthenticated tcp burst permanently terminates a node s entire networking stack with no recovery short of a process restart

Submitted on Aug 12th 2026 at 11:34:18 UTC by @shadowHunter for Audit Comp | Quantus

  • Report ID: #88250

  • Report Type: Blockchain/DLT

  • Report severity: High

  • Target: https://github.com/immunefi-team/audit-comp-quantus-chain

  • Impacts:

    • Network not being able to confirm new transactions (total network shutdown)

Description

1. Summary

A remote attacker who has never completed a handshake, never proved any work, and never sent a single valid protocol message can permanently destroy a Quantus node's networking. The node process stays alive and looks healthy; it simply becomes unreachable, forever, until an operator restarts it.

The whole attack is ~1,000 plain TCP connections, opened in about 60 milliseconds, carrying zero bytes of payload.

Three defects compose:

  1. Pre-handshake connections are counted against no limit. on_pending_incoming_connection (client/litep2p/src/transport/manager/mod.rs:731-733) consults a limit that only tracks established connections, so connections that never complete the noise handshake are unbounded.

  2. The node sets no file-descriptor limit for itself. A repository-wide search for setrlimit / RLIMIT_NOFILE / LimitNOFILE / ulimit returns nothing, so the ceiling is whatever the launcher provides — commonly 1024.

  3. Any accept error kills the listener permanently. On EMFILE the transport returns Poll::Ready(None), ending the Stream. EMFILE is transient — descriptors are released within milliseconds — but it is treated as fatal.

Measured result, reproduced 4/4:

Not degraded — terminated. An honest node that connected to the victim seconds earlier could not reach it again, during the attack or after it stopped.

2. Description

2.1 Unauthenticated connections are admitted without being counted

ConnectionLimits::on_incoming tests self.incoming_connections.len() — a set of established connections. A pending connection is never inserted into it. So MAX_CONNECTIONS_ESTABLISHED_INCOMING = 10_000 (client/network/src/lib.rs:310, applied by Quantus at client/network/src/litep2p/mod.rs:521) does not bound the pre-handshake population at all.

Each such connection holds a file descriptor for up to SUBSTREAM_OPEN_TIMEOUT = 5 s (client/litep2p/src/transport/mod.rs:47).

2.2 The node never limits its own descriptors

The node inherits the launcher's limit. Docker's default is 1024; many service managers use the same. The repository ships no unit file, no container limit, and no runtime call.

2.3 A transient accept error terminates the transport for good

This is the defect that turns a temporary resource shortage into permanent death.

Returning Poll::Ready(None) from a Stream means finished. There is no retry, no backoff, and no re-listen. EMFILE means "no descriptors right now" — the correct response is to log, pause briefly, and continue accepting. Instead the node's entire transport is retired.

The identical pattern exists in the WebSocket transport:

Neither accept path distinguishes transient errors (EMFILE, ENFILE, ECONNABORTED) from fatal ones.

2.4 The blast radius is the whole networking stack

The termination does not stop at the listener. The observed cascade, in the same second:

block-announces is how the node learns of new blocks. Once it is closed the node cannot receive blocks, cannot announce its own, and cannot acquire peers — inbound or outbound — because the backend that would service them is gone.

3. Steps to reproduce

1

A node is running normally

Any reachable Quantus node. Confirm an honest peer can connect to it; this is the control and it must not be skipped.

2

The attacker opens ~ulimit -n plain TCP connections

No handshake, no valid protocol data, no payload at all. Simply connecting is the attack. The connections need only be concurrent, which they will be, since each is held for up to 5 seconds by the negotiation timeout.

3

The victim's descriptor table saturates and the listener dies

4

The attacker disconnects entirely

Every attacker socket is closed. The victim's descriptors are released within ~50 ms and its resource usage returns to baseline.

5

The node is still dead

An honest peer — the same one that connected in Step 1 — can no longer reach the node. It never will again. The node's CPU is idle, its memory is at baseline, its descriptors are free, and its RPC port is still bound. Only a process restart restores networking, after which the attack can be repeated immediately.

4. Impact

4.1 What is demonstrated, without extrapolation

An unauthenticated remote party, with no handshake, no key material, no peer identity, no proof of work, and no protocol data of any kind, permanently removes a targeted node from the network in ~110 milliseconds, at a cost of roughly 1,000 TCP connections. The node does not recover when the attack stops. Only an operator restart restores it — and the node can be killed again immediately.

There is no rate limit, no reputation consequence, no ban, and no alarm. The node's own logs describe the event with ignore if the node is stopping.

The measurement, reproduced 4/4 with a passing control each time (§6.6):

Victim ulimit -n

Connections opened

Listener terminated

Litep2p backend terminated

Control passed

1024

994

yes

yes

✓

1024

991

yes

yes

✓

1024

992

yes

yes

✓

4096

4,065

yes

yes

✓

Raising the descriptor limit does not mitigate — it only prices the attack. Quadrupling the limit quadrupled the connections required and changed nothing else.

4.2 Why this is total network shutdown

The demonstrated primitive is per-node, but nothing about it is node-specific: it requires only that the target accept TCP connections. Applying it to the reachable node set stops the network confirming transactions, for three compounding reasons:

  • Bootnodes are publicly reachable by definition. Killing them prevents any node from joining or rejoining. Since the attack is permanent per victim, restarted nodes can be re-killed on restart.

  • Block propagation dies with block-announces. A node whose transport terminated cannot receive or announce blocks. Miners that survive are mining in isolation; their blocks reach no one.

  • Survivors are isolated, not healthy. A node behind NAT with only outbound connections cannot be attacked directly — but it has nothing left to dial once the reachable set is dead, and it loses its existing peers as those nodes' transports terminate.

The cost is ~1,000 TCP connections per node, in parallel, once. This is not a sustained flood, does not require holding state, and cannot be outlasted, because the damage persists after the attacker disconnects.

4.3 The fallback, stated explicitly and uncontested

Only the per-node shutdown was executed. The network-scale conclusion in §4.2 is reasoned, not measured, because it requires a live network of independently-operated nodes, which this competition's scope does not provide.

If the triager considers that step unproven, the demonstrated primitive maps directly onto the node-shutdown impacts — "Shutdown of ≥30% of network processing nodes" (Medium) or "…>10% and <30%" (Low) — according to what fraction of nodes are judged reachable. This report does not contest such a re-grading and asks that it be re-graded rather than closed, since every impact in that family is expressed as a percentage of nodes, and the per-node result underlying all of them is measured, reproduced 4/4, and controlled.

4.4 Honest limits

  • The fd limit is the operator's, and this report cannot state it for production. Tested at 1024 and 4096. A node at 65,536 requires ~65,000 concurrent connections — more effort, same outcome, and still well within a single host's capability.

  • Tested on --dev with a single victim. The transport code exercised is chain-agnostic.

  • RPC responsiveness was noisy and is deliberately not relied upon. Baseline RPC answered 10/10 in three runs but 10/33 in the fourth, so the availability conclusion rests on the peer-connection probe and the victim's own log, both of which were unambiguous in all four runs.

  • The peak descriptor count is under-sampled. The 50 ms trace caught 567; the true peak is the fd ceiling, evidenced by the EMFILE error rather than by the sampler. An earlier version of this harness sampled every 5 s, reported a peak of 32, and missed the entire event — that harness bug is disclosed here because the corrected trace is what §6.5 relies on.

5. Recommendation

5.1 Do not treat a transient accept error as terminal

This is the fix that matters, and it is small:

5.2 Count pending inbound connections against a limit

on_pending_incoming_connection should consult a bound that includes in-negotiation connections, not only established ones. This alone would prevent the descriptor table from ever being exhausted by unauthenticated peers.

5.3 Set the node's own descriptor limit

Call setrlimit(RLIMIT_NOFILE, …) at startup and/or ship a service unit with an explicit LimitNOFILE, so a node's survival does not depend on how an operator happened to launch it.

5.4 Make transport death loud

Litep2p backend terminated and ignore if the node is stopping are the only signals that a node has permanently left the network. This warrants a fatal-level alarm and, arguably, process exit — a node that cannot network is not serving any purpose, and exiting would at least let a supervisor restart it.

Proof of Concept

6. Proof of concept

Everything below is complete and self-contained. No external references, no downloads beyond the repository itself, and no third-party Python packages.

6.1 Environment

OS

Ubuntu 24.04.1 LTS (WSL2 on Windows)

CPU / RAM

AMD Ryzen 7 5800H, 12 cores, 9,944 MB

Victim binary

quantus-node-victim, unmodified release build, sha256 84c31d34edc2440f779f9f24d7947cd2e6523d56e99afa21abacbd8ee997670c

Victim fd limit

set explicitly per run, verified from /proc/<pid>/limits

Attacker

one Python 3 process, no dependencies

The victim is launched through bash -c "ulimit -n N; exec quantus-node …" so the constraint is real, and the harness verifies it from the kernel rather than assuming it:

6.2 Setup

No patch of any kind is applied. The victim is the stock release binary.

6.3 The driver — poc12_fdflood.py

Complete and verbatim. Save as ~/quantus-poc/poc12-fdflood/poc12_fdflood.py.

Note on the payload. The driver sends two bytes (\xff\x7f) per connection, which is a leftover from an earlier experiment on the same code path. It is not required for this finding. Setting payload to b"" produces the same result: the descriptors are consumed by the act of connecting, and the negotiation timeout holds them for 5 seconds whether or not anything is sent.

6.4 The orchestrator — poc12_run.sh

Complete and verbatim. Save alongside the driver.

6.5 How it was invoked

6.6 PoC output — the 50 ms descriptor trace

The attack completes in about 110 milliseconds:

The descriptor count collapses below baseline because the accepted connections are torn down when the transport dies. The victim's descriptors are free within 110 ms of the attack starting — and it is already permanently dead. Nothing about its resource state after that point explains why it will not accept a connection; the transport Stream has simply ended.

The "1084 refused locally" and the "18/sec average" are artefacts of the flooder continuing to attempt connections for the remainder of the 45-second phase, long after the victim stopped listening. The meaningful cost figure is the ~1,000 connections opened before the listener died.

6.7 PoC output — the availability verdict

The control and the probe, side by side:

Phase
Honest peer connected?
Victim peers

Control — honest node, no flood

yes

control_peers=1

During flood

no

0 across all 23 samples

After flood stops

no

0

The honest node in the third row is a fresh, correctly-configured node using the same bootnode multiaddr that worked in the first row. It logged (0 peers) on every sample. The orchestrator aborts the whole experiment if the control fails, so no result here rests on an untested assumption.

6.8 Victim-side evidence

After this point the log contains only SyncingEngine shut down, repeating every five seconds for the remainder of the run, while the process remains alive and the harness reports victim STILL RUNNING.

Was this helpful?