✓ProveIt
Examples / Report #3459417 Saved analysis

CVE-2025-14524: bearer token leak on cross-protocol redirect

curl · 2025-12-0911.6s analysis · $0.021 in model calls

What the report claims

libcurl fails to clear OAuth2 Bearer tokens during cross-protocol redirects, allowing token leakage to attacker-controlled servers.

Compare with curl's recorded outcome

Known vulnerability

A real vulnerability: CVE-2025-14524 bearer token leak on cross-protocol redirect

Shown for comparison after analysis. The pipeline did not see this label.

ProveIt's finding

Code supports the claim

The code supports the reported flaw. A maintainer should review its impact and decide what to do next.

Why this finding?
  • The Curl_http_follow function in lib/http.c (lines 1298-1348) clears user/password on cross-port/protocol redirects but omits clearing the OAuth2 Bearer token.
  • The deterministic checks confirm CURLOPT_XOAUTH2_BEARER exists and the code is grounded at the reported revision.
  • The PoC demonstrates a working exploit: HTTP->IMAP redirect leaks the Bearer token via SASL AUTHENTICATE XOAUTH2.
  • This is a regression/incomplete fix of CVE-2022-27774 which only addressed user/password credentials.
  • No project policy section classifies this as a non-security issue; it is a credential leakage across trust boundaries.
How this analysis ran 5 claims · 5 citations verified

Checked against curl master on 2025-12-09 (073b85cefe30). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.

1 · Grounding
grounded
no model · 9 references checked out, 0 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
5 claims · 3965 tokens · $0.0003
3 · Evidence
4 code snippets
real curl source at 073b85cefe30 · 4 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
15567 tokens · 9.4s · $0.0210
5 · Receipts
5 of 9 citations verified
every citation must point at code the judge was shown
Review a draft reply to the reporter

Edit and verify before sending.

Thank you for the detailed report and reproducible PoC. The code in lib/http.c (Curl_http_follow, lines 1298-1348) confirms the Bearer token is not cleared when user/password are cleared during cross-protocol redirects. This is a valid security issue — an incomplete fix for CVE-2022-27774. We will prepare a patch to clear the Bearer token alongside the other credentials.

Inspect all claims and source code 5 claims · 4 code excerpts

Report claims

C1flaw supported

OAuth2 Bearer token is not cleared when clearing standard credentials during redirects

Read the reasoning

The redirect handling code in Curl_http_follow explicitly clears user and password when redirecting to a different port or scheme (protocol), but there is no corresponding clear for the OAuth2 Bearer token. The Bearer token persists in the handle and is reused on the new connection.

C2location supported

in lib/http.c redirect handling logic

Read the reasoning

The vulnerable logic is in lib/http.c in the Curl_http_follow function, specifically the 'Clear auth' block starting at line 1298.

C3trigger supported

redirect to a different protocol that supports SASL (e.g., IMAP)

Read the reasoning

The code detects protocol changes via scheme handler comparison (line 1337). The PoC demonstrates a cross-protocol redirect from HTTP to IMAP, which triggers the credential clearing logic but leaves the Bearer token intact.

lib/http.c:1327 rogue_server.py:0
C4impact supported

Bearer token is sent to attacker-controlled server, enabling credential theft

Read the reasoning

The PoC demonstrates successful exfiltration of the Bearer token to an attacker-controlled IMAP server. The token persists because it is not cleared during the cross-protocol redirect.

rogue_server.py:0 lib/http.c:1345
C5reproduction supported

Use rogue_server.py to redirect HTTP request to imap://victim@127.0.0.1:1430/ and capture token via AUTHENTICATE XOAUTH2

Read the reasoning

The reproduction steps are concrete and the provided PoC code and server script demonstrate the vulnerability end-to-end.

rogue_server.py:0 poc.c:0
Read the original report (excerpt)

Full report on HackerOne ↗

## Summary: A vulnerability exists in `libcurl` regarding the handling of OAuth2 Bearer tokens (`CURLOPT_XOAUTH2_BEARER`) during HTTP redirects. While `libcurl` correctly clears standard authentication credentials (`CURLOPT_USERPWD`) when following a redirect to a different host, port, or protocol (a security hardening introduced to fix CVE-2022-27774), it fails to apply the same logic to the OAuth2 Bearer token. If an application using `libcurl` connects to a trusted server but is redirected to a malicious server (e.g., via an Open Redirect vulnerability) on a protocol supporting SASL (like IMAP, SMTP, or POP3), the valid Bearer token is automatically sent to the attacker. This happens because the token remains in the handle and is reused for the new connection, allowing the attacker to steal the credentials. **AI Statement:** This report was researched and generated with the assistance of an AI agent to analyze the `libcurl` source code and identify inconsistent state management logic. However, the vulnerability has been manually verified, the Proof of Concept code was compiled and executed locally, and the findings have been confirmed against a custom TCP server to ensure validity and reproducibility. ## Affected version The vulnerability was reproduced on the following version: ```text curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 (+libidn2/2.3.7) libssh/0.10.6/openssl/zlib nghttp2/1.59.0 librtmp/2.3 OpenLDAP/2.6.7 Release-Date: 2023-12-06, security patched: 8.5.0-2ubuntu10.6 Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets zstd ``` ## Vulnerable Code Analysis The issue lies in the HTTP redirect handling logic (typically within `lib/http.c`, function `Curl_http_follow` or similar transfer logic depending on the version). When `libcurl` detects a redirect that crosses a security boundary (change of host, port, or protocol), it explicitly clears the username and password to prevent leaks. However, it overlooks the Bearer token string. ```c /* Pseudo-code representation of the logic in lib/http.c */ /* ... inside redirect handling …

Source code

lib/http.clines 1206–1366 · definition of Curl_http_follow (truncated)
1206CURLcode Curl_http_follow(struct Curl_easy *data, const char *newurl,
C2: Curl_http_follow function definition at line 1206 contains the redirect handling logic including the credential clearing block at lines 1298-1348.
1207 followtype type)
1208{
1209 bool disallowport = FALSE;
1210 bool reachedmax = FALSE;
1211 char *follow_url = NULL;
1212 CURLUcode uc;
1213 CURLcode rewind_result;
1214 bool switch_to_get = FALSE;
1215
1216 DEBUGASSERT(type != FOLLOW_NONE);
1217
1218 if(type != FOLLOW_FAKE)
1219 data->state.requests++; /* count all real follows */
1220 if(type == FOLLOW_REDIR) {
1221 if((data->set.maxredirs != -1) &&
1222 (data->state.followlocation >= data->set.maxredirs)) {
1223 reachedmax = TRUE;
1224 type = FOLLOW_FAKE; /* switch to fake to store the would-be-redirected
1225 to URL */
1226 }
1227 else {
1228 data->state.followlocation++; /* count redirect-followings, including
1229 auth reloads */
1230
1231 if(data->set.http_auto_referer) {
1232 CURLU *u;
1233 char *referer = NULL;
1234
1235 /* We are asked to automatically set the previous URL as the referer
1236 when we get the next URL. We pick the ->url field, which may or may
1237 not be 100% correct */
1238 Curl_bufref_free(&data->state.referer);
1239
1240 /* Make a copy of the URL without credentials and fragment */
1241 u = curl_url();
1242 if(!u)
1243 return CURLE_OUT_OF_MEMORY;
1244
1245 uc = curl_url_set(u, CURLUPART_URL,
1246 Curl_bufref_ptr(&data->state.url), 0);
1247 if(!uc)
1248 uc = curl_url_set(u, CURLUPART_FRAGMENT, NULL, 0);
1249 if(!uc)
1250 uc = curl_url_set(u, CURLUPART_USER, NULL, 0);
1251 if(!uc)
1252 uc = curl_url_set(u, CURLUPART_PASSWORD, NULL, 0);
1253 if(!uc)
1254 uc = curl_url_get(u, CURLUPART_URL, &referer, 0);
1255
1256 curl_url_cleanup(u);
1257
1258 if(uc || !referer)
1259 return CURLE_OUT_OF_MEMORY;
1260
1261 Curl_bufref_set(&data->state.referer, referer, 0, curl_free);
1262 }
1263 }
1264 }
1265
1266 if((type != FOLLOW_RETRY) &&
1267 (data->req.httpcode != 401) && (data->req.httpcode != 407) &&
1268 Curl_is_absolute_url(newurl, NULL, 0, FALSE)) {
1269 /* If this is not redirect due to a 401 or 407 response and an absolute
1270 URL: do not allow a custom port number */
1271 disallowport = TRUE;
1272 }
1273
1274 DEBUGASSERT(data->state.uh);
1275 uc = curl_url_set(data->state.uh, CURLUPART_URL, newurl, (unsigned int)
1276 ((type == FOLLOW_FAKE) ? CURLU_NON_SUPPORT_SCHEME :
1277 ((type == FOLLOW_REDIR) ? CURLU_URLENCODE : 0) |
1278 CURLU_ALLOW_SPACE |
1279 (data->set.path_as_is ? CURLU_PATH_AS_IS : 0)));
1280 if(uc) {
1281 if((uc == CURLUE_OUT_OF_MEMORY) || (type != FOLLOW_FAKE)) {
1282 failf(data, "The redirect target URL could not be parsed: %s",
1283 curl_url_strerror(uc));
1284 return Curl_uc_to_curlcode(uc);
1285 }
1286
1287 /* the URL could not be parsed for some reason, but since this is FAKE
1288 mode, just duplicate the field as-is */
1289 follow_url = curlx_strdup(newurl);
1290 if(!follow_url)
1291 return CURLE_OUT_OF_MEMORY;
1292 }
1293 else {
1294 uc = curl_url_get(data->state.uh, CURLUPART_URL, &follow_url, 0);
1295 if(uc)
1296 return Curl_uc_to_curlcode(uc);
1297
1298 /* Clear auth if this redirects to a different port number or protocol,
C1: The 'Clear auth' block (lines 1298-1348) detects cross-port/protocol redirects but only clears user/password credentials.
1299 unless permitted */
1300 if(!data->set.allow_auth_to_other_hosts && (type != FOLLOW_FAKE)) {
1301 int port;
1302 bool clear = FALSE;
1303
1304 if(data->set.use_port && data->state.allow_port)
1305 /* a custom port is used */
1306 port = (int)data->set.use_port;
1307 else {
1308 curl_off_t value;
1309 char *portnum;
1310 const char *p;
1311 uc = curl_url_get(data->state.uh, CURLUPART_PORT, &portnum,
1312 CURLU_DEFAULT_PORT);
1313 if(uc) {
1314 curlx_free(follow_url);
1315 return Curl_uc_to_curlcode(uc);
1316 }
1317 p = portnum;
1318 curlx_str_number(&p, &value, 0xffff);
1319 port = (int)value;
1320 curlx_free(portnum);
1321 }
1322 if(port != data->info.conn_remote_port) {
1323 infof(data, "Clear auth, redirects to port from %u to %u",
1324 data->info.conn_remote_port, port);
1325 clear = TRUE;
1326 }
1327 else {
C3: The code compares p->protocol != data->info.conn_protocol at line 1337 (master) / 1337 (release) to detect protocol/scheme changes, which would trigger clear=TRUE for IMAP, SMTP, POP3, etc.
1328 char *scheme;
1329 const struct Curl_handler *p;
1330 uc = curl_url_get(data->state.uh, CURLUPART_SCHEME, &scheme, 0);
1331 if(uc) {
1332 curlx_free(follow_url);
1333 return Curl_uc_to_curlcode(uc);
1334 }
1335
1336 p = Curl_get_scheme_handler(scheme);
1337 if(p && (p->protocol != data->info.conn_protocol)) {
1338 infof(data, "Clear auth, redirects scheme from %s to %s",
1339 data->info.conn_scheme, scheme);
1340 clear = TRUE;
1341 }
1342 curlx_free(scheme);
1343 }
1344 if(clear) {
C1: When clear=TRUE, only data->state.aptr.user and data->state.aptr.passwd are freed (lines 1345-1346). No clearing of data->set.str[STRING_BEARER] or equivalent Bearer token storage.
1345 Curl_safefree(data->state.aptr.user);
C4: Because the Bearer token is not cleared at lines 1345-1346, it remains in the handle and is sent to the new protocol endpoint.
1346 Curl_safefree(data->state.aptr.passwd);
1347 }
1348 }
1349 }
1350 DEBUGASSERT(follow_url);
1351
1352 if(type == FOLLOW_FAKE) {
1353 /* we are only figuring out the new URL if we would have followed locations
1354 but now we are done so we can get out! */
1355 data->info.wouldredirect = follow_url;
1356
1357 if(reachedmax) {
1358 failf(data, "Maximum (%d) redirects followed", data->set.maxredirs);
1359 return CURLE_TOO_MANY_REDIRECTS;
1360 }
1361 return CURLE_OK;
1362 }
1363
1364 if(disallowport)
1365 data->state.allow_port = FALSE;
1366
lib/http.c@curl-8_17_0lines 1206–1366 · definition of Curl_http_follow as in release curl-8_17_0; differs from master; commits that changed it since: 2cb868242d lib: turn state.referer into a struct bufref | 36542b7349 lib: turn state.url into a struct bufref | c3b030b860 lib: fix formatting nits (part 3)
1206CURLcode Curl_http_follow(struct Curl_easy *data, const char *newurl,
1207 followtype type)
1208{
1209 bool disallowport = FALSE;
1210 bool reachedmax = FALSE;
1211 char *follow_url = NULL;
1212 CURLUcode uc;
1213 CURLcode rewind_result;
1214 bool switch_to_get = FALSE;
1215
1216 DEBUGASSERT(type != FOLLOW_NONE);
1217
1218 if(type != FOLLOW_FAKE)
1219 data->state.requests++; /* count all real follows */
1220 if(type == FOLLOW_REDIR) {
1221 if((data->set.maxredirs != -1) &&
1222 (data->state.followlocation >= data->set.maxredirs)) {
1223 reachedmax = TRUE;
1224 type = FOLLOW_FAKE; /* switch to fake to store the would-be-redirected
1225 to URL */
1226 }
1227 else {
1228 data->state.followlocation++; /* count redirect-followings, including
1229 auth reloads */
1230
1231 if(data->set.http_auto_referer) {
1232 CURLU *u;
1233 char *referer = NULL;
1234
1235 /* We are asked to automatically set the previous URL as the referer
1236 when we get the next URL. We pick the ->url field, which may or may
1237 not be 100% correct */
1238
1239 if(data->state.referer_alloc) {
1240 Curl_safefree(data->state.referer);
1241 data->state.referer_alloc = FALSE;
1242 }
1243
1244 /* Make a copy of the URL without credentials and fragment */
1245 u = curl_url();
1246 if(!u)
1247 return CURLE_OUT_OF_MEMORY;
1248
1249 uc = curl_url_set(u, CURLUPART_URL, data->state.url, 0);
1250 if(!uc)
1251 uc = curl_url_set(u, CURLUPART_FRAGMENT, NULL, 0);
1252 if(!uc)
1253 uc = curl_url_set(u, CURLUPART_USER, NULL, 0);
1254 if(!uc)
1255 uc = curl_url_set(u, CURLUPART_PASSWORD, NULL, 0);
1256 if(!uc)
1257 uc = curl_url_get(u, CURLUPART_URL, &referer, 0);
1258
1259 curl_url_cleanup(u);
1260
1261 if(uc || !referer)
1262 return CURLE_OUT_OF_MEMORY;
1263
1264 data->state.referer = referer;
1265 data->state.referer_alloc = TRUE; /* yes, free this later */
1266 }
1267 }
1268 }
1269
1270 if((type != FOLLOW_RETRY) &&
1271 (data->req.httpcode != 401) && (data->req.httpcode != 407) &&
1272 Curl_is_absolute_url(newurl, NULL, 0, FALSE)) {
1273 /* If this is not redirect due to a 401 or 407 response and an absolute
1274 URL: do not allow a custom port number */
1275 disallowport = TRUE;
1276 }
1277
1278 DEBUGASSERT(data->state.uh);
1279 uc = curl_url_set(data->state.uh, CURLUPART_URL, newurl, (unsigned int)
1280 ((type == FOLLOW_FAKE) ? CURLU_NON_SUPPORT_SCHEME :
1281 ((type == FOLLOW_REDIR) ? CURLU_URLENCODE : 0) |
1282 CURLU_ALLOW_SPACE |
1283 (data->set.path_as_is ? CURLU_PATH_AS_IS : 0)));
1284 if(uc) {
1285 if(type != FOLLOW_FAKE) {
1286 failf(data, "The redirect target URL could not be parsed: %s",
1287 curl_url_strerror(uc));
1288 return Curl_uc_to_curlcode(uc);
1289 }
1290
1291 /* the URL could not be parsed for some reason, but since this is FAKE
1292 mode, just duplicate the field as-is */
1293 follow_url = strdup(newurl);
1294 if(!follow_url)
1295 return CURLE_OUT_OF_MEMORY;
1296 }
1297 else {
1298 uc = curl_url_get(data->state.uh, CURLUPART_URL, &follow_url, 0);
1299 if(uc)
1300 return Curl_uc_to_curlcode(uc);
1301
1302 /* Clear auth if this redirects to a different port number or protocol,
1303 unless permitted */
1304 if(!data->set.allow_auth_to_other_hosts && (type != FOLLOW_FAKE)) {
1305 char *portnum;
1306 int port;
1307 bool clear = FALSE;
1308
1309 if(data->set.use_port && data->state.allow_port)
1310 /* a custom port is used */
1311 port = (int)data->set.use_port;
1312 else {
1313 uc = curl_url_get(data->state.uh, CURLUPART_PORT, &portnum,
1314 CURLU_DEFAULT_PORT);
1315 if(uc) {
1316 free(follow_url);
1317 return Curl_uc_to_curlcode(uc);
1318 }
1319 port = atoi(portnum);
1320 free(portnum);
1321 }
1322 if(port != data->info.conn_remote_port) {
1323 infof(data, "Clear auth, redirects to port from %u to %u",
1324 data->info.conn_remote_port, port);
1325 clear = TRUE;
1326 }
1327 else {
1328 char *scheme;
1329 const struct Curl_handler *p;
1330 uc = curl_url_get(data->state.uh, CURLUPART_SCHEME, &scheme, 0);
1331 if(uc) {
1332 free(follow_url);
1333 return Curl_uc_to_curlcode(uc);
1334 }
1335
1336 p = Curl_get_scheme_handler(scheme);
1337 if(p && (p->protocol != data->info.conn_protocol)) {
1338 infof(data, "Clear auth, redirects scheme from %s to %s",
1339 data->info.conn_scheme, scheme);
1340 clear = TRUE;
1341 }
1342 free(scheme);
1343 }
1344 if(clear) {
1345 Curl_safefree(data->state.aptr.user);
1346 Curl_safefree(data->state.aptr.passwd);
1347 }
1348 }
1349 }
1350 DEBUGASSERT(follow_url);
1351
1352 if(type == FOLLOW_FAKE) {
1353 /* we are only figuring out the new URL if we would have followed locations
1354 but now we are done so we can get out! */
1355 data->info.wouldredirect = follow_url;
1356
1357 if(reachedmax) {
1358 failf(data, "Maximum (%d) redirects followed", data->set.maxredirs);
1359 return CURLE_TOO_MANY_REDIRECTS;
1360 }
1361 return CURLE_OK;
1362 }
1363
1364 if(disallowport)
1365 data->state.allow_port = FALSE;
1366
lib/curl_setup.hlines 931–932 · definition of Curl_safefree
Show 2 lines (not cited by the judge)
931#define Curl_safefree(ptr) \
932 do { curlx_free(ptr); (ptr) = NULL;} while(0)
lib/curl_setup.h@curl-8_17_0lines 984–985 · definition of Curl_safefree as in release curl-8_17_0; differs from master
Show 2 lines (not cited by the judge)
984#define Curl_safefree(ptr) \
985 do { free((ptr)); (ptr) = NULL;} while(0)