curl · Report #3733984
HSTS multi-trailing-dot bypass-ish: possible incomplete fix for CVE-2022-30115
How it was found
Hi all,
Honestly, I'm not completely certain about this issue, but I think the CVE-2022-30115 fix ("HSTS bypass via trailing dot") is incomplete: the same asymmetry exists for hostnames with two or more trailing dots, so http://example.com../ still gets sent in plaintext when there's a valid HSTS entry for example.com. The fix commit itself — fae6fea209 (2022-05-09, "hsts: ignore trailing dots when comparing hosts names") — used a single if rather than a while, so anything beyond one trailing dot still slips through.
The short version: the HSTS layer normalises the runtime hostname by stripping one trailing dot before lookup; the URL parser and the peer layer preserve every trailing dot verbatim. So a single-dot host (example.com.) is correctly normalised down to example.com and matches the stored entry — that's the 2022 fix doing its job — but example.com.. ends up looking for example.com. in the store and never matches.
The strip is a single if, not a while, in all three places it appears:
lib/hsts.c:117-119(hsts_create, store path)lib/hsts.c:156-158(hsts_check, lookup path)lib/hsts.c:439-441(hsts_add_host_expire, file-load path)
So, this is the same hostname-normalisation issue as CVE-2022-30115, just with multiple trailing dots rather than one: the 2022 fix handles exactly one trailing dot, but not two or more. Replacing each if with a while makes the normalisation consistent, and hsts_check would probably also needs an if(!hlen) return NULL; guard for an all-dots host (untested, jfyi).
Using while instead of if would match how major browser HSTS implementations treat this class of hostname canonicalisation. This would match how major browser HSTS implementations treat this class of hostname canonicalisation, e.g. Chromium's HSTS preload lookup strips the full trailing-dot suffix before lookup in DecodeHSTSPreload() (size_t trailing_dot_found = host.find_last_not_of('.');). Firefox's HSTS path canonicalises hostnames through CanonicalizeHostname(), whose contract explicitly covers hostnames with multiple trailing dots and normalises them to lowercase with no trailing dot.
Anyways, a quick Reproducer on master:
echo 'example.com "29991231 23:59:59"' > /tmp/hsts.txt
for H in 'example.com' 'example.com.' 'example.com..'; do
echo "=== http://$H/ ===";
./src/curl -sv --hsts /tmp/hsts.txt \
--resolve "$H:80:127.0.0.1" --resolve "$H:443:127.0.0.1" \
--max-time 1 --connect-timeout 1 -o /dev/null "http://$H/" 2>&1 \
| grep -E "Switched|Trying " | head -2
done
Output:
=== http://example.com/ ===
* Switched from HTTP to HTTPS due to HSTS => https://example.com/
* Trying 127.0.0.1:443...
=== http://example.com./ ===
* Switched from HTTP to HTTPS due to HSTS => https://example.com./
* Trying 127.0.0.1:443...
=== http://example.com../ ===
* Trying 127.0.0.1:80...
--resolve for both 80 and 443 isolates the upgrade decision — the destination address is identical, the only thing that varies is whether libcurl picked https://.
Caveat: example.com.. is of course notreally a legal DNS name (empty label) and standard resolvers reject it — getent, host, dig and curl's own default resolver all fail. So the bypass needs /etc/hosts, attacker-controlled DNS, or app code feeding the multi-dot name into a --resolve-equivalent (like similar to how we use --resolve above). The url-parser test in tests/libtest/lib1560.c:209 actually mentions this specific case, so it is seemingly a valid usage: "Multiple trailing dots, not okay in DNS but works in /etc/hosts".
Also, same single-if pattern in lib/altsvc.c — altsvc_createid at :98-101 on store and hostcompare at :440-441 on lookup — with the same multi-dot miss:
echo 'h2 example.com 443 h2 alt.example.com 8443 "20991231 23:59:59" 1 0' \
> /tmp/altsvc.txt
for H in 'example.com' 'example.com.' 'example.com..'; do
echo "=== https://$H/ ==="
./src/curl -v --alt-svc /tmp/altsvc.txt \
--resolve "$H:443:127.0.0.1" \
--resolve "alt.example.com:8443:127.0.0.1" \
--max-time 1 --connect-timeout 1 -k -o /dev/null "https://$H/" 2>&1 \
| grep -E "Trying" | head -1
done
=== https://example.com/ ===
* Trying 127.0.0.1:8443... <- alt-svc hit, alt port used
=== https://example.com./ ===
* Trying 127.0.0.1:8443... <- alt-svc hit (1-dot strip works)
=== https://example.com../ ===
* Trying 127.0.0.1:443... <- alt-svc miss, canonical port
The alt-svc miss is just a missed optimisation, not a security downgrade — but worth fixing for consistency (and I'll report a similar actual security issue with that later).
Impact
HSTS protection is silently bypassed for any hostname with two or more trailing dots, downgrading HTTPS to plaintext and exposing credentials and cookies to a network attacker who can influence the URL being fetched.