CVE-2025-14524: bearer token leak on cross-protocol redirect
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
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.
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
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.
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.
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.
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.
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.
Read the original report (excerpt)
Source code
| 1206 | CURLcode 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 |
| 1206 | CURLcode 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 |
Show 2 lines (not cited by the judge)
| 931 | #define Curl_safefree(ptr) \ |
| 932 | do { curlx_free(ptr); (ptr) = NULL;} while(0) |
Show 2 lines (not cited by the judge)
| 984 | #define Curl_safefree(ptr) \ |
| 985 | do { free((ptr)); (ptr) = NULL;} while(0) |