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:
- Code changed in the last 90 days (fewer eyes, not yet fuzzed long)
- Obscure protocols nobody uses or audits: DICT, GOPHER, TELNET, MQTT, RTSP, TFTP, LDAP, SMB, IMAP/POP3
- Error/cleanup paths (fuzzers hit happy paths more)
- Integer arithmetic on attacker-controlled lengths
- The tool (src/) rather than the library - less scrutiny than libcurl
- Build system / test infra (supply chain angle)
- Platform-specific code (#ifdef WIN32, VMS, AmigaOS etc) - rarely compiled by reviewers
- 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:
- Data origin traced: the dangerous value provably comes from network/file/user input, not a constant or already-validated source
- No upstream guard: checked the full call chain for earlier length/range/NULL checks that would prevent the bad case
- No type-level mitigation: e.g. value is
unsigned charso "negative" is impossible, or buffer is sized by the same variable - Reachable: the code path is actually compiled in a normal build (not dead
#ifdef VMSetc) and reachable at runtime - 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_datavsssl_primary_config→ N2 (fsslctx); all other fields covered via ssl_options bitmask - A4: Cross-backend cert acceptance differential → 2 differentials found (D1, D2)
- A5:
CURLSHshare-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.mdfollow-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:
- 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.
- F5 — CVE-2023-27537 fix covers one of five code paths;
hsts_checkmutates during lookup so exclusive locking needed everywhere. Suggested fix included. - F1 —
CURLOPT_PROXY_CRLFILE/ISSUERCERT/ISSUERCERT_BLOBungated (empirically proven against wolfSSL). Mechanical fix. - F3 — STARTTLS SSL config not compared on reuse. Source-verified.
- 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) onlycallocs the struct.rsa/rsa_pubare populated later in the SSH state machine (libssh2.c:1086+), which never runs for a needle - the needle is built,setup_connectionruns, thenConnectionExistschecks 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 incomingStrict-Transport-Securityheader → appends/removes entriesCurl_hsts_applies→hsts_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) inCurl_close→ walks the listCurl_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
- https://github.com/curl/curl/blob/455bebc2c7/lib/rtsp.c#L887-L890 -
bufnot advanced after secondrtsp_filter_rtpcall; on borked-response path writes wrong (in-bounds, same-origin) slice - https://github.com/curl/curl/blob/455bebc2c7/lib/socks_sspi.c#L453-L465 -
FreeContextBufferon non-base pointer (LocalFree fails harmlessly) - https://github.com/curl/curl/blob/455bebc2c7/lib/tftp.c#L278 -
checkprefix("blksize",...)matchesblksizeFOO(value still validated) - lib/cookie.c -
strstorecomment says it frees old pointer but no longer does (no actual leak, comment is stale) - https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/x509asn1.c#L1016 -
for(q = elem.beg; !*q && q < elem.end; q++)dereferences before bounds check; with zero-length INTEGER reads one byte at elem.end. Traced: that byte is provably still inside the certificate buffer (≥4 bytes follow tbsCertificate). No info leak, just operand order. - https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/x509asn1.c#L821 - return value of
getASN1Elementnot checked before reuse asbeg; harmless because next call's!begguard catches NULL. - https://github.com/curl/curl/blob/455bebc2c7/lib/http2.c#L709 -
h2_duphandlecallshttp2_data_setupwhilenewhandle->mid==UINT32_MAX, creating an orphanedh2_stream_ctxunder sentinel key whenpush_promiselater re-runs setup. Minor leak per accepted server push; no UAF (no real handle uses that mid). - https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/schannel_verify.c#L140 -
begin_cert_ptr[begin_cert_len]reads one byte pastCURLOPT_CAINFO_BLOBif the blob ends exactly with-----BEGIN CERTIFICATE-----(file path is safe, allocates +1 NUL). Local trusted config, not server data; 1-byte heap over-read. - https://github.com/curl/curl/blob/455bebc2c7/lib/vtls/schannel.c#L311-L360 -
_tcsncmpwithstore_name_lenaccepts any prefix of store names (e.g.C\My\…matches CurrentUser). Local config only. - https://github.com/curl/curl/blob/455bebc2c7/lib/ftp.c#L2066-L2095 - EPSV
(at end of dynbuf readsptr[1]one past NUL; dynbuf allocation slack (min 32, doubling, never shrinks) keeps it in-allocation. Read-only, nothing disclosed. - lib/ftplistparser.c -
parser->offsets.symlink_targetnot cleared between entries; after a symlink line, the next non-symlink entry'sfinfo->strings.targetpoints at a stale offset. libcurl only dereferencestargetwhenfiletype==CURLFILETYPE_SYMLINKso no in-library issue, but the pointer is exposed toCURLOPT_CHUNK_BGN_FUNCTION.
Hardening observations (not bugs)
- lib/http2.c - curl does not send
SETTINGS_MAX_HEADER_LIST_SIZEand sets nonghttp2_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*1024is generous; hostile server can open many uni streams. Resource pressure only. - lib/vtls/hostcheck.c - no public-suffix list, so
*.co.ukaccepted 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
returnskipspeer_parse_clear: only reached ifcurlx_str_numberfails on port string fromcurl_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_host → Curl_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_BLOB → CURLE_NOT_BUILT_IN (4) while CURLOPT_PROXY_CRLFILE/PROXY_ISSUERCERT/PROXY_ISSUERCERT_BLOB → CURLE_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.