Why Arguments Break — The Rules of Windows Command-Line Arguments

· Updated: · · Windows, Windows Development, C#, C++, Win32 API, .NET, Process

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.

The layer this article coversPowerShell 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 parserScope of this articlePowerShell argument passing (separate article).NET ProcessStartInfoWin32 CreateProcessWA single command-line stringThe target exe's 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 CreateProcess reaches the new process (the OS may fill in the full path for the leading executable name only), and GetCommandLineW returns it. argv is 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. If lpApplicationName is NULL, the interpretation of a path containing spaces becomes ambiguous and C:\Program.exe is 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.ArgumentList in .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

The path arguments take to reach the child processThe 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 argvThe caller's argument arrayConcatenated into one string (the caller's responsibility)CreateProcessW's lpCommandLineThe new process's process parametersThe string GetCommandLineW returnsThe receiving side's parser splits itThe 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.

The three parsers on the receiving sideThe 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 codeThe GetCommandLineW stringC runtime startup codeCommandLineToArgvW.NET's own splitting code (when loaded by a host).NET via apphost / dotnet.exe is the sameSame skeleton of rules, separate implementations

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

  1. Arguments are separated by spaces or tabs.
  2. 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.
  3. A backslash is treated as an ordinary character. Only when a double quote follows it immediately do the next rules apply.
  4. If 2n backslashes precede a double quote, n backslashes are output, and the quote acts as the “start or end of quoting”.
  5. If 2n+1 backslashes precede a double quote, n backslashes and a literal quote are output, and the quoting state does not change.
  6. 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.

The splitting flow that toggles between inside and outside quotesOutside 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 itA quote is encounteredA quote is encounteredA backslash is immediately followed by a quoteA backslash is immediately followed by a quote2n: output n and open/close2n+1: output n and a literal quoteOutside quotes: split on spacesInside quotes: spaces are part of the argumentApply the backslash ruleFlip the quoting stateKeep 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.

How a trailing backslash swallows the next argumentWhen 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 argumentDouble the backslashA quoted path ending in one backslashAn odd number of backslashes precede the closing quoteThe quote is output as a character and quoting does not closeSubsequent spaces no longer separateEverything up to the next argument arrives as one argumentQuoting 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

The order in which the executable is guessed when lpApplicationName is NULLIf 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 runsExistsDoes not existPass lpApplicationName, or quote the leading tokenPass an unquoted path (containing spaces) in lpCommandLineCandidate 1: try C:\Program.exeAn unintended executable startsCandidate 2: try C:\Program Files\MyApp.exeThe intended executable starts

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

How the executed module and argv[0] are decidedWhen 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 GetModuleFileNameWBreaks when they divergeUse insteadlpApplicationNameThe module that runsThe leading token of lpCommandLineThe child's argv[0]Code that derives its own path from argv[0]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

  1. If it is not empty and contains neither spaces nor quotes, place it as is.
  2. 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.
  3. Place an empty string as "".
The decision flow for building one argumentIf 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 closeNoYesReceive one argumentEmpty, or contains a space or a quote?Place it as isOpening quoteScan from the leftk backslashes before a quote → 2k+1k trailing backslashes → 2kEverything else as isClosing quote

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

Where ArgumentList and Arguments become a stringWith 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 stringArgumentList (1 element = 1 argument).NET escapes each element and concatenatesArguments (a single string you built yourself)As isA single command-line stringCreateProcess

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.

Choosing the building method by .NET versionOn .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 concatenationCore 2.1 or laterFrameworkWhich .NET version?Add to ArgumentList one element at a timeBuild Arguments with your own functionNever hand-write quotes

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
The division of roles between the two arguments passed to CreateProcessWlpApplicationName 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 lpApplicationNameKeep consistentlpApplicationName: the executable's full pathThe module to execute is fixedlpCommandLine: a writable bufferThe string the child receives through GetCommandLineWLeading token = argv[0]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.

cmd.exe in between adds stages of interpretationLaunching 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 stageYour process → the target exeSplit once, by the target's parser onlyYour process → cmd.exe /c → the target execmd.exe's syntax interpretation is added (ampersand, pipe, parentheses, caret)Split by the target's parserYour process → cmd.exe /c → a batch fileThe batch file receives the raw stringPassing 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.

The limits of passing variable-length values as arguments, and the way around themLaying 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 callsThe target can read a response fileAn off-the-shelf exe that cannotLay out variable-length values (a file list, etc.) as argumentsThe string grows as the count growsThe limit is reached (cmd.exe 8,191 / CreateProcess 32,767)One day the launch suddenly failsWrite the values to a file and pass only the path (response file)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.

  1. On the calling side, log the string you built. This is the lpCommandLine immediately before it is passed to CreateProcess. This comparison assumes a launch with UseShellExecute = false or a direct call to CreateProcess. When you open a document or a URL with UseShellExecute = true, the file association builds the actual command line via ShellExecuteEx (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’s ArgumentList, 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 as BuildArguments in chapter 7 and record that (it gives the same result as the formatting ArgumentList does 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).
  2. Prepare an exe that only displays its arguments. Launch it in place of the target exe and have it print the args it 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++ with wmain if 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 log argv at 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
  1. 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”.
  2. 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 GetCommandLineW as 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.

The order for isolating an argument problemFirst 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 parserNoYesYes: split, but not the array you wantNo: not split by the rulesThe arguments are wrongLook at the string built on the calling side (the caller's log)Look at the target's string (Process Explorer / the target's startup log)Do they match apart from the leading executable name?A stage in between transformed it (see chapters 9 and 7)Look at the array after splitting (a display exe on the same runtime as the target)Do the string and the array correspond by the rules?A building-side problem: replace with the function of chapter 6A 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.

Five promises that prevent argument failuresPass 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 backslashesPass the full path in lpApplicationName and quote the leading token tooLeave quoting to a rule-following function or ArgumentListNever generate adjacent quotes inside quotingNever pass values from outside through cmd.exe or a batch fileUse a response file for growing arguments (when the target can read one)No failures from spaces or trailing backslashes

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.

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”.

References

  1. Microsoft Learn, CreateProcessW function (processthreadsapi.h). On lpCommandLine being 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 when lpApplicationName is NULL with a path containing spaces being interpreted starting from c:\program.exe, the danger of a different executable running if Program.exe is placed there and the need to avoid NULL or wrap the path in quotes, argv[0] possibly not matching the module name when both are specified, the module-name part being limited to MAX_PATH when NULL, 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

  2. 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 to CreateProcess because the OS fills in the full path of the executable name.  2

  3. 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 lpCmdLine starts 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 single LocalFree 2 3 4 5

  4. Microsoft Learn, main function 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 and argv, wildcard expansion with setargv.obj, and argv[0] possibly not being the executable name when both lpApplicationName and lpCommandLine are specified, so that it should be obtained with GetModuleFileName 2 3 4 5 6 7 8

  5. Microsoft Learn, ProcessStartInfo.ArgumentList Property. On the added strings needing no escaping in advance, ArgumentList and Arguments being independent and not usable at the same time, ArgumentList escaping the arguments and building a single string internally that is passed to the OS at Process.Start, ArgumentList being 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

  6. 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 for argv[0] only wrapping it in quotes if it has spaces and throwing if it contains a quote.  2 3 4 5 6

  7. 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

  8. 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

  9. Microsoft Learn, WinMain function (winbase.h). On lpCmdLine being the command line without the program name, the whole command line being obtained with GetCommandLine, and wWinMain existing as the Unicode entry point. 

  10. dotnet/runtime (GitHub), apphost.c and dotnet.cpp. On the entry points of the apphost and dotnet.exe being wmain(int argc, wchar_t* argv[]) on Windows and passing the argv built by the C runtime straight to the host’s startup processing. 

  11. dotnet/runtime (GitHub), corhost.cpp. On ExecuteAssembly building the Environment.GetCommandLineArgs() array with SetCommandLineArgs(pwzAssemblyPath, argc, argv), the first element being the launch name passed from the host (or the assembly path if none) followed by argv, and only that argv being passed to Main 2

  12. dotnet/runtime (GitHub), Environment.cs and Environment.Windows.cs. On GetCommandLineArgs returning the array initialized at startup (s_commandLineArgs), a hosted library without it falling back to splitting the return value of GetCommandLineW with the runtime’s own SegmentCommandLine, those rules following the documentation of MSVC’s main function, and CommandLineToArgvW not being used because its behavior differs slightly.  2 3

  13. Microsoft Learn, Main() and command-line arguments. On args in Main never being null and, unlike C/C++, the program name not being included at the start of args but being the first element of GetCommandLineArgs()

  14. 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. 

  15. 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. 

  16. 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

  17. 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 /c or /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. 

  18. Microsoft Security Response Center, MS14-019 – Fixing a binary hijacking via .cmd or .bat file and Microsoft Security Bulletin MS14-019. On CreateProcess having 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. 

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

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.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog