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

88386 bc medium unsubscribe lifecycle bug bypasses txwatch rpc caps and increases full node resource use by 121

Submitted on Aug 12th 2026 at 23:41:46 UTC by @Porcupine17126 for Audit Comp | Quantus

  • Report ID: #88386

  • 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

Summary

txWatch_watchAddress spawns one Tokio task and one broadcast receiver per accepted subscription. The task waits only on listener_rx.recv().await; it checks sink.is_closed() only after a transfer is broadcast.

When a client invokes txWatch_unwatchAddress or disconnects, closing the JSON-RPC SubscriptionSink does not wake broadcast::Receiver::recv(). The task therefore remains allocated until a later decoded transfer reaches the broadcast channel. Reconnecting lets the same unauthenticated client repeat the process, so --rpc-max-connections and --rpc-max-subscriptions-per-connection limit concurrent clients but do not bound cumulative retained tasks.

The runnable PoC configures the server to allow only one connection at a time, explicitly unsubscribes every subscription, and disconnects before opening the next connection. In 2.93 seconds, 64 sequential connection cycles retain 16,384 closed subscriptions and increase the in-process RPC server's RSS from 16,089,088 bytes to 46,383,104 bytes: +188.3%. This proves linear cumulative retention beyond the configured limits. An actual full-node process measurement is provided below.

Severity and in-scope impact

  • Suggested severity: Medium

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

  • Commit tested: 3b243b870a5442a6e5f443056f67bbba87d472f5

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

The attack requires no valid account, signature, on-chain transaction, fee, privileged role, secret, or guessed value. It consists only of deterministic public WebSocket RPC subscribe/unsubscribe calls. The PoC uses one concurrent connection, so its 188.3% RSS increase cannot be attributed to raising the configured connection limit.

Production reachability

This is not an unsafe or development-only RPC. node/src/rpc.rs merges TxWatch::new(pool).into_rpc() into every full node's RPC module and explicitly classifies TxWatch as Safe, describing its work as bounded by the fixed broadcast capacity. Safe methods remain available when a public node denies unsafe RPC calls.

On 13 August 2026, a read-only rpc_methods query to Quantus's official public endpoint https://a1-planck.quantus.cat returned both vulnerable methods:

RPC call rate limiting is disabled by default. Default concurrent limits are 100 connections and 1,024 subscriptions per connection, but the PoC shows why even limits reduced to 1 and 256 do not cap retained tasks across connection reuse.

Root cause

In node/src/txwatch.rs, lines 151-159 accept the subscription, create a broadcast receiver, and spawn a detached task:

jsonrpsee::SubscriptionSink provides the cancellation-aware closed().await future, but this task never awaits it. Consequently:

  1. The client cleanly unsubscribes or closes its connection.

  2. jsonrpsee marks the sink closed and releases the active connection for reuse.

  3. The detached task stays blocked in listener_rx.recv().await and retains its broadcast receiver, closed sink handle, target account, and future/task allocation.

  4. A new connection can allocate another full set of subscriptions.

  5. Repeating the cycle grows memory linearly beyond both server-level limits.

The fanout task broadcasts only decoded transfer calls. Non-transfer pool traffic does not clean the retained listeners. A later transfer wakes every retained task simultaneously, which creates an additional CPU wake-up burst before the closed tasks exit. An attacker can continuously replenish listeners between broadcasts. Accordingly, this report claims unbounded cumulative task retention between transfer-bearing pool broadcasts, not a permanent leak.

Distinction from PR #630

PR #630 addressed a shared live-listener pool of 32 slots and replaced it with server-level limits on active connections and active subscriptions per connection. Its stated security assumption was that those server-level limits bound txWatch resource use.

This report identifies a different lifecycle failure that bypasses that replacement bound. After txWatch_unwatchAddress or connection closure, jsonrpsee releases the active subscription and connection capacity, allowing those slots to be reused. Quantus's separately spawned task nevertheless remains blocked on listener_rx.recv().await and retains its receiver and task allocation. A single client can therefore reuse one connection slot repeatedly and accumulate retained tasks beyond both server-level limits.

PR #630 does not identify post-unsubscribe task retention, SubscriptionSink::closed(), cumulative cap reuse, or receivers surviving connection closure. The PoC specifically demonstrates this distinct state by setting max_connections = 1, explicitly unsubscribing every subscription, and reaching 16,384 retained receivers.

The PR #630 review discussed the resource cost of concurrently active subscriptions under the default caps. It did not identify that closed subscriptions survive after their capacity slots are released, or that one connection slot can be reused to accumulate tasks beyond those caps.

Runnable proof of concept

Apply the poc.patch reproduced in the Proof of Concept section from the repository root, then run:

The two Apple-specific library variables can be omitted on Linux when libclang is already discoverable.

Observed output:

The test also proves causality: after asserting that all 16,384 closed receivers remain, it sends one broadcast item and waits until receiver_count() becomes zero.

The complete cargo test -p quantus-node -- --nocapture suite also passes with the PoC applied: 39 passed, 0 failed. That full-suite run independently measured 16,384 retained receivers and a 132.1% in-process RSS increase. Its relevant output is reproduced in the Proof of Concept section below.

Full-node impact measurement

The separate full-node-churn.js driver was run against an unmodified quantus-node --dev process with Safe RPC methods, one maximum concurrent connection, and 1,024 maximum subscriptions per connection. It opens exactly one WebSocket connection at a time, creates 1,024 watchers, explicitly invokes txWatch_unwatchAddress for every returned subscription ID, closes the connection, and repeats. It submits no transaction.

Using Node.js v24.5.0, /usr/bin/time -p measured the full 64-cycle, 65,536-subscription churn at 2.12 seconds. The local development node received no transfer-bearing pool submission during that interval, and the driver itself submitted no transaction.

After the node had started and its RPC was ready, macOS footprint reported a settled physical footprint of 71 MB. After 64 sequential cycles (65,536 explicitly unsubscribed watchers), it reported 157 MB, a conservative +121.1% increase. Ten seconds later, with the client disconnected and the node idle, the footprint remained elevated at 163 MB.

This is a measurement of the actual full-node process, not the minimal in-process server. On macOS, dirty anonymous pages may be compressed or paged, which makes instantaneous ps RSS unstable; the same process physical-footprint metric was therefore used before and after. The complete commands and raw measurements are reproduced in the Proof of Concept section below.

Wait for sink closure and the next broadcast concurrently:

Add a regression test which unsubscribes without sending a transfer and asserts that broadcast.receiver_count() promptly returns to zero.

As a cross-check, the PoC was run against this proposed select! change. The leak assertion failed on the first cycle with only 2 transient receivers remaining instead of 256; those tasks were already being cancelled without any transfer broadcast.

Why server-level caps do not mitigate this

The source comment says capacity is bounded by --rpc-max-connections and --rpc-max-subscriptions-per-connection. Those limits bound active state on a connection. They do not bound tasks retained after a connection has been released. The PoC deliberately sets both limits tightly—one active connection and 256 subscriptions per connection—then reuses the single connection slot 64 times. Retained receivers reach 16,384 despite never exceeding either live limit.

Proof of Concept

All testing was local. No load or exploit traffic was sent to the public Quantus endpoint.

A. Exact lifecycle and causality test

1

Check out the scoped commit

Check out scoped commit 3b243b870a5442a6e5f443056f67bbba87d472f5.

2

Apply the patch

Save the patch below as poc.patch and run:

3

Run the test

The test starts the real Quantus TxWatch::into_rpc() module over loopback WebSocket with max_connections=1 and max_subscriptions_per_connection=256. It creates and explicitly unsubscribes 256 watchers over each of 64 sequential connections. It asserts all 16,384 broadcast receivers remain after the live RPC slots are reusable. Finally, one synthetic transfer broadcast reduces receiver count to zero, proving the blocked detached tasks are the cause.

Patch

B. Actual full-node measurement

1

Build and start an unmodified development node

2

Save the driver

Save the following as full-node-churn.js.

3

Run the driver

This driver opens exactly one connection at a time and calls txWatch_unwatchAddress for every subscription ID. It submits no transaction.

Full-node output

Full regression output

The full suite passes 39/39. The focused lifecycle test independently reports 16,384 retained receivers and +132.1% in-process RSS. The actual quantus-node --dev process rises from a settled 71 MB physical footprint to 157 MB (+121.1%) and remains 163 MB after ten idle seconds.

Was this helpful?