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
- Unauthenticated TCP attacker connects to the
mxd listener (default 0.0.0.0:5500).
listener.accept() succeeds; join_set.spawn() creates a new task.
handle_client sends the MXD banner immediately and enters its command loop.
- Attacker closes/disconnects;
handle_client exits normally via EOF.
- The accept loop is never woken to reap the completed task; the
JoinSet entry persists.
- Steps 1–5 are repeated; retained completed-task records accumulate without bound.
- 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.
Summary
Severity: High
Affected commit: fccafc3
Raised via: PR
#323, requested by@leynosThe new graceful-shutdown implementation stores every accepted client task in a Tokio
JoinSet, but the accept loop never callsjoin_next,try_join_next, or otherwise reaps completed tasks whilst the server is running. Completed client tasks remain owned by theJoinSetuntil 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 theJoinSet. Over time this creates unbounded memory growth and can exhaust memory or degrade service availability.The previous code used detached
tokio::spawntasks, which did not retain all completed task handles in the main accept loop.Evidence
src/main.rs(lines 103–134)JoinSetis created to own all per-client tasks, but is never drained during normal server operation.JoinSet. The accept loop has no branch that joins or removes tasks that have already completed.Attack Path
mxdlistener (default0.0.0.0:5500).listener.accept()succeeds;join_set.spawn()creates a new task.handle_clientsends the MXD banner immediately and enters its command loop.handle_clientexits normally via EOF.JoinSetentry persists.Impact
Existing Controls
JoinSetdraining is present.Suggested Remediation Direction
Add a non-blocking
try_join_nextarm (or an explicit drain step) inside the accept loop so completed tasks are reaped promptly. Alternatively, use detachedtokio::spawnwith a separate atomic counter or semaphore to track active tasks for graceful shutdown, avoiding unboundedJoinSetaccumulation entirely.Assumptions / Blindspots