Skip to content

Security: completed client tasks retained in JoinSet until shutdown — unbounded memory DoS #327

Description

@coderabbitai

Summary

Severity: High
Affected commit: fccafc3
Raised via: PR #323, requested by @leynos

The new graceful-shutdown implementation stores every accepted client task in a Tokio JoinSet, but the accept loop never calls join_next, try_join_next, or otherwise reaps completed tasks whilst the server is running. Completed client tasks remain owned by the JoinSet until they are joined, and this code only joins them after a shutdown signal. An unauthenticated remote attacker can repeatedly open a TCP connection, receive the banner, then disconnect; each handler exits, but its task record is retained in the JoinSet. Over time this creates unbounded memory growth and can exhaust memory or degrade service availability.

The previous code used detached tokio::spawn tasks, which did not retain all completed task handles in the main accept loop.


Evidence

src/main.rs (lines 103–134)

let (shutdown_tx, _) = broadcast::channel(1);
let mut join_set = JoinSet::new();
let mut shutdown = Box::pin(shutdown_signal());

loop {
    tokio::select! {
        _ = &mut shutdown => {
            println!("shutdown signal received");
            break;
        }
        res = listener.accept() => {
            let (socket, peer) = res?;
            let pool = pool.clone();
            let mut rx = shutdown_tx.subscribe();
            join_set.spawn(async move {
                if let Err(e) = handle_client(socket, peer, pool, rx).await {
                    eprintln!("connection error: {}", e);
                }
            });
        }
    }
}

// notify all tasks to shut down
let _ = shutdown_tx.send(());

while let Some(res) = join_set.join_next().await {
    if let Err(e) = res {
        eprintln!("task error: {}", e);
    }
}
  • The JoinSet is created to own all per-client tasks, but is never drained during normal server operation.
  • Each accepted connection is inserted into the JoinSet. The accept loop has no branch that joins or removes tasks that have already completed.
  • Completed tasks are only joined after shutdown, so task records from short-lived attacker connections accumulate for the entire uptime of the daemon.
  • Client handlers can finish normally when a client disconnects (lines 149–178), making it cheap for an unauthenticated attacker to create many completed-but-unjoined tasks.

Attack Path

  1. Unauthenticated TCP attacker connects to the mxd listener (default 0.0.0.0:5500).
  2. listener.accept() succeeds; join_set.spawn() creates a new task.
  3. handle_client sends the MXD banner immediately and enters its command loop.
  4. Attacker closes/disconnects; handle_client exits normally via EOF.
  5. The accept loop is never woken to reap the completed task; the JoinSet entry persists.
  6. Steps 1–5 are repeated; retained completed-task records accumulate without bound.
  7. Memory is exhausted; the daemon becomes unavailable to legitimate clients.

Impact

Dimension Assessment
Likelihood High — unauthenticated, reachable over the primary TCP service; no credentials or expensive resources required beyond repeated TCP connections.
Impact High — unbounded memory growth; service unavailability. No RCE, authentication bypass, or data disclosure.
Severity High

Existing Controls

  • Authentication is not required before a connection-handler task is spawned.
  • No in-loop JoinSet draining is present.
  • No repository evidence of connection rate limiting or maximum concurrent/completed task accounting.
  • Default bind address is all interfaces on TCP port 5500.
  • Shutdown handling drains tasks only after the daemon is already stopping.

Suggested Remediation Direction

Add a non-blocking try_join_next arm (or an explicit drain step) inside the accept loop so completed tasks are reaped promptly. Alternatively, use detached tokio::spawn with a separate atomic counter or semaphore to track active tasks for graceful shutdown, avoiding unbounded JoinSet accumulation entirely.


Assumptions / Blindspots

  • The crate could not be built or dynamically exercised in the validation environment due to dependency/network restrictions; however, the source path and a dependency-free harness strongly support reachability.
  • No deployment manifests or IaC files were present to confirm whether real deployments expose the default public bind address or restrict it with a firewall.
  • The exact memory growth rate per retained completed Tokio task was not measured; time-to-exhaustion is workload- and host-dependent.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions