HexBrain

curl · Report #3735193

CVE-2026-8925: SASL double-free

Validstatus
mediumseverity
2upvotes
24 Jun 2026disclosed

How it was found

Hi all,

We found a double-free in the GSASL authentication path — Curl_auth_gsasl_is_supported() frees gsasl->ctx on a failed gsasl_client_start() but never nulls the pointer, and then Curl_auth_gsasl_cleanup() frees it again unconditionally at connection teardown.

The bug lives in two spots. lib/vauth/gsasl.c:47-50:

res = gsasl_client_start(gsasl->ctx, mech, &gsasl->client);
if(res != GSASL_OK) {
  gsasl_done(gsasl->ctx);   /* frees gsasl->ctx ... */
  return FALSE;              /* ... but gsasl->ctx still holds the freed pointer */
}

And the cleanup function at lib/vauth/gsasl.c:108-115:

void Curl_auth_gsasl_cleanup(struct gsasldata *gsasl)
{
  gsasl_finish(gsasl->client);
  gsasl->client = NULL;

  gsasl_done(gsasl->ctx);   /* no NULL guard — second free if probe failed */
  gsasl->ctx = NULL;
}

Curl_auth_gsasl_cleanup() is called unconditionally at connection close via gsasl_conn_dtor at lib/vauth/vauth.c:201-209. What makes this land reliably is that sasl_choose_gsasl() in lib/curl_sasl.c:354-383 allocates a single struct gsasldata for the connection and passes the same pointer to each mechanism probe:

gsasl = Curl_auth_gsasl_get(sctx->conn);   /* one struct, reused across all probes */

if((sctx->enabledmechs & SASL_MECH_SCRAM_SHA_256) &&
   Curl_auth_gsasl_is_supported(data, SASL_MECH_STRING_SCRAM_SHA_256, gsasl)) {
  ...
}
else if((sctx->enabledmechs & SASL_MECH_SCRAM_SHA_1) &&
        Curl_auth_gsasl_is_supported(data, SASL_MECH_STRING_SCRAM_SHA_1, gsasl)) {
  ...
}

So if SCRAM-SHA-256 fails, gsasl->ctx holds a freed pointer when SCRAM-SHA-1 is tried. That second call reinitialises gsasl->ctx with a fresh allocation — but if it also fails, another free-without-null occurs. Whatever stale pointer gsasl->ctx holds at the end of mechanism selection is what gsasl_conn_dtor passes to gsasl_done() at teardown.

The trigger is server-controlled: any server that advertises AUTH SCRAM-SHA-256 (or SCRAM-SHA-1) in its capability response causes libcurl to call Curl_auth_gsasl_is_supported(). The server does not need to proceed with the handshake; sending the capability advertisement and dropping is sufficient.

gsasl_client_start() can fail — and thus trigger this bug without any shim — under several real conditions:

  • libgsasl < 1.4.0 (pre-2011): neither SCRAM-SHA-1 nor SCRAM-SHA-256 exist in the mechanism table — both calls return GSASL_UNKNOWN_MECHANISM, double-free fires on every connection.
  • OOM (GSASL_MALLOC_ERROR): gsasl_client_start does a calloc(1, 0xf0) internally; both calls fail under genuine memory pressure — possible in memory-capped containers.
  • Library built with --disable-client: returns GSASL_NO_CLIENT_CODE — uncommon but a valid configuration.
  • Runtime library downgrade/mismatch: curl compiled against libgsasl 2.x, loads 1.3.x at runtime.

Since the reproduction steps are narrow, the PoC below uses an LD_PRELOAD shim to force gsasl_client_start() to return GSASL_UNKNOWN_MECHANISM — simulating a stripped or older installation. The shim only intercepts the return code; gsasl_init/gsasl_done calls go directly to the real libgsasl, so ASan catches the double-free on its actual allocations.

ASan output from a live run against the current tree (CMake, -DCURL_USE_GSASL=ON -DCURL_USE_OPENSSL=ON -DBUILD_SHARED_LIBS=OFF, -fsanitize=address -g3 -O0, libgsasl 2.2.1, mock IMAP server advertising AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-1):

=================================================================
==2529510==ERROR: AddressSanitizer: attempting double-free on 0x6f2bd1fe5880 in thread T0:
    #0 0x716bd3f212ab in free asan_malloc_linux.cpp:51
    #1 0x716bd34efb04 in gsasl_done (libgsasl.so.18+0x4b04)
    #2 0x6371f1ea7b8f in Curl_auth_gsasl_cleanup gsasl.c:113    ← second free
    #3 0x6371f1d905f5 in gsasl_conn_dtor vauth.c:207
    #4 0x6371f1d6ddaf in Curl_conn_free url.c:520
    ...
    #10 0x6371f1d1c1bf in multi_done_locked multi.c:662

freed by thread T0 here:
    #0 0x716bd3f212ab in free asan_malloc_linux.cpp:51
    #1 0x716bd34efb04 in gsasl_done (libgsasl.so.18+0x4b04)
    #2 0x6371f1ea772f in Curl_auth_gsasl_is_supported gsasl.c:49    ← first free
    #3 0x6371f1eeae85 in sasl_choose_gsasl curl_sasl.c:367
    #4 0x6371f1eec6eb in Curl_sasl_start curl_sasl.c:536
    #5 0x6371f1efd033 in imap_perform_authentication imap.c:713
    #6 0x6371f1eff078 in imap_state_capability_resp imap.c:1092

previously allocated by thread T0 here:
    #0 0x716bd3f2154b in realloc asan_malloc_linux.cpp:81
    #1 0x716bd34efb04 in gsasl_register (libgsasl.so.18+0x4bb4)
    #2 0x716bd34efa8c in gsasl_init (libgsasl.so.18+0x4a8c)
    #3 0x6371f1ea767c in Curl_auth_gsasl_is_supported gsasl.c:41

SUMMARY: AddressSanitizer: double-free (libgsasl.so.18+0x4b04) in gsasl_done
=================================================================
(second and third violations follow — one per internal gsasl_init allocation,
 both double-freed at gsasl.c:113 via gsasl_conn_dtor at vauth.c:207)

The first-free stack runs through Curl_auth_gsasl_is_supported at gsasl.c:49; the second-free through Curl_auth_gsasl_cleanup at gsasl.c:113 via gsasl_conn_dtor. Exactly the two-site pattern described above.

A minimal reproducer — compile against a --with-gsasl --enable-debug ASan build and point it at any server whose EHLO/CAPABILITY response includes SCRAM-SHA-256 or SCRAM-SHA-1:

To force the failure regardless of libgsasl version / memory conditions, use an LD_PRELOAD shim that overrides gsasl_client_start to always return GSASL_UNKNOWN_MECHANISM:

/* gsasl_shim.c */
#include <gsasl.h>

int gsasl_client_start(Gsasl *ctx, const char *mech, Gsasl_session **out)
{
    (void)ctx; (void)mech; (void)out;
    return GSASL_UNKNOWN_MECHANISM;
}

Build and use it:

gcc -shared -fPIC -o gsasl_shim.so gsasl_shim.c $(pkg-config --cflags gsasl)
LD_PRELOAD=./gsasl_shim.so ASAN_OPTIONS=detect_leaks=0 \
    ./src/curl -v imaps://user:[email protected]/
#include <curl/curl.h>

int main(void)
{
  CURL *easy;
  curl_global_init(CURL_GLOBAL_DEFAULT);
  easy = curl_easy_init();
  curl_easy_setopt(easy, CURLOPT_URL,      "smtps://mail.example.com/");
  curl_easy_setopt(easy, CURLOPT_USERNAME, "[email protected]");
  curl_easy_setopt(easy, CURLOPT_PASSWORD, "hunter2");
  curl_easy_perform(easy);   /* sasl_choose_gsasl() -> failed probe */
  curl_easy_cleanup(easy);   /* gsasl_conn_dtor() -> double-free */
  curl_global_cleanup();
  return 0;
}

The fix is two changes. The root cause fix is one line in Curl_auth_gsasl_is_supported():

   res = gsasl_client_start(gsasl->ctx, mech, &gsasl->client);
   if(res != GSASL_OK) {
     gsasl_done(gsasl->ctx);
+    gsasl->ctx = NULL;
     return FALSE;
   }

The defence-in-depth change adds NULL guards to Curl_auth_gsasl_cleanup(), matching the pattern used by the NTLM and Kerberos cleanup helpers:

 void Curl_auth_gsasl_cleanup(struct gsasldata *gsasl)
 {
-  gsasl_finish(gsasl->client);
-  gsasl->client = NULL;
-
-  gsasl_done(gsasl->ctx);
-  gsasl->ctx = NULL;
+  if(gsasl->client) {
+    gsasl_finish(gsasl->client);
+    gsasl->client = NULL;
+  }
+  if(gsasl->ctx) {
+    gsasl_done(gsasl->ctx);
+    gsasl->ctx = NULL;
+  }
 }

The first change is the necessary one; the second ensures any future code path that arrives at Curl_auth_gsasl_cleanup() with a partially-freed struct cannot reproduce the same outcome. Same anti-pattern as CVE-2018-16840 and CVE-2023-27537 — free without clear, second cleanup path frees again.

Thank you, AISLE Research Team

Impact

Double free

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