Skip to content

SOCKS5 UDP associations have no idle timeout — ephemeral port exhaustion #1677

Description

@eidos-case

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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