curl · Report #4125065
curl EXPIRE_SHUTDOWN timer never armed for silent servers: connection-shutdown timeout bypass in cshutdn (event-driven mode)
How it was found
Summary
curl's non-blocking connection shutdown machinery (lib/cshutdn.c) is supposed to enforce a 2-second shutdown timeout (DEFAULT_SHUTDOWN_TIMEOUT_MS) on connections whose peer never answers the graceful shutdown. In curl_multi_socket_action() event-driven mode the machinery deadlocks: the only code that arms the admin handle's EXPIRE_SHUTDOWN timer is cshutdn_perform(), and cshutdn_perform() is only invoked from the multi's processing of the admin handle, which only happens when the admin is woken by a timer or by socket I/O. When a connection enters shutdown, the timer is deliberately not armed for the admin handle (if(data->mid) in Curl_cshutdn_start_timer()), and Curl_cshutdn_add() neither arms a timer nor marks the admin dirty. A server that completes the request normally but then goes silent (no TLS close-notify, no FTP QUIT reply, TCP still open) it generates no socket I/O either. So nothing ever schedules cshutdn_perform(): the code that would arm the wakeup timer can only run after the wakeup has already happened. The 2-second timeout is never evaluated and the connection lingers with its socket and file descriptor open until the application itself drives the multi again (in the reproduction, only curl_multi_cleanup()).
This is harmless in curl_multi_perform() polling mode, where the admin handle is unconditionally processed every call and the shutdown-timeout check is eventually reached. In event-driven mode, a malicious server that withholds its close response while keeping the TCP session open defeats the timeout entirely. Repeating this against a multi-socket client accumulates connections (memory + file descriptors) for as long as the attacker keeps the TCP sessions open.
| Variant | Interface | Server behavior | EXPIRE_SHUTDOWN armed? | Shutdown-timeout (2s) enforced? | Observed linger |
|---|---|---|---|---|---|
| Polling mode (control) | curl_multi_perform | withholds close-notify | no, but irrelevant | yes (admin processed every call) | cleared within ~2s |
| Event-driven mode (vulnerable) | curl_multi_socket_action | withholds close-notify | no | no | >15s, only cleared at curl_multi_cleanup() |
| Event-driven mode | curl_multi_socket_action | sends close-notify normally | no | n/a (shutdown completes via I/O) | clears promptly |
Affected versions
- curl 8.21.0-DEV (OpenSSL/3.5.5, c-ares/1.34.6, debug build).
- curl master
9b2caf3(8.22.0-DEV, verified 2026-08-31). - Current master
5d4eaac8f(8.23.1-DEV, verified 2026-10-18). Still affected, including after the 8.23.0cshutdnrefactor ("cshutdown: make it smaller" / "use ptrarray instead of llist"). The harness was re-run against a libcurl built from current master: 10 of 10 transfers complete, then all 10 connections linger for the full 15-second observation window, the process FD count rises from 6 to 16 and stays there, zeroEXPIRE_SHUTDOWNtimers are ever armed (Shutdown-range timers (1-2000ms): 0), and the connections are only force-closed duringcurl_multi_cleanup(). For contrast, the same harness against the system curl 7.81.0 (pre-cshutdnimplementation) shows 5 shutdown-range timers armed and 0 lingering connections: the older implementation cleans these connections up, the current one does not.
Note: the 8.23.0 refactor already fixed the next_expire_ms accumulator pattern inside cshutdn_perform() (the first-assignment clause is present on current master). That fix does not change the behavior, because in this scenario cshutdn_perform() is never invoked at all (see Root cause).
Root cause
Three pieces of the current shutdown machinery interact (code quoted from 5d4eaac8f):
- When a connection enters shutdown, the timeout state is stored on the connection, but the timer is not armed for the admin handle:
// lib/cshutdn.c (Curl_cshutdn_start_timer)
conn->shutdown.start_ms[sockindex] = curlx_ptimediff_ms(pnow, &conn->created);
conn->shutdown.timeout_ms = ... : DEFAULT_SHUTDOWN_TIMEOUT_MS; /* 2000 */
/* Set a timer, unless we operate on the admin handle */
if(data->mid)
Curl_expire_set(data, EXPIRE_SHUTDOWN, conn->shutdown.timeout_ms, pnow);
Shutdown of pooled connections is driven through the multi's admin handle (mid == 0), so this arming is always skipped in that flow. Instrumented run: start_timer: sock=0 timeout_ms=2000 mid=0 for all 10 connections.
-
Curl_cshutdn_add()puts the connection on the shutdown list and registers its sockets with the event callback (so socket I/O would wake the loop), but arms no timer and does not mark the admin handle dirty. -
The only place that arms the admin's
EXPIRE_SHUTDOWNtimer iscshutdn_perform():
// lib/cshutdn.c (cshutdn_perform)
ms = cshutdn_conn_timeleft(admin, conn);
if(ms && (!next_expire_ms || (ms < next_expire_ms)))
next_expire_ms = ms;
...
if(next_expire_ms)
Curl_expire(admin, next_expire_ms, EXPIRE_SHUTDOWN);
The accumulator itself is correct on current master. But cshutdn_perform() is only called from the multi's processing of the admin handle (lib/multi.c, Curl_multi_process: if(data == multi->admin) { ... Curl_cshutdn_perform(...) ... }), and the admin is only processed when it has been marked dirty, either by a timer expiring or by socket I/O. With the arming skipped (piece 1), no dirty-marking at add time (piece 2), and a silent peer producing no I/O, nothing ever wakes the admin.
This is a scheduling deadlock: the code that would arm the wakeup timer can only run after the wakeup has already happened. Instrumented run on current master (5d4eaac8f): during a 15-second idle observation with 10 connections sitting in the shutdown list, cshutdn_perform() was invoked zero times. At the application's next multi call it ran once and terminated all 10 connections immediately. Curl_cshutdn_try_once() returned done for every one of them, meaning the cleanup itself needs no I/O, only scheduling.
Why this only bites in event-driven mode: curl_multi_perform() processes the admin handle unconditionally on every call, so cshutdn_perform() runs there regardless of timers and the 2-second timeout is enforced. curl_multi_socket_action() processes only what is dirty.
PoC
The harness uses the curl_multi_socket_action event-driven API with CURLMOPT_SOCKETFUNCTION and CURLMOPT_TIMERFUNCTION callbacks. It performs 10 sequential HTTPS transfers against a mock server, with CURLOPT_FORBID_REUSE set so each connection enters the shutdown path once its transfer completes. The mock server is a normal Python HTTPS server except that it does not send a TLS close-notify and keeps the TCP connections open without sending further data, which is the behavior of a malicious or compromised server.
- Build curl from current master (plain cmake Debug build) and instrument
lib/cshutdn.cwith fprintf tracing onCurl_cshutdn_start_timer(sockindex, timeout_ms, mid),Curl_cshutdn_add, andcshutdn_perform(entry, per-connection try_once result and timeleft, exit with the arming decision). - Start the mock HTTPS server.
- Run the harness: it completes all 10 transfers, then observes the idle event loop for 15 seconds, then calls into the multi once more before cleanup.
- The server completes each HTTP exchange normally but, on shutdown, withholds TLS
close-notifyand sends nothing further.
Expected (vulnerable) behavior: the trace shows the 10 start_timer calls (all with mid=0, so no arming) and the 10 add calls during the transfers. Then the 15-second observation passes with the process FD count frozen at 16 and zero shutdown-range timer callbacks, and cshutdn_perform does not appear once in that window. Only at the application's next multi call does perform enter: conns=10 appear, followed by 10 immediate done=1 / remove / terminate lines.
Negative control (polling mode): driving the same transfers through curl_multi_perform clears the connections within ~2s because the admin handle is processed every call and the shutdown-timeout check is reached without depending on the armed timer.
Reproduction output
Current master 5d4eaac8f (8.23.1-DEV, 2026-10-18, instrumented as above), trace lines in chronological order:
[CSHUTDN] start_timer: sock=0 timeout_ms=2000 mid=0 start_ms=101
[CSHUTDN] add: conn=0x55db0e035a40
... (10x start_timer mid=0 + add during the transfers)
[EXP] === POST-TRANSFER OBSERVATION ===
[EXP] post: el=3s sk=10 fds=16 rss=9336KB tc=15 sh_timers=0
[EXP] post: el=15s sk=10 fds=16 rss=9336KB tc=15 sh_timers=0
... (15 seconds, no CSHUTDN line at all: cshutdn_perform never runs)
[CSHUTDN] perform enter: conns=10 <- only at the application's next multi call
[CSHUTDN] try_once result: conn=0x55db0e035a40 done=1
[CSHUTDN] REMOVE+TERMINATE: conn=0x55db0e035a40
... (10x done=1 + REMOVE+TERMINATE)
[CSHUTDN] perform exit: next_expire_ms=0 will_arm=0
Harness summary from the same run (uninstrumented build, identical behavior):
[EXP] Transfers completed: 10/10
[EXP] Tracked sockets: 10
[EXP] FDs at transfer completion: 16
[EXP] Timer callbacks: 13 (shutdown-range: 0)
[EXP] post: el=15s sk=10 fds=16 rss=9148KB tc=13 sh_timers=0
[EXP] Sockets still tracked: 10
[EXP] Shutdown-range timers (1-2000ms): 0
[EXP] FDs before multi_cleanup: 16
[EXP] FDs after multi_cleanup: 4
Same harness against the system curl 7.81.0 (pre-cshutdn implementation), for contrast: Shutdown-range timers (1-2000ms): 5, sockets tracked 0, FDs back to 6 at completion. The older implementation arms and honors shutdown timers. The current one, in this mode, never runs the code that would.
Impact
- CVSS 3.1: 5.3 (Medium), vector
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L. - Why we report this as a security issue: it defeats curl's own protection mechanism, not just the server. A stalled or silent server is a well-known condition, and curl's answer to it is the shutdown timeout:
EXPIRE_SHUTDOWNandDEFAULT_SHUTDOWN_TIMEOUT_MSexist precisely to close lingering shutdown connections after 2 seconds when the server stops responding. In the event-driven API this mechanism is never invoked. Not because the server stalls, but because nothing schedules the code that enforces the timeout. This places the issue under the exception in your disclosure policy ("issues that bypass regular countermeasures"): the countermeasure exists, works in the polling API, and demonstrably worked in the pre-cshutdnimplementation (curl 7.81.0 arms 5 shutdown-range timers and closes all such connections in the same harness), while current master in event-driven mode never runs it. The same reasoning was applied to CVE-2026-92387 (never-ending loop that bypassed the transfer timeout), which was accepted as a security issue this release cycle. - Trigger conditions: the client uses the
curl_multi_socket_actionevent-driven API (a core multi interface usage pattern), establishes a connection that requires graceful shutdown (FTP, IMAP, SMTP, TLS, etc.), and the server withholds its close response (no TLSclose-notify, no FTPQUITreply) while keeping the TCP connection open. No special payload is required. The server simply goes silent at shutdown. - Realized impact (measured on current master
5d4eaac8f, 2026-10-18): 10 sequential transfers to a silent server all complete, then all 10 connections linger for the full 15-second observation window (and beyond, untilcurl_multi_cleanup()), with the process FD count rising from 6 to 16 and staying there, and zero shutdown-range timers armed. A long-running application that makes many sequential requests through silent or malicious servers accumulates connections and file descriptors without bound until the fd limit, at which point new connections fail for the whole process. Thecurl_multi_performpolling mode is not affected because the admin handle is processed unconditionally each call. - Severity bound: this is an availability / resource-linger issue with no confidentiality or integrity impact. Exploitation requires the attacker to be (or to control) the server the client connects to, and we understand the low end of the severity range. If the security team prefers to treat it as a regular bug we will accept that. The mechanism (unschedulable timeout enforcement in event-driven mode) and the contrast with the 7.81 behavior are the facts we want on record either way.