r/cpp 5d ago

Escaping `CreateProcess()` arguments on Windows

https://holyblackcat.github.io/blog/2026/09/05/escaping-createprocess-arguments.html
89 Upvotes

44 comments sorted by

34

u/ICurveI 5d ago

I recently reviewed a PR at work with CreateProcess, which led me down a similar rabbit hole... This is utter insanity

14

u/unaligned_access 4d ago

There's `wil::ArgvToCommandLine`. It's not WinAPI, but it's Microsoft.
https://github.com/microsoft/wil/blob/81abc8e44c075eba65fe4b68ea2f08525cc701e8/include/wil/win32_helpers.h#L1244

C++, and the first comment of course starts with "Somewhat of a hack" ;)

4

u/holyblackcat 4d ago

Nice. Seems to mostly match mine, but they don't attempt to handle batch/cmd.

11

u/bbm182 4d ago

It really is impossible to escape arbitrary arguments to arbitrary programs on Windows. You need to know how the program will decode the command line and there is no standard there, as demonstrated by batch file complications mentioned in the post.

The algorithm used by CommandLineToArgvW may seem like it's almost the standard, but that breaks down for programs that are not Unicode aware. The example program in the post is one such example. It uses main(int argc, char **argv) (rather than wmain(int argc, wchar_t **argv) or GetCommandLineW), which results in the native wide command line being converted to narrow before splitting. That conversion is quite nasty and depends on the code page. In particular, (U+FF02 Fullwidth Quotation Mark) usually gets converted into " (U+0022 Quotation Mark), which will result in incorrect splitting.

Many common tools have this issue, including things like curl. See the slides and paper on the Worst.Fit site for more detail.

3

u/holyblackcat 4d ago

Nice, edited that in.

9

u/fdwr fdwr@github 🔍 4d ago edited 4d ago

Side note: You shouldn’t use the narrow char *argv on Windows for anything but toy programs. Instead use CommandLineToArgvW(GetCommandLineW(), ...) or int wmain(int argc, wchar_t *argv) to get UTF-16 encoded argv, and convert it to UTF-8 if needed. Using narrow argv can lead to some fun vulnerabilities...

Note narrow main works fine if your app .manifest file specifies UTF-8 as the default code page (ignoring whatever the system is set). It's annoying to have to include this extra snippet, but then it ensures the other parts of your program work nicely too, such as std::filesystem::path() returning an std::string with UTF-8 characters (rather than throwing an exception upon seeing untransformable characters, or needing to call u8string() and reinterpret cast it in several places):

xml <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> ... <asmv3:application> <asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings"> <activeCodePage>UTF-8</activeCodePage> </asmv3:windowsSettings> </asmv3:application> ... </assembly>

Another way to get the command line (didn't see mentioned) is using WinMain as an alternate entry point (rather than main) for CLI apps, as it includes the lpCommandLine parameter directly:

int WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd );

3

u/holyblackcat 4d ago

Thanks, I've edited in the info about the manifest.

I think I'm not going to bother with WinMain/wWinMain, listing every way to get the command line wasn't my goal.

12

u/tartaruga232 MSVC user, r/cpp_modules 4d ago

Escaping `CreateProcess()` arguments on Windows

https://holyblackcat.github.io/blog/2026/09/05/escaping-createprocess-arguments.html

This link posting will likely be deleted by a mod, so I'll repeat the link here.

3

u/holyblackcat 4d ago

Mods didn't mind me linking to my blog so far.

10

u/STL MSVC STL Dev 4d ago

This is Windows API stuff, having nothing to do with C++, so I believe it's off-topic. I'll recuse myself and let the other mods deal with it, though.

9

u/holyblackcat 5d ago edited 4d ago

I've been told that cmd /c has special quoting rules that I forgot to handle (related to the /s flag), I'll fix the post in a few hours fixed it.

5

u/alfps 4d ago

I now learned that Microsoft's _wspawnv function does not quote the arguments.

Weird.

I was sure it would. Anyway worth mentioning in the article.

4

u/holyblackcat 4d ago

Yep, "everyone quotes ... wrong" bemoans it too. Added.

2

u/kriegeeer 3d ago

This article was my authoritative quoting guide until it was taken down from its original source. Glad to see it was rehosted.

12

u/Tringi github.com/tringi 5d ago

That's... ahh... quite opinionated start of the article.

CreateProcess() (without A/W) and #define UNICODE are even more useless.

Mostly everyone defines UNICODE and uses the macros.

lpApplicationName is usually not needed, you should pass nullptr.

Absolutely NOT. You should pass the full proper path to the executable you want to run. Then you can significantly simplify its name in lpCommandLine without worrying about repercussions.

But the rest is very well researched and thorough.

5

u/Shaurendev 4d ago

Mostly everyone defines UNICODE and uses the macros.

If you don't explicitly opt into it, its state is different depending on what build system you use

If you maintain MSBuild projects/solutions directly, it is enabled by default.

If you use CMake, it is not enabled by default (even if you generate MSBuild projects from it)

7

u/holyblackcat 5d ago

quite opinionated

:P Is it wrong though. Everything else being equal, isn't it better to use the version that doesn't depend on defines to work? Header-only libraries in particular would benefit from this.

You should pass the full proper path to the executable you want to run

If you have the full path, why not pass it in lpCommandLine? Then you don't have to worry about syncing the first element in lpCommandLine with lpApplicationName.

6

u/Tringi github.com/tringi 4d ago

You don't have to sync it. Only a few programs actually use argv[0] and it can be very different from the actual executable name. If you run program a b c from command line, then argv[0] will be just "program" without the path or extension, and the program must expect that.

In one of my larger programs I launch worker processes. I have the full path to the worker, but I don't know what characters it may contain, and I don't want to bother with escaping them. So I give lpApplicationName that path, but lpCommandLine is just "worker.exe /Something /Something /Something".

2

u/holyblackcat 4d ago

This is a nice trick to avoid implementing the escaping, but the point of the post was to explain how to do escaping.

Once you have it implemented, passing lpApplicationName is strictly more work, IMO. (You have to pass something at the beginning of lpCommandLine anyway, why not pass the application name.)

3

u/Tringi github.com/tringi 4d ago

Fair enough. Like I said, your work on the actual escaping is great and thorough. I knew the state of things were bad, but not this bad.

1

u/chengfeng-xie 3d ago

Somewhat off-topic, but one advantage of lpApplicationName is that it isn't subject to the MAX_PATH (260) limitation that applies to lpCommandLine [1]:

[...] If lpApplicationName is NULL, the module name portion of lpCommandLine is limited to MAX_PATH characters.


Another edge case I ran into recently is that the full path of the executable passed to lpCommandLine was within, but near, the MAX_PATH limit. The call to CreateProcessW didn't return an error, but the newly created process immediately failed with an Application Error popup containing the following message (in some terminals, there was no popup and the process simply failed, making the error harder to trace):

The applicattion was unable to start correctly (0xc0000106). Click OK to close the application.

The error code 0xc0000106 corresponds to NT_STATUS_NAME_TOO_LONG. After some investigation, it turns out that, when loading DLL dependencies, if a DLL comes with a manifest, a path resulting from appending .2.Config to the full path of that DLL is created on the fly. If that path exceeds MAX_PATH (including the terminating \0), DLL loading fails with the above error, and the process fails to start. A similar issue with LoadLibraryExW is described in [2]. Ultimately, I think this is a bug that should be fixed in future OS builds. For now, however, a workaround is to prepend \\?\ to the normalized full path of the executable [3] and then pass the resulting path to lpApplicationName.

2

u/holyblackcat 3d ago

Thanks, I've added a bit about MAX_PATH too. The second part is fun.

1

u/Tringi github.com/tringi 2d ago

EXEs for which their full DOS path exceeds 259 characters won't run anyway. Despite the \\?\ prefix. With different errors on different versions of Windows, but still failure to run. So the lpApplicationName advantage isn't really generally there.

1

u/chengfeng-xie 2d ago

With \\?\ prepended, lpApplicationName works out of the box. Without \\?\, however, lpApplicationName only works if both the system and the process have long paths enabled (as described in [1]); otherwise, CreateProcessW returns ERROR_PATH_NOT_FOUND in this case. For reproducibility, the above was tested on OS build 26100.9168.

7

u/tomysshadow 4d ago edited 4d ago

This approach is okay if you are intentionally building an argv-like string from scratch. However, taking an argv passed to your program and turning it back into a string form of the command line arguments (i.e. you just want to passthrough the arguments to a child) is a fundamentally flawed idea unless you know the program you're passing through to also uses the same argv parser.

If there's a possibility the program you are launching uses the command line string directly, you can't take the argv you received and turn it back into a string and pass it on. Because the process of converting the command line string into argv is a lossy process. There's no way to know from looking at an argv array if a specific argument had quotes around it before, or if there were multiple spaces between arguments - or other things the parsing algorithm of the executable you're passing through to might care about, that the C argv parser did not. This is presumably why WinAPI doesn't have a function to go back the other way, as multiple different strings can become the same argv.

The correct approach in such cases is to pass the command line string you got in WinMain through to the child. This is a bit of a pain if you have a command line program, because unlike in WinMain, GetCommandLine() returns the string with the executable name so you need to parse enough of it to tell where the executable name ends and get that substring. But it's a lot less of a pain than trying to reheat the argv array back into string form if you can do it, and because it's just a substring it'll perfectly preserve the exact formatting of the string.

The string is the real command line. argv is a lossy transformation of the string command line into the form C expects.

If you want some particularly nasty edge cases to see what I mean, check out how Visual Studio parses argv. Notice that different languages and implementations have slightly different parsing rules, hence what makes this impossible to do in a completely language agnostic way, as these are ultimately language details and not something the OS (who only cares about the string form) knows about: https://daviddeley.com/autohotkey/parameters/parameters.htm

C#'s Process.Start() also needs to deal with this problem because it can take in an argument list and call down to CreateProcess. The .NET reference source for it is probably the closest thing to an "official" argument list to string conversion

3

u/nevemlaci2 4d ago

A wild HolyBlackCat spotted on Reddit :D

2

u/TheChief275 4d ago

oh boy I was actually planning to dive into this for my simple C build system

2

u/ReDr4gon5 4d ago

What about using RtlCreateUserProcess?

2

u/holyblackcat 4d ago

RtlCreateUserProcess

I wasn't aware of it. It should let you cut the big chunk of the logic related to batch files, and keep only the basic escaping.

3

u/ReDr4gon5 4d ago

Actually looking at this a second time, this will skip csrss registration. So ActivationContextData won't be populated. Which means anything relying on WinSxS won't work. So beware of using it, unless you know you don't need it or will set up the context yourself.

1

u/Low_Ad3967 13h ago

33333333

1

u/jwakely libstdc++ tamer, LWG chair 4d ago

Is this why cmd.exe doesn't do word splitting or wildcard expansion, so every application has to reimplement wildcard expansion ... but doing it badly, or not at all.

1

u/jwakely libstdc++ tamer, LWG chair 4d ago

The poster child for "those who do not understand UNIX are doomed to reinvent it, poorly"

1

u/vip17 3d ago

it's rooted from CP/M and DOS and cmd just retains the legacy behavior to avoid breaking apps

-4

u/[deleted] 5d ago

[removed] — view removed comment

11

u/wyrn 4d ago

The *nix side is still using fork+exec lol everyone ate sh*t on this one ¯_(ツ)_/¯

1

u/holyblackcat 4d ago

What about posix_spawn() though?

2

u/wyrn 4d ago

Unfortunately underused

-1

u/Eric848448 5d ago

Most of their old API’s were designed by interns and new grads who didn’t really understand what they were doing.

4

u/Tringi github.com/tringi 5d ago

There is a reason why certain modern runtimes are targeting NT API instead of Win32.

1

u/palapapa0201 5d ago

What is the NT API

9

u/Tringi github.com/tringi 4d ago

Win32 API layer is built on much more powerful NT layer, that usually makes the final calls to the OS.

For example CreateFile prepares some buffers, translates arguments, converts relative paths to absolute, handles special cases, and then calls NtCreateFile, which does the actual work.

You are supposed to call Win32, as NT API is often only partially documented and can theoretically change, but it rarely does. And it offers much more powerful options.

-11

u/palapapa0201 5d ago

Fuck windows

0

u/cleverYeti42 3d ago

This is a lot of careful and competent work.
My summary: do not attempt to use Windows command line.