curl · Report #3735193
CVE-2026-8925: SASL double-free
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_startdoes acalloc(1, 0xf0)internally; both calls fail under genuine memory pressure — possible in memory-capped containers. - Library built with
--disable-client: returnsGSASL_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