curl · Report #4040799
Windows filename sanitizer misses Unicode COM/LPT reserved device names
How it was found
Hi all,
I found a way for a server-selected Windows filename to bypass curl's reserved DOS device-name sanitizer. With curl -O -J, a Content-Disposition filename such as COM¹, COM², COM³, LPT¹, LPT², or LPT³ is left unchanged, even though Windows treats all six as reserved device names.
Curl explicitly sanitizes server-selected filenames before opening them locally, including renaming reserved devices such as COM1 to _COM1. The check only recognizes ASCII digits 1 through 9, however. On a Unicode Windows build, a UTF-8 superscript digit survives sanitization, is converted to UTF-16, and reaches the Windows file-opening API as a reserved device name (yes, really).
This can make curl address a serial or parallel device instead of creating a regular downloaded file. Depending on the output mode and available devices, the result can be a failed or hung download, or response-body data written to a local device.
Why it happens
--remote-header-name documents that characters which could make a server-provided filename refer to another Windows drive, device, or directory are removed (documented here). Curl implements that protection by calling sanitize_file_name() with flags 0 for a filename extracted from Content-Disposition.
The reserved-name check in rename_if_reserved_dos() handles CON, PRN, AUX, NUL, CLOCK$, and the ASCII forms COM1 through COM9 and LPT1 through LPT9. For COM and LPT, it only recognizes the fourth byte when it is between ASCII '1' and '9':
(curl_strnequal(p, "COM", 3) || curl_strnequal(p, "LPT", 3)) ?
(('1' <= p[3] && p[3] <= '9') ? 4 : 3) : 0;
Windows has six additional reserved forms. Microsoft documents that the ISO-8859-1 superscript digits ¹, ², and ³ are recognized as device-number digits, making COM¹, COM², COM³, LPT¹, LPT², and LPT³ reserved in every directory. Microsoft gives echo test > COM¹ as an example that does not create a regular file. See Naming Files, Paths, and Namespaces.
For example, the UTF-8 representation of COM¹ is:
43 4f 4d c2 b9
C O M ¹
At p[3], curl sees 0xc2, which does not match an ASCII digit. The code temporarily treats the prefix as plain COM, then notices that another byte follows and concludes that the complete name is not reserved. The general filename sanitizer also leaves both high bytes unchanged.
The output path later calls curlx_win32_fopen() or curlx_win32_open(). In a Unicode build, curl converts the UTF-8 filename with MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, ...), producing the Windows filename COM¹, and passes it to _wfsopen() or _wsopen_s().
This is not the same as an application explicitly requesting --output COM¹. With -J, the server controls the proposed filename and curl documents that it removes device syntax before using that name. The -J warning about server-selected DLL or other dangerous regular filenames does not disable or qualify the separate reserved-device sanitizer.
PoC
The server sends either an ASCII reserved filename, which curl correctly renames, or the equivalent superscript form, which curl leaves unchanged.
Run this from a shell on a Windows curl build with Unicode filename support:
#!/bin/sh
# control: Content-Disposition names COM1, which curl renames to _COM1
# exploit: Content-Disposition names COM¹, which bypasses the rename
set -e
MODE="${1:-exploit}"
PORT="${VF_PORT:-18080}"
WORK="${VF_WORK:-./curl-device-name-poc}"
mkdir -p "$WORK"
cd "$WORK"
python3 - "$MODE" "$PORT" <<'PY' &
import socket
import sys
mode = sys.argv[1]
port = int(sys.argv[2])
if mode == "control":
filename = b"COM1"
else:
# UTF-8 U+00B9 SUPERSCRIPT ONE
filename = b"COM\xc2\xb9"
body = b"CURL-DEVICE-NAME-TEST\r\n"
response = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Disposition: attachment; filename=\"" + filename + b"\"\r\n"
b"Content-Type: application/octet-stream\r\n"
b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n"
b"Connection: close\r\n"
b"\r\n" +
body
)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port))
s.listen(1)
c, _ = s.accept()
c.recv(65536)
c.sendall(response)
c.close()
s.close()
PY
sleep 1
rc=0
curl.exe -sS -O -J --clobber \
"http://127.0.0.1:$PORT/download.bin" || rc=$?
wait
echo "mode=$MODE rc=$rc"
find . -maxdepth 1 -type f -print
The relevant sanitizer results are:
control: COM1 -> _COM1
exploit: COM¹ -> COM¹
The control produces a regular _COM1 file because curl recognizes and renames the ASCII device name. The exploit never applies the underscore, so the output layer passes COM¹ to Windows.
--clobber is used here to take the ordinary truncating fopen("wb") path and make the device-open behavior direct. Without --clobber, -J uses an exclusive no-clobber open; depending on CRT handling of the device alias, that path may reject the name as already existing. It still demonstrates the sanitizer failure, but it may stop before writing response data.
The same issue applies to:
COM¹ COM² COM³
LPT¹ LPT² LPT³
and to names with extensions, because Windows also treats forms such as COM¹.txt as device names.
There is existing evidence that curl's Windows output path can interact with reserved devices and hang. In curl issue #8608, opening AUX.txt reportedly spent roughly 30 seconds opening and another 29 seconds closing the output. That report involved an application-selected --output name, while this report concerns a server-selected name that is supposed to pass through curl's device-name sanitizer.
Suggested direction
The reserved-name check should recognize the three superscript digits after COM and LPT, in addition to ASCII 1 through 9.
For UTF-8 filenames, the relevant sequences are:
¹ c2 b9
² c2 b2
³ c2 b3
The non-Unicode Windows path should also be considered. On an ANSI code page such as Windows-1252, a server may supply the corresponding single bytes b9, b2, and b3, which the CRT can convert to the same superscript characters. A minimal byte-oriented fix can recognize both the UTF-8 sequences and the single-byte forms.
The existing tool1604 sanitizer test should gain cases covering bare names, case-insensitive names, extensions, and SANITIZE_ALLOW_RESERVED, for example:
{ "COM\xc2\xb9", 0,
"_COM\xc2\xb9", SANITIZE_ERR_OK
},
{ "lpt\xc2\xb2.txt", 0,
"lpt\xc2\xb2_txt", SANITIZE_ERR_OK
},
{ "COM\xc2\xb3", SANITIZE_ALLOW_RESERVED,
"COM\xc2\xb3", SANITIZE_ERR_OK
},
A command-line regression test should serve a UTF-8 Content-Disposition filename such as COM¹, invoke curl -O -J, and assert that curl uses a renamed regular filename rather than passing the reserved name to the Windows output layer.
Preconditions: curl's command-line tool running on Windows; a server able to provide a Content-Disposition filename; -O -J enabled; and a filename encoding that reaches the Windows API as one of the superscript-digit device names. Unicode builds accept the UTF-8 form directly. Actual device writing is most direct with --clobber; default -J may instead reject the device during its exclusive no-clobber open.
Cheers, Josh
Impact
A server a Windows user downloads from with curl -O -J can bypass curl's reserved-device filename protection by returning a Content-Disposition name such as COM¹ or LPT². Curl leaves that name unchanged and can pass it to the Windows device namespace instead of creating a regular downloaded file.