HexBrain

curl · Report #3823985

Use-after-free in `mev_forget_socket` when `curl_easy_pause()` is called from a `CURL_POLL_REMOVE` socket callback (incomplete fix of CVE-2026-9080)

Informativestatus
lowseverity
13upvotes
28 Jun 2026disclosed

How it was found

Summary

libcurl's event interface lets the application's socket callback (CURLMOPT_SOCKETFUNCTION) call curl_easy_pause(). CVE-2026-9080 was issued for a use-after-free that this triggers, and the fix added a post-callback re-fetch of the socket-hash entry in the UPDATE leg (mev_sh_entry_update, lib/multi_ev.c).

The same re-entrancy hazard exists in the REMOVE leg, mev_forget_socket(), which was not fixed: after invoking the CURL_POLL_REMOVE callback it dereferences the hash entry (entry->announced = FALSE, lib/multi_ev.c:216) with no re-fetch. When the callback calls curl_easy_pause(), libcurl re-enters mev_assess() and frees that same entry via Curl_hash_delete(), so line 216 is a use-after-free. The companion path Curl_multi_ev_socket_done() (lib/multi_ev.c:618 → mev_forget_socket) is affected identically.

This reproduces with a small standalone program (no fuzzer).

Supported operation (forecloses "you're holding it wrong")

Calling curl_easy_pause() from within a callback is explicitly documented and intentionally implemented — this is not an API misuse:

  • docs/libcurl/curl_easy_pause.md:30-32: "Unlike most other libcurl functions, curl_easy_pause(3) can be used from within callbacks." (no socket-callback carve-out).
  • Unlike the functions that reject re-entrant use with CURLE_RECURSIVE_API_CALL, curl_easy_pause() (lib/easy.c:1147-1152) deliberately has no Curl_is_in_callback rejection — it only records recursive to restore the in-callback flag afterward. mev_assess() never checks multi->in_callback. So the recursion guard provides no protection on this path.
  • CVE-2026-9080 itself established pausing from the socket callback as supported and security-relevant, and shipped a fix for it — in the UPDATE leg only.

Affected versions

  • Reproduced against curl 8.21.0 (tag curl-8_21_0, LIBCURL_VERSION "8.21.0-DEV"), the tree carrying the CVE-2026-9080 UPDATE-leg re-fetch.
  • Independently confirmed unpatched on curl master, HEAD c10a7aa (2026-06-25): mev_forget_socket line 216 dereferences the entry after the CURL_POLL_REMOVE callback with no re-fetch — unchanged from 8.21.0, while the UPDATE leg retains the fix.

Root cause

lib/multi_ev.c, mev_forget_socket():

static CURLMcode mev_forget_socket(struct Curl_multi *multi,
                                   struct Curl_easy *data,
                                   curl_socket_t s, const char *cause)
{
  struct mev_sh_entry *entry = mev_sh_entry_get(&multi->ev.sh_entries, s);  // 202
  ...
  if(entry->announced && multi->socket_cb) {
    mev_in_callback(multi, TRUE);
    rc = multi->socket_cb(data, s, CURL_POLL_REMOVE,
                          multi->socket_userp, entry->user_data);          // 213  callback
    mev_in_callback(multi, FALSE);
    entry->announced = FALSE;                                              // 216  <-- UAF
  }
  mev_sh_entry_kill(multi, s);                                             // 219
  ...
}

The fix author already documented this exact hazard — in the UPDATE leg (mev_sh_entry_update), lib/multi_ev.c:293:

/* ... the callback ... re-enters mev_assess() which may free this 'entry'. Re-fetch. */
entry = mev_sh_entry_get(&multi->ev.sh_entries, s);   // 294  (the CVE-2026-9080 fix)

mev_forget_socket() has the identical pattern but performs no such re-fetch before using entry at line 216.

Crash (AddressSanitizer)

(entry->announced is a BIT() bitfield, so entry->announced = FALSE at line 216 is a read-modify-write of the byte — ASan reports the load half as "READ of size 1".)

Use:

heap-use-after-free  READ of size 1
  #0 mev_forget_socket        lib/multi_ev.c:216
  #1 socket_close             lib/cf-socket.c
  #2 cf_tcp_connect           lib/cf-socket.c:1435
  #3 cf_ip_attempt_connect    lib/cf-ip-happy.c:252   (Happy-Eyeballs closing a losing socket)
  #6 cf_ip_happy_connect      lib/cf-ip-happy.c:880
  #14 multi_socket            lib/multi.c:3302
  #15 main                    uaf_repro.c               (curl_multi_socket_action)

Freed by (the re-entrant pause):

  Curl_hash_delete            lib/hash.c:225
  mev_sh_entry_kill           lib/multi_ev.c:135
  mev_forget_socket           lib/multi_ev.c:219     (inner, during the pause)
  mev_pollset_diff            lib/multi_ev.c:439
  mev_assess                  lib/multi_ev.c:531     (curl_easy_pause re-entered here)

So: the outer mev_forget_socket (closing a Happy-Eyeballs socket during connect) calls the REMOVE callback; the callback's curl_easy_pause() re-enters mev_assess → mev_pollset_diff → an inner mev_forget_socket that Curl_hash_deletes the same entry; control returns to the outer frame, which uses the freed entry at line 216.

Steps to reproduce (standalone, no fuzzer)

  1. Build curl with AddressSanitizer (release config so the program reaches the UAF rather than an unrelated debug assertion):

    ./configure --with-openssl --disable-shared --disable-debug \
        CFLAGS="-fsanitize=address -g" LDFLAGS="-fsanitize=address"
    make
    

    (Any TLS backend works; OpenSSL used here. A --enable-debug build instead trips DEBUGASSERT(NULL) at lib/multi_ev.c:423 — the same re-entrancy corrupting the pollset book-keeping.)

  2. Compile and run the attached uaf_repro.c against that libcurl:

    cc -fsanitize=address -g uaf_repro.c -o uaf_repro -lcurl -lpthread
    ./uaf_repro
    

    Expected: AddressSanitizer: heap-use-after-free in mev_forget_socket (lib/multi_ev.c:216), deterministic (5/5 here).

The program: starts a loopback HTTP listener (IPv4); drives one transfer through the event interface (curl_multi_socket_action); resolves the host to [::1],127.0.0.1 so Happy-Eyeballs abandons the ::1 attempt and closes its socket during connect; the socket callback calls curl_easy_pause() on CURL_POLL_REMOVE.

(uaf_repro.c is attached.)

Suggested fix

Apply the same fix as the UPDATE leg: do not use entry after the callback in mev_forget_socket() — either re-fetch it (entry = mev_sh_entry_get(...)) before line 216, or set entry->announced = FALSE before invoking the callback. Apply the same to Curl_multi_ev_socket_done() (lib/multi_ev.c:618).

Impact

Heap use-after-free in libcurl, reachable from a normal application using the event interface (the standard libevent / libuv integration) that calls curl_easy_pause() from its socket callback — for example for read/write backpressure — when connecting to a dual-stack host (Happy-Eyeballs is the default). It is the direct, unpatched sibling of CVE-2026-9080.

Severity: Low (same as the CVE-2026-9080 parent, by the same reasoning). The use is a single read-modify-write that clears one bitfield bit at a fixed offset in the freed 80-byte mev_sh_entry; the value and offset are not attacker-controlled, no information is leaked, and there is no double-free — the subsequent mev_sh_entry_kill (line 219) safely no-ops because the re-entrant path already removed the key via Curl_hash_delete. The significance is that it is the unpatched twin of a fix you just shipped, reachable via a documented-supported operation.

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