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

88226 bc medium two unauthenticated bytes make a node commit 16 kib of resident memory in the pre handshake multistream select framing path

Submitted on Aug 12th 2026 at 10:03:48 UTC by @shadowHunter for Audit Comp | Quantus

  • Report ID: #88226

  • Report Type: Blockchain/DLT

  • Report severity: Medium

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

  • Impacts:

    • Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours

Description

1. Summary

During multistream-select protocol negotiation, which happens on a plaintext socket, before the Dilithium/ML-KEM noise handshake, with no authentication of any kind, litep2p reads an unsigned-varint length prefix and immediately sizes its read buffer to the value the remote peer declared:

client/litep2p/src/multistream_select/length_delimited.rs:174

this.read_buffer.resize(len as usize, 0);

len is capped at MAX_FRAME_SIZE = (1 << 14) - 1 = 16_383 (line 31), so a peer cannot declare more than 16 KiB. But nothing requires the peer to ever send those bytes. Two bytes — \xff\x7f, the varint for 16383 — cause the node to commit 16,383 bytes and then wait.

Crucially, read_buffer is a BytesMut and this is BytesMut::resize, which writes the fill byte. The pages are therefore touched and become resident. This is the distinction that matters: the sibling allocation pattern vec![0u8; n] / BytesMut::zeroed lowers to alloc_zeroed, whose zero pages are committed lazily and stay virtual. A previously-reported 16 MiB request-frame finding in this same subsystem was correctly refuted on exactly that ground. This site behaves differently, and the measurement below confirms it.

Measured: 10,000 concurrent connections, 2 bytes each (20,000 bytes total from the attacker), grew the victim's resident set by 211.4 MiB, of which 155.6 MiB is attributable to this line after subtracting the control. (All memory figures in this report are binary units converted from the kB values reported by /proc/<pid>/status, which are KiB.)

2. Description

2.1 The allocation is attacker-controlled and pre-authentication

client/litep2p/src/transport/tcp/connection.rs:413-453, negotiate_connection:

negotiate_protocol is where LengthDelimited lives. It runs at line 435, and noise::handshake runs at line 444. The buffer is therefore sized nine lines before any cryptography happens. The attacker needs no key material, no valid peer identity, and no proof of work. It needs a TCP connection and two bytes.

2.2 The size is exactly what the remote asked for

length_delimited.rs:30-31:

The varint decoder reads at most 2 bytes, so len is structurally capped at 16,383 — there is no missing bounds check, and this report does not claim one. The defect is that a declared length is committed as written memory before a single byte of the frame body has arrived, on an unauthenticated connection.

\xff\x7f decodes as 0x7f | (0x7f << 7) = 16383.

2.3 Why it is resident and not virtual

read_buffer is declared at client/litep2p/src/multistream_select/length_delimited.rs:48:

BytesMut::resize(new_len, value) reserves capacity and then fills the new region with value via put_bytes, i.e. an explicit write_bytes. Every page is touched. Additionally, 16,383 bytes is below glibc's 128 KiB M_MMAP_THRESHOLD, so the allocation is served from the heap arena rather than a fresh lazily-committed mmap.

The measurement in Step 6 does not rely on this reasoning — it measures VmRSS directly but the reasoning predicts the measured result, and the prediction matched to 99.6 %.

2.4 What bounds it

Two mechanisms, both measured:

The 5-second negotiation timeout. client/litep2p/src/transport/tcp/connection.rs:394:

with SUBSTREAM_OPEN_TIMEOUT = Duration::from_secs(5) (transport/mod.rs:47). Note this is the inner and tighter of two timeouts; the outer CONNECTION_OPEN_TIMEOUT of 10 s (client/litep2p/src/transport/mod.rs:44, applied at client/litep2p/src/transport/tcp/connection.rs:328) never gets the chance to fire on this path. Measured connection lifetime was 5.15 s (§6.6).

The plateau. Freed 16 KiB chunks are retained by the allocator rather than returned to the OS, so the victim's RSS stays elevated after the attack — but for the same reason a second wave reuses those chunks instead of adding to them. Waves 2 and 3 added +20 kB and +8 kB over wave 1's +43,292 kB. The effect is a permanent step to a high-water mark, not a ratchet.

2.5 The 10,000-connection limit does not apply here

MAX_CONNECTIONS_ESTABLISHED_INCOMING = 10_000 (client/network/src/lib.rs:310) is applied at client/network/src/litep2p/mod.rs:521. Following it through, ConnectionLimits::on_incoming (transport/manager/limits.rs) tests self.incoming_connections.len(), a set of established connections, and on_pending_incoming_connection (transport/manager/mod.rs:731-733) calls it without registering the pending connection anywhere.

So connections that never complete the noise handshake are not counted against that ceiling. This cuts both ways and both are stated: the attack is not capped at 10,000, but it is capped by the victim's file-descriptor limit, which the node never sets for itself — a repository-wide search for setrlimit / RLIMIT_NOFILE / LimitNOFILE returns nothing, so the node inherits whatever its launcher provides.

3. Steps to reproduce

1

Start a pristine node

Start a pristine node with local mining disabled (so RSS is quiet):

2

Record baseline memory

Record VmRSS from /proc/<pid>/status.

3

Open connections and declare a large frame

Open N TCP connections to port 31333. On each, send the two bytes \xff\x7f and then send nothing.

4

Measure memory during the negotiation window

Within 5 seconds, read VmRSS again.

5

Observe the allocation growth

Observe roughly N × 22.2 kB of growth, of which N × 16.3 kB is attributable to the two bytes (see the control arm in step 6).

6

Run the control arm

Repeat with connections that send nothing at all. Growth falls to N × 5.9 kB — this is the cost of the TCP connection and its task, and it is what must be subtracted.

4. Impact

4.1 Measured, not extrapolated

Every figure in this table is a direct measurement against the pristine release binary quantus-node-victim, sha256 84c31d34edc2440f779f9f24d7947cd2e6523d56e99afa21abacbd8ee997670c.

N concurrent
Arm
Attacker bytes total
Victim VmRSS delta
Per connection

2,000

silent (control)

0

+11,428 kB

5,851 B

2,000

small, len=64 (control)

2,000

+11,488 kB

5,882 B

2,000

big, len=16383

4,000

+43,292 kB

22,166 B

10,000

silent (control)

0

+57,188 kB

5,856 B

10,000

big, len=16383

20,000

+216,496 kB

22,169 B

Attributable to read_buffer.resize:

N
big − silent, per connection
vs. predicted 16,383 B

2,000

16,315 B

99.6 %

10,000

16,313 B

99.6 %

At N = 10,000 the total attributable growth is 159,308 KiB ≈ 155.6 MiB, produced by 20,000 attacker bytes. That is an amplification of 8,157 resident bytes per attacker byte.

The victim's total RSS went from 250,904 KiB to 467,400 KiB — a rise of 216,496 KiB (211.4 MiB, an 86 % increase) — and did not fall back after the connections closed.

4.2 The small control is what makes this attributable

The small arm sends a one-byte varint declaring a 64-byte frame. It traverses the identical code path and executes the identical resize() call. It produced 5,882 B/conn against the silent control's 5,851 B/conn — a difference of 31 bytes.

So the growth in the big arm is caused by the declared frame length, not by the connection, not by the task, and not by the code path being entered. Without this arm the headline number would have been the uninterpretable 22,166 B/conn.

4.3 Scope, stated honestly

  • This is not unbounded. The plateau result (§2.4) means an attacker cannot drive memory up indefinitely by reconnecting. They reach a high-water mark set by peak concurrency and stay there.

  • The peak does not need to be sustained, because the memory is never returned. This is the point most likely to be misread, so it is stated precisely. Reaching the measured peak took one burst of 1.89 seconds (opened 10000, failed 0, in 1.89s). Every connection then expired on its own after ~5.15 s — and the victim's RSS did not fall: VmRSS after release: 467,412 kB (baseline 250,904, retained +216,508 kB). glibc retains the freed 16 KiB chunks rather than returning them to the OS, so the increase persists for the lifetime of the process. A single sub-two-second burst permanently raises the node's resident floor by 211 MiB. Holding the connections open is unnecessary, and repeating the burst is pointless — waves 2 and 3 added 20 kB and 8 kB (§6.7).

  • On "without brute force actions". The relevant impact excludes brute force, so what the attack actually costs is worth being exact about: 20,000 bytes total, delivered in 1.89 seconds, once. There is no sustained flood, no repetition, and no need to hold state — the effect outlives the connections that caused it. Opening 10,000 TCP connections in under two seconds is the whole of the effort involved.

  • The ceiling depends on the operator's fd limit, which this report cannot state for production deployments. The measurements used ulimit -n 65536. A node run with the common default of 1,024 would cap the attack at roughly 16 MiB of attributable resident memory; one at 65,536 would reach roughly 1.0 GiB. The repository ships no fd-limit configuration, so this is entirely deployment-dependent.

  • N = 1 per configuration. Each cell in the table above is a single run. The consistency across three values of N (280, 2,000, 10,000) and across three waves within each run is the evidence for stability, not repeated independent trials.

  • Measured on one host (WSL2 Ubuntu, 12 cores, 9.9 GB RAM, glibc malloc). The residency conclusion depends on allocator behaviour; a node built against a different allocator (jemalloc, mimalloc) could plausibly differ, and this was not tested.

4.4 Why it still matters

The attacker requirement is: reach the p2p port, and send two bytes per connection. No proof of work, no stake, no valid peer identity, no completed handshake, no protocol conformance beyond a single varint. The cost asymmetry is roughly four orders of magnitude, and the memory is committed before the expensive Dilithium handshake — so this composes with, rather than competes against, any other pre-authentication pressure on the same port.

5. Recommendation

The fix is to stop pre-committing the declared length and instead grow the buffer as data actually arrives.

with the ReadData arm extending the buffer towards len as pos advances.

Two cheaper mitigations, either of which materially reduces the exposure:

  • Cap the negotiation frame to what the protocol actually needs. The module's own documentation says frames "mostly consist in a short protocol name, which is highly unlikely to be more than 16KiB long". The longest protocol string this node negotiates is well under 128 bytes. Rejecting a declared multistream-select frame length above, say, 1 KiB would cost nothing in functionality and cut the amplification by 16×.

  • Count pending inbound connections against a limit. on_pending_incoming_connection currently consults a limit that only tracks established connections (§2.5). Tracking in-negotiation connections and capping them would bound this and every other pre-handshake allocation at once.

Lowering SUBSTREAM_OPEN_TIMEOUT would shrink the window but is not recommended as the primary fix — it trades against legitimate slow peers.

Proof of Concept

6. Proof of concept

6.1 Environment

  • Host: WSL2 Ubuntu, 12 cores, 9,944 MB RAM

  • Victim binary: quantus-node-victim, an unmodified release build of 223d938, sha256 84c31d34edc2440f779f9f24d7947cd2e6523d56e99afa21abacbd8ee997670c (verified by the harness at every run)

  • Victim launched with --miner-listen-port so local mining is off; the node's own help states "When --miner-listen-port is set, local mining is disabled." Without this the dev node burns ~85 % of a core and its allocation churn swamps the measurement.

  • ulimit -n 65536 (the victim needs one fd per inbound connection; the default 1024 would silently cap the experiment), ulimit -c 0 (a memory PoC must never be able to write a core dump).

  • Each arm runs against a freshly started victim process. This is required: glibc does not return the freed 16 KiB chunks to the OS, so running two arms against one process would let the first arm's high-water mark contaminate the second.

6.2 The driver — poc10_msalloc.py

Complete; this is the file that produced every number in §4. It needs only Python 3 and a Linux /proc — no third-party packages, no build step.

Provenance: the executable code below is byte-for-byte the code that ran (verified by comparing the parsed AST of this listing against the on-disk script, docstrings excluded — identical). The only edits are to the module docstring: two references to internal working notes were removed, and the TIMEOUT NOTE was corrected. The original docstring said the bound was CONNECTION_OPEN_TIMEOUT = 10s; the 5-second SUBSTREAM_OPEN_TIMEOUT was only identified afterwards, from the measured 5.15 s lifetime in §6.6. No executable line differs.

The whole attack is four lines of that file — socket.connect, sendall(b"\xff\x7f"), and never sending anything else. Everything else is measurement and controls.

6.3 The orchestrator — poc10_run.sh

Complete and verbatim.

One portability note on the above: BASE_PATH is under /tmp, which is what the runs used. On a host where systemd-tmpfiles may clean /tmp, point it at a persistent directory instead — a /tmp wiped under a running node surfaces as a misleading RocksDB IO error: No such file or directory: While mkdir if missing.

6.4 How it was invoked

The exact commands behind every figure in §4:

Each invocation starts and stops its own victim, so the arms are independent by construction.

6.5 Precondition gate

Two earlier experiments in this engagement produced meaningless "no effect" data because the attack never actually landed. Every arm here therefore asserts, before any number is recorded, that the victim really accepted the connections — by counting entries in /proc/<pid>/fd:

An arm whose fd count does not rise by at least half of N is printed as GATE FAILED, INVALID rather than as a measurement. No arm in this report failed the gate.

6.6 Connection lifetime, measured

From samples.csv of the N = 2,000 big arm, victim_fds over time:

Established at t = 14.957, fully closed by t = 20.096: a lifetime of 5.15 s, matching SUBSTREAM_OPEN_TIMEOUT = 5s and confirming that the tighter of the two timeouts is the operative one.

The last two lines are the other half of the result: the connections are gone and VmRSS has not moved. The memory is retained by the allocator.

6.7 The plateau, measured

N = 2,000, three consecutive waves against one victim:

Waves 2 and 3 add 20 kB and 8 kB. This is reported because it limits the finding: the attack is a step to a high-water mark, not a ratchet, and any claim of unbounded growth would be wrong.

6.8 Full output, N = 10,000

against the control:

22,169 − 5,856 = 16,313 bytes per connection attributable to read_buffer.resize, against 16,383 predicted from source.

Was this helpful?