mythos scan of curl 2026-05-06

36 min read Original article ↗

curl security review log

Started: 2026-05-06
Commit: 455bebc2c7 (master)
Scope: 178k LoC C, lib/ + src/
Code links: https://github.com/curl/curl/tree/455bebc2c76223a1be26042f6d2393715c0df0cd (all line refs below pin to this commit)

Approach

curl is one of the most fuzzed and audited C codebases in existence (OSS-Fuzz, Coverity, CodeQL, multiple paid audits). Finding anything in the hot paths (HTTP/1, TLS, URL parsing core) is unlikely. Focus instead on:

  1. Code changed in the last 90 days (fewer eyes, not yet fuzzed long)
  2. Obscure protocols nobody uses or audits: DICT, GOPHER, TELNET, MQTT, RTSP, TFTP, LDAP, SMB, IMAP/POP3
  3. Error/cleanup paths (fuzzers hit happy paths more)
  4. Integer arithmetic on attacker-controlled lengths
  5. The tool (src/) rather than the library - less scrutiny than libcurl
  6. Build system / test infra (supply chain angle)
  7. Platform-specific code (#ifdef WIN32, VMS, AmigaOS etc) - rarely compiled by reviewers
  8. Interaction bugs between features (e.g. HSTS + altsvc + connection reuse)

Method per area

  • Read the code directly, don't rely on grep patterns alone
  • For each suspicious site: trace data origin back to network/user input
  • Check bounds, signedness, overflow, lifetime
  • Note even low-severity findings - they indicate code quality in that area
  • Record NEGATIVE results too (looked at X, found nothing) so restart doesn't repeat work

Verification standard (no false positives)

Before anything goes in Findings it must pass ALL of:

  1. Data origin traced: the dangerous value provably comes from network/file/user input, not a constant or already-validated source
  2. No upstream guard: checked the full call chain for earlier length/range/NULL checks that would prevent the bad case
  3. No type-level mitigation: e.g. value is unsigned char so "negative" is impossible, or buffer is sized by the same variable
  4. Reachable: the code path is actually compiled in a normal build (not dead #ifdef VMS etc) and reachable at runtime
  5. Consequence stated concretely: "writes N bytes past buffer X of size Y" not "might overflow"

Anything that fails a check goes in "Investigated - not a bug" with the reason. Uncertain items go in "Needs PoC" not Findings.


Plan / progress checklist

  • P1: Survey recent commits (90 days) for risky changes (peer.c reviewed in depth)
  • P2: lib/dict.c - DICT protocol (clean)
  • P3: lib/gopher.c - GOPHER (clean)
  • P4: lib/telnet.c - TELNET (clean)
  • P5: lib/mqtt.c - MQTT (clean, 1 out-of-scope note)
  • P6: lib/rtsp.c - RTSP (clean, 1 correctness-only bug noted)
  • P7: lib/tftp.c - TFTP (clean)
  • P8: lib/smb.c - SMB (clean)
  • P9: lib/ldap.c + openldap.c (2 minor leaks, not security)
  • P10: src/tool_*.c - CLI argument/config handling (2 real bugs, outside threat model)
  • P11: Integer overflow sweep (clean - dynbuf/memdup0/str_number/bufq all guard)
  • P12: Format string sweep (clean - all literals, CURL_PRINTF attr enforced)
  • P13: lib/vssh/vssh.c (was curl_path.c), escape.c, headers.c, parsedate.c (clean)
  • P14: Cookie / HSTS / altsvc / netrc file parsers (clean)
  • P15: Platform ifdef code (WIN32, MSDOS, AMIGA) - clean
  • P16: lib/socks.c + socks_gssapi.c / socks_sspi.c (clean, 1 code-quality wart)
  • P17: Connection reuse / state confusion (clean, 1 item for further analysis)
  • P18: lib/ws.c WebSocket (clean)
  • P19: Build system: configure.ac, CMake, scripts/ for injection (clean)
  • P20: lib/netrc.c, lib/parsedate.c, lib/headers.c parsers (clean, covered in P13/P14)
  • P21: lib/vtls/x509asn1.c - custom ASN.1 parser (clean, 1 code-quality note)
  • P22: lib/doh.c - DNS-over-HTTPS response parsing (clean)
  • P23: lib/curl_sasl.c, lib/vauth/* - auth mechanisms (clean; krb5.c removed)
  • P24: lib/content_encoding.c (clean)
  • P25: lib/imap.c / pop3.c / smtp.c (clean)
  • P26: lib/http2.c (clean, 1 minor leak noted, 1 hardening note)
  • P27: lib/vquic/* (clean; osslq/msh3 not in tree)
  • P28: lib/ftp.c (clean)
  • P29: lib/mprintf.c (clean)
  • P30: lib/vtls/{openssl,gtls,wolfssl,mbedtls,hostcheck}.c cert verify (clean)
  • P31: lib/vtls/schannel.c + schannel_verify.c (clean, 1 local-config over-read noted)
  • P32: lib/vtls/rustls.c + vtls_scache.c + http_chunks.c (clean)
  • P33: lib/http.c + http1.c (clean)
  • P34: lib/ftplistparser.c (clean, 1 data-quality note)
  • P35: CVE-pattern variant analysis → F1-F3 + HAPROXY (later demoted)

Phase 2: CVE-driven variant analysis

Source: https://curl.se/docs/vuln.json - 188 CVEs total. Categorized by recurring bug class:

Class Count Examples Variant-hunt target
Connection reuse confusion ~22 CVE-2026-5773, 5545, 4873, 3784; 2023-27535/6/8; 2022-27782, 22576 Every CURLOPT that affects connection identity vs url_match_*
Per-backend cert-verify gap ~22 CVE-2026-7009; 2025-13034, 10966, 5025, 4947; 2024-8096, 2466 Feature×backend matrix: pinning/OCSP/hostname/IP for each TLS+QUIC+SSH backend
Credential leak on redirect ~15 CVE-2026-6253, 3783; 2025-14524; 2024-11053; 2022-27776 Each credential type × each redirect dimension
HSTS / cookie hostname-match bypass ~10 CVE-2024-9681; 2023-46218; 2022-43551, 42916, 30115 Hostname normalization: trailing dot, IDN, case, %-enc, zone-id
Buffer overflow / OOB ~35 CVE-2025-9086; 2024-7264, 6197; 2023-38545; 2019-5482 Already covered in phase 1 file audits
UAF / double-free ~12 CVE-2026-3805; 2023-28319; 2022-43552; 2021-22945, 22901 Error-path cleanup, shared-object lifetime
STARTTLS injection / downgrade ~3 CVE-2021-22947, 22946 Pre-TLS response buffering across all STARTTLS protocols
netrc handling ~5 CVE-2026-6429; 2025-0167; 2024-11053; 2022-35260 Already audited file parser; check usage sites

Method: for each class, search the WHOLE codebase (not just recent changes) for the same structural pattern in places the original CVE didn't touch.

  • V1: Per-backend security-feature parity matrix → 3 gaps found (F1)
  • V2: Connection-reuse property completeness vs all CURLOPTs → F2, F3 + HAPROXY (demoted)
  • V3: Redirect credential-leak surface (all cred types × all redirect dimensions) → clean
  • V4: Hostname-normalization bypass (HSTS/cookie/altsvc/noproxy) → clean
  • V5: STARTTLS pre-auth buffer carryover across IMAP/POP3/SMTP/FTP/LDAP → clean
  • V6: UAF on error paths in shared/refcounted objects → clean

Phase 3: additional approaches

Ranked by expected yield. A1-A3 extend the methods that produced F1-F3.

  • A1: Systematic data->set.* audit → F4 (ssh_config_matches no-op); full table in work log
  • A2: Setopt gating completeness → no new gaps beyond F1; minor return-code inconsistency noted
  • A3: ssl_config_data vs ssl_primary_config → N2 (fsslctx); all other fields covered via ssl_options bitmask
  • A4: Cross-backend cert acceptance differential → 2 differentials found (D1, D2)
  • A5: CURLSH share-object thread safety → F5 (HSTS unlocked access); cookie/DNS/SSL/PSL/conn all clean
  • A6: ECH review → 3 issues (experimental, not advisories)
  • A7: docs/KNOWN_RISKS.md follow-up → all entries are user-behavior disclaimers, nothing actionable
  • A8: OSS-Fuzz coverage gaps → infrastructure built, basic profile generated, no new gaps (see work log)
  • A9: CURLOPT doc-vs-impl diff → clean; independently rediscovered F1 (cross-validation)

A4 results (verify_a4/ in this repo): built openssl/wolfssl/gnutls/mbedtls/rustls, generated 7 pathological certs, ran each via openssl s_server. Differential matrix:

cert / target           openssl   wolfssl   gnutls    mbedtls   rustls    [expect]
baseline / localhost    pass      pass      pass      pass      pass      [PASS]
ip_as_dns / 127.0.0.1   FAIL60    FAIL60    FAIL60    pass      FAIL60    [FAIL]
ip_san / 127.0.0.1      pass      pass      pass      pass      pass      [PASS]
wild_local / a.localhostFAIL60    pass      FAIL60    pass      FAIL60    [?]
wild_bare / localhost   FAIL60    pass      FAIL60    FAIL60    FAIL60    [FAIL]
wild_multi / a.b.local  FAIL60    pass      FAIL60    FAIL60    FAIL60    [FAIL]
nul / localhost         FAIL60    FAIL60    FAIL60    FAIL60    FAIL60    [FAIL]

Sanity check: wolfSSL correctly rejects DNS:totally.different.example for localhost (rc=60), so hostname verification IS active.

See D1, D2 in Findings for analysis.


Summary

Phase 1 (file-by-file memory safety): 34 areas, ~55 source files (~64k LoC read line-by-line) plus pattern sweeps across all of lib/. Zero memory-safety vulnerabilities found.

Phase 2 (CVE-pattern variant analysis): mapped 188 historical CVEs into 8 bug classes, hunted each class across the whole codebase. Produced F1-F3 plus the HAPROXY item (later demoted to a docs note).

Phase 3 (systematic generalization + experiments): mechanically extended the methods that produced F1-F3. Two more confirmed findings: F4 (CVE-2023-27538 fix is ineffective — ssh_config_matches always compares NULL==NULL) and F5 (CVE-2023-27537 variant — shared HSTS cache mutated without lock on four code paths). Cross-backend cert differential testing produced D1/D2. Plus N2 (fsslctx reuse) and three ECH issues (experimental).

Total: 5 confirmed in-scope findings (F1-F5), 2 backend differentials (D1-D2), 4 out-of-scope bugs (B1-B4), 2 needs-discussion (N1-N2), 3 ECH pre-graduation issues, HAPROXY docs note, ~13 code-quality observations, 3 hardening notes.

Recommended disclosure order:

  1. F4 + F2 together — CVE-2023-27538 fix is non-functional (empirically reproduced in Docker: handle B with unauthorized key operates as handle A's identity), and the same function also omits host-key settings. Suggested fix included.
  2. F5 — CVE-2023-27537 fix covers one of five code paths; hsts_check mutates during lookup so exclusive locking needed everywhere. Suggested fix included.
  3. F1CURLOPT_PROXY_CRLFILE/ISSUERCERT/ISSUERCERT_BLOB ungated (empirically proven against wolfSSL). Mechanical fix.
  4. F3 — STARTTLS SSL config not compared on reuse. Source-verified.
  5. D1/D2 — backend cert-verify differentials (mbedTLS IP-as-dNSName, wolfSSL bare-* wildcard). Curl-side defense-in-depth fix possible without waiting on upstream.

Separately as normal bugs/PRs: ECH unrecognized-string fail-open (priority), HAPROXY reuse docs note, B1-B4, N2 question, code-quality list.

Empirical reproductions: F1 (verify_f1.c + wolfSSL build), F4 (verify_f5/ Docker — directory name predates renumbering), D1/D2 (verify_a4/), B1/B2 (UBSan/ASan on build/).

Methodology note: this review is hand-driven analysis using LLM subagents for parallel file reads, with every candidate finding re-verified by direct source inspection in the main session before being recorded. The CVE→variant-hunt mapping was built from curl's own vuln.json. No automated SAST tooling was used.

This outcome is consistent with curl's status as one of the most heavily fuzzed and audited C codebases. The defensive infrastructure (capped dynbufs everywhere, curlx_str_number with explicit max on every numeric parse, curlx_memdup0 overflow guard, CURL_PRINTF format-string enforcement, per-protocol response-size caps, pingpong 64KB line cap) systematically closes the bug classes that would normally be productive in a codebase this size.

Coverage now includes: all minor protocols, all file parsers, all TLS backends' verify paths, http/1/2/3, ftp full depth, mprintf, x509asn1, doh, all auth mechanisms, content encoding, connection reuse, session cache, CLI tool, platform-specific code, and CI/build supply chain.

Findings

(each entry: file:line, severity, description, exploitability, verification)

Confirmed security vulnerabilities

These match the pattern of past curl CVEs and pass the verification standard above. All are logic bugs (no memory corruption). Severity estimates use curl's own scale from VULN-DISCLOSURE-POLICY.md.

F1. CURLOPT_PROXY_CRLFILE / CURLOPT_PROXY_ISSUERCERT / CURLOPT_PROXY_ISSUERCERT_BLOB silently ignored on backends that don't support them — severity Low

https://github.com/curl/curl/blob/455bebc2c7/lib/setopt.c#L1786-L1797 https://github.com/curl/curl/blob/455bebc2c7/lib/setopt.c#L2837-L2841 https://github.com/curl/curl/blob/455bebc2c7/lib/setopt.c#L1890-L1897 (non-proxy CRLFILE, correctly gated) https://github.com/curl/curl/blob/455bebc2c7/lib/setopt.c#L2247-L2254 (non-proxy ISSUERCERT, correctly gated)

CURLOPT_CRLFILE checks Curl_ssl_supports(data, SSLSUPP_CRLFILE) and returns CURLE_NOT_BUILT_IN on unsupported backends. CURLOPT_PROXY_CRLFILE does not - it stores the string and returns CURLE_OK regardless. Same for ISSUERCERT and ISSUERCERT_BLOB. Affected backends all declare SSLSUPP_HTTPS_PROXY (wolfssl.c:2318, mbedtls.c:1597, schannel.c:2855, rustls.c:1424) so they do handle HTTPS-proxy connections, but they never read the proxy CRL/issuer settings.

Impact: an application sets CURLOPT_PROXY_CRLFILE with wolfSSL or Schannel, gets CURLE_OK, and assumes revoked proxy certificates will be rejected. They are not. CURLOPT_PROXY_ISSUERCERT is silently ignored on wolfSSL/mbedTLS/Schannel/rustls. CURLOPT_PROXY_ISSUERCERT_BLOB on everything except OpenSSL. verifypeer/verifyhost still run; only the additional restriction is dropped.

Same bug class as CVE-2025-5025 (no QUIC pinning with wolfSSL), CVE-2025-13034 (no QUIC pinning with GnuTLS), CVE-2024-2466 - all rated Low.

For completeness: OpenSSL's proxy leg does consume these via Curl_ssl_cf_get_primary_config(cf)conn_config->issuercert_blob at openssl.c:4507 and ssl_config->primary.CRLfile at openssl.c:3100, so the option is functional on at least one backend; the setopt-acceptance asymmetry is the bug.

Reproduced empirically against a wolfSSL build (verify_f1.c in this repo):

SSL backend: wolfSSL/5.9.1
CURLOPT_CRLFILE                  ->  4 A requested feature ... not found built-in
CURLOPT_PROXY_CRLFILE            ->  0 No error
CURLOPT_ISSUERCERT               ->  4 A requested feature ... not found built-in
CURLOPT_PROXY_ISSUERCERT         ->  0 No error
CURLOPT_ISSUERCERT_BLOB          ->  4 A requested feature ... not found built-in
CURLOPT_PROXY_ISSUERCERT_BLOB    ->  0 No error

F2. SSH host-key verification settings not compared on connection reuse — addendum to F4

https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L686-L695

ssh_config_matches() compares only the client keypair (sshc->rsa, sshc->rsa_pub). It does not compare CURLOPT_SSH_KNOWNHOSTS, CURLOPT_SSH_HOST_PUBLIC_KEY_MD5, CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256, or CURLOPT_SSH_HOSTKEYFUNCTION. These live in data->set.str[] and are read at handshake time (lib/vssh/libssh2.c:458-580, lib/vssh/libssh.c:133-171); they are never copied onto connectdata and never compared.

Impact: handle A connects to sftp://host with a permissive CURLOPT_SSH_HOSTKEYFUNCTION that returns CURLKHSTAT_FINE (TOFU first-connect, or accepts any key), or with CURLOPT_SSH_KNOWNHOSTS pointing at a file the attacker has previously seeded. An on-path attacker MITMs the handshake. Handle B in the same multi/share sets CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256 (or a different known_hosts that excludes the attacker key) expecting server-identity verification, same user/host. B reuses A's already-established session; B's pin never runs.

This is the second half of the same fix as F4: once ssh_config_matches is repaired to actually compare client keys, it should also compare STRING_SSH_KNOWNHOSTS / STRING_SSH_HOST_PUBLIC_KEY_MD5 / STRING_SSH_HOST_PUBLIC_KEY_SHA256 and whether ssh_hostkeyfunc is set. Direct analogue of TLS verifypeer/pinned_key which ARE compared (vtls.c:202,221). Filed together with F4 rather than as a separate CVE candidate to avoid count inflation; the function needs one rewrite that covers both.

F3. SSL config not compared when reusing STARTTLS-upgraded connections — severity Low/Medium

https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L1024-L1036 https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L825-L844

url_match_ssl_config() is gated on m->needle->scheme->flags & PROTOPT_SSL. For imap://, pop3://, smtp://, ftp://, ldap:// with CURLOPT_USE_SSL (STARTTLS), the scheme lacks PROTOPT_SSL, so Curl_ssl_conn_config_match is never called even though the connection runs TLS. url_match_ssl_use() at L833-838 lets a plain-scheme needle reuse an SSL-upgraded conn via PROTOPT_SSL_REUSE without ever reaching the config comparison; the req_tls check at L840 only fires when the existing conn is NOT ssl.

Impact: handle A connects imap://host with CURLOPT_USE_SSL=CURLUSESSL_ALL and CURLOPT_SSL_VERIFYPEER=0; on-path attacker MITMs the STARTTLS upgrade. Handle B connects imap://host with USE_SSL=ALL, VERIFYPEER=1, and a pinned key. B reuses A's unverified TLS session; B's verifypeer/pinning never run. (FTP's ftp_conns_match checks use_ssl level and ftp_ccc but not ssl_primary_config; IMAP/POP3/SMTP have no protocol-specific check at all.)

Same bug class as CVE-2022-27782 (TLS/SSH too-eager reuse) - that fix added Curl_ssl_conn_config_match but only behind a PROTOPT_SSL gate, missing the STARTTLS path. Verified by reading both functions and tracing the needle for imap:// + USE_SSL through url_match_ssl_use → returns TRUE at L838, then url_match_ssl_config → returns TRUE at L1028 without calling Curl_ssl_conn_config_match.

F4. ssh_config_matches() is a no-op — CVE-2023-27538 fix is ineffective — severity Medium

https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L686-L695 https://github.com/curl/curl/blob/455bebc2c7/lib/vssh/libssh2.c#L3324-L3348 https://github.com/curl/curl/blob/455bebc2c7/lib/vssh/libssh2.c#L1540-L1541 https://github.com/curl/curl/blob/455bebc2c7/lib/strcase.c#L119-L124

ssh_config_matches() compares sshc->rsa and sshc->rsa_pub between needle and pooled conn. But both are always NULL at comparison time:

  • Needle side: ssh_setup_connection() (libssh2.c:3324) only callocs the struct. rsa/rsa_pub are populated later in the SSH state machine (libssh2.c:1086+), which never runs for a needle - the needle is built, setup_connection runs, then ConnectionExists checks for reuse before any actual connect.
  • Pooled side: after pubkey auth completes, libssh2.c:1540-1541 does curlx_safefree(sshc->rsa_pub); curlx_safefree(sshc->rsa);. So a pooled, idle SSH connection also holds NULL.
  • Curl_safecmp(NULL, NULL) returns TRUE (strcase.c:123: return !a && !b).

So ssh_config_matches() always passes regardless of CURLOPT_SSH_PUBLIC_KEYFILE / CURLOPT_SSH_PRIVATE_KEYFILE. The CVE-2023-27538 fix (which added this function) is ineffective in the current tree. Same applies to libssh backend (myssh_setup_connection at libssh.c:2494 only callocs; rsa freed at libssh.c:1853-1854).

Impact: shared multi/connection-cache, two SFTP transfers to git@host (git hosting uses one username, identity is the key). Handle A with CURLOPT_SSH_PRIVATE_KEY=alice.key connects and authenticates as Alice. Handle B with CURLOPT_SSH_PRIVATE_KEY=bob.key reuses A's session (url_match_auth matches on user="git", ssh_config_matches matches on NULL==NULL) and operates as Alice. Combined with F2 (host-key settings also not compared), the entire SSH-specific reuse check is non-functional.

Verified by reading ssh_setup_connection (only calloc), the state-machine assignment site, the post-auth free, and Curl_safecmp.

Reproduced empirically (Docker, see verify_f5/ in this repo): debian:bookworm + openssh-server, only /tmp/alice.pub in authorized_keys. libcurl program on a shared multi handle: handle A with CURLOPT_SSH_PRIVATE_KEYFILE=/tmp/alice connects (rc=0, num_connects=1); handle B with CURLOPT_SSH_PRIVATE_KEYFILE=/tmp/bob then reuses A's session (rc=0, num_connects=0). curl verbose: * Reusing existing sftp: connection with host 127.0.0.1. B succeeds despite bob.pub not being authorized.

Suggested fix: copy data->set.str[STRING_SSH_PRIVATE_KEY] / STRING_SSH_PUBLIC_KEY (and STRING_KEY_PASSWD since it doubles as the SSH key passphrase) into struct ssh_conn during ssh_setup_connection(), compare those in ssh_config_matches(), and don't free them post-auth. Same pattern as ftp_conns_match storing account/alternative_to_user on the conn. While there, add the host-key fields from F2 below.

F5. Shared HSTS cache accessed without lock — CVE-2023-27537 variant — severity Medium

https://github.com/curl/curl/blob/455bebc2c7/lib/hsts.c#L160-L168 https://github.com/curl/curl/blob/455bebc2c7/lib/http.c#L3571 https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L1441 https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L265 https://github.com/curl/curl/blob/455bebc2c7/lib/transfer.c#L613 https://github.com/curl/curl/blob/455bebc2c7/lib/curl_share.c#L459 https://github.com/curl/curl/blob/455bebc2c7/lib/hsts.c#L605-L613

When CURL_LOCK_DATA_HSTS is shared, data->hsts aliases share->hsts (curl_share.c:459). The CVE-2023-27537 fix added locking only to Curl_hsts_loadfiles (hsts.c:605-613). All other paths access the shared list without locking:

  • Curl_hsts_parse (http.c:3571) on incoming Strict-Transport-Security header → appends/removes entries
  • Curl_hsts_applieshsts_check (url.c:1441) on every http:// URL → walks the list AND frees expired entries (hsts.c:166-167: Curl_node_remove(&sts->node); hsts_free(sts);)
  • Curl_hsts_save (url.c:265) in Curl_close → walks the list
  • Curl_hsts_loadcb (transfer.c:613) → appends entries

Race A (double-free): two threads each call hsts_check on the same expired entry; both reach line 166-167 with the same sts pointer; double-free + llist corruption.

Race B (UAF): thread 1 in Curl_hsts_parse appending (writes list->tail->next); thread 2 in hsts_check frees that tail node as expired.

Note hsts_check is not read-only despite the name — it mutates during lookup, so even "read" paths need exclusive locking.

Same bug class as CVE-2023-27537 (HSTS double-free), in code paths that fix did not cover. Contrast with cookies in the same function: http.c:3550-3553 wraps Curl_cookie_add in Curl_share_lock(CURL_LOCK_DATA_COOKIE); the HSTS call 18 lines later has no equivalent.

Suggested fix: wrap all four call sites in Curl_share_lock/unlock(data, CURL_LOCK_DATA_HSTS, CURL_LOCK_ACCESS_SINGLE). Note CURL_LOCK_ACCESS_SINGLE (exclusive), not shared, on every path including the lookup at url.c:1441 — hsts_check mutates the list during what looks like a read, so a reader/writer split would still race.

Verified by grep for CURL_LOCK_DATA_HSTS (only 4 hits: 2 in share.c registration, 2 in the loadfiles lock/unlock pair) and reading each unlocked call site.

Backend differentials (need upstream coordination — may or may not be curl CVEs)

These are per-backend parity gaps in the same structural class as F1: curl has protective code for backend A but not backend B. Both have a curl-side defense-in-depth fix that doesn't depend on upstream timing; upstream bugs should be filed in parallel. Likely Low severity. Public CAs won't issue the certs in question (CAB Forum BR §7.1.4.2.1), so impact is bounded to private/internal PKIs (corporate MITM appliances, internal mTLS).

D1. mbedTLS backend: IP-address target matches dNSName SAN containing the IP string https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/mbedtls.c#L934-L942

When the target is an IP literal, connssl->peer.sni is NULL, so curl passes peer.dest->hostname (the string "127.0.0.1") to mbedtls_ssl_set_hostname(). mbedTLS string-compares this against dNSName SANs, so subjectAltName=DNS:127.0.0.1 matches. OpenSSL, wolfSSL, GnuTLS, and rustls all reject (rc=60); only mbedTLS accepts. The code comment at L937-939 acknowledges curl deliberately passes IPs here but doesn't note the consequence — this reads as an oversight in awareness rather than deliberate delegation. Reproduced in verify_a4/.

Same class as CVE-2024-2466 / CVE-2016-3739 (mbedTLS-specific) and CVE-2014-0139/1263/2522 (IP-address cert validation). curl-side fix: when peer.is_ipaddr, do a direct iPAddress-SAN check (mbedTLS exposes the SAN list via mbedtls_x509_crt.subject_alt_names) and skip mbedtls_ssl_set_hostname-based name matching, mirroring how Curl_cert_hostcheck handles IPs for the OpenSSL backend.

D2. wolfSSL backend: bare * and multi-level *.* wildcards accepted https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/wolfssl.c#L1539 https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/hostcheck.c#L92-L104

curl has its own hardened wildcard matcher (Curl_cert_hostcheck) that rejects bare * and requires ≥2 dots in the pattern; it applies it for the OpenSSL backend. For wolfSSL, curl delegates entirely to wolfSSL_check_domain_name(), which accepts all three test patterns. Reproduced in verify_a4/.

The three cases are not equivalent: DNS:* matching any host and DNS:*.*.x (multi-level wildcard) violate RFC 6125 §6.4.3; DNS:*.localhost matching a.localhost is arguably RFC-compliant (the ≥2-dots rule is curl's stricter-than-spec policy, not a standard requirement). The first two are clear bugs; the third is a policy difference.

Same class as CVE-2016-9952 (Schannel wildcard too permissive) and CVE-2016-3739. curl-side fix: after the wolfSSL handshake, extract SAN dNSNames (wolfSSL_X509_get_ext_d2i(..., NID_subject_alt_name, ...)) and run them through Curl_cert_hostcheck for hostname targets, the same way curl already layers wolfSSL_X509_check_ip_asc for IP targets at L1768. File the bare-* and *.* cases with wolfSSL upstream in parallel.

Worth a docs PR / regular issue (not for security disclosure)

CURLOPT_HAPROXYPROTOCOL / CURLOPT_HAPROXY_CLIENT_IP not compared on connection reuse https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L715-L752 https://github.com/curl/curl/blob/455bebc2c7/lib/cf-haproxy.c#L91

The PROXY-protocol header is sent once at connect from data->set; neither stored on connectdata nor compared. Handle B with a different HAPROXY_CLIENT_IP reuses handle A's connection and runs under A's announced source IP. Application controls both handles and chose the upstream. Removed from the security findings after review; better filed as a documentation note in CURLOPT_HAPROXY*.md ("connection reuse ignores this option") or a regular bug.

Real bugs, but outside curl's security threat model

Per docs/VULN-DISCLOSURE-POLICY.md: command-line/config-file input is trusted ("if an attacker can trick the user to run a specifically crafted curl command line, all bets are off"), small leaks are not security issues, and server-triggered crashes without further impact are generally not security issues. The following are real, verified bugs worth fixing as quality/hardening but would not get CVEs.

B1. Unbounded recursion over -F sibling parts https://github.com/curl/curl/blob/455bebc2c7/src/tool_formparse.c#L183 https://github.com/curl/curl/blob/455bebc2c7/src/tool_formparse.c#L263 https://github.com/curl/curl/blob/455bebc2c7/src/tool_formparse.c#L42

tool_mime_new() links each -F part into a singly-linked list via m->prev (L42). Both tool_mime_free() (L183) and tool2curlparts() (L263) walk this list recursively, one stack frame per part. No cap on part count. A config file with ~50-100k -F a=b lines (≈1MB on disk) crashes the tool with stack exhaustion when building or freeing the mime tree. Verified: read tool_mime_new linkage, both recursion sites, no depth limit found. Reproduced empirically: 150k-line config (1.6MB) under ASan → AddressSanitizer: stack-overflow ... tool2curlparts tool_formparse.c:263. Trigger requires user to run curl with attacker-supplied config or args, so excluded by policy. Fix: convert to iterative loop.

B2. Signed integer overflow in URL-glob numeric range carry https://github.com/curl/curl/blob/455bebc2c7/src/tool_urlglob.c#L588 https://github.com/curl/curl/blob/455bebc2c7/src/tool_urlglob.c#L321-L325 https://github.com/curl/curl/blob/455bebc2c7/src/tool_urlglob.h#L52-L55

pat->c.num.idx += pat->c.num.step where idx/step/max are signed curl_off_t. Range validation at L321-325 checks step <= max-min but not max+step <= CURL_OFF_T_MAX. Trigger: curl 'http://x/[1-2][9223372036854775806-9223372036854775807]' - on the third glob_next_url call the rightmost pattern computes 9223372036854775807 + 1. C undefined behaviour; on wrap-around builds emits negative-numbered URLs, on -ftrapv/UBSan aborts. No memory corruption (value never sizes a buffer). Reproduced empirically: UBSan output tool_urlglob.c:588:24: runtime error: signed integer overflow: 9223372036854775807 + 1 cannot be represented in type 'curl_off_t'. Excluded by policy (command-line input). Fix: clamp max at parse time or check before add.

B3. BerElement leak when write callback aborts https://github.com/curl/curl/blob/455bebc2c7/lib/ldap.c#L595-L601 https://github.com/curl/curl/blob/455bebc2c7/lib/ldap.c#L472

ber allocated by ldap_first_attribute() at L472 leaks if the trailing-newline Curl_client_write fails (goto quit skips ber_free). One small allocation per aborted transfer. Policy excludes small leaks.

B4. WinLDAP attribute leak on UTF-8 conversion OOM https://github.com/curl/curl/blob/455bebc2c7/lib/ldap.c#L478-L486

OOM-only, WinLDAP build only. Policy excludes.

Needs further analysis (not confident enough to report)

N1. Origin not compared for non-SSL protocols in CURL_DISABLE_PROXY builds https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L994-L998 https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L746 https://github.com/curl/curl/blob/455bebc2c7/lib/url.c#L1017-L1019

When proxy support is compiled out, the url_match_destination gate reduces to if(PROTOPT_SSL). For plain FTP/IMAP/SMTP etc, origin and via_peer are never compared - reuse decided solely by the bucket key (conn->destination = the peer actually dialled). Two requests using CURLOPT_CONNECT_TO to route different origins through the same intermediate land in the same bucket and match. url_match_connect_config:746 only checks via_peer presence, not value. Pre-dates the bc40e09f63 refactor.

Why I'm not reporting this: (a) for plain-text protocols the origin hostname never appears on the wire (no SNI, no Host header), so "reuse" is connecting to the exact same server the app explicitly asked for; (b) requires uncommon build config + unusual API usage; (c) both handles under same app's control. Could be argued either way against curl's past connection-reuse CVE precedent. Worth raising as a question to maintainers, not as a vulnerability claim.

N2. CURLOPT_SSL_CTX_FUNCTION not compared on connection reuse https://github.com/curl/curl/blob/455bebc2c7/lib/urldata.h#L172-L173 https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/openssl.c#L3936-L3947

fsslctx/fsslctxp (the SSL_CTX callback) is in ssl_config_data not ssl_primary_config, so Curl_ssl_conn_config_match never sees it. Handle B with a CTX callback that adds extra verification can reuse handle A's connection where the callback never ran. The callback is opaque so curl cannot compare what it does; a defensible fix is "if either side has fsslctx set, configs don't match". Listing as needs-discussion because it's arguably a documented limitation of an escape-hatch API rather than a bug.

ECH (experimental — not security advisories per policy, but should fix before graduation)

https://github.com/curl/curl/blob/455bebc2c7/lib/setopt.c#L2509-L2524 — unrecognized CURLOPT_ECH strings (e.g. "Hard", "strict", typos) fall through and return CURLE_OK with ECH left disabled. Caller believes SNI is hidden; it isn't. Should return CURLE_BAD_FUNCTION_ARGUMENT.

https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/wolfssl.c#L1468-L1473 — wolfSSL has no post-handshake CURLECH_HARD enforcement (no equivalent of OpenSSL's SSL_ech_get1_status check at openssl.c:4338). If wolfSSL completes the handshake without ECH acceptance, hard mode doesn't detect it.

https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/openssl.c#L4279-L4346 — BoringSSL/AWS-LC: post-handshake hard-mode block is #if !defined(HAVE_BORINGSSL_LIKE). BoringSSL itself fails closed today, but curl's guarantee is delegated rather than asserted.

Non-security correctness bugs noted in passing

Hardening observations (not bugs)

  • lib/http2.c - curl does not send SETTINGS_MAX_HEADER_LIST_SIZE and sets no nghttp2_option_set_max_* limits; hostile server can drive large allocations inside nghttp2 via HEADERS+CONTINUATION. Resource exhaustion only; policy excludes.
  • lib/vquic/curl_ngtcp2.c:73 - QUIC_MAX_STREAMS = 256*1024 is generous; hostile server can open many uni streams. Resource pressure only.
  • lib/vtls/hostcheck.c - no public-suffix list, so *.co.uk accepted as wildcard. Documented RFC6125-conformant choice.

Investigated - not a bug

  • lib/peer.c:340 curl_strequal(p1->zoneid, p2->zoneid) with possibly-NULL zoneid: curl_strequal handles NULL (strequal.c:78-83). Safe.
  • lib/peer.c:425 early return skips peer_parse_clear: only reached if curlx_str_number fails on port string from curl_url_get(CURLUPART_PORT), which always returns a pre-validated numeric string. Unreachable.
  • lib/peer.c:121 calloc size arithmetic: hostname capped at MAX_URL_LEN (line 379), no overflow.
  • lib/peer.c:146 zoneid offset when host_alen==0: traced layout, fits in allocation.

Work log

Session 1 - 2026-05-06

Setup. Project surveyed: ~123k lines in lib/+src/ .c files. 1837 commits in last 6 months - very active.

P2 dict.c - DONE, clean

Read in full. unescape_word uses bounded dynbuf (10k cap). Curl_urldecode with REJECT_CTRL blocks CRLF injection into MATCH/DEFINE commands. Fallback path (line 255) sends arbitrary user-supplied command after / - intentional feature, CRLF still blocked by REJECT_CTRL. Signed-char comparison ch <= 32 at line 79 over-escapes high bytes on signed-char platforms; cosmetic only. No findings.

P3 gopher.c - DONE, clean

Read in full. Path+query concatenated, first 2 chars dropped, url-decoded with REJECT_ZERO (not REJECT_CTRL - intentional, gopher selectors may contain control chars). Send loop has defensive nwritten > buf_len check. No server-response parsing at all. No findings.

P4 telnet.c - DONE, clean

Suboption buffer: subpointer -= 2 after bounded ACCUM cannot underflow (worst case both ACCUMs drop at cap 512, -2 → 510). printsub reads at most subbuffer[511]. Option arrays indexed by unsigned char from network, arrays are [256]. NEW_ENVIRON (CVE-2021-22898/22925 site) now uses bounded msnprintf with explicit space check. str_is_nonascii + bad_option block IAC injection in TTYPE/XDISPLOC. No findings.

P5 mqtt.c - DONE, clean

Varint decode capped at 4 bytes / 268M. CONNACK/SUBACK length-checked. PUBLISH streams in ≤4096 chunks without parsing topic length. Zero-length packets cause protocol desync only, no OOB. One out-of-scope note: mqtt_publish line 576 payloadlen + 2 + topiclen could wrap on 32-bit if caller sets CURLOPT_POSTFIELDSIZE near SIZE_MAX, but that's caller-controlled not server-controlled. No security findings.

P6 rtsp.c - DONE, clean

RTP interleave state machine: every state guarded by while(blen). Channel byte gives idx 0-31 into 32-byte mask. rtp_len max 65539 vs dynbuf cap 1M. Transport header parsing uses curlx_str_number with cap 255. Session ID uses null-terminated dynbuf walk + curlx_memdup0 (overflow-checked). One correctness-only bug: rtsp.c:887-890 second rtsp_filter_rtp call reduces blen but doesn't advance buf, so fallback Curl_client_write at :904 may write wrong (but in-bounds, same-origin) slice on borked-response path. Not security. No findings.

P7 tftp.c - DONE, clean

Full read of OACK parsing, blksize negotiation, recvfrom bounds, ERROR/DATA handling, option_add, tsize. CVE-2019-5482 fix intact: server blksize capped at requested_blksize which is immutable after connect, buffers sized max(requested,512)+4. tftp_strnlen uses bounded memchr before any %s. All integer arithmetic bounded ≤65468. One non-security quirk: checkprefix("blksize",...) matches blksizeFOO but value still fully validated. No findings.

P8 smb.c - DONE, clean

recv_buf is 0x9000. nbt_size = 16-bit BE + 4, max 65539, rejected if > MAX_MESSAGE_SIZE. word_count read guarded. READ response: data_offset/data_length both unsigned short, sum max 131074 in size_t, checked against got before Curl_client_write. NEGOTIATE challenge memcpy(8) guarded by got≥81. Outbound: byte_count from strlen rejected if >1024 before copy. No findings.

P16 socks.c / socks_gssapi.c / socks_sspi.c - DONE, clean

Heavily refactored recently but solid. SOCKS5 reply: ATYP=3 gives resp_len max 262 vs 1024 iobuf. Username/password >255 rejected before cast. Hostname >255 rejected in req0 before req1's DEBUGASSERT. GSSAPI/SSPI: token length is u16, zero rejected, malloc(us_length) then exact-fill blockread. Outbound tokens >0xFFFF rejected. One code-quality wart: socks_sspi.c:453-465 calls FreeContextBuffer on a pointer inside a curlx_malloc'd buffer (LocalFree on non-base ptr fails harmlessly), pre-dates refactor, requires completed Kerberos handshake to reach. Not a vuln. No findings.

P9 ldap.c / openldap.c - DONE, 2 minor leaks

bv_val null-termination never assumed (uses bv_len). ;binary suffix arithmetic guarded by len>7. openldap.c sets LBER_SB_OPT_SET_MAX_INCOMING=256KB capping all values. base64 encode capped. Two minor error-path leaks (B3, B4 above), not security per policy.

P10 src/tool_* - DONE, 2 bugs outside threat model

urlglob: multiply() overflow-checked, pattern array capped 255, set elements capped 100k, peek_ipv6 buffer guarded. parsecfg: dynbuf-capped 10MB lines, nested config bounded. paramhlp: secs2ms safe, proto2num bounded. formparse: get_param_word shrinks in place, headers dynbuf 8KB. Two real bugs found (B1 recursion, B2 signed overflow) but command-line/config input is trusted per curl policy.

P11/P12 integer overflow + format string sweep - DONE, clean

Infrastructure verified: dynbuf caps at toobig, memdup0 guards len<SIZE_MAX, wcsdup guards SIZE_MAX/sizeof(wchar_t), str_number uses (max-n)/base check, bufq guards chunk_size+sizeof. Checked all malloc/calloc with +/* in lib/: every site either uses bounded input (u16, MAX_URL_LEN, MAX_ALTSVC_HOSTLEN, etc) or has explicit guard. Format strings: zero non-literal infof/failf calls; CURL_PRINTF attribute on all.

P13 vssh.c/escape.c/headers.c/parsedate.c - DONE, clean

curl_path.c moved to vssh.c. Quote handling checks !*cp before every advance. escape: len > SIZE_MAX/16 guard. headers: hlen bounded by MAX_HTTP_RESP_HEADER_SIZE upstream, count by MAX_HTTP_RESP_HEADER_COUNT. parsedate: year capped 99999999, mon range-checked before array index.

P14 netrc/hsts/altsvc/cookie - DONE, clean

All use Curl_get_line with capped dynbuf (4-16KB). netrc: token dynbuf 4096, unterminated quote → syntax error, tok_end walk stops at \n which is always present. hsts/altsvc: curlx_str_word enforces max 2048 hostlen, dbuf[18] gets ≤17+NUL. cookie: only stack buffer dbuf[81] guarded by vlen<80, max-age overflow-checked.

P1 recent commits / lib/peer.c - DONE, clean

peer.c is brand-new (1 day old, bc40e09f63). Reviewed in full. calloc sizing correct for all host_alen/zone_alen combinations. zoneid NULL-safe via curl_strequal. Refcount uint32 with DEBUGASSERT on wrap. Early return at :425 skips cleanup but is unreachable (port from CURLU is pre-validated). The HEAD commit already fixed one issue in this file (case-insensitive UDS compare); no further issues found.

P18 ws.c - DONE, clean

64-bit payload length: top bit rejected (head[2]>127), so fits signed curl_off_t. curl_off_t build-asserted ≥8 bytes. Masked server frames rejected per RFC. Control frames hard-capped 125 bytes before memcpy into payload[125]. No reassembly buffer (fragments delivered individually). All curl_off_t→size_t via curlx_sotouz_range (clamps). No malloc(payload_len). Streaming through fixed 2×64KB bufq. No findings.

P21 x509asn1.c - DONE, clean

ASN.1 length: short/long form bounded by end-beg, 32-bit shift guarded by 0xFF000000 mask, indefinite recursion capped at 16. OID decode checks beg<end before every read, 0xFE000000 shift guard. utf8asn1str: inlength % size alignment, fixed buf[4], dynbuf cap. Time parsers validate digit count via switch before indexing. One code-quality issue at :1016 (deref before bounds check) but read stays inside cert buffer.

P22 doh.c - DONE, clean

Response capped at DYN_DOH_RESPONSE=3000 so all u32 arithmetic safe. skipqname doesn't follow pointers (just skips 2-byte encoding). store_cname follows pointers with 128-iteration cap, index>=dohlen checked before every read. HTTPS RR: plen > len rejected before each param. No findings.

P23 vauth/* + curl_sasl.c - DONE, clean

krb5.c removed (FTP Kerberos dropped). NTLM type-2: target_info_offset checked before sum, target_info_len is u16. CVE-2019-3822 fix intact (ntresplen ≤ 65583, checked vs sizeof(ntlmbuf)=1024). digest_get_pair: stack buffers 256/1024 with decrementing counters. krb5 wrap output checked == 4 before indexing. base64_decode always NUL-terminates.

P24 content_encoding.c - DONE, clean

MAX_ENCODE_STACK=5 caps chained encodings. gzip header delegated to zlib (inflateInit2 +32), no manual parser. No ratio cap but output streams in 16KB chunks through cwriter chain with MAXFILESIZE enforcement - documented model.

P25 imap/pop3/smtp - DONE, clean

All share pingpong recvbuf dynbuf capped at 64KB. IMAP literal {size} via curlx_str_number (overflow-safe). (size_t)size cast on 32-bit only clamps downward. POP3 APOP timestamp memchr-bounded. SMTP status code into tmpline[6] via 3-or-5 byte memcpy.

P15 platform code - DONE, clean

multibyte.c: size-then-convert pattern, returns checked. idn.c WIN32: 255-wchar buffers with IDN_MAX_LENGTH cap passed to API. schannel.c password: (len+1)*sizeof(WCHAR), UTF-8→UTF-16 never expands wchar count. tool_doswin.c: buffer 2×szExePath, CP_ACP ≤2 bytes/wchar. No unguarded strcpy/sprintf in VMS/MSDOS/AMIGA blocks.

P19 build system / CI - DONE, clean

label.yml uses pull_request_target but no PR checkout, only SHA-pinned actions/labeler. appveyor-status.yml passes event data via env vars not template interpolation. All third-party actions SHA-pinned. checksrc.pl backtick interpolation only with opt-in COPYRIGHTYEAR check in unprivileged context.

P26 http2.c - DONE, clean

nghttp2 NUL-terminates and validates header name/value (no no_http_messaging option set). Reconstructed headers go through scratch dynbuf capped 100KB. :status via Curl_http_decode_status (3 digits only). PUSH_PROMISE headers capped at 1280 pointers. Trailers in dynhds capped 1MB. GOAWAY opaque copied ≤127 bytes into scratch[128]. stream_id never an array index. user_data lifetime: cleared in http2_data_done before easy freed, callbacks NULL-check via GOOD_EASY_HANDLE. One minor leak (h2_duphandle orphan), one hardening note (no MAX_HEADER_LIST_SIZE).

P27 vquic/* - DONE, clean

vquic.c recvmmsg/recvmsg pass fixed iov_len, kernel truncates. GRO gso_size: zero replaced before division. ngtcp2: header callback into scratch dynbuf capped CURL_MAX_HTTP_HEADER, nghttp3 rejects CR/LF/NUL. quiche: explicit is_valid_h3_header rejects same. cb_h3_acked_req_body: server datalen clamped to in_flight before cast. Stream lookup keyed on data->mid hash, NULL-checked. osslq/msh3 not in tree.

P28 ftp.c - DONE, clean

Pingpong invariant: final line has digits[0..2], space[3], \n present, NUL-terminated. PASV 6 numbers via curlx_str_number(...,0xff). EPSV: 1-byte read past NUL stays in dynbuf allocation. SIZE: fdigit[-1] deref guarded by buf[3]==' '. MDTM: 14-char check before indexing. PWD: dynbuf cap 1000, CR/LF rejected → no command injection from hostile PWD. PORT/EPRT use local socket data. Path: REJECT_CTRL blocks injection, FTP_MAX_DIR_DEPTH=1000. AUTH rejects pipelined overflow (CVE-2021-22946 class).

P29 mprintf.c - DONE, clean

MAX_PARAMETERS=128 enforced in dollarstring and sequential paths. work[328] for digit conversion, precision zero-fill guarded w>=work. Width padding via OUTCHAR callback only. msnprintf addbyter aborts at max; maprintf alloc_addbyter aborts at DYN_APRINTF=8MB. Negative * width/precision: INT_MIN handled, negative prec → -1. %.*s stops at NUL regardless of prec. Float: width clamped BUFFSIZE-1, precision clamped, fmt[32] holds ≤14 bytes. %n exists but format strings are literal-only.

P30 TLS hostname/cert verify - DONE, clean

hostcheck.c: length-aware pmatch, wildcard requires leading *. + ≥2 dots, IP targets excluded from wildcard. OpenSSL: SAN dNSName embedded-NUL guard altlen==strlen(altptr), iPAddress binary memcmp, CN fallback NUL guard. GnuTLS/wolfSSL/mbedTLS delegate to library. wolfSSL IP path fails closed. Pinned pubkey: default NOTMATCH, only set OK on positive match. OpenSSL OCSP: fully delegated. CERTINFO: dynbuf-capped, no strlen on cert bytes.

P31 schannel + schannel_verify - DONE, clean

encdata/decdata realloc: offset bounded by prior length, cbBuffer ≤ what we passed in. SECBUFFER_DATA memcpy: ensure_decoding_size guarantees space. SECBUFFER_EXTRA memmove: offset > cbBuffer guard. SAN extraction: two-pass with per-write actual+current > length guard. IP SAN: memcmp only when cbData==4 or 16. CA PEM 1-byte over-read on CAINFO_BLOB only (local config). Manual chain verify: trust mask never widens UNTRUSTED_ROOT/PARTIAL_CHAIN.

P32 vtls_scache/rustls/http_chunks - DONE, clean

scache key: hostname, port, transport, verify flags, TLS versions, ciphers, curves, CA/CRL/issuer paths + SHA256 of blobs, pin, client-cert marker, backend id. Session owns deep copies, no dangling. Locking: put/take/remove lock internally; add_obj/get_obj rely on caller (schannel wraps). rustls: verifypeer=0 → cr_verify_none; verifyhost coupled to verifypeer (stricter, never weaker); pinning unsupported and SSLSUPP flag absent. http_chunks: hex parse via curlx_str_hex (overflow-safe, non-negative), hexbuffer[17] bounds-checked, trailers dynbuf 4096, extensions discarded unbuffered.

P33 http.c + http1.c - DONE, clean

Status: 3 digits max 999, 4th digit ignored (documented browser compat). CL: curlx_str_numblanks overflow-safe, negative/+ rejected, mismatched duplicates rejected, overflow → streamclose. TE without chunked → ignore_cl + connclose. Header limits: 100KB per line (dynbuf), 300KB per request (allheadercount), 6MB cross-redirect. Folds merged before parse, still capped. NUL via memchr rejected. 0.9 fallback refused on reused conn. http1.c parses requests not responses (out of scope).

P17 connection reuse - DONE, one item flagged for further analysis (N1)

bc40e09f63 refactor preserves prior matching semantics. UDS check relocation safe (httpproxy always false for UDS needles). zoneid/scopeid now stricter than before. via_peer presence-check at url.c:746 unchanged. CURL_DISABLE_PROXY + non-SSL skips origin compare - see N1, not confident enough to report.

A1 data->set audit - DONE, 1 new gap (F4)

Enumerated every data->set.* read in vssh/ftp/imap/pop3/smtp/openldap/vtls/cf-*/connect/socks. Most either: per-request, perf-only, opaque-callback, or already compared. SSH client keys (rsa/rsa_pub) ARE in ssh_config_matches but the values are always NULL on both sides → F4. tls_ech not compared but reuse leaks nothing new (SNI already exposed by A). Full table available.

A2 setopt gating - DONE, no new gaps

Every SSLSUPP_* × CURLOPT_* pair checked. PROXY_CRLFILE/ISSUERCERT/ISSUERCERT_BLOB are the only ungated proxy variants (= F1). No backend false-advertises a flag. Minor: CURLOPT_SSL_EC_CURVES/SIG_ALGS/PINNEDPUBLICKEY return CURLE_UNKNOWN_OPTION (not NOT_BUILT_IN) on non-TLS builds; cosmetic.

A3 ssl_config_data coverage - DONE, N2

ssl_primary_config: all 21 fields compared except cache_session (harmless). ssl_config_data extras: all 7 CURLSSLOPT_ bits encoded in compared ssl_options; key/key_blob/key_type/key_passwd bound to compared clientcert; cert_type is parse-format only; certinfo output-only; custom_ca* select defaults that populate compared fields. Only fsslctx/fsslctxp escapes → N2.

A5 share locking - DONE, 1 new gap (F5)

COOKIE/DNS/SSL_SESSION/CONNECT/PSL: all read+write paths lock. HSTS: only loadfiles locks (the CVE-2023-27537 fix). hsts_check frees expired entries during lookup; called unlocked from url.c:1441, http.c:3571, url.c:265, transfer.c:613 → F5.

A6 ECH - DONE, 3 issues (experimental)

Properly gated to ECH-capable backends. Issues: unrecognized CURLOPT_ECH values silently accepted; wolfSSL no post-handshake hard check; BoringSSL hard check #if'd out (delegated). DNS ECHConfig parsing delegated to library. GREASE/public-name OK.

A7 KNOWN_RISKS - DONE, nothing actionable

All entries are user-behavior disclaimers (untrusted input, command-line misuse, terminal escapes, compression bombs). Compression-bomb mitigation via MAXFILESIZE confirmed in P24.

V1 per-backend feature parity - DONE, 3 gaps (F1)

Built feature×backend matrix. Direct CRLFILE/ISSUERCERT/PINNEDPUBLICKEY/VERIFYSTATUS all correctly gated by SSLSUPP_* in setopt.c. PROXY_ variants of CRLFILE/ISSUERCERT/ISSUERCERT_BLOB are NOT gated - accepted on all backends, silently no-op on most. QUIC pinning/OCSP verified present via vquic-tls.c:149-193. SSH: wolfSSH backend removed; libssh2/libssh both check known_hosts/MD5; SHA256/HOSTKEYFUNCTION correctly #ifdef-gated to libssh2.

V2 connection-reuse completeness - DONE, F2/F3 + HAPROXY

Verified compared: SASL_AUTHZID, XOAUTH2_BEARER, FTP_ACCOUNT/ALT_USER/USE_SSL/CCC, GSSAPI_DELEGATION, TLS13_CIPHERS/EC_CURVES/SIG_ALGS, all PROXY_SSL_*, SSH client keys, proxy creds. match_ssl_primary_config covers every field of ssl_primary_config except cache_session. Not security-relevant: AWS_SIGV4 (per-request), DOH_URL (post-resolve), SOCKS5_AUTH (mechanism only). Gaps: SSH host-key settings (F2), SSL config for STARTTLS schemes (F3), HAPROXY settings (demoted to docs note).

V3 redirect credential leak - DONE, clean

Curl_auth_allowed_to_hostCurl_peer_equal compares scheme+host+port. Every credential type traced: USERPWD/Basic/NTLM/Negotiate/Digest gated at http.c:842; XOAUTH2_BEARER explicit check at http.c:718 + sasl.c:445; custom Authorization/Cookie headers stripped at http.c:1843; AWS_SIGV4 recomputed per host; netrc looked up fresh per host; TLS-SRP gated. CURLOPT_COOKIE and CURLOPT_AUTOREFERER are sent cross-origin but documented as such.

V4 hostname normalization - DONE, clean

All 9 match sites use conn->origin->hostname from peer_parse_host(): %-decoded, IDN-decoded to punycode, brackets/zone stripped. Single canonical form. HSTS/altsvc/noproxy strip trailing dot on both store and lookup. Cookie/conn-reuse/auth-allowed don't strip but that only narrows matches (conservative). All comparisons curl_str(n)equal (case-insensitive). PSL check lowercases both sides. No site compares normalized vs un-normalized.

V5 STARTTLS injection - DONE, clean

All four pingpong protocols carry the CVE-2021-22947 pp.overflow rejection at the STARTTLS response handler (imap.c:1109, pop3.c:944, smtp.c:1170, ftp.c:3249) and the CVE-2021-22946 use_ssl != CURLUSESSL_TRY enforcement on denial. LDAP uses libldap msgid-matched reads with no curl-side recvbuf; structurally immune.

V6 UAF in shared objects - DONE, clean

Curl_peer: every persistent store goes through Curl_peer_link (increment-before-store); the one direct assignment (url.c:2551) is fresh-object ownership transfer into pre-cleared slot. scache: put/take/return all lock; take removes node under lock before return. DNS: refcounted, all error paths unlink. DoH double-add through Curl_hash_add2 dtor is redundant but balanced. No store→free→use sequence found.

Compiler experiments

Built 8.21.0-DEV at 455bebc2c7 with cmake -DCMAKE_C_FLAGS="-fsanitize=undefined,address -fno-sanitize-recover=undefined" (Apple clang 17). Build artifacts in ./build/ (not committed).

B2 reproduced: ./build/src/curl 'http://x/[1-2][9223372036854775806-9223372036854775807]' → UBSan abort at tool_urlglob.c:588:24, exact value predicted.

B1 reproduced: 150001-line config (1.6MB) of form = a=b → ASan stack-overflow in tool2curlparts at tool_formparse.c:263.

F1 reproduced: built with -DCURL_USE_WOLFSSL=ON (build-wolf/), compiled verify_f1.c against it. Output confirms CURLOPT_CRLFILE/ISSUERCERT/ISSUERCERT_BLOBCURLE_NOT_BUILT_IN (4) while CURLOPT_PROXY_CRLFILE/PROXY_ISSUERCERT/PROXY_ISSUERCERT_BLOBCURLE_OK (0). Asymmetry proven.

F4 reproduced in Docker (verify_f5/): openssh-server, alice.pub authorized, bob.pub not. Handle A (alice key) connects; handle B (bob key) on same multi reuses A's session: rc=0 num_connects=0, verbose log shows * Reusing existing sftp: connection. F2/F3 verified by direct source inspection.

A4 cross-backend cert differential - DONE, 2 differentials (D1, D2)

Built openssl/wolfssl/gnutls/mbedtls/rustls. Generated 7 pathological certs (verify_a4/gen_certs.sh): baseline, ip-as-dnsname, ip-san, *.localhost, bare *, ..localhost, embedded-NUL. Ran each backend against each via local openssl s_server. mbedTLS accepts DNS:127.0.0.1 for IP target (others reject). wolfSSL accepts bare * and multi-level wildcards (others reject). Embedded-NUL rejected by all 5. Sanity-checked wolfSSL rejects clearly-wrong hostnames so verification is active.

curl_fnmatch.c (from A8 0%-list) - DONE, clean

charset[271] indexed by unsigned char (≤255) or named flags (256-266). parsekeyword keyword[10] bounds-checked before write. setcharorrange reads gated on prior byte non-NUL. Recursion on * capped at maxstars=2. CVE-2017-8817 fix at L301-302 (if(!*s) return NOMATCH before charset lookup) intact and complete. One non-security note: CURLFNM_SPACE uses ISBLANK not ISSPACE.

A8 fuzz coverage - DONE (basic), no new audit targets

Built curl with -fprofile-instr-generate -fcoverage-mapping (build-cov/). Cloned curl-fuzzer. Generated a basic profile from CLI runs against unreachable ports: 28% function coverage, 0%-covered files listed. Cross-referenced against Phase 1 audits: every 0%-covered file either already audited (dict, gopher, content_encoding, http_chunks, parsedate, pop3, http_aws_sigv4, file parsers) or non-security (md5, hmac, sha512_256 wrappers, curl_gethostname). One gap: curl_fnmatch.c (CVE-2017-8817 site) — audited separately, see below. Full corpus run via curl-fuzzer harness not done (multi-hour build); the basic profile already confirmed Phase 1's manual targeting hit the right files.

A9 doc-vs-impl - DONE, clean

Every security-relevant CURLOPT_SSL_/PIN/CRL/ISSUERCERT/SSH_/DOH_*/ECH/USE_SSL option checked: docs match implementation. All read via the shared vtls cfilter layer so there's no per-protocol skip. Independently rediscovered F1 as "side observation" (man pages do restrict to OpenSSL/GnuTLS, so it's a fail-open inconsistency not a doc lie).

P34 ftplistparser.c - DONE, clean

All bytes into dynbuf capped 10000. mem[idx]=0 writes use item_offset+item_length-1 always < len. Permissions: mem[10]=0 only after exactly 11 bytes appended. All integers via curlx_str_number with cap. Symlink ->: item_length≥4 by state machine before -4 index. Stale offsets.symlink_target between entries (data-quality only, libcurl gates on filetype). Malformed input → error, frees file_data, short-circuits.