Vulnerability Report: Local File Disclosure via file:// Protocol in cURL
What the report claims
The report claims that cURL's file:// protocol allows unauthorized local file system access, especially when run with elevated privileges, leading to disclosure of sensitive files like /etc/shadow.
Compare with curl's recorded outcome
Labeled AI slop
curl's maintainer lists this report as AI slop
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.
curl policy: Tricking a user to run a command lineSee the withdrawn model reasoning
The following reasoning failed the citation check and does not support the final finding.
- The file:// protocol is an intentional, documented feature enabled by default; reading local files is its purpose.
- The report's claim that file_do calls fopen on a raw path without validation is contradicted by the provided code: file_do uses a file descriptor opened during the connection phase (line 470).
- Filesystem permissions are enforced by the OS at open time; path traversal and symlinks are standard filesystem behavior, not vulnerabilities.
- The attack scenarios require the user to explicitly invoke curl with file:// URLs (or be tricked into doing so), which falls under the project's 'Tricking a user to run a command line' and 'Local attackers already present' non-security-issue categories.
- Applications can disable file:// via CURLOPT_PROTOCOLS if they do not want local file access.
How this analysis ran 6 claims · 7 citations verified
Evidence check: judge quoted an external source that does not say that (https://lists.gnu.org/archive/html/gnunet-svn/2017-02/msg00651.html)
Checked against curl master on 2025-08-10 (626051cd8bed). 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.
The file:// protocol is a documented feature, not a vulnerability. It reads local files the calling process has permission to access—exactly as designed. The code shows file_do uses an already-opened file descriptor (lib/file.c:470), not a raw fopen on the URL path. Filesystem permissions are enforced by the OS. Running curl with sudo gives it root access; that is a system configuration choice, not a curl flaw. Applications can restrict protocols via CURLOPT_PROTOCOLS. This report describes intended behavior.
Inspect all claims and source code 6 claims · 2 code excerpts
This model judgment was withdrawn. The claim assessments below are preserved for inspection and may be wrong.
Report claims
The file handler is enabled by default in the protocols array
Read the reasoning
The file:// protocol handler is intentionally enabled by default. This is a documented feature, not a flaw.
The file_do function reads the raw path from data->state.up.path and opens it with fopen without validation
Read the reasoning
The provided file_do code (lines 443-603) reads from an already-opened file descriptor (file->fd), not by calling fopen on the raw path. The fopen occurs in the connection phase (file_connect), which is not shown but is where path parsing happens.
No validation of path traversal sequences, symbolic links, or filesystem permissions
Read the reasoning
Filesystem permissions are validated by the OS when the file is opened (in the connection phase). Path traversal (../) and symlinks are standard filesystem semantics, not vulnerabilities. The handler follows RFC 1738 for file:// URLs.
curl "file:///etc/passwd" reads system user info
Read the reasoning
curl 'file:///etc/passwd' works as intended: the file:// protocol reads local files the user has permission to access. This is documented functionality.
sudo curl "file:///etc/shadow" exposes password hashes
Read the reasoning
Running curl with sudo gives it root privileges; reading /etc/shadow is then permitted by the OS. This is not a curl vulnerability—it is how Unix permissions work.
echo "test" > /tmp/test_file && curl "file:///tmp/test_file"
Read the reasoning
The reproduction demonstrates the file:// protocol working as designed for a user-writable file.
What the report says about other projects
confirmed CURLOPT_PROTOCOLS restricts which protocols can be used
“CURLOPT_PROTOCOLS restricts which protocols can be used” source
Read the original report
Source code
| 443 | static CURLcode file_do(struct Curl_easy *data, bool *done) |
| C2: file_do function definition shows no fopen call; the report's quoted line 'FILE *file = fopen(path, "rb");' does not appear in this function | |
| C4: file_do implements reading of local files via file:// protocol as designed | |
| C5: file_do reads any file the process can access; when run via sudo, the process has root permissions | |
| C6: file_do reads local files; the reproduction shows normal file:// usage | |
| 444 | { |
| 445 | /* This implementation ignores the hostname in conformance with |
| C3: Comment states implementation follows RFC 1738: 'Only local files (reachable via the standard file system) are supported' | |
| 446 | RFC 1738. Only local files (reachable via the standard file system) |
| 447 | are supported. This means that files on remotely mounted directories |
| 448 | (via NFS, Samba, NT sharing) can be accessed through a file:// URL |
| 449 | */ |
| 450 | struct FILEPROTO *file = Curl_meta_get(data, CURL_META_FILE_EASY); |
| 451 | CURLcode result = CURLE_OK; |
| 452 | struct_stat statbuf; /* struct_stat instead of struct stat just to allow the |
| 453 | Windows version to have a different struct without |
| 454 | having to redefine the simple word 'stat' */ |
| 455 | curl_off_t expected_size = -1; |
| 456 | bool size_known; |
| 457 | bool fstated = FALSE; |
| 458 | int fd; |
| 459 | char *xfer_buf; |
| 460 | size_t xfer_blen; |
| 461 | |
| 462 | *done = TRUE; /* unconditionally */ |
| 463 | if(!file) |
| 464 | return CURLE_FAILED_INIT; |
| 465 | |
| 466 | if(data->state.upload) |
| 467 | return file_upload(data, file); |
| 468 | |
| 469 | /* get the fd from the connection phase */ |
| 470 | fd = file->fd; |
| C2: file_do uses fd = file->fd (opened during connection phase), not fopen on raw path | |
| 471 | |
| 472 | /* VMS: This only works reliable for STREAMLF files */ |
| 473 | if(fstat(fd, &statbuf) != -1) { |
| C3: fstat on the opened fd validates the file exists and is accessible; filesystem permissions are enforced by the OS at open time | |
| 474 | if(!S_ISDIR(statbuf.st_mode)) |
| 475 | expected_size = statbuf.st_size; |
| 476 | /* and store the modification time */ |
| 477 | data->info.filetime = statbuf.st_mtime; |
| 478 | fstated = TRUE; |
| 479 | } |
| 480 | |
| 481 | if(fstated && !data->state.range && data->set.timecondition && |
| 482 | !Curl_meets_timecondition(data, data->info.filetime)) |
| 483 | return CURLE_OK; |
| 484 | |
| 485 | if(fstated) { |
| 486 | time_t filetime; |
| 487 | struct tm buffer; |
| 488 | const struct tm *tm = &buffer; |
| 489 | char header[80]; |
| 490 | int headerlen; |
| 491 | static const char accept_ranges[]= { "Accept-ranges: bytes\r\n" }; |
| 492 | if(expected_size >= 0) { |
| 493 | headerlen = |
| 494 | msnprintf(header, sizeof(header), "Content-Length: %" FMT_OFF_T "\r\n", |
| 495 | expected_size); |
| 496 | result = Curl_client_write(data, CLIENTWRITE_HEADER, header, headerlen); |
| 497 | if(result) |
| 498 | return result; |
| 499 | |
| 500 | result = Curl_client_write(data, CLIENTWRITE_HEADER, |
| 501 | accept_ranges, sizeof(accept_ranges) - 1); |
| 502 | if(result != CURLE_OK) |
| 503 | return result; |
| 504 | } |
| 505 | |
| 506 | filetime = (time_t)statbuf.st_mtime; |
| 507 | result = Curl_gmtime(filetime, &buffer); |
| 508 | if(result) |
| 509 | return result; |
| 510 | |
| 511 | /* format: "Tue, 15 Nov 1994 12:45:26 GMT" */ |
| 512 | headerlen = |
| 513 | msnprintf(header, sizeof(header), |
| 514 | "Last-Modified: %s, %02d %s %4d %02d:%02d:%02d GMT\r\n", |
| 515 | Curl_wkday[tm->tm_wday ? tm->tm_wday-1 : 6], |
| 516 | tm->tm_mday, |
| 517 | Curl_month[tm->tm_mon], |
| 518 | tm->tm_year + 1900, |
| 519 | tm->tm_hour, |
| 520 | tm->tm_min, |
| 521 | tm->tm_sec); |
| 522 | result = Curl_client_write(data, CLIENTWRITE_HEADER, header, headerlen); |
| 523 | if(!result) |
| 524 | /* end of headers */ |
| 525 | result = Curl_client_write(data, CLIENTWRITE_HEADER, "\r\n", 2); |
| 526 | if(result) |
| 527 | return result; |
| 528 | /* set the file size to make it available post transfer */ |
| 529 | Curl_pgrsSetDownloadSize(data, expected_size); |
| 530 | if(data->req.no_body) |
| 531 | return CURLE_OK; |
| 532 | } |
| 533 | |
| 534 | /* Check whether file range has been specified */ |
| 535 | result = Curl_range(data); |
| 536 | if(result) |
| 537 | return result; |
| 538 | |
| 539 | /* Adjust the start offset in case we want to get the N last bytes |
| 540 | * of the stream if the filesize could be determined */ |
| 541 | if(data->state.resume_from < 0) { |
| 542 | if(!fstated) { |
| 543 | failf(data, "cannot get the size of file."); |
| 544 | return CURLE_READ_ERROR; |
| 545 | } |
| 546 | data->state.resume_from += (curl_off_t)statbuf.st_size; |
| 547 | } |
| 548 | |
| 549 | if(data->state.resume_from > 0) { |
| 550 | /* We check explicitly if we have a start offset, because |
| 551 | * expected_size may be -1 if we do not know how large the file is, |
| 552 | * in which case we should not adjust it. */ |
| 553 | if(data->state.resume_from <= expected_size) |
| 554 | expected_size -= data->state.resume_from; |
| 555 | else { |
| 556 | failf(data, "failed to resume file:// transfer"); |
| 557 | return CURLE_BAD_DOWNLOAD_RESUME; |
| 558 | } |
| 559 | } |
| 560 | |
| 561 | /* A high water mark has been specified so we obey... */ |
| 562 | if(data->req.maxdownload > 0) |
| 563 | expected_size = data->req.maxdownload; |
| 564 | |
| 565 | if(!fstated || (expected_size <= 0)) |
| 566 | size_known = FALSE; |
| 567 | else |
| 568 | size_known = TRUE; |
| 569 | |
| 570 | /* The following is a shortcut implementation of file reading |
| 571 | this is both more efficient than the former call to download() and |
| 572 | it avoids problems with select() and recv() on file descriptors |
| 573 | in Winsock */ |
| 574 | if(size_known) |
| 575 | Curl_pgrsSetDownloadSize(data, expected_size); |
| 576 | |
| 577 | if(data->state.resume_from) { |
| 578 | if(!S_ISDIR(statbuf.st_mode)) { |
| 579 | #if defined(__AMIGA__) || defined(__MINGW32CE__) |
| 580 | if(data->state.resume_from != |
| 581 | lseek(fd, (off_t)data->state.resume_from, SEEK_SET)) |
| 582 | #else |
| 583 | if(data->state.resume_from != |
| 584 | lseek(fd, data->state.resume_from, SEEK_SET)) |
| 585 | #endif |
| 586 | return CURLE_BAD_DOWNLOAD_RESUME; |
| 587 | } |
| 588 | else { |
| 589 | return CURLE_BAD_DOWNLOAD_RESUME; |
| 590 | } |
| 591 | } |
| 592 | |
| 593 | result = Curl_multi_xfer_buf_borrow(data, &xfer_buf, &xfer_blen); |
| 594 | if(result) |
| 595 | goto out; |
| 596 | |
| 597 | if(!S_ISDIR(statbuf.st_mode)) { |
| 598 | while(!result) { |
| 599 | ssize_t nread; |
| 600 | /* Do not fill a whole buffer if we want less than all data */ |
| 601 | size_t bytestoread; |
| 602 | |
| 603 | if(size_known) { |
| 443 | static CURLcode file_do(struct Curl_easy *data, bool *done) |
| 444 | { |
| 445 | /* This implementation ignores the hostname in conformance with |
| 446 | RFC 1738. Only local files (reachable via the standard file system) |
| 447 | are supported. This means that files on remotely mounted directories |
| 448 | (via NFS, Samba, NT sharing) can be accessed through a file:// URL |
| 449 | */ |
| 450 | struct FILEPROTO *file = Curl_meta_get(data, CURL_META_FILE_EASY); |
| 451 | CURLcode result = CURLE_OK; |
| 452 | struct_stat statbuf; /* struct_stat instead of struct stat just to allow the |
| 453 | Windows version to have a different struct without |
| 454 | having to redefine the simple word 'stat' */ |
| 455 | curl_off_t expected_size = -1; |
| 456 | bool size_known; |
| 457 | bool fstated = FALSE; |
| 458 | int fd; |
| 459 | char *xfer_buf; |
| 460 | size_t xfer_blen; |
| 461 | |
| 462 | *done = TRUE; /* unconditionally */ |
| 463 | if(!file) |
| 464 | return CURLE_FAILED_INIT; |
| 465 | |
| 466 | if(data->state.upload) |
| 467 | return file_upload(data, file); |
| 468 | |
| 469 | /* get the fd from the connection phase */ |
| 470 | fd = file->fd; |
| 471 | |
| 472 | /* VMS: This only works reliable for STREAMLF files */ |
| 473 | if(-1 != fstat(fd, &statbuf)) { |
| 474 | if(!S_ISDIR(statbuf.st_mode)) |
| 475 | expected_size = statbuf.st_size; |
| 476 | /* and store the modification time */ |
| 477 | data->info.filetime = statbuf.st_mtime; |
| 478 | fstated = TRUE; |
| 479 | } |
| 480 | |
| 481 | if(fstated && !data->state.range && data->set.timecondition && |
| 482 | !Curl_meets_timecondition(data, data->info.filetime)) |
| 483 | return CURLE_OK; |
| 484 | |
| 485 | if(fstated) { |
| 486 | time_t filetime; |
| 487 | struct tm buffer; |
| 488 | const struct tm *tm = &buffer; |
| 489 | char header[80]; |
| 490 | int headerlen; |
| 491 | static const char accept_ranges[]= { "Accept-ranges: bytes\r\n" }; |
| 492 | if(expected_size >= 0) { |
| 493 | headerlen = |
| 494 | msnprintf(header, sizeof(header), "Content-Length: %" FMT_OFF_T "\r\n", |
| 495 | expected_size); |
| 496 | result = Curl_client_write(data, CLIENTWRITE_HEADER, header, headerlen); |
| 497 | if(result) |
| 498 | return result; |
| 499 | |
| 500 | result = Curl_client_write(data, CLIENTWRITE_HEADER, |
| 501 | accept_ranges, sizeof(accept_ranges) - 1); |
| 502 | if(result != CURLE_OK) |
| 503 | return result; |
| 504 | } |
| 505 | |
| 506 | filetime = (time_t)statbuf.st_mtime; |
| 507 | result = Curl_gmtime(filetime, &buffer); |
| 508 | if(result) |
| 509 | return result; |
| 510 | |
| 511 | /* format: "Tue, 15 Nov 1994 12:45:26 GMT" */ |
| 512 | headerlen = |
| 513 | msnprintf(header, sizeof(header), |
| 514 | "Last-Modified: %s, %02d %s %4d %02d:%02d:%02d GMT\r\n", |
| 515 | Curl_wkday[tm->tm_wday ? tm->tm_wday-1 : 6], |
| 516 | tm->tm_mday, |
| 517 | Curl_month[tm->tm_mon], |
| 518 | tm->tm_year + 1900, |
| 519 | tm->tm_hour, |
| 520 | tm->tm_min, |
| 521 | tm->tm_sec); |
| 522 | result = Curl_client_write(data, CLIENTWRITE_HEADER, header, headerlen); |
| 523 | if(!result) |
| 524 | /* end of headers */ |
| 525 | result = Curl_client_write(data, CLIENTWRITE_HEADER, "\r\n", 2); |
| 526 | if(result) |
| 527 | return result; |
| 528 | /* set the file size to make it available post transfer */ |
| 529 | Curl_pgrsSetDownloadSize(data, expected_size); |
| 530 | if(data->req.no_body) |
| 531 | return CURLE_OK; |
| 532 | } |
| 533 | |
| 534 | /* Check whether file range has been specified */ |
| 535 | result = Curl_range(data); |
| 536 | if(result) |
| 537 | return result; |
| 538 | |
| 539 | /* Adjust the start offset in case we want to get the N last bytes |
| 540 | * of the stream if the filesize could be determined */ |
| 541 | if(data->state.resume_from < 0) { |
| 542 | if(!fstated) { |
| 543 | failf(data, "cannot get the size of file."); |
| 544 | return CURLE_READ_ERROR; |
| 545 | } |
| 546 | data->state.resume_from += (curl_off_t)statbuf.st_size; |
| 547 | } |
| 548 | |
| 549 | if(data->state.resume_from > 0) { |
| 550 | /* We check explicitly if we have a start offset, because |
| 551 | * expected_size may be -1 if we do not know how large the file is, |
| 552 | * in which case we should not adjust it. */ |
| 553 | if(data->state.resume_from <= expected_size) |
| 554 | expected_size -= data->state.resume_from; |
| 555 | else { |
| 556 | failf(data, "failed to resume file:// transfer"); |
| 557 | return CURLE_BAD_DOWNLOAD_RESUME; |
| 558 | } |
| 559 | } |
| 560 | |
| 561 | /* A high water mark has been specified so we obey... */ |
| 562 | if(data->req.maxdownload > 0) |
| 563 | expected_size = data->req.maxdownload; |
| 564 | |
| 565 | if(!fstated || (expected_size <= 0)) |
| 566 | size_known = FALSE; |
| 567 | else |
| 568 | size_known = TRUE; |
| 569 | |
| 570 | /* The following is a shortcut implementation of file reading |
| 571 | this is both more efficient than the former call to download() and |
| 572 | it avoids problems with select() and recv() on file descriptors |
| 573 | in Winsock */ |
| 574 | if(size_known) |
| 575 | Curl_pgrsSetDownloadSize(data, expected_size); |
| 576 | |
| 577 | if(data->state.resume_from) { |
| 578 | if(!S_ISDIR(statbuf.st_mode)) { |
| 579 | #if defined(__AMIGA__) || defined(__MINGW32CE__) |
| 580 | if(data->state.resume_from != |
| 581 | lseek(fd, (off_t)data->state.resume_from, SEEK_SET)) |
| 582 | #else |
| 583 | if(data->state.resume_from != |
| 584 | lseek(fd, data->state.resume_from, SEEK_SET)) |
| 585 | #endif |
| 586 | return CURLE_BAD_DOWNLOAD_RESUME; |
| 587 | } |
| 588 | else { |
| 589 | return CURLE_BAD_DOWNLOAD_RESUME; |
| 590 | } |
| 591 | } |
| 592 | |
| 593 | result = Curl_multi_xfer_buf_borrow(data, &xfer_buf, &xfer_blen); |
| 594 | if(result) |
| 595 | goto out; |
| 596 | |
| 597 | if(!S_ISDIR(statbuf.st_mode)) { |
| 598 | while(!result) { |
| 599 | ssize_t nread; |
| 600 | /* Do not fill a whole buffer if we want less than all data */ |
| 601 | size_t bytestoread; |
| 602 | |
| 603 | if(size_known) { |