Skip to content

[Security] Permanent DoS via connection slot exhaustion — no read timeout on agent sockets (agent_server.py) #470

@verovaleros

Description

@verovaleros

Summary

AgentServer.handle_new_agent uses await reader.read(ProtocolConfig.BUFFER_SIZE) with no timeout. Because max_connections is typically 2 (one attacker, one defender), an attacker who opens that many idle TCP connections blocks every legitimate agent from ever connecting — permanently, at zero cost.

Vulnerable Code

File: netsecgame/game/agent_server.py, lines 57–69

if self.current_connections < self.max_connections:
    self.current_connections += 1
    ...
    while True:
        # NO timeout — blocks indefinitely if client sends nothing
        data = await reader.read(ProtocolConfig.BUFFER_SIZE)

There is no asyncio.wait_for, no keepalive, and no idle-connection reaper. A client that connects but never sends data holds its slot until it explicitly closes the TCP connection.

Steps to Reproduce

import socket, time

HOST, PORT = "localhost", 5000  # game port
MAX_CONNECTIONS = 2             # typical value for 1 attacker + 1 defender

slots = []
for _ in range(MAX_CONNECTIONS):
    s = socket.create_connection((HOST, PORT))
    slots.append(s)
    # do NOT send any data — server coroutine blocks at reader.read() forever

print("All connection slots exhausted. Legitimate agents cannot connect.")
time.sleep(99999)  # hold slots open indefinitely

After this, any legitimate agent attempting to connect receives the log message "Max connections reached. Rejecting new connection" and is immediately dropped.

Expected Behaviour

The server should enforce an idle/read timeout so that connections that do not send data within a configurable window are closed and their slots released.

Actual Behaviour

Idle TCP connections are held open indefinitely. Once max_connections slots are full, no legitimate agent can join the game.

Impact

  • Complete availability loss: with max_connections = 2, two idle TCP sockets from a single attacker machine are enough to permanently prevent any training run from starting.
  • No authentication is required to open those sockets (see related issue), so the attack is reachable from any host on the network.
  • The max(0, self.current_connections - 1) guard in the finally block (line 110) only fires on disconnect, so slots remain consumed for as long as the attacker maintains the connections.

Recommendation

Wrap the read call with an asyncio timeout:

data = await asyncio.wait_for(
    reader.read(ProtocolConfig.BUFFER_SIZE),
    timeout=IDLE_TIMEOUT_SECONDS
)

Catch asyncio.TimeoutError to close the connection cleanly. An appropriate idle timeout (e.g., 30–60 s) should be made configurable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions