#1677·hysteria

SOCKS5 UDP associations have no idle timeout — ephemeral port exhaustion

Author: eidos-caseCreated Sep 10, 2026Updated Sep 10, 2026

Describe the bug

Every SOCKS5 UDP association binds an ephemeral port for its relay socket and holds it until the control TCP connection closes. There is no idle timeout, so a client that opens many associations and keeps the control connections open — the ordinary shape of a tun-to-SOCKS bridge forwarding DNS — pins one port per association indefinitely.

Once the ephemeral range is exhausted the damage is host-wide, not local to the proxy: any process that needs a new socket fails, and the proxy itself stops accepting connections while its listen backlog grows.

app/internal/socks5/server.go, handleUDP() creates a fresh net.ListenUDP per association plus a QUIC session and two goroutines, then waits on closeErr = <-errChan — that is, until the control connection ends. No deadline is set anywhere in that file:

grep -nE "SetDeadline|SetReadDeadline|Timeout|idle|Idle" app/internal/socks5/server.go
→ no matches

udpServer() blocks on udpConn.ReadFromUDP(buf) with no deadline, and the control connection is drained with io.Copy(io.Discard, conn), also with none. UDP has no FIN or RST, so an association can only be known to be finished by the control connection closing or by an idle timeout — and the second does not exist. A client is under no obligation to close anything.

To Reproduce

Open several SOCKS5 UDP associations, leave every control connection open, send no datagrams, and count how many relay ports stay bound. A test doing exactly this is included in the linked PR:

go test ./app/internal/socks5/ -run TestUDPAssociationsDoNotAccumulate -v

Expected behavior

An association that stops carrying traffic releases its ephemeral port on its own, as tproxy, tun and forwarding already do, rather than waiting for a close that may never come.

Logs

From the test above rather than from a deployment, so the numbers are reproducible. 50 associations, all control connections open, no datagrams sent:

before the fix
    held while live:  50 of 50
    held after idle:  50 of 50   (control connections still open)
    FAIL: all idle associations must release their ports

after the fix
    held while live:  50 of 50
    held after idle:   0 of 50   (control connections still open)
    PASS

The first line of each run is a control: without it the test would pass even if UDP ASSOCIATE had never bound anything at all. The only difference between the two runs is whether the idle deadline is refreshed.

Two companion tests cover the boundaries:

  • TestUDPAssociationIdleTimeout — one idle association releases its port; on unpatched code it fails with idle association must release its ephemeral port.
  • TestUDPAssociationSurvivesTraffic — an association carrying traffic is not torn down, so the fix cannot be mistaken for a hard lifetime cap.

Device and Operating System

Linux x86_64, app/v2.12.2. master is two commits ahead of that tag; one of them, #1676, touches app/internal/http/server.go. app/internal/socks5/server.go is unchanged since the tag. The linked PR is based on master, not on the tag.

Additional context

Three of the four UDP entry points already solve this, which is why the fix is a port of existing code rather than a new mechanism:

entry point idle timeout
tproxy yes — defaultTimeout = 60 * time.Second, configurable, refreshed on activity
tun yes — UDPTimeout
forwarding yes — defaultTimeout = 60 * time.Second
socks5 no

UDPTProxy.updateConnDeadline in app/internal/tproxy/udp_linux.go is already the right shape.

Observed in production before the fix as ephemeral port exhaustion on a client carrying DNS over SOCKS5 UDP. After deploying the patch the same counter peaks and falls back instead of climbing, and it does so while offered traffic is rising — so the fall is not simply load going away. That still only shows the absence of accumulation; the causal evidence is the test, where the deadline refresh is the only variable.

A closed report, #923, describes this exhaustion class reaching the system file-descriptor limit instead of the ephemeral port range. It is a different code path — the server's socks5 outbound, not the client's inbound listener — and it was closed without a root cause after the reporter was asked to check lsof, so it is offered as context rather than as a duplicate.

The same class is documented publicly by proxy operators and tracked in Envoy as issue #21712; the failure mode there is identical, because the exhausted resource is host-wide.