CVE-2022-43551: Another HSTS bypass via IDN
What the report claims
HSTS bypass via IDN where a hostname containing a Unicode character that converts to '.' bypasses HSTS checks.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2022-43551 Another HSTS bypass via IDN
Shown for comparison after analysis. The pipeline did not see this label.
ProveIt's finding
The available evidence does not settle the report. A maintainer should investigate.
Why this finding?
- The report cites http.c:3727 (data->state.up.hostname) as the flaw location, but the provided snippets do not show where this field is set relative to IDN conversion.
- The IDN conversion (Nameprep) code and the hostname resolution code are not included, so the central claim—that the pre-conversion hostname is stored in the HSTS cache while the post-conversion hostname is used for lookup—cannot be confirmed or refuted from the given context.
- The report references a prior CVE (CVE-2022-42916) with a similar pattern, making the claim plausible, but the provided code is insufficient to decide.
What still needs checking
- The code that assigns data->state.up.hostname (likely in lib/url.c or lib/urldata.h area) and the IDN conversion function (likely Curl_idnconvert or similar).
- The HSTS lookup path used when processing an HTTP request (to see which hostname form is used for the cache query).
How this analysis ran 5 claims · 0 citations verified
Checked against curl master on 2022-10-29 (a55256cfb242). Test evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.
Review a draft reply to the reporter
Edit and verify before sending.
Thank you for the detailed report and reproduction steps. To evaluate this, we need to see where `data->state.up.hostname` is populated and whether that happens before or after IDN conversion. Could you point to the function that sets this field, or provide the relevant url.c/urldata.c snippets? Also, the HSTS lookup path used during an HTTP request (not just the parse-on-HTTPS-response path shown) would clarify whether the same hostname form is used for the cache query.
Inspect all claims and source code 5 claims · 2 code excerpts
Report claims
HSTS checks are bypassed if any character in the IDN converts to '.'
Read the reasoning
The claim that HSTS checks are bypassed when IDN converts a character to '.' requires seeing both the IDN conversion code and the HSTS lookup code to verify a mismatch. The provided snippets show only the call to Curl_hsts_parse (http.c:3727) and the parse function itself (hsts.c:141-239), but not where data->state.up.hostname is populated or where IDN conversion occurs.
The host name before IDN conversion is used when writing to the HSTS cache
Read the reasoning
The report asserts that data->state.up.hostname holds the pre-IDN-conversion hostname. The provided code at http.c:3727 passes this field to Curl_hsts_parse, and hsts.c:214,229,236 shows it is used for both storage and lookup. However, the code that assigns data->state.up.hostname (and whether that happens before or after IDN conversion) is not in the provided snippets.
Using a hostname with a Unicode character that converts to '.' in an HTTP request
Read the reasoning
This describes a trigger condition (using a Unicode character that IDN-converts to '.'). Verifying whether such a character reaches the HSTS cache in unconverted form requires the IDN conversion code and the hostname assignment code, which are not provided.
Access to the site with HTTPS after bypassing HSTS
Read the reasoning
The impact claim (HTTPS access after bypass) is a behavioral consequence. The provided code does not show the HSTS lookup path used during an HTTP request to determine whether an upgrade to HTTPS would occur.
Step 2: curl -v --hsts hsts.txt https://accounts.google%E3%80%82com; Step 3: curl -v --hsts hsts.txt http://accounts.google%E3%80%82com
Read the reasoning
Reproduction steps are user-level instructions. They cannot be verified against the provided source snippets alone; they require a running build and the full hostname-resolution/IDN code paths.
Read the original report
Source code
| 3702 | data->req.newurl = strdup(data->req.location); /* clone */ |
| 3703 | if(!data->req.newurl) |
| 3704 | return CURLE_OUT_OF_MEMORY; |
| 3705 | |
| 3706 | /* some cases of POST and PUT etc needs to rewind the data |
| 3707 | stream at this point */ |
| 3708 | result = http_perhapsrewind(data, conn); |
| 3709 | if(result) |
| 3710 | return result; |
| 3711 | } |
| 3712 | } |
| 3713 | } |
| 3714 | |
| 3715 | #ifndef CURL_DISABLE_HSTS |
| 3716 | /* If enabled, the header is incoming and this is over HTTPS */ |
| 3717 | else if(data->hsts && checkprefix("Strict-Transport-Security:", headp) && |
| 3718 | ((conn->handler->flags & PROTOPT_SSL) || |
| 3719 | #ifdef CURLDEBUG |
| 3720 | /* allow debug builds to circumvent the HTTPS restriction */ |
| 3721 | getenv("CURL_HSTS_HTTP") |
| 3722 | #else |
| 3723 | 0 |
| 3724 | #endif |
| 3725 | )) { |
| 3726 | CURLcode check = |
| 3727 | Curl_hsts_parse(data->hsts, data->state.up.hostname, |
| 3728 | headp + strlen("Strict-Transport-Security:")); |
| 3729 | if(check) |
| 3730 | infof(data, "Illegal STS header skipped"); |
| 3731 | #ifdef DEBUGBUILD |
| 3732 | else |
| 3733 | infof(data, "Parsed STS header fine (%zu entries)", |
| 3734 | data->hsts->list.size); |
| 3735 | #endif |
| 3736 | } |
| 3737 | #endif |
| 3738 | #ifndef CURL_DISABLE_ALTSVC |
| 3739 | /* If enabled, the header is incoming and this is over HTTPS */ |
| 3740 | else if(data->asi && checkprefix("Alt-Svc:", headp) && |
| 3741 | ((conn->handler->flags & PROTOPT_SSL) || |
| 3742 | #ifdef CURLDEBUG |
| 3743 | /* allow debug builds to circumvent the HTTPS restriction */ |
| 3744 | getenv("CURL_ALTSVC_HTTP") |
| 3745 | #else |
| 3746 | 0 |
| 3747 | #endif |
| 3748 | )) { |
| 3749 | /* the ALPN of the current request */ |
| 3750 | enum alpnid id = (conn->httpversion == 20) ? ALPN_h2 : ALPN_h1; |
| 3751 | result = Curl_altsvc_parse(data, data->asi, |
| 3752 | headp + strlen("Alt-Svc:"), |
| 141 | CURLcode Curl_hsts_parse(struct hsts *h, const char *hostname, |
| 142 | const char *header) |
| 143 | { |
| 144 | const char *p = header; |
| 145 | curl_off_t expires = 0; |
| 146 | bool gotma = FALSE; |
| 147 | bool gotinc = FALSE; |
| 148 | bool subdomains = FALSE; |
| 149 | struct stsentry *sts; |
| 150 | time_t now = time(NULL); |
| 151 | |
| 152 | if(Curl_host_is_ipnum(hostname)) |
| 153 | /* "explicit IP address identification of all forms is excluded." |
| 154 | / RFC 6797 */ |
| 155 | return CURLE_OK; |
| 156 | |
| 157 | do { |
| 158 | while(*p && ISBLANK(*p)) |
| 159 | p++; |
| 160 | if(Curl_strncasecompare("max-age=", p, 8)) { |
| 161 | bool quoted = FALSE; |
| 162 | CURLofft offt; |
| 163 | char *endp; |
| 164 | |
| 165 | if(gotma) |
| 166 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 167 | |
| 168 | p += 8; |
| 169 | while(*p && ISBLANK(*p)) |
| 170 | p++; |
| 171 | if(*p == '\"') { |
| 172 | p++; |
| 173 | quoted = TRUE; |
| 174 | } |
| 175 | offt = curlx_strtoofft(p, &endp, 10, &expires); |
| 176 | if(offt == CURL_OFFT_FLOW) |
| 177 | expires = CURL_OFF_T_MAX; |
| 178 | else if(offt) |
| 179 | /* invalid max-age */ |
| 180 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 181 | p = endp; |
| 182 | if(quoted) { |
| 183 | if(*p != '\"') |
| 184 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 185 | p++; |
| 186 | } |
| 187 | gotma = TRUE; |
| 188 | } |
| 189 | else if(Curl_strncasecompare("includesubdomains", p, 17)) { |
| 190 | if(gotinc) |
| 191 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 192 | subdomains = TRUE; |
| 193 | p += 17; |
| 194 | gotinc = TRUE; |
| 195 | } |
| 196 | else { |
| 197 | /* unknown directive, do a lame attempt to skip */ |
| 198 | while(*p && (*p != ';')) |
| 199 | p++; |
| 200 | } |
| 201 | |
| 202 | while(*p && ISBLANK(*p)) |
| 203 | p++; |
| 204 | if(*p == ';') |
| 205 | p++; |
| 206 | } while (*p); |
| 207 | |
| 208 | if(!gotma) |
| 209 | /* max-age is mandatory */ |
| 210 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 211 | |
| 212 | if(!expires) { |
| 213 | /* remove the entry if present verbatim (without subdomain match) */ |
| 214 | sts = Curl_hsts(h, hostname, FALSE); |
| 215 | if(sts) { |
| 216 | Curl_llist_remove(&h->list, &sts->node, NULL); |
| 217 | hsts_free(sts); |
| 218 | } |
| 219 | return CURLE_OK; |
| 220 | } |
| 221 | |
| 222 | if(CURL_OFF_T_MAX - now < expires) |
| 223 | /* would overflow, use maximum value */ |
| 224 | expires = CURL_OFF_T_MAX; |
| 225 | else |
| 226 | expires += now; |
| 227 | |
| 228 | /* check if it already exists */ |
| 229 | sts = Curl_hsts(h, hostname, FALSE); |
| 230 | if(sts) { |
| 231 | /* just update these fields */ |
| 232 | sts->expires = expires; |
| 233 | sts->includeSubDomains = subdomains; |
| 234 | } |
| 235 | else |
| 236 | return hsts_create(h, hostname, subdomains, expires); |
| 237 | |
| 238 | return CURLE_OK; |
| 239 | } |