CVE-2023-27537: HSTS double-free
What the report claims
Double-free or use-after-free may occur in libcurl's HSTS handling when multiple threads process HSTS entries concurrently, leading to memory corruption.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2023-27537 HSTS double free
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.
See the withdrawn model reasoning
The following reasoning failed the citation check and does not support the final finding.
- Curl_hsts_parse (lines 213-221) and Curl_hsts (lines 270-275) remove and free HSTS entries without acquiring the share lock (CURL_LOCK_DATA_HSTS) that applications register via CURLOPT_SHARE.
- The share mechanism expects curl to call the lock/unlock callbacks when accessing shared data; the HSTS code does not, so concurrent threads race on list removal and free.
- Double-free and use-after-free are exploitable heap corruption bugs; a malicious server can trigger the max-age=0 or expiry path while other threads traverse the list.
- The provided code snippets show no locking in the HSTS implementation. The deterministic checks confirm the quoted lines exist at the target revision.
How this analysis ran 6 claims · 19 citations verified
Evidence check: judge quoted an external source that does not say that (https://datatracker.ietf.org/doc/html/draft-ietf-websec-key-pinning-13)
Checked against curl master on 2023-03-08 (b1d735956f42). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.
Inspect the withdrawn draft reply
This draft belongs to the withdrawn judgment. It may contain unsupported claims.
Thank you for the detailed report and reproduction case. The code in lib/hsts.c (Curl_hsts_parse lines 213-221 and Curl_hsts lines 270-275) removes and frees HSTS entries without acquiring the share lock (CURL_LOCK_DATA_HSTS), so concurrent threads sharing an HSTS cache can double-free or use-after-free. This is a valid thread-safety bug in the HSTS implementation when used with CURLOPT_SHARE. We will investigate the proper locking integration and prepare a fix.
Inspect all claims and source code 6 claims · 4 code excerpts
This model judgment was withdrawn. The claim assessments below are preserved for inspection and may be wrong.
Report claims
Double-free may occur when multiple threads call Curl_hsts_free on the same HSTS entry simultaneously
Read the reasoning
The code in Curl_hsts_parse (lines 213-221) and Curl_hsts (lines 270-275) removes entries from the shared list and frees them via hsts_free without acquiring any lock. When multiple threads share an HSTS cache via CURLOPT_SHARE with CURL_LOCK_DATA_HSTS, the lock callbacks are never invoked by this code, allowing concurrent free of the same entry.
Use-after-free may occur when other threads access HSTS entries after they have been freed
Read the reasoning
Curl_hsts traverses the list (line 267) and accesses entry fields (lines 270, 276-282) without locking. Another thread could have removed and freed the same entry via the max-age=0 or expiry path, leading to use-after-free.
The function Curl_hsts_parse removes HSTS entries without proper synchronization
Read the reasoning
Curl_hsts_parse removes entries when max-age=0 (lines 213-221) without any synchronization primitive. The share mechanism's lock callbacks are not used.
The removal of HSTS entries occurs without exclusivity control in multi-threaded context
Read the reasoning
The term 'exclusivity control' maps to the share lock (CURL_LOCK_DATA_HSTS). The code performs lookup, removal, and free without acquiring that lock, so concurrent threads have no exclusion.
The code removes entries verbatim without subdomain matching when max-age=0 is received
Read the reasoning
Line 215 passes FALSE as the third argument to Curl_hsts, disabling subdomain matching for the verbatim removal when max-age=0.
Lines 270-275 in lib/hsts.c have a similar concurrency issue
Read the reasoning
Lines 270-275 in the provided source (Curl_hsts function) show the same pattern: expired entries are removed and freed without locking, creating an identical race condition.
Deterministic checks that failed
- quote only 0/4 quoted lines appear anywhere in curl at b1d735956f42
What the report says about other projects
confirmed HSTS entries disappear when they expire or when "max-age=0" is received
“When a UA connects to a Known Expect-CT Host using a TLS connection, if the TLS connection has errors, the UA MUST terminate the connection without allowing the user to proceed anyway. (This behavior is the same as that required by [RFC6797].) If the connection has no errors, then the UA will apply an additional correctness check: compliance with a CT Policy. A UA should evaluate compliance with its CT Policy whenever connecting to a Known Expect-CT Host, as soon as possible. It is acceptable to skip this CT compliance check for some hosts according to local policy. For example, a UA may disable CT compliance checks for hosts whose validated certificate chain terminates at a user-defined trust anchor, rather than a trust anchor built-in to the UA (or underlying platform). To perform Pin Validation, the UA will compute the SPKI Fingerprints for each certificate in the Pinned Host's” source
Read the original report (excerpt)
Source code
| 142 | CURLcode Curl_hsts_parse(struct hsts *h, const char *hostname, |
| 143 | const char *header) |
| 144 | { |
| 145 | const char *p = header; |
| 146 | curl_off_t expires = 0; |
| 147 | bool gotma = FALSE; |
| 148 | bool gotinc = FALSE; |
| 149 | bool subdomains = FALSE; |
| 150 | struct stsentry *sts; |
| 151 | time_t now = time(NULL); |
| 152 | |
| 153 | if(Curl_host_is_ipnum(hostname)) |
| 154 | /* "explicit IP address identification of all forms is excluded." |
| 155 | / RFC 6797 */ |
| 156 | return CURLE_OK; |
| 157 | |
| 158 | do { |
| 159 | while(*p && ISBLANK(*p)) |
| 160 | p++; |
| 161 | if(strncasecompare("max-age=", p, 8)) { |
| 162 | bool quoted = FALSE; |
| 163 | CURLofft offt; |
| 164 | char *endp; |
| 165 | |
| 166 | if(gotma) |
| 167 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 168 | |
| 169 | p += 8; |
| 170 | while(*p && ISBLANK(*p)) |
| 171 | p++; |
| 172 | if(*p == '\"') { |
| 173 | p++; |
| 174 | quoted = TRUE; |
| 175 | } |
| 176 | offt = curlx_strtoofft(p, &endp, 10, &expires); |
| 177 | if(offt == CURL_OFFT_FLOW) |
| 178 | expires = CURL_OFF_T_MAX; |
| 179 | else if(offt) |
| 180 | /* invalid max-age */ |
| 181 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 182 | p = endp; |
| 183 | if(quoted) { |
| 184 | if(*p != '\"') |
| 185 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 186 | p++; |
| 187 | } |
| 188 | gotma = TRUE; |
| 189 | } |
| 190 | else if(strncasecompare("includesubdomains", p, 17)) { |
| 191 | if(gotinc) |
| 192 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 193 | subdomains = TRUE; |
| 194 | p += 17; |
| 195 | gotinc = TRUE; |
| 196 | } |
| 197 | else { |
| 198 | /* unknown directive, do a lame attempt to skip */ |
| 199 | while(*p && (*p != ';')) |
| 200 | p++; |
| 201 | } |
| 202 | |
| 203 | while(*p && ISBLANK(*p)) |
| 204 | p++; |
| 205 | if(*p == ';') |
| 206 | p++; |
| 207 | } while (*p); |
| 208 | |
| 209 | if(!gotma) |
| 210 | /* max-age is mandatory */ |
| 211 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 212 | |
| 213 | if(!expires) { |
| C3: Entry removal path starts here | |
| 214 | /* remove the entry if present verbatim (without subdomain match) */ |
| 215 | sts = Curl_hsts(h, hostname, FALSE); |
| C4: Curl_hsts lookup without lock | |
| C5: Curl_hsts called with FALSE for subdomain parameter | |
| 216 | if(sts) { |
| 217 | Curl_llist_remove(&h->list, &sts->node, NULL); |
| C1: Curl_llist_remove called without locking | |
| C3: Curl_llist_remove without lock | |
| C4: List removal without lock | |
| 218 | hsts_free(sts); |
| C1: hsts_free called immediately after removal, no synchronization visible | |
| C3: hsts_free without lock | |
| C4: Free without lock | |
| 219 | } |
| 220 | return CURLE_OK; |
| 221 | } |
| 222 | |
| 223 | if(CURL_OFF_T_MAX - now < expires) |
| 224 | /* would overflow, use maximum value */ |
| 225 | expires = CURL_OFF_T_MAX; |
| 226 | else |
| 227 | expires += now; |
| 228 | |
| 229 | /* check if it already exists */ |
| 230 | sts = Curl_hsts(h, hostname, FALSE); |
| 231 | if(sts) { |
| 232 | /* just update these fields */ |
| 233 | sts->expires = expires; |
| 234 | sts->includeSubDomains = subdomains; |
| 235 | } |
| 236 | else |
| 237 | return hsts_create(h, hostname, subdomains, expires); |
| 238 | |
| 239 | return CURLE_OK; |
| 240 | } |
| 241 | |
| 242 | /* |
| 243 | * Return TRUE if the given host name is currently an HSTS one. |
| 244 | * |
| 245 | * The 'subdomain' argument tells the function if subdomain matching should be |
| 246 | * attempted. |
| 248 | struct stsentry *Curl_hsts(struct hsts *h, const char *hostname, |
| 249 | bool subdomain) |
| C5: Curl_hsts signature shows subdomain parameter | |
| 250 | { |
| 251 | if(h) { |
| 252 | char buffer[MAX_HSTS_HOSTLEN + 1]; |
| 253 | time_t now = time(NULL); |
| 254 | size_t hlen = strlen(hostname); |
| 255 | struct Curl_llist_element *e; |
| 256 | struct Curl_llist_element *n; |
| 257 | |
| 258 | if((hlen > MAX_HSTS_HOSTLEN) || !hlen) |
| 259 | return NULL; |
| 260 | memcpy(buffer, hostname, hlen); |
| 261 | if(hostname[hlen-1] == '.') |
| 262 | /* remove the trailing dot */ |
| 263 | --hlen; |
| 264 | buffer[hlen] = 0; |
| 265 | hostname = buffer; |
| 266 | |
| 267 | for(e = h->list.head; e; e = n) { |
| C2: Curl_hsts iterates list without locking | |
| 268 | struct stsentry *sts = e->ptr; |
| 269 | n = e->next; |
| 270 | if(sts->expires <= now) { |
| C2: Accesses sts->expires after potential concurrent free | |
| C6: Expired entry check | |
| 271 | /* remove expired entries */ |
| 272 | Curl_llist_remove(&h->list, &sts->node, NULL); |
| C1: Same pattern in Curl_hsts for expired entries | |
| C6: Curl_llist_remove without lock | |
| 273 | hsts_free(sts); |
| C1: hsts_free called without locking | |
| C6: hsts_free without lock | |
| 274 | continue; |
| 275 | } |
| 276 | if(subdomain && sts->includeSubDomains) { |
| C2: Accesses sts->includeSubDomains and sts->host after potential concurrent free | |
| 277 | size_t ntail = strlen(sts->host); |
| 278 | if(ntail < hlen) { |
| 279 | size_t offs = hlen - ntail; |
| 280 | if((hostname[offs-1] == '.') && |
| 281 | strncasecompare(&hostname[offs], sts->host, ntail)) |
| 282 | return sts; |
| 283 | } |
| 284 | } |
| 285 | if(strcasecompare(hostname, sts->host)) |
| 286 | return sts; |
| 287 | } |
| 288 | } |
| 289 | return NULL; /* no match */ |
| 290 | } |
| 85 | static void hsts_free(struct stsentry *e) |
| 86 | { |
| 87 | free((char *)e->host); |
| C1: hsts_free frees host and entry memory | |
| 88 | free(e); |
| 89 | } |
Show 38 lines (not cited by the judge)
| 94 | void |
| 95 | Curl_llist_remove(struct Curl_llist *list, struct Curl_llist_element *e, |
| 96 | void *user) |
| 97 | { |
| 98 | void *ptr; |
| 99 | if(!e || list->size == 0) |
| 100 | return; |
| 101 | |
| 102 | if(e == list->head) { |
| 103 | list->head = e->next; |
| 104 | |
| 105 | if(!list->head) |
| 106 | list->tail = NULL; |
| 107 | else |
| 108 | e->next->prev = NULL; |
| 109 | } |
| 110 | else { |
| 111 | if(e->prev) |
| 112 | e->prev->next = e->next; |
| 113 | |
| 114 | if(!e->next) |
| 115 | list->tail = e->prev; |
| 116 | else |
| 117 | e->next->prev = e->prev; |
| 118 | } |
| 119 | |
| 120 | ptr = e->ptr; |
| 121 | |
| 122 | e->ptr = NULL; |
| 123 | e->prev = NULL; |
| 124 | e->next = NULL; |
| 125 | |
| 126 | --list->size; |
| 127 | |
| 128 | /* call the dtor() last for when it actually frees the 'e' memory itself */ |
| 129 | if(list->dtor) |
| 130 | list->dtor(user, ptr); |
| 131 | } |