Skip to content

DTLS: cap concurrent half-open handshakes (pre-cookie state-exhaustion) - #2012

Merged
eakraly merged 1 commit into
masterfrom
fix/dtls-halfopen-cap
Jul 26, 2026
Merged

DTLS: cap concurrent half-open handshakes (pre-cookie state-exhaustion)#2012
eakraly merged 1 commit into
masterfrom
fix/dtls-halfopen-cap

Conversation

@eakraly

@eakraly eakraly commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

A DTLS ClientHello from a new source makes the listener allocate a per-peer SSL + ioa_socket + ts_ur_super_session in dtls_server_input_handler before the source has answered the RFC 6347 cookie challenge. A cookie-less ClientHello only elicits a HelloVerifyRequest, which a source-spoofing attacker or botnet never answers, so one datagram per forged source accumulated durable state with no bound until the allocate timeout

Bound the number of concurrent half-open (handshake-incomplete) DTLS sockets across all relay threads with a global atomic counter (turn_dtls_half_open). The cap scales with server size - TURN_DTLS_HALF_OPEN_PER_THREAD (16) times the relay-thread count - so a bigger deployment tolerates proportionally more concurrent handshakes and a small box is not over-committed. A single global counter (rather than a hard per-thread partition) lets a busy relay thread use headroom left idle by others while the process-wide ceiling still bounds total memory.

The listener reserves a slot before allocating any per-peer state and drops the datagram when the cap is reached; the slot is released when the handshake completes (data path) or the socket is closed (close_ioa_socket, covering handshakes that never finish and are reaped by the allocate timeout). A per-socket dtls_half_open flag makes the release idempotent.

This does not touch the DTLS cookie/handshake flow, so legitimate handshakes are unchanged - a completing client releases its slot within a few round trips, so the cap only bites under a flood.

Also stops a DTLS handshake datagram that yields no socket (cap reached or handshake error) from falling through to create_ioa_socket_from_fd and becoming a plain-UDP session.

Builds on the DTLS_OTHER drop in the previous commit.

A DTLS ClientHello from a new source makes the listener allocate a
per-peer SSL + ioa_socket + ts_ur_super_session in dtls_server_input_handler
before the source has answered the RFC 6347 cookie challenge. A cookie-less
ClientHello only elicits a HelloVerifyRequest, which a source-spoofing
attacker or botnet never answers, so one datagram per forged source
accumulated durable state with no bound until the allocate timeout -
GHSA-5x2p-4vqj-f6m4 (CWE-770 / CWE-400). Measured ~120 kB/source, RSS
growing linearly to OOM.

Bound the number of concurrent half-open (handshake-incomplete) DTLS
sockets across all relay threads with a global atomic counter
(turn_dtls_half_open). The cap scales with server size -
TURN_DTLS_HALF_OPEN_PER_THREAD (16) times the relay-thread count - so a
bigger deployment tolerates proportionally more concurrent handshakes and
a small box is not over-committed. A single global counter (rather than a
hard per-thread partition) lets a busy relay thread use headroom left idle
by others while the process-wide ceiling still bounds total memory.

The listener reserves a slot before allocating any per-peer state and
drops the datagram when the cap is reached; the slot is released when the
handshake completes (data path) or the socket is closed (close_ioa_socket,
covering handshakes that never finish and are reaped by the allocate
timeout). A per-socket dtls_half_open flag makes the release idempotent.

This does not touch the DTLS cookie/handshake flow, so legitimate
handshakes are unchanged - a completing client releases its slot within a
few round trips, so the cap only bites under a flood. Turns an unbounded
memory-exhaustion DoS into a fixed ceiling: measured ~7.9 MB total at
10 000 spoofed sources with 4 relay threads (cap 64), vs ~1.2 GB unbounded.

Also stops a DTLS handshake datagram that yields no socket (cap reached or
handshake error) from falling through to create_ioa_socket_from_fd and
becoming a plain-UDP session.

Builds on the DTLS_OTHER drop in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@eakraly
eakraly marked this pull request as ready for review July 26, 2026 22:18
@eakraly
eakraly merged commit 37e13d1 into master Jul 26, 2026
57 checks passed
@eakraly
eakraly deleted the fix/dtls-halfopen-cap branch July 26, 2026 22:18
eakraly added a commit that referenced this pull request Aug 3, 2026
* DTLS: verify the cookie statelessly (DTLSv1_listen) before allocating state

The DTLS listener created a per-peer SSL + ioa_socket + ts_ur_super_session
for every ClientHello from a new source, before the RFC 6347 section 4.2.1
cookie was verified. A source-spoofing flood of cookieless ClientHellos thus
accumulated per-source state - GHSA-5x2p-4vqj-f6m4. The half-open cap (#2012)
bounded the total but did not remove it: a flood could still saturate the cap
and deny handshakes to legitimate DTLS clients.

Run the cookie exchange through OpenSSL's stateless DTLSv1_listen() instead.
The ClientHello already read off the shared listener fd is wrapped in a memory
BIO (rbio); a datagram BIO addressed to the peer (wbio) carries the
HelloVerifyRequest. A ClientHello without a valid cookie only elicits the
HelloVerifyRequest and the throwaway SSL is freed immediately - zero retained
state. Only a cookie-verified ClientHello, which proves the source can receive
at its claimed address, is promoted to a real socket + session; the half-open
cap now applies only to these return-routable peers. One SSL_do_handshake()
emits the ServerHello flight so the handshake does not wait for a retransmit;
the rest completes over the child socket's existing read path.

This is why the earlier "gate on a cookie-verified flag with a fresh SSL"
attempt could not work: a fresh SSL cannot resume the manual SSL_read cookie
exchange (the client's second ClientHello carries message_seq=1). DTLSv1_listen
is stateless about the cookie (an HMAC of the peer address), so a fresh SSL
verifies the second ClientHello correctly. Feasible now that master is
OpenSSL-3.0+-only (#2011): DTLSv1_listen and BIO_ADDR are uniform 3.x APIs.

The now-unused dtls_accept_client_connection() is removed.

Validated (OpenSSL 3.x/macOS): DTLS relay handshake completes end-to-end
(run_tests.sh -S, run_tests_dtls_default.sh); a 10 000-source cookieless flood
grows RSS 0.5 MB (allocator noise) vs 6.9 MB on the cap-only build with the
identical flood; unit tests 18/18; run_tests.sh TCP/TLS/UDP/DTLS all pass.
Not yet run: the Linux/Docker validation matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Update dtls_listener.c

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant