curl · Report #3770979
SMTP connection reuse ignores --ssl-reqd / CURLOPT_USE_SSL and reuses a clear-text STARTTLS session on current master
How it was found
Summary:
Current master reintroduces a STARTTLS connection-reuse bug in SMTP. After commit 91dcf4e610 (url: url_match_destination fix), curl/libcurl can reuse an already-established clear-text smtp:// session for a later logical request that explicitly requires TLS via --ssl-reqd or CURLOPT_USE_SSL >= CURLUSESSL_CONTROL. The key issue is that same-scheme candidates no longer hit the unconditional url_match_ssl_use() gate, so the later request can inherit a plaintext session without STARTTLS ever running. In my validated PoC below, the second transfer is marked --ssl-reqd, curl prints Reusing existing smtp: connection, the test server sees only one TCP connection, and it never receives STARTTLS. The PoC uses the curl CLI with --next because that is the simplest end-to-end reproduction, but the root cause is in libcurl connection reuse and affects normal shared-cache / multi-handle usage as well.
Affected version
Validated locally on current repository HEAD 6ac42e5691, built from https://github.com/curl/curl, on:
curl 8.21.0-DEV (x86_64-pc-linux-gnu) libcurl/8.21.0-DEV OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libpsl/0.21.2
Platform:
Linux L-G4274LMD-1548 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun 5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
This appears to be an unreleased regression introduced by commit 91dcf4e610e3094d1ad55eb3bf9b99c0b6fef27b on current master. As of my validation, I am reproducing it on 8.21.0-DEV, not on a released tarball version.
Steps To Reproduce:
Run the following self-contained script from the repository root. It does not rely on any local PoC file. The script tries to find an already-built curl binary in the tree with SMTP + SSL enabled and falls back to building one in a temporary directory if needed. It then starts a minimal SMTP server that intentionally does not offer STARTTLS, runs one permissive SMTP transfer followed by a second --ssl-reqd transfer in the same curl process using --next, and proves that the second transfer reuses the same plaintext session instead of negotiating STARTTLS:
set -euo pipefail
ROOT="$PWD"
TMPDIR="$(mktemp -d)"
trap 'jobs -p | xargs -r kill 2>/dev/null || true; rm -rf "$TMPDIR"' EXIT
find_curl_bin() {
local cand out
for cand in "$ROOT"/build-*/src/curl "$ROOT"/src/curl; do
[ -x "$cand" ] || continue
out="$("$cand" --version 2>/dev/null || true)"
if printf '%s' "$out" | grep -q 'Protocols:.*smtp' &&
printf '%s' "$out" | grep -q 'Features:.*SSL'; then
printf '%s\n' "$cand"
return 0
fi
done
return 1
}
CURL_BIN="$(find_curl_bin || true)"
if [ -z "${CURL_BIN:-}" ]; then
command -v cmake >/dev/null 2>&1 || {
echo "cmake is required when no built curl binary is present" >&2
exit 1
}
command -v cc >/dev/null 2>&1 || {
echo "a C compiler is required when no built curl binary is present" >&2
exit 1
}
cmake -S "$ROOT" -B "$TMPDIR/build" -DCURL_USE_OPENSSL=ON >/dev/null
cmake --build "$TMPDIR/build" -j"${JOBS:-4}" >/dev/null
CURL_BIN="$TMPDIR/build/src/curl"
fi
echo "Using curl binary: $CURL_BIN"
"$CURL_BIN" --version | sed -n '1,4p'
printf 'Subject: one\r\n\r\nbody1\r\n' > "$TMPDIR/msg1.txt"
printf 'Subject: two\r\n\r\nbody2\r\n' > "$TMPDIR/msg2.txt"
PORT_FILE="$TMPDIR/port"
SRV_LOG="$TMPDIR/server.log"
CURL_ERR="$TMPDIR/curl.err"
cat > "$TMPDIR/server.py" <<'PY'
import base64
import socket
import threading
import sys
port_file, log_file = sys.argv[1:3]
log = open(log_file, "w", buffering=1)
def w(msg):
log.write(msg + "\n")
def b64(s):
try:
return base64.b64decode(s).decode("utf-8", "replace")
except Exception:
return "<bad-base64>"
lsock = socket.socket()
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lsock.bind(("127.0.0.1", 0))
lsock.listen(5)
port = lsock.getsockname()[1]
open(port_file, "w", encoding="ascii").write(str(port))
w(f"LISTEN {port}")
accepts = 0
def handle(conn, cid):
conn.sendall(b"220 test.smtp.local ESMTP ready\r\n")
mode = None
data_mode = False
buf = b""
while True:
b = conn.recv(1)
if not b:
w(f"EOF {cid}")
return
buf += b
if not buf.endswith(b"\n"):
continue
line = buf.decode("utf-8", "replace").rstrip("\r\n")
buf = b""
w(f"C{cid} {line}")
upper = line.upper()
if data_mode:
if line == ".":
data_mode = False
conn.sendall(b"250 2.0.0 queued\r\n")
continue
if mode == "auth_plain_resp":
w(f"AUTHPLAIN {cid} {b64(line)!r}")
mode = None
conn.sendall(b"235 2.7.0 authenticated\r\n")
continue
if upper.startswith("EHLO") or upper.startswith("HELO"):
conn.sendall(b"250-localhost\r\n250-AUTH PLAIN LOGIN\r\n250 OK\r\n")
elif upper == "AUTH PLAIN":
mode = "auth_plain_resp"
conn.sendall(b"334 \r\n")
elif upper.startswith("AUTH PLAIN "):
w(f"AUTHPLAIN {cid} {b64(line.split(' ', 2)[2])!r}")
conn.sendall(b"235 2.7.0 authenticated\r\n")
elif upper == "STARTTLS":
w(f"STARTTLS {cid}")
conn.sendall(b"454 4.7.0 TLS not available\r\n")
elif upper.startswith("MAIL FROM:"):
conn.sendall(b"250 2.1.0 sender ok\r\n")
elif upper.startswith("RCPT TO:"):
conn.sendall(b"250 2.1.5 recipient ok\r\n")
elif upper == "DATA":
data_mode = True
conn.sendall(b"354 end with .\r\n")
elif upper in ("HELP", "NOOP", "RSET"):
conn.sendall(b"250 2.0.0 ok\r\n")
elif upper == "QUIT":
conn.sendall(b"221 2.0.0 bye\r\n")
w(f"QUIT {cid}")
return
else:
conn.sendall(b"250 ok\r\n")
while True:
conn, addr = lsock.accept()
accepts += 1
w(f"ACCEPT {accepts} {addr}")
threading.Thread(target=handle, args=(conn, accepts), daemon=True).start()
PY
python3 -u "$TMPDIR/server.py" "$PORT_FILE" "$SRV_LOG" &
for _ in $(seq 1 100); do
[ -s "$PORT_FILE" ] && break
sleep 0.05
done
[ -s "$PORT_FILE" ] || {
echo "SMTP server did not start" >&2
exit 1
}
PORT="$(cat "$PORT_FILE")"
"$CURL_BIN" -sv "smtp://127.0.0.1:$PORT" \
--user alice:secret \
--mail-from alice@example \
--mail-rcpt bob@example \
-T "$TMPDIR/msg1.txt" \
--next "smtp://127.0.0.1:$PORT" \
--ssl-reqd \
--user alice:secret \
--mail-from alice@example \
--mail-rcpt bob@example \
-T "$TMPDIR/msg2.txt" \
> /dev/null 2> "$CURL_ERR" || true
sleep 0.2
echo
echo "=== curl stderr ==="
sed -n '1,220p' "$CURL_ERR"
echo
echo "=== server log ==="
sed -n '1,220p' "$SRV_LOG"
ACCEPT_COUNT="$(grep -c '^ACCEPT ' "$SRV_LOG" || true)"
if grep -q 'Reusing existing smtp: connection' "$CURL_ERR" &&
[ "$ACCEPT_COUNT" -eq 1 ] &&
! grep -q '^STARTTLS ' "$SRV_LOG"; then
echo
echo "BUG CONFIRMED: the second --ssl-reqd transfer reused the first plaintext SMTP session without STARTTLS."
else
echo
echo "PoC did not reproduce as expected." >&2
exit 1
fi
Expected safe behavior:
- the second
--ssl-reqdtransfer should either create a new connection and then fail because the server does not supportSTARTTLS, or fail before reusing the existing plaintext session - a TLS-required SMTP request should never run on a previously established clear-text session from the connection cache
Observed vulnerable behavior:
- curl prints
Reusing existing smtp: connection - the server log shows exactly one TCP
ACCEPT - the server log never contains
STARTTLS - the second SMTP transaction (
MAIL FROM,RCPT TO,DATA) is sent on the same clear-text session that was created by the first permissive transfer
Relevant code path in the current tree:
lib/url.c:url_match_conn()no longer performs the unconditionalurl_match_ssl_use()gate before other connection-match checkslib/url.c:url_match_destination()now callsurl_match_ssl_use()only when the candidate and requested schemes differ- for same-scheme STARTTLS cases such as
smtp://->smtp://, them->require_tlsguard is skipped, so a clear-text pooled connection can still match - this regression was introduced by commit
91dcf4e610e3094d1ad55eb3bf9b99c0b6fef27b(url: url_match_destination fix)
Why attackers can use this in practice:
- applications commonly reuse libcurl connections on purpose; it is a documented performance feature, not an API misuse case
- one earlier permissive transfer (
CURLUSESSL_NONE,CURLUSESSL_TRY, or CLI without--ssl-reqd) can leave a clear-text authenticated SMTP session in the connection cache - a later logical request that the application explicitly marks as TLS-required can silently inherit that session instead of performing
STARTTLS - a network attacker on the same LAN, hostile Wi-Fi, enterprise interception point, or any other observer on the path can then read or tamper with data that the application expected to be TLS-protected
Impact
Summary:
This bug can cause a later SMTP request that explicitly requires TLS to be sent over an already-established plaintext connection. In practice, that can expose sender/recipient metadata and message content in clear text instead of failing or creating a new TLS-protected session. The security impact is unintended plaintext transmission despite an explicit TLS requirement.