curl · Report #4122175
CVE-2026-111950: TLS session cache diff scheme reuse
How it was found
Summary
curl 8.12.0 through 8.23.0 can reuse a TLS session created for an FTPS connection when opening an HTTPS connection to the same hostname and port. The TLS session-cache key contains the hostname, port, transport, TLS backend, and TLS configuration, but it does not contain the URL scheme or application protocol family.
This restores the condition described as CRL-01-011 FTPS TLS session reuse
in curl's 2016 Cure53 audit. That finding was rated Low, and curl fixed it in
commit 1671d84b38ac61f0852e8ff2915fef3346dc53f7 by requiring the cached
session's scheme to match. The scheme check was lost when the TLS session
cache was rewritten in commit
fa0ccd9f1fbbbd77bf50b26e3ba231ea6c729474.
Under the same limited attacker model as the earlier finding, an active network attacker can redirect an HTTPS connection to an FTPS data port. curl then presents the FTPS control session while believing it is opening HTTPS. An FTPS server that requires control/data TLS-session reuse accepts the connection and emits an attacker-controlled file. If that file contains a valid HTTP response, curl accepts its body as the verified HTTPS response.
I suggest Low severity because exploitation requires several uncommon preconditions and timing, but the result is a concrete HTTPS integrity bypass.
Affected versions
- First affected release tested: curl 8.12.0
- Latest affected release tested: curl 8.23.0
- Current
masterateaa9103225is also affected - curl 8.11.1 is not affected
The regression was introduced by the TLS session-cache rewrite in
fa0ccd9f1fbbbd77bf50b26e3ba231ea6c729474, first released in 8.12.0.
I reproduced the issue from the official curl-8_23_0 tag with shared Release
builds using OpenSSL and GnuTLS. The peer key is backend-independent, so other
backends that support cached session resumption should be reviewed, but I have
not included them in the confirmed scope.
Test environment
- Ubuntu 24.04.3 LTS, GCC 13.3.0
- OpenSSL 3.0.13 and GnuTLS 3.8.3
curl-8_23_0:3163ec065643ba20709f190111f42c3e1fe95273curl-8_12_0:34cf9d54a46598c44938aa7598820484d7af7133curl-8_11_1:75a2079d5c28debb2eaa848ca9430f1fe0d7844c
Root cause
ssl_peer_key_build() in lib/vtls/vtls_scache.c starts the cache key with:
result = curlx_dyn_addf(&buf, "%s:%d:KEY-V2",
peer->origin->hostname, peer->origin->port);
It then adds transport, verification settings, TLS versions/options,
trust-store inputs, pins, client-auth fields, and the TLS implementation. It
never adds peer->origin->scheme or peer->origin->scheme->family.
Consequently, verified FTPS and HTTPS filters with the same host, port, and
TLS configuration construct the same key. Curl_ssl_scache_take() returns the
FTPS session to the HTTPS filter. OpenSSL applies it without an application
protocol check. GnuTLS also applies it when the cached ALPN is null, which is
the normal FTPS case.
ALPN does not contain this case. FTPS does not negotiate ALPN, so the cached session has no ALPN. On resumption, the FTPS endpoint ignores the HTTP ALPN offer, and curl falls back to HTTP/1.x. The reproducer confirms this behavior with both OpenSSL and GnuTLS clients.
Before the rewrite, Curl_ssl_getsessionid() contained this comparison:
strcasecompare(cf->conn->handler->scheme, check->scheme)
The rewrite removed Curl_ssl_session.scheme and did not put the scheme into
the replacement ssl_peer_key.
Relevant history:
- Prior Low finding: https://curl.se/docs/audit/cure53-curl-report-2016.pdf
- Prior fix: https://github.com/curl/curl/commit/1671d84b38ac61f0852e8ff2915fef3346dc53f7
- Regression commit: https://github.com/curl/curl/commit/fa0ccd9f1fbbbd77bf50b26e3ba231ea6c729474
- Pre-rewrite design discussion listing
schemeas a cache discriminator: https://github.com/curl/curl/discussions/15684
Attack sequence represented by the reproducer
- curl starts
ftps://host:P/evil-fileand establishes FTPS control sessionSwith full certificate verification. - The FTPS server advertises a passive data port. The MitM redirects curl's
first connection to that port back to an FTPS control endpoint and holds it
open. curl correctly resumes
Sthere. - curl concurrently starts
https://host:P/good-fileusing the same shared TLS session cache. - The MitM redirects that connection to the real FTPS data port.
- Because the cache key does not distinguish FTPS from HTTPS, curl presents
S. The data endpoint's session-association check succeeds. - The endpoint emits
evil-file, whose raw bytes form an HTTP/1.1 response. curl reports successful HTTPS verification and returns the attacker's body.
The test server compares the TLS 1.2 session IDs. It releases
ATTACKER-CONTROLLED-CONTENT only when the HTTPS session ID exactly matches
the FTPS control session ID. It sends the raw HTTP-shaped file immediately
after that check, without interpreting the HTTP request; it only drains the
already-sent request afterward to make socket shutdown deterministic. A
generic malicious HTTPS server cannot satisfy the session-ID condition.
Minimal reproducer
Attach the cross-scheme-tls-session directory and run:
python3 run.py \
--build /path/to/curl-8.23.0-build \
--source /path/to/curl-8.23.0
Requirements are Python 3, a C compiler, pthreads, the OpenSSL CLI and
development libraries, and a CMake-style shared curl build. run.py compiles
server.c and repro.c in a temporary directory, generates a localhost
certificate, starts the server, and runs the client. It links the absolute
libcurl.so path from --build and fails if that library is absent.
The certificate is explicitly trusted with CURLOPT_CAINFO; neither
CURLOPT_SSL_VERIFYPEER nor CURLOPT_SSL_VERIFYHOST is disabled. The client
uses the documented CURL_LOCK_DATA_SSL_SESSION share option and documented
lock callbacks.
Expected vulnerable output (abridged):
ftps_result=0 (No error)
https_result=0 (No error)
https_verify_result=0
https_body=ATTACKER-CONTROLLED-CONTENT
attack_content_accepted=1
FTPS_FAKE_DATA_REUSED=1
FTPS_FAKE_DATA_MATCHES_CONTROL=1
HTTPS_CROSS_SCHEME_REUSED=1
HTTPS_SESSION_MATCHES_FTPS_CONTROL=1
ATTACK_RELEASED=1
client_exit=0
server_exit=0
I obtained this result in 10/10 consecutive runs against the curl-8_23_0
tag built in Release mode with OpenSSL.
This is a state-separation flaw rather than memory corruption, so an ASan trace is not applicable. The equivalent direct evidence is the server-side comparison of the TLS session IDs, together with the version and patch controls below.
Controls
Pre-regression control
Running the same harness against curl 8.11.1 with --expect-blocked produces:
ftps_result=0 (No error)
https_result=52 (Server returned nothing (no headers, no data))
attack_content_accepted=0
FTPS_FAKE_DATA_MATCHES_CONTROL=1
HTTPS_CROSS_SCHEME_REUSED=0
HTTPS_SESSION_MATCHES_FTPS_CONTROL=0
ATTACK_RELEASED=0
Patched control
fix.patch adds the origin scheme to the peer key and a unit test. After
applying it and rebuilding:
python3 run.py --expect-blocked \
--build /path/to/patched-build \
--source /path/to/patched-source
The patched current tree produces the same blocked outcome as 8.11.1. The existing FTPS control-to-data reuse continues to work:
FTPS_FAKE_DATA_REUSED=1
FTPS_FAKE_DATA_MATCHES_CONTROL=1
The proposed unit test (unit3304) also passes.
Suggested fix
Restore scheme separation in ssl_peer_key_build(), for example:
result = cf_ssl_peer_key_add_string(&buf, "SCHEME",
peer->origin->scheme->name);
if(result)
goto out;
The attached fix.patch contains this change and a regression test. If curl
wants to preserve session reuse between secure and STARTTLS variants of the
same application protocol, keying by peer->origin->scheme->family instead
would also stop the demonstrated FTP-to-HTTP crossing. Exact scheme matching
restores the invariant enforced from 2016 through 8.11.1.
Suggested classification
- Severity: Low
- Impact: HTTPS response integrity violation under a constrained active-MitM scenario
- Possible CWE: CWE-345 (Insufficient Verification of Data Authenticity) or CWE-488 (Exposure of Data Element to Wrong Session)
Impact
Impact and attack prerequisites
The impact is substitution of attacker-chosen bytes for an HTTPS response without disabling certificate or hostname verification.
The demonstrated attack requires all of the following:
- The application lets the attacker cause an FTPS transfer and an HTTPS transfer to the same hostname and numeric port.
- The two easy handles share a TLS session cache. This is a documented libcurl configuration; easy handles in one multi handle also share this cache by default.
- The attacker has an active MitM position and can redirect TCP connections, but cannot forge certificates or decrypt TLS.
- An FTPS service for that hostname uses TLS session reuse to associate its control and data connections.
- The attacker can select or supply the downloaded FTP file. Its bytes begin with a syntactically valid HTTP response, which is necessary because modern curl rejects HTTP/0.9 by default.
- The demonstrated path uses TLS 1.2 and requires concurrent transfers.
These constraints make Low appropriate. They do not eliminate the security boundary: without the cross-scheme session reuse, the FTPS data endpoint rejects the redirected HTTPS connection and releases no file.