Revision history (first version, published Sep 2, 2026)
- First published
Cite this article(DOI: 10.5281/zenodo.22640284)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Why Arguments Break — The Rules of Windows Command-Line Arguments. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22640284 https://comcomponent.com/en/blog/windows-command-line-argument-rules/
- DOI (latest version)
- 10.5281/zenodo.22640284
- DOI (this version)
- 10.5281/zenodo.22640285
“It passed in testing, but the external tool will not start on a PC whose path contains a space.” “I passed C:\data\ and it was merged with the next argument into one.” “I passed JSON as an argument, the quotes disappeared, and the other side failed to parse it.” These are failures that keep recurring in code that launches child processes. Most of them are caused not by logic but by code written without the premise that Windows has no mechanism for passing an “array of arguments”.
What CreateProcess, the function that creates a process on Windows, receives is a single string called lpCommandLine. No matter how carefully the caller prepares an array, it is always concatenated into one string when it crosses the OS boundary, and the receiving side splits it again. The splitting rules are decided by the receiving side’s runtime, and the C runtime, CommandLineToArgvW, the .NET runtime, and cmd.exe are each separate code. Passing arguments means building a string that the other side’s parser will split back into the original pieces.
This article takes the standpoint of launching child processes from Win32 and .NET code, not from PowerShell scripts, and lays out where the string is concatenated, where it is split, and which rules apply. The PowerShell side (the change to argument passing in 7.3, --%, $PSNativeCommandArgumentPassing) is covered in “Calling External EXEs Correctly from PowerShell”, so this article digs into the layer beneath it.
flowchart TB
accTitle: The layer this article covers
accDescr: PowerShell argument passing is covered in a separate article; this article covers the layer beneath it, from Win32 CreateProcess and .NET ProcessStartInfo down to the target exe's parser
ps["PowerShell argument passing (separate article)"] --> net[".NET ProcessStartInfo"]
net --> win["Win32 CreateProcessW"]
win --> str["A single command-line string"]
str --> parser["The target exe's parser"]
net -.->|"Scope of this article"| parser
Figure 1: Beneath PowerShell sit the .NET and Win32 layers, and whichever one you launch from, the result is a single string. This article covers the rules of that layer.
1. The Bottom Line First
- A Windows process never receives an array of arguments. The single string passed to
CreateProcessreaches the new process (the OS may fill in the full path for the leading executable name only), andGetCommandLineWreturns it.argvis something the receiving side creates by itself.1 2 - The core of the splitting rules is three items: split on spaces and tabs, do not split inside a double-quoted region, and a backslash is special only when a double quote follows it immediately (2n backslashes become n plus the quote opens or closes quoting; 2n+1 become n plus a literal quote).3 4
- Only the leading token (
argv[0], the executable name) follows a different rule: it can be wrapped in quotes, but backslash escaping does not apply. IflpApplicationNameisNULL, the interpretation of a path containing spaces becomes ambiguous andC:\Program.exeis tried first.1 4 - On the building side, one rule is enough: “if the argument contains a space or a quote, or is empty, wrap it in quotes, double the backslashes that precede a quote and the trailing backslashes, and write quotes as
\".”ProcessStartInfo.ArgumentListin .NET Core 2.1 and later does this for you.5 6 - Do not generate the form that places two adjacent quotes inside a non-empty argument (something like
"ab""c"), because receivers interpret it differently. The""that represents an empty argument is a different thing and is correct. cmd.exe and batch files sit outside these rules, so do not pass untrusted values through them.6 7 - The limits are 32,767 UTF-16 code units for
lpCommandLine(including the terminating null; characters that are surrogate pairs, such as emoji, count as two) and 8,191 characters for cmd.exe. If you are likely to exceed them, switch to a response file, but only when the target can read one (or can be fixed to read one) using a syntax such as@file.1 8
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (28 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. There Is No Argument Array — CreateProcess and the Single String
The second parameter of CreateProcessW, lpCommandLine, is a single null-terminated string in which the executable name and the arguments are laid out separated by spaces. The length limit is 32,767 UTF-16 code units including the terminating null (the number of wchar_t elements; a surrogate-pair character such as an emoji consumes two per character, so never pre-check by the apparent character count), and because the Unicode version may modify this string, passing a string literal or a const buffer can cause an access violation.1
This string is handed to the new process unchanged as part of its process parameters, and the child process retrieves it with GetCommandLineW. Because the OS may fill in the full path for the leading executable name, the string the child sees does not necessarily match the string the parent passed exactly.2 The lpCmdLine passed to a GUI application’s WinMain is this string with the program name removed.9
flowchart TB
accTitle: The path arguments take to reach the child process
accDescr: The caller's argument array is concatenated into a single string in CreateProcess's lpCommandLine and passed to the new process, and the child process splits the string it retrieves with GetCommandLineW using its own parser to create argv
arr["The caller's argument array"] --> join["Concatenated into one string (the caller's responsibility)"]
join --> cp["CreateProcessW's lpCommandLine"]
cp --> peb["The new process's process parameters"]
peb --> gcl["The string GetCommandLineW returns"]
gcl --> parse["The receiving side's parser splits it"]
parse --> argv["The argv / args array"]
Figure 2: The array never crosses the boundary. Concatenation is the caller’s responsibility, splitting is the receiver’s, and the original array is restored only when both sides’ rules agree.
The point to hold on to here is that concatenation and splitting happen in different processes, in different code. The caller cannot concatenate correctly without knowing what the other side will split with, and the receiver has no way of knowing how the string was concatenated. On Unix-like operating systems, execve accepts the array as is, so this problem does not exist. It is a premise specific to Windows, but one that follows every process launch.
3. Who Does the Splitting — Three Parsers
On the receiving side, the code that splits the string into argv comes mainly in three kinds.
| Receiving side | Code that splits | When it is invoked |
|---|---|---|
main / wmain in C/C++ |
The MSVC C runtime startup code | Creates argc / argv automatically at program start4 |
| Code that uses the Win32 API directly | CommandLineToArgvW |
You pass it the return value of GetCommandLineW to convert it into argv form3 |
.NET’s Main(string[] args) / Environment.GetCommandLineArgs() (the usual configuration launched through the apphost or dotnet.exe) |
The C runtime startup code of the host (apphost / dotnet.exe) |
On Windows the host is a wmain program; it takes the argv the C runtime built, removes its own options and the app path, and passes the remainder to the runtime together with the app path. At startup the runtime builds an array whose first element is the program name (the launch name passed from the host, or the assembly path if none) and keeps it for GetCommandLineArgs(), while Main receives in args only the arguments with the program name removed10 11 12 |
| A configuration that loads the .NET runtime as a hosted library and receives no startup arguments | The .NET runtime’s own splitting code (SegmentCommandLine) |
As a fallback, GetCommandLineArgs() splits the return value of GetCommandLineW itself. It is implemented to match the C runtime’s rules and does not use CommandLineToArgvW, because that “behaves slightly differently”12 |
The splitting code comes in three lineages, the C runtime startup code, CommandLineToArgvW, and the .NET runtime’s own splitting code, and they implement rules with the same skeleton, but they are not the same code. A .NET app launched through the apphost or dotnet.exe is effectively split by the rules of the first lineage (the C runtime startup code), because the host itself is a wmain program built with the MSVC C runtime. The .NET runtime source still carries a comment saying that CommandLineToArgvW is not used because it behaves slightly differently.12 The differences appear at the edges, such as the handling of "" described later, and everyday arguments rarely hit them, but assuming that “the rules are the same, so anything goes” is what breaks at the edges.
flowchart TB
accTitle: The three parsers on the receiving side
accDescr: The single string GetCommandLineW returns is split by the C runtime startup code for C/C++, by CommandLineToArgvW for direct Win32 use, and by the runtime's own splitting code for .NET loaded as a hosted library; each follows rules with the same skeleton but is a separate implementation. A normal .NET app launched through the apphost or dotnet.exe receives the array split by the host's C runtime startup code
s["The GetCommandLineW string"] --> crt["C runtime startup code"]
s --> api["CommandLineToArgvW"]
s --> net[".NET's own splitting code (when loaded by a host)"]
crt --> app[".NET via apphost / dotnet.exe is the same"]
crt --> same["Same skeleton of rules, separate implementations"]
api --> same
net --> same
Figure 3: There are three lineages of splitting code. A .NET app launched through the apphost or dotnet.exe receives the array split by the host’s C runtime startup code, and the runtime’s own splitting code is the fallback for the hosted-library configuration. Because you cannot tell from outside which one the target exe runs on, the practical answer is to build a string that gives the same result on all of them.
Note that args in .NET’s Main(string[] args) does not include the program name, whereas the first element of Environment.GetCommandLineArgs() does. The latter occupies the same position as argv[0] in C/C++.13 In a normal launch such as dotnet app.dll x, the host removes the host options and the app path (dotnet.exe and app.dll), and only x reaches args in Main.14 GetCommandLineArgs(), on the other hand, returns the array to which the runtime prepended the program name at startup (the path of app.dll followed by x).11 The runtime’s own splitting code splits GetCommandLineW only in the hosted-library configuration that receives no startup arguments; in a configuration where a native host passes its own argc/argv and calls Main, args in Main is whatever the host passed.
4. The Splitting Rules — Spaces, Quotes, and Backslashes
Here are the rules the three parsers share, for argv[1] onward.3 4
- Arguments are separated by spaces or tabs.
- A region wrapped in double quotes becomes one argument even if it contains spaces. The quotes themselves are not part of the argument. A quote may begin in the middle of an argument, and if the string ends without a closing quote, everything up to the end becomes the last argument.
- A backslash is treated as an ordinary character. Only when a double quote follows it immediately do the next rules apply.
- If 2n backslashes precede a double quote, n backslashes are output, and the quote acts as the “start or end of quoting”.
- If 2n+1 backslashes precede a double quote, n backslashes and a literal quote are output, and the quoting state does not change.
- The caret (
^) is not an escape character (that is a cmd.exe rule, not a parser rule).
The parser keeps one bit of state, “am I inside quotes”, flips it at each quote, and reads the string from left to right. Whether a space separates arguments is decided by this state.
flowchart TB
accTitle: The splitting flow that toggles between inside and outside quotes
accDescr: Outside quotes the parser separates arguments on spaces; when it meets a quote it goes inside and treats spaces as part of the argument; when it meets another quote it returns outside. A backslash is treated specially only when a quote immediately follows it
out["Outside quotes: split on spaces"] -->|"A quote is encountered"| inq["Inside quotes: spaces are part of the argument"]
inq -->|"A quote is encountered"| out
out -->|"A backslash is immediately followed by a quote"| bs["Apply the backslash rule"]
inq -->|"A backslash is immediately followed by a quote"| bs
bs -->|"2n: output n and open/close"| toggle["Flip the quoting state"]
bs -->|"2n+1: output n and a literal quote"| lit["Keep the quoting state"]
Figure 4: The core of splitting is decided by just one bit, “inside or outside quotes”, and the number of backslashes immediately before a quote.
Rather than memorizing the rules as prose, it is more reliable to look at the correspondence between input and output.
| Part of the command line (input) | Resulting arguments | Rule at work |
|---|---|---|
a b c |
a, b, c |
Split on spaces |
"a b" c |
a b, c |
A quoted region is not split |
C:\data\ next |
C:\data\, next |
The backslash is not followed by a quote, so it is an ordinary character |
"C:\data\\" next |
C:\data\, next |
The two before the quote become one, and the quote closes |
"C:\data\" next |
C:\data" next |
One backslash, so the quote becomes a literal quote and quoting never closes, swallowing the next argument |
"say \"hi\"" |
say "hi" |
An odd count, so literal quotes |
"" |
Empty string | The only way to pass an empty argument |
'a b' |
'a, b' |
Single quotes have no special meaning15 |
Row 5 is the true identity of “I passed C:\data\ and it was merged with the next argument into one” from the opening. The moment you wrap a path with a trailing backslash in quotes, the closing quote turns into a character and quoting never closes.
flowchart TB
accTitle: How a trailing backslash swallows the next argument
accDescr: When a path with a trailing backslash is wrapped in quotes, the quote that should close it sits immediately after a single backslash and is interpreted as a literal quote, so quoting never closes and everything up to the next argument is read as one argument
a["A quoted path ending in one backslash"] --> b["An odd number of backslashes precede the closing quote"]
b --> c["The quote is output as a character and quoting does not close"]
c --> d["Subsequent spaces no longer separate"]
d --> e["Everything up to the next argument arrives as one argument"]
a -.->|"Double the backslash"| ok["Quoting closes and the arguments separate"]
Figure 5: Why “double the trailing backslash” is necessary. Quoting written without knowing the rules breaks at the end of a path.
“Two Consecutive Quotes Inside Quoting”, Where Implementations Differ
The rules of the MSVC C runtime have one more item: “two consecutive quotes inside a quoted string are treated as one quote” (a form like "ab""c", which is a separate matter from the "" that represents an empty argument).4 The official rules for CommandLineToArgvW, however, have no such item, and the .NET runtime’s builder code explicitly avoids generating this form because “a quote following a closing quote is interpreted differently by VC before and after 2008”.6
As a receiver, it is enough to know that such input can arrive. As a builder, when you want to pass a quote as a character, use only the \" form. It gives the same result on every parser.
5. argv[0] Follows a Different Rule — lpApplicationName and the Program.exe Problem
The leading token, that is, the executable name, is outside the rules so far. It is assumed to be a string valid as a file-system path, so it can be wrapped in quotes to include spaces, but the backslash escaping rules do not apply. There is also no way to include a quote itself in argv[0].4 3 The .NET builder code also handles the first element separately: “wrap it in quotes if it has spaces, and throw if it contains a quote”.6
What becomes a problem on the calling side is the behavior when lpApplicationName of CreateProcess is NULL. In that case, the module to execute is inferred from the leading space-delimited token of lpCommandLine. When the path contains spaces, several candidates arise, and the OS tries them starting from the shortest.1
flowchart TB
accTitle: The order in which the executable is guessed when lpApplicationName is NULL
accDescr: If C:\Program Files\MyApp -L -S is passed without quotes, CreateProcess tests for C:\Program.exe and then C:\Program Files\MyApp.exe in that order, so if C:\Program.exe has been placed there, it is the one that runs
in["Pass an unquoted path (containing spaces) in lpCommandLine"] --> t1["Candidate 1: try C:\Program.exe"]
t1 -->|"Exists"| bad["An unintended executable starts"]
t1 -->|"Does not exist"| t2["Candidate 2: try C:\Program Files\MyApp.exe"]
t2 --> ok["The intended executable starts"]
in -.->|"Pass lpApplicationName, or quote the leading token"| ok
Figure 6: Placing a path with spaces at the start without quotes makes the OS try candidates from the shortest. The official documentation calls this “dangerous” in plain terms.
The official documentation states that if C:\Program.exe is placed there, it runs instead of the intended app, and asks you not to pass NULL for lpApplicationName and, if you do, to wrap the leading path in quotes.1 In practice, do both. Pass the full path of the executable in lpApplicationName, and also place the same path, wrapped in quotes, at the start of lpCommandLine. When both are passed, the module that runs is decided by lpApplicationName, and the child process’s argv[0] becomes the leading token of lpCommandLine. Unless you keep the two consistent by convention, code that derives its own path from argv[0] breaks. The reliable way to get your own path is GetModuleFileNameW.4
flowchart TB
accTitle: How the executed module and argv[0] are decided
accDescr: When both lpApplicationName and lpCommandLine are passed, the module that runs is decided by lpApplicationName and the child's argv[0] is the leading token of lpCommandLine. Code that derives its own path from argv[0] breaks when the two diverge, so get your own path with GetModuleFileNameW
app["lpApplicationName"] --> run["The module that runs"]
cl["The leading token of lpCommandLine"] --> a0["The child's argv[0]"]
a0 -.->|"Breaks when they diverge"| self["Code that derives its own path from argv[0]"]
self -.->|"Use instead"| gmf["GetModuleFileNameW"]
Figure 7: “What runs” and “what goes into argv[0]” are decided separately. A design that derives its own path from argv[0] cannot stand on top of this separation.
One more point: when lpApplicationName is NULL, the executable-name part of lpCommandLine is limited to MAX_PATH.1 For the handling of long paths, see “MAX_PATH and Windows Path/Filename Pitfalls”.
6. The Rules on the Building Side — One Function Is Enough
Once you know the splitting rules, you can build “a string the other side will split back into the original” simply by running them in reverse. For each argument from argv[1] onward, do the following.6
- If it is not empty and contains neither spaces nor quotes, place it as is.
- Otherwise, wrap the whole thing in quotes. Inside the quoting,
- turn a run of k backslashes immediately before a quote into 2k+1 and then place the quote (making the count odd turns it into a “literal quote”);
- turn a run of k trailing backslashes into 2k (they precede the closing quote, so an even count turns it into the “end of quoting”);
- leave every other backslash as is.
- Place an empty string as
"".
flowchart TB
accTitle: The decision flow for building one argument
accDescr: If the argument is non-empty and contains neither spaces nor quotes, place it as is; otherwise wrap it in quotes, make backslashes before a quote 2k+1 and trailing backslashes 2k, prefix quotes with a backslash, and close
s["Receive one argument"] --> q{"Empty, or contains a space or a quote?"}
q -->|"No"| raw["Place it as is"]
q -->|"Yes"| open["Opening quote"]
open --> scan["Scan from the left"]
scan --> bq["k backslashes before a quote → 2k+1"]
scan --> be["k trailing backslashes → 2k"]
scan --> other["Everything else as is"]
bq --> close["Closing quote"]
be --> close
other --> close
Figure 8: Building is the inverse of the splitting rules. There are only three branches, and you adjust the backslash count only at the end and immediately before a quote; with that, any string round-trips, provided the receiving side splits wide characters with the same splitting rules as CommandLineToArgvW, the C runtime, and .NET (chapter 4) (a target that interprets the raw command line with its own grammar, or a shell parser in between, is out of scope), has not enabled wildcard expansion such as wsetargv.obj, the string contains no NUL characters, and the whole built string fits within the lpCommandLine limit (32,767 UTF-16 code units including the terminating null). (The command line is a null-terminated string, so a NUL character is the one thing that cannot be passed in principle. On a target with wildcard expansion enabled, an argument containing * or ? is replaced by file names; see chapter 8. A string over the limit is rejected by CreateProcessW; see chapter 10.)
This rule reflects the asymmetry “a backslash is special only immediately before a quote” as it is. There is no need to mechanically double the backslashes that separate path components; the point is that you touch only those immediately before a quote and at the end.
7. Implementation in .NET — ArgumentList and Arguments
ProcessStartInfo in .NET Core 2.1 and later has ArgumentList, which takes over this building. One element is one argument, the strings you add need no escaping in advance, and at Process.Start .NET builds them into one string internally and hands it to the OS.5
var psi = new ProcessStartInfo
{
FileName = @"C:\Program Files\MyTool\convert.exe",
UseShellExecute = false,
};
psi.ArgumentList.Add("--input");
psi.ArgumentList.Add(inputPath); // may contain spaces, trailing backslashes, and quotes
psi.ArgumentList.Add("--output");
psi.ArgumentList.Add(outputPath);
psi.ArgumentList.Add("--label");
psi.ArgumentList.Add(""); // an empty argument is passed correctly as ""
using var proc = Process.Start(psi)
?? throw new InvalidOperationException("Process.Start returned null");
proc.WaitForExit();
if (proc.ExitCode != 0)
throw new InvalidOperationException($"convert.exe failed (ExitCode={proc.ExitCode})");
Arguments is a property that passes a single string you built yourself as is. The two are independent, and when you use one, the other must be empty.16 The official documentation also advises choosing ArgumentList if you are not confident about quoting.5
flowchart TB
accTitle: Where ArgumentList and Arguments become a string
accDescr: With ArgumentList, .NET escapes each element and builds a single string before passing it to CreateProcess; with Arguments, the string the caller built is passed as is. Either way, what reaches the OS is a single string
al["ArgumentList (1 element = 1 argument)"] --> esc[".NET escapes each element and concatenates"]
ar["Arguments (a single string you built yourself)"] --> pass["As is"]
esc --> cmd["A single command-line string"]
pass --> cmd
cmd --> cp["CreateProcess"]
Figure 9: Whichever you use, what reaches the OS is a single string. The only difference is who builds it, and ArgumentList leaves that to the side that knows the rules.
The builder code behind ArgumentList is exactly the rules of chapter 6. If the argument is non-empty and contains neither spaces nor quotes, it is placed as is; otherwise it is wrapped in quotes, backslashes immediately before a quote become 2k+1, trailing backslashes become 2k, and every quote is always prefixed with a backslash. It never generates the form with adjacent quotes inside a non-empty argument. Only an empty argument is placed as "", and that is the correct form.6
On .NET Framework, Build It Yourself
ArgumentList is an API introduced in .NET Core 2.1 and does not exist on ProcessStartInfo in .NET Framework.5 In a .NET Framework 4.8 app, or an in-house tool built on one, write the rules of chapter 6 yourself and pass the result to Arguments.
// For .NET Framework. Builds the single string to pass to ProcessStartInfo.Arguments.
// The rules are the same ones ProcessStartInfo.ArgumentList uses internally.
static string BuildArguments(IEnumerable<string> args)
{
var sb = new StringBuilder();
foreach (var arg in args)
{
if (sb.Length > 0) sb.Append(' ');
AppendArgument(sb, arg);
}
return sb.ToString();
}
static void AppendArgument(StringBuilder sb, string arg)
{
if (arg.IndexOf('\0') >= 0)
throw new ArgumentException("An argument cannot contain a NUL character (the command line is a null-terminated string and would be cut there)");
bool needsQuote = arg.Length == 0 || arg.Any(c => char.IsWhiteSpace(c) || c == '"');
if (!needsQuote)
{
sb.Append(arg); // as is
return;
}
sb.Append('"');
int i = 0;
while (i < arg.Length)
{
int backslashes = 0;
while (i < arg.Length && arg[i] == '\\') { i++; backslashes++; }
if (i == arg.Length)
{
sb.Append('\\', backslashes * 2); // trailing: doubled because the closing quote follows
}
else if (arg[i] == '"')
{
sb.Append('\\', backslashes * 2 + 1).Append('"'); // before a quote: doubled plus one
i++;
}
else
{
sb.Append('\\', backslashes).Append(arg[i]); // everything else: as is
i++;
}
}
sb.Append('"');
}
Here are inputs and outputs side by side.
| Value to pass | String AppendArgument outputs |
|---|---|
strict |
strict |
| Empty string | "" |
C:\Program Files\input |
"C:\Program Files\input" |
C:\Program Files\input\ |
"C:\Program Files\input\\" |
say "hi" |
"say \"hi\"" |
a\"b |
"a\\\"b" |
C:\data\ (no spaces) |
C:\data\ |
Note the last row. A value containing neither spaces nor quotes is not wrapped, so the trailing backslash comes out as is. Without wrapping, rules 4 and 5 never fire, and C:\data\ arrives correctly.
flowchart TB
accTitle: Choosing the building method by .NET version
accDescr: On .NET Core 2.1 or later, leave it to ProcessStartInfo.ArgumentList; on .NET Framework, build the Arguments string with your own function using the same rules. In neither case do you hand-write quotes with string concatenation
v{"Which .NET version?"}
v -->|"Core 2.1 or later"| al["Add to ArgumentList one element at a time"]
v -->|"Framework"| own["Build Arguments with your own function"]
al --> no["Never hand-write quotes"]
own --> no
Figure 10: Two methods, one principle. Keep to “never hand-write quotes” and the breakage at the end of a path never happens.
Note that with UseShellExecute = true, the launch goes through ShellExecuteEx rather than CreateProcess, and the contents of ArgumentList become the parameters passed to the shell. When opening a document or a URL, the file association builds the actual handler’s command line, so the string you built here does not necessarily reach the target as is. For uses where you redirect output or need the exit code reliably, set UseShellExecute = false and design the code to read standard output and standard error at the same time. That part is covered in “A Checklist for Safely Handling Child Processes in Windows Apps”.
8. Implementation in C++ / Win32
In C++ you write both sides yourself, building and splitting. For building, turn the rules of chapter 6 directly into a function.
#include <windows.h>
#include <string>
#include <stdexcept>
#include <string_view>
#include <vector>
// Appends one argument for argv[1] onward. The rules are the inverse of the CommandLineToArgvW / CRT splitting rules.
void AppendArgument(std::wstring& cmd, std::wstring_view arg)
{
if (!cmd.empty()) cmd += L' ';
if (arg.find(L'\0') != std::wstring_view::npos)
throw std::invalid_argument("An argument cannot contain a NUL character (the command line is a null-terminated string and would be cut there)");
const bool needsQuote =
arg.empty() || arg.find_first_of(L" \t\"") != std::wstring_view::npos;
if (!needsQuote) { cmd += arg; return; }
cmd += L'"';
for (size_t i = 0; ; ) {
size_t backslashes = 0;
while (i < arg.size() && arg[i] == L'\\') { ++i; ++backslashes; }
if (i == arg.size()) {
cmd.append(backslashes * 2, L'\\'); // trailing: doubled
break;
}
if (arg[i] == L'"') {
cmd.append(backslashes * 2 + 1, L'\\'); // before a quote: doubled plus one
cmd += L'"';
} else {
cmd.append(backslashes, L'\\'); // everything else: as is
cmd += arg[i];
}
++i;
}
cmd += L'"';
}
// argv[0] (the executable) follows a different rule: only wrap it in quotes if it has spaces. It cannot contain a quote.
std::wstring QuoteArgv0(std::wstring_view exe)
{
if (exe.find(L'\0') != std::wstring_view::npos)
throw std::invalid_argument("The executable path cannot contain a NUL character (both lpApplicationName and the command line would be cut there, and the path up to that point might be launched)");
if (exe.find(L'"') != std::wstring_view::npos)
throw std::invalid_argument("The executable path cannot contain a quote");
if (exe.empty() || exe.find_first_of(L" \t") != std::wstring_view::npos)
return L'"' + std::wstring(exe) + L'"';
return std::wstring(exe);
}
In the call, pass the full path of the executable in lpApplicationName and a writable buffer in lpCommandLine.
const std::wstring exe = LR"(C:\Program Files\MyTool\convert.exe)";
std::wstring cmd = QuoteArgv0(exe); // keep argv[0] consistent with the executable
AppendArgument(cmd, L"--input");
AppendArgument(cmd, inputPath);
AppendArgument(cmd, L"--output");
AppendArgument(cmd, outputPath);
std::vector<wchar_t> buffer(cmd.begin(), cmd.end());
buffer.push_back(L'\0'); // CreateProcessW may modify the string
STARTUPINFOW si{}; si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(exe.c_str(), // lpApplicationName: never NULL
buffer.data(), // lpCommandLine: starts with the same path, quoted
nullptr, nullptr, FALSE, CREATE_UNICODE_ENVIRONMENT,
nullptr, nullptr, &si, &pi)) {
const DWORD err = GetLastError();
// Log err here and return it to the caller. Do not swallow it
return;
}
CloseHandle(pi.hThread); // the main thread handle is not needed, so close it first
switch (WaitForSingleObject(pi.hProcess, INFINITE)) { // add a timeout if needed
case WAIT_OBJECT_0: { // it exited. Read the exit code only in this branch
DWORD exitCode = 0;
if (!GetExitCodeProcess(pi.hProcess, &exitCode)) {
const DWORD err = GetLastError();
// Log the retrieval failure too, and return it to the caller as a failure
} else if (exitCode != 0) {
// The target started but its processing failed. Do not treat it like 0;
// log the exit code and return it to the caller (same as the ExitCode check in the C# example)
}
break;
}
case WAIT_TIMEOUT:
// Still running. Calling GetExitCodeProcess here only returns STILL_ACTIVE (259),
// which is not an exit code. This example takes the policy "fold a timeout into a failure":
// only when the termination request goes through do we see it exit, then proceed to CloseHandle below.
// If the policy is to keep waiting, do not break here and close the handles (that would
// let go of the child while it is still running). Go back to waiting
if (!TerminateProcess(pi.hProcess, 1)) {
const DWORD err = GetLastError();
// Could not terminate it (insufficient rights, etc.). Waiting with INFINITE here would make
// the deadline added to prevent overruns meaningless. Log err and return a failure to the
// caller without waiting (the child is let go while still running, so log that as well)
break;
}
WaitForSingleObject(pi.hProcess, INFINITE); // the termination request went through, so see it exit before closing
// Return the timeout to the caller as a failure
break;
default: { // WAIT_FAILED
const DWORD err = GetLastError();
// Log the failure of the wait itself too
break;
}
}
CloseHandle(pi.hProcess); // forgetting this leaks one handle per launch
flowchart TB
accTitle: The division of roles between the two arguments passed to CreateProcessW
accDescr: lpApplicationName fixes the module to execute, and lpCommandLine decides the string the child process receives through GetCommandLineW. Pass lpCommandLine as a writable buffer and keep the leading argv[0] consistent with lpApplicationName
app["lpApplicationName: the executable's full path"] --> mod["The module to execute is fixed"]
cl["lpCommandLine: a writable buffer"] --> child["The string the child receives through GetCommandLineW"]
child --> a0["Leading token = argv[0]"]
a0 -.->|"Keep consistent"| app
child --> rest["The rest = arguments built by the rules of chapter 6"]
Figure 11: “What to execute” and “what to pass” are decided by different arguments. Make both explicit and neither the Program.exe problem nor the access violation from a non-writable buffer occurs.
On the receiving side, pass the return value of GetCommandLineW to CommandLineToArgvW to get it in argv form. Free the return value with a single LocalFree. There are edge behaviors: if lpCmdLine is an empty string, the path of the current executable is returned, and if it starts with a space, the first argument becomes an empty string.3
int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (argv == nullptr) {
const DWORD err = GetLastError();
// Log the parse failure too
return 1;
}
for (int i = 0; i < argc; ++i) {
// argv[0] is the executable name. The OS may have filled in the full path
}
LocalFree(argv);
If you use main / wmain, the C runtime does the same thing for you at startup. Note, however, that argv in main is a narrow string converted to the current code page, so characters the code page cannot represent (for example a Japanese path on a PC outside a Japanese environment) are lost here. The builder function of chapter 6 “round-trips” against receivers that split wide characters as they are, such as wmain, CommandLineToArgvW, and .NET. By default wildcards are not expanded, but linking setargv.obj (wsetargv.obj for wmain) makes it expand * and ?.4 If you pass an argument containing * in a file name to a target with that setting, the arguments that arrive differ from what you intended.
9. When cmd.exe and Batch Files Get in the Way
The rules so far apply when the string goes directly from CreateProcess to the target exe. When cmd.exe sits in between, one more stage of interpretation is added.
cmd.exe treats &, |, (, and ) as syntax, and to pass them as arguments you must escape them with ^ or wrap them in quotes. The handling of quotes in the string following /c or /k has its own rules, and whether “the outer quotes are stripped” changes with the presence of /s, the number of quotes, and the presence of special characters.17 Furthermore, a batch file receives the arguments not split but as a raw command-line string. The official PowerShell documentation clearly warns against passing untrusted input to batch files.7 The CreateProcess documentation says that to launch a batch file you specify cmd.exe in lpApplicationName and pass /c plus the batch name, and then notes that the MSRC engineering team does not recommend this, with a link to the MS14-019 write-up.1 What MS14-019 fixed was the problem that, when a batch file was passed directly to CreateProcess, cmd.exe was searched for in the current directory first and could be hijacked, and the MSRC recommendation is to “pass the fully qualified path of cmd.exe and make the batch file its argument”.18 In other words, the problem is launching a batch file without naming cmd.exe by its full path (setting lpApplicationName to NULL and letting the batch name start it), not the /c launch itself with the full path of cmd.exe in lpApplicationName.
flowchart TB
accTitle: cmd.exe in between adds stages of interpretation
accDescr: Launching the target exe directly means splitting happens once, in the target's parser, but going through cmd.exe /c adds cmd.exe's syntax interpretation, and a batch file on top of that receives the raw string, so the quoting rules change at every stage
direct["Your process → the target exe"] --> p1["Split once, by the target's parser only"]
p1 ~~~ via
via["Your process → cmd.exe /c → the target exe"] --> p2["cmd.exe's syntax interpretation is added (ampersand, pipe, parentheses, caret)"]
p2 --> p3["Split by the target's parser"]
p3 ~~~ bat
bat["Your process → cmd.exe /c → a batch file"] --> p4["The batch file receives the raw string"]
p4 --> danger["Passing untrusted values through it becomes command injection"]
Figure 12: The more stages, the more the rules get mixed. Launch directly whatever can be launched directly, and never pass values that came from outside to a batch file.
The practical decision is simple. If the target is an exe, do not put cmd.exe in between. If you have no choice but to call a .bat, the principle is not to let the batch interpret values that came from outside. Write the values to a file, have the batch pass only that file’s path, as a fixed string, to the downstream exe, and read the file’s contents on the exe side. Putting the value in an environment variable is not a boundary, because the moment the batch expands it as %VAR%, & and | are reinterpreted by cmd.exe. Passing through an environment variable is acceptable only when the downstream exe reads the variable directly without going through the batch. If even that is difficult, move the batch’s contents to PowerShell or your own exe (“Should That Batch File Move to PowerShell?”).
10. Length Limits
The limits also differ by route.
| Route | Limit | Source |
|---|---|---|
lpCommandLine of CreateProcess |
32,767 UTF-16 code units (including the terminating null; a surrogate pair counts as two) | 1 |
The executable-name part when lpApplicationName is NULL |
MAX_PATH |
1 |
| The cmd.exe command line (including lines in a batch file) | 8,191 characters | 8 |
.NET’s ProcessStartInfo.Arguments |
String length (UTF-16 code units) under 32,699 | 16 |
A design that lays out variable-length values such as a file list as arguments hits the limit on the day the count grows. For uses that approach the limit, switch to the “response file” method: write the arguments to a single file and pass only that file’s path. The official workaround for the cmd.exe limit is the same method.8 Neither CreateProcess nor cmd.exe, however, expands the file for you. This method works only if the target program can read a response file with a syntax such as @file, or if you can fix the target so that it can. If the target is an off-the-shelf exe you cannot modify, the only option is to split the calls so that each fits within the limit.
flowchart TB
accTitle: The limits of passing variable-length values as arguments, and the way around them
accDescr: Laying out variable-length values such as a file list as arguments reaches the cmd.exe limit of 8191 characters or the CreateProcess limit of 32767 UTF-16 code units as the count grows. If the target can read a response file (or can be fixed to), switch to the response-file method of writing the values to a file and passing only the path; if the target is an off-the-shelf exe that cannot, split the calls
list["Lay out variable-length values (a file list, etc.) as arguments"] --> grow["The string grows as the count grows"]
grow --> lim["The limit is reached (cmd.exe 8,191 / CreateProcess 32,767)"]
lim --> fail["One day the launch suddenly fails"]
fail -.->|"The target can read a response file"| resp["Write the values to a file and pass only the path (response file)"]
fail -.->|"An off-the-shelf exe that cannot"| split["Split the calls"]
Figure 13: The limit is the kind of problem that is “fine today”. For arguments that grow in proportion to the count, if the target can read a response file (or can be fixed to), do it that way from the start.
11. Check What Actually Arrived
Before adding quoting by guesswork, the shortest route is to look at the arguments that reached the target. There are three things to look at, “the string built on the calling side”, “the string that reached the target side”, and “the array after splitting”, and four means of doing so. Before that, one promise. Whichever means you use, redact secrets before recording a command line in a log. If the design puts passwords, API keys, or tokens in the arguments, writing them as is leaves the secrets in the log, whether it is the caller’s log or the target’s startup log. Logs are kept longer than the process and seen by more people. In the first place, a command line can be read by other processes on the same machine, as with Process Explorer described later, so the fundamental countermeasure is a design that passes passwords and tokens not as arguments but through another route such as standard input or a protected configuration store; redaction in logs is a safeguard on top of that. Either interpret the split arguments (on the calling side, the elements before building) and redact the values of options that could be secret before recording, or enable recording of the raw string only in a restricted diagnostic mode.
- On the calling side, log the string you built. This is the
lpCommandLineimmediately before it is passed toCreateProcess. This comparison assumes a launch withUseShellExecute = falseor a direct call toCreateProcess. When you open a document or a URL withUseShellExecute = true, the file association builds the actual command line viaShellExecuteEx(chapter 7), so the caller’s string and the target’s string differ even without cmd.exe or a batch file, and that is not the problem of chapter 9. If you use .NET’sArgumentList, recording the list of elements as is cannot be used for the comparison. The elements are the values before quoting and before trailing backslashes are doubled, and what reaches the OS is the string .NET formatted from them. Either reconstruct a single string from the elements with the same rules asBuildArgumentsin chapter 7 and record that (it gives the same result as the formattingArgumentListdoes internally), or compare the list of elements directly against the array after splitting. This is the only means of seeing “the caller’s original buffer”; Process Explorer and the target’s log, described below, show only the string that a cmd.exe or batch stage in between rebuilt. When recording, keep the promise from the opening and redact the values of elements that could be secret (a redacted element no longer matches the target’s string, so exclude that element from the comparison). - Prepare an exe that only displays its arguments. Launch it in place of the target exe and have it print the
argsit received, one per line. If you write the values as they are, an argument containing newlines or control characters can appear as multiple lines or overwrite neighboring lines and you miscount, so print each value escaped as a JSON string together with its length (the escaping is reversible, so the original value can be recovered). Remember, though, that as chapter 3 explains there are three lineages of parser, and they interpret edge forms such as two consecutive quotes inside quoting differently. Use a display exe built with the same runtime as the target (C++ withwmainif the target is MSVC C/C++, .NET if it is .NET). If the target is your own program, the most reliable approach is to skip the display exe and logargvat the target’s own startup (under the redaction rule in the next item). For .NET, the following few lines are enough.
using System.Text.Encodings.Web;
using System.Text.Json;
// Escape newlines, control characters, quotes, and backslashes; output Japanese text as is
var json = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
Console.WriteLine("CommandLine: " + JsonSerializer.Serialize(Environment.CommandLine, json)); // the single string
for (int i = 0; i < args.Length; i++)
Console.WriteLine($"[{i}] len={args[i].Length} {JsonSerializer.Serialize(args[i], json)}");
// After splitting. Each entry always fits on one line, and an empty string shows as len=0 and "". len is in UTF-16 code units
- Look at the child process’s command line in Process Explorer. The process properties show the command-line string the child process holds. This is a means of checking “the string that reached the target side”; it does not tell you “the array after splitting”. What is shown is the string held on the child process side, so as touched on in chapter 2 the OS may have filled in the full path for the leading executable name, and if cmd.exe or a batch file is in between, what you see is the string cmd.exe rebuilt. The key points are not to panic over a difference in the leading token alone, and that the caller’s original string can be known only from the log in item 1. Usage is covered in “Process Explorer / Handle / VMMap in Practice”.
- At your own app’s startup, log the command line it received. When someone in the field says “it will not start”, having a record of the string it was started with lets you isolate first whether it is an argument problem. Here too, do not save the return value of
GetCommandLineWas is. Keep the promise from the opening: either interpret the split arguments and redact values that could be secret before recording, or enable recording of the raw string only in a restricted diagnostic mode.
The order of comparison is as follows. First compare the caller’s string (item 1) with the target’s string (item 3 or 4). If they do not match apart from the leading executable name, a stage in between has transformed it: cmd.exe or a batch file for a direct launch (chapter 9), or the shell’s file association for UseShellExecute = true (chapter 7). Replacing your code with the function of chapter 6 will not fix that. If they match, compare that string with the array after splitting (item 2). If it is split according to the rules but is not the array you want, the problem is on the building side; if it is not split according to the rules, the problem is the receiver’s parser.
flowchart TB
accTitle: The order for isolating an argument problem
accDescr: First compare the log of the string built on the calling side with the target's string as seen in Process Explorer or the target's startup log. If they do not match apart from the leading executable name, a stage in between (cmd.exe or a batch file for a direct launch, the shell's file association for UseShellExecute=true) has transformed it. If they match, compare with the array after splitting; if it is split by the rules but is not the array you want, the problem is on the building side, and if it is not split by the rules, the problem is the receiver's parser
s["The arguments are wrong"] --> caller["Look at the string built on the calling side (the caller's log)"]
caller --> target["Look at the target's string (Process Explorer / the target's startup log)"]
target --> same{"Do they match apart from the leading executable name?"}
same -->|"No"| mid["A stage in between transformed it (see chapters 9 and 7)"]
same -->|"Yes"| arr["Look at the array after splitting (a display exe on the same runtime as the target)"]
arr --> cmp{"Do the string and the array correspond by the rules?"}
cmp -->|"Yes: split, but not the array you want"| build["A building-side problem: replace with the function of chapter 6"]
cmp -->|"No: not split by the rules"| recv["A problem in the receiver's parser"]
Figure 14: Compare the three things in order, “the caller’s string”, “the target’s string”, and “the array”, and it is decided mechanically whether the responsibility lies with a stage in between, the building side, or the receiving side. Adding escapes by guesswork can wait until after this check.
12. A Rough Guide (Decision Table)
| Situation | What to do |
|---|---|
| Launching an exe from .NET Core 2.1 or later / .NET 5 or later | Add to ProcessStartInfo.ArgumentList one element at a time |
| Launching an exe from .NET Framework | Build Arguments with a function following the rules of chapter 6. Never hand-write quotes |
| Launching from C++ | Pass lpApplicationName, and build lpCommandLine by the rules in a writable buffer |
| You want a quote inside an argument value | Use only the \" form. Never place adjacent quotes inside a non-empty argument |
| The path ends in a backslash | If you wrap it, double the trailing backslash. If there are no spaces, do not wrap it |
| You want to pass an empty argument | Put "". If you omit it, the whole argument disappears |
| The executable’s path contains a space | Pass lpApplicationName, and wrap the leading token in quotes too |
You have no choice but to call a .bat |
Do not let the batch interpret values that came from outside. Write them to a file and have the downstream exe read it (an environment variable expanded as %VAR% inside the batch is not a boundary) |
| The arguments get long | If the target can read a response file (or can be fixed to), switch to a response file. For an off-the-shelf exe, split the calls |
| You do not know what is arriving | Compare the three in order: the caller’s log, the target’s string (Process Explorer / the startup log), and the array after splitting (a display exe on the same runtime as the target) |
13. Summary
Windows command-line arguments cross the boundary not as an array but as a single string. The caller concatenates, the receiver splits, and the splitting rules boil down to three: “split on spaces”, “wrap in quotes”, and “only backslashes immediately before a quote are special”. Only the leading executable name follows a different rule, and omitting lpApplicationName makes the interpretation of a path containing spaces ambiguous.
What the building side has to do fits in one function, and on .NET Core 2.1 or later ArgumentList takes care of it. For the executable, pass the full path in lpApplicationName and also place the same path, wrapped in quotes, at the start of lpCommandLine (in .NET, leave it to FileName). Never generate the form with adjacent quotes inside a non-empty argument (the "" that represents an empty argument is different), never pass values that came from outside through cmd.exe or a batch file, and for arguments that grow in proportion to the count, use a response file only when the target can read one (or can be fixed to read one), and otherwise split the calls. Keep these five points and the failures “it will not start only on a PC with a space in the path” and “the next argument disappears because of a trailing backslash” never happen.
flowchart TB
accTitle: Five promises that prevent argument failures
accDescr: Pass the executable's full path in lpApplicationName and wrap the leading token in quotes too, leave quoting to a function that follows the rules or to ArgumentList, never generate the form with adjacent quotes inside a non-empty argument, never pass values from outside through cmd.exe or a batch file, and use a response file for arguments that grow with the count only when the target can read one. Assuming the target interprets by the published splitting rules and has not enabled wildcard expansion, these five points prevent the failures caused by paths with spaces and trailing backslashes
r0["Pass the full path in lpApplicationName and quote the leading token too"]
r1["Leave quoting to a rule-following function or ArgumentList"]
r2["Never generate adjacent quotes inside quoting"]
r3["Never pass values from outside through cmd.exe or a batch file"]
r4["Use a response file for growing arguments (when the target can read one)"]
goal["No failures from spaces or trailing backslashes"]
r0 ~~~ r1 ~~~ r2 ~~~ r3 ~~~ r4
r0 --> goal
r1 --> goal
r2 --> goal
r3 --> goal
r4 --> goal
Figure 15: Each of the five promises is a rephrasing of “fix the module to execute, and pass only strings the target’s parser can split”. The premise is that the target interprets by the published splitting rules and has not enabled wildcard expansion (chapters 6 and 8); on top of that, these five points prevent the failures caused by spaces and trailing backslashes.
When things do not work, before adding escapes by guesswork, look at the three things: the string built on the calling side, the string that reached the target side, and the array after splitting. If the caller’s and the target’s strings differ, a stage in between is responsible (cmd.exe or a batch file, or the shell’s file association for UseShellExecute = true); if they are the same, the correspondence between the string and the array decides whether it is the building side or the receiving side.
Related Articles
- Calling External EXEs Correctly from PowerShell — The Pitfalls of Argument Quoting, Exit Codes, and Mojibake
- A Checklist for Safely Handling Child Processes in Windows Apps — Best Practices for Job Objects, Exit Propagation, Standard I/O, and Watchdogs
- What Remains After the Parent Dies — Keeping Child Processes in a Job Object
- MAX_PATH and Windows Path/Filename Pitfalls — the 260-Character Limit, Reserved Names, Trailing Dots, and Case Sensitivity
- Windows Shell Integration Today — Context Menus, File Associations, and What Changed in Windows 11
- Preventing Multiple Instances of a Windows App — Named Mutexes and Activating the Existing Window on a Second Launch
- Safely Calling Win32 APIs from C# — A Practical P/Invoke Guide (DllImport / LibraryImport / CsWin32)
- Should That Batch File Move to PowerShell? — Auditing cmd/bat Assets and Making the Migration Call
Related Consulting Areas
KomuraSoft LLC handles the design of Windows apps that combine external tools and in-house EXEs, root-cause investigation of child-process launches that “start on some environments and not on others”, and reviews of process-launching code as part of migrating from .NET Framework to .NET. Feel free to contact us even about a single case of “the arguments get mangled”.
- Windows App Development
- Bug Investigation and Root Cause Analysis
- Legacy Asset Migration and Reuse
- Technical Consulting and Design Review
- Contact
References
-
Microsoft Learn, CreateProcessW function (processthreadsapi.h). On
lpCommandLinebeing a single string of at most 32,767 characters (including the terminating null; UTF-16 code units, since it is a wide string), the Unicode version possibly modifying its contents so that read-only memory cannot be passed, the leading space-delimited token becoming the module name whenlpApplicationNameisNULLwith a path containing spaces being interpreted starting fromc:\program.exe, the danger of a different executable running ifProgram.exeis placed there and the need to avoidNULLor wrap the path in quotes,argv[0]possibly not matching the module name when both are specified, the module-name part being limited toMAX_PATHwhenNULL, and cmd.exe /c being required to launch a batch file. See also the note in CreateProcessA function that the MSRC engineering team does not recommend this method (with a link to the MS14-019 write-up). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 -
Microsoft Learn, GetCommandLineW function (processenv.h). On its returning the command-line string of the current process, the return value not to be freed or modified, its convertibility into argv form via
CommandLineToArgvW, and its possibly not matching the string the parent passed toCreateProcessbecause the OS fills in the full path of the executable name. ↩ ↩2 -
Microsoft Learn, CommandLineToArgvW function (shellapi.h). On the special handling of backslashes immediately before a double quote (2n gives n plus opening or closing quoting, 2n+1 gives n plus a literal quote, and they stay as is when no quote follows), spaces becoming part of the argument in “in quotes” mode, the leading program name being allowed with or without quotes, the first argument becoming an empty string when
lpCmdLinestarts with a space, the path of the current executable being returned when an empty string is passed, and the return value being freed with a singleLocalFree. ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn,
mainfunction and command-line arguments. On the rules by which the Microsoft C/C++ startup code interprets the command line (separation by spaces and tabs,argv[0]being quotable but not subject to the following rules, a quoted string being one argument, the caret not being an escape character, two consecutive quotes inside quotes being one quote, everything up to the end being the last argument when there is no closing quote, and the handling of even and odd numbers of backslashes), the table of inputs andargv, wildcard expansion withsetargv.obj, andargv[0]possibly not being the executable name when bothlpApplicationNameandlpCommandLineare specified, so that it should be obtained withGetModuleFileName. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 -
Microsoft Learn, ProcessStartInfo.ArgumentList Property. On the added strings needing no escaping in advance,
ArgumentListandArgumentsbeing independent and not usable at the same time,ArgumentListescaping the arguments and building a single string internally that is passed to the OS atProcess.Start,ArgumentListbeing the choice if you are not confident about quoting, the danger of combining it with untrusted data, and its applying to .NET Core 2.1 and later. ↩ ↩2 ↩3 ↩4 -
dotnet/runtime (GitHub), PasteArguments.cs and PasteArguments.Windows.cs. The builder code used inside
ArgumentList. On placing a non-empty argument with neither spaces nor quotes as is, otherwise wrapping it in quotes, doubling trailing backslashes, making backslashes before a quote doubled plus one, always prefixing quotes with a backslash, not generating the form of a quote following a closing quote because VC before and after 2008 interpret it differently, and forargv[0]only wrapping it in quotes if it has spaces and throwing if it contains a quote. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
Microsoft Learn, about_Parsing. On arguments to a batch file being passed to cmd.exe as a raw command-line string, and the warning against passing untrusted input. ↩ ↩2
-
Microsoft Learn, Command prompt (Cmd.exe) command-line string limitation. On the maximum length of a string usable at the command prompt being 8,191 characters, its also applying to command lines inside batch files, and the workaround of writing the arguments to a file and passing that file name. ↩ ↩2 ↩3
-
Microsoft Learn, WinMain function (winbase.h). On
lpCmdLinebeing the command line without the program name, the whole command line being obtained withGetCommandLine, andwWinMainexisting as the Unicode entry point. ↩ -
dotnet/runtime (GitHub), apphost.c and dotnet.cpp. On the entry points of the apphost and
dotnet.exebeingwmain(int argc, wchar_t* argv[])on Windows and passing theargvbuilt by the C runtime straight to the host’s startup processing. ↩ -
dotnet/runtime (GitHub), corhost.cpp. On
ExecuteAssemblybuilding theEnvironment.GetCommandLineArgs()array withSetCommandLineArgs(pwzAssemblyPath, argc, argv), the first element being the launch name passed from the host (or the assembly path if none) followed byargv, and only thatargvbeing passed toMain. ↩ ↩2 -
dotnet/runtime (GitHub), Environment.cs and Environment.Windows.cs. On
GetCommandLineArgsreturning the array initialized at startup (s_commandLineArgs), a hosted library without it falling back to splitting the return value ofGetCommandLineWwith the runtime’s ownSegmentCommandLine, those rules following the documentation of MSVC’smainfunction, andCommandLineToArgvWnot being used because its behavior differs slightly. ↩ ↩2 ↩3 -
Microsoft Learn, Main() and command-line arguments. On
argsinMainnever being null and, unlike C/C++, the program name not being included at the start ofargsbut being the first element ofGetCommandLineArgs(). ↩ -
Microsoft Learn, dotnet command. On running an app taking the form
dotnet [runtime options] <app path> [arguments], where everything after the app path is the arguments passed to the app. ↩ -
Microsoft Learn, Environment.GetCommandLineArgs Method. On the first element being the executable name, arguments being separated by spaces with double quotes allowing spaces inside, single quotes not having that function, the rules for even and odd numbers of backslashes and quotes, and the table of inputs and results. ↩
-
Microsoft Learn, ProcessStartInfo.Arguments Property. On the string length being under 32,699, the arguments being interpreted by the target application so that they must match its expectations, the quotes themselves not being passed to the target when an argument containing spaces is quoted, and its independence from
ArgumentList. ↩ ↩2 -
Microsoft Learn, cmd. On
&,|, and( )being special characters that require^or quotes, the list of special characters that should be wrapped in quotes, the conditions under which quotes are preserved with/cor/k(no/s, exactly one pair of quotes, no special characters, containing spaces, and being an executable name), and how the leading quote is stripped when the conditions are not met. ↩ -
Microsoft Security Response Center, MS14-019 – Fixing a binary hijacking via .cmd or .bat file and Microsoft Security Bulletin MS14-019. On
CreateProcesshaving searched for cmd.exe in the current directory first when passed a .cmd / .bat directly, which allowed hijacking, the fix always using the system’s cmd.exe, and the recommendation that applications pass the fully qualified path of cmd.exe with the batch file as an argument. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
What Is Left After the Parent Dies — Keeping Child Processes in a Job Object
Why do SDK helpers outlive a killed UI and keep the camera or COM port? Designing child process lifetime with Job Objects, KillOnJobClose...
Named Pipes in Practice — Windows' Standard IPC from Design to Security
A practical guide to named pipes, Windows' standard IPC. Covers byte vs. message mode, servers that handle multiple clients, ACL and impe...
A Checklist for Safely Handling Child Processes in Windows Apps
Handling child processes safely in a Windows app depends less on the launch API than on who owns the process tree and how shutdown is des...
Time Travel Debugging — Recording and Rewinding the Bugs That Never Reproduce in Long-Running Apps
A once-a-month bug leaves only its result in a crash dump. Record and rewind execution with WinDbg Time Travel Debugging (TTD): TTD.exe, ...
End of Servicing for Windows Printer Drivers — How Business Apps Should Prepare Their Report and Label Printing
Microsoft is phasing out v3/v4 printer drivers. What Windows protected print mode removes, and how to inventory and prepare report and la...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Frequently Asked Questions
Common questions about the topic of this article.
- Is there no API on Windows that passes an array of arguments?
- No. What CreateProcess receives is a single string called lpCommandLine, and that string is what reaches the new process (the OS may fill in the full path for the leading executable name only). What looks like an argv array is created inside the receiving process by the C runtime startup code, CommandLineToArgvW, or the .NET runtime splitting the string. Passing arguments is therefore the same thing as building a string that the other side's parser will split back into the original pieces.
- When does a backslash become an escape character?
- Only when a double quote follows it immediately. A backslash that is not followed by a double quote stays as is, no matter how many are in a row. If 2n backslashes precede a double quote, they become n backslashes and the quote opens or closes quoting; if 2n+1 precede it, they become n backslashes and a literal quote character. Because of this asymmetry, you have to double a trailing backslash in a path only when you wrap the path in quotes.
- Which should I use, ProcessStartInfo.ArgumentList or Arguments?
- If the values come from variables, ArgumentList. One element becomes one argument, .NET applies the necessary quoting and escaping, and it builds a single string internally before handing it to the OS. Arguments is a property that passes a string you built yourself as is; the two are independent and cannot be used at the same time. Note that ArgumentList is an API introduced in .NET Core 2.1 and does not exist on .NET Framework. On .NET Framework, build Arguments with the builder function in this article.
- Can I write two adjacent quotes inside a quoted argument?
- Do not generate it on the building side, because receivers interpret it differently. What is meant here is wrapping a non-empty argument in quotes and placing two adjacent quotes inside it. The "" that represents an empty argument (just two quotes) is a different thing, and is the correct way to pass an empty string. Under the rules of the MSVC C runtime, two consecutive quotes inside a quoted string are treated as one quote, but the official rules for CommandLineToArgvW do not describe this handling, and the .NET runtime source explicitly says it does not generate the form because VC before and after 2008 interpret it differently. When you want to pass a quote as a character, put a backslash in front of it, and every parser gives the same result.
- When the executable's path contains a space, what should I pass to CreateProcess to be safe?
- The reliable way is to pass the full path of the executable in lpApplicationName and also place the same path, wrapped in quotes, at the start of lpCommandLine. If lpApplicationName is NULL, CreateProcess guesses the executable name from the start of lpCommandLine, splitting on spaces. For the string C:\Program Files\MyApp -L -S it first tests whether C:\Program.exe exists, so if a malicious file is there, that one runs. The official documentation states this danger explicitly and asks you to avoid NULL or to wrap the path in quotes.
- Do the same rules apply when passing arguments to a batch file?
- No. A batch file is interpreted by cmd.exe, and cmd.exe treats the command line as a raw string without splitting it into arguments. Symbols such as &, |, parentheses, and ^ act as cmd.exe syntax, so quoting by the CommandLineToArgvW rules does not make them safe. The official documentation warns against passing untrusted input to batch files. Write the values to a file and have the downstream exe, not the batch, read it, or move the batch's contents to PowerShell or your own exe. Putting the value in an environment variable is not a boundary either, because once the batch expands it as %VAR%, cmd.exe reinterprets the symbols.