HexBrain

curl · Report #3770979

SMTP connection reuse ignores --ssl-reqd / CURLOPT_USE_SSL and reuses a clear-text STARTTLS session on current master

N/Astatus
lowseverity
1upvotes
3 Jun 2026disclosed

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-reqd transfer should either create a new connection and then fail because the server does not support STARTTLS, 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 unconditional url_match_ssl_use() gate before other connection-match checks
  • lib/url.c: url_match_destination() now calls url_match_ssl_use() only when the candidate and requested schemes differ
  • for same-scheme STARTTLS cases such as smtp:// -> smtp://, the m->require_tls guard 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.

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