「テストでは通ったのに、パスに空白が入っているPCで外部ツールが起動しない」「C:\data\ を渡したら、次の引数までまとめて1つになった」「JSONを引数で渡したら引用符が消えて相手がパースに失敗した」── 子プロセスを起動するコードで繰り返し起きる事故です。原因のほとんどはロジックではなく、Windowsには「引数の配列」を渡す仕組みが存在しないことを前提にしていない書き方にあります。
Windowsでプロセスを作る CreateProcess が受け取るのは、lpCommandLine という1本の文字列です。呼び出し側がどれだけ丁寧に配列を用意しても、OSの境界を越えるときには必ず1本に連結され、受け取った側がもう一度分割します。分割の規則は受け取り側のランタイムが決めるもので、Cランタイム、CommandLineToArgvW、.NETランタイム、cmd.exe はそれぞれ別のコードです。引数を渡すとは、相手のパーサが元どおりに割ってくれる文字列を組み立てることです。
この記事では、PowerShellのスクリプトではなく Win32 と .NET のコードから子プロセスを起動する立場で、文字列がどこで連結され、どこで分割され、どの規則に従うのかを整理します。PowerShell側の事情(7.3での引数渡しの変更、--%、$PSNativeCommandArgumentPassing)は「PowerShellから外部exeを正しく呼ぶ」で扱っているので、この記事ではその下の層を掘ります。
flowchart TB
accTitle: この記事が扱う層
accDescr: PowerShellの引数渡しは別記事で扱い、この記事はその下にあるWin32のCreateProcessと.NETのProcessStartInfoから相手のexeのパーサまでの層を扱う
ps["PowerShell の引数渡し(別記事)"] --> net[".NET の ProcessStartInfo"]
net --> win["Win32 の CreateProcessW"]
win --> str["1本のコマンドライン文字列"]
str --> parser["相手の exe のパーサ"]
net -.->|"この記事の範囲"| parser
図1: PowerShellの下には.NETとWin32の層があり、どちらから起動しても最後は1本の文字列になる。この記事はその層の規則を扱う。
1. まず結論
- Windowsのプロセスには引数の配列は渡らない。
CreateProcessに渡した1本の文字列が新しいプロセスに届き(先頭の実行ファイル名だけはOSがフルパスを補うことがある)、GetCommandLineWがそれを返す。argvは受け取り側が自分で作る。1 2 - 分割規則の本体は3つ。空白とタブで区切る、二重引用符で囲んだ範囲は区切らない、バックスラッシュは直後に二重引用符が来るときだけ特別扱い(2n個ならn個+引用符の開閉、2n+1個ならn個+文字としての引用符)。3 4
- 先頭のトークン(
argv[0]、実行ファイル名)だけは別規則で、引用符で囲めるがバックスラッシュのエスケープは効かない。lpApplicationNameをNULLにすると、空白を含むパスの解釈が曖昧になり、C:\Program.exeが先に試される。1 4 - 組み立て側は「空白か引用符を含むか空文字列なら引用符で囲み、引用符の直前と末尾のバックスラッシュを2倍にし、引用符は
\"にする」の1本で足りる。.NET Core 2.1以降のProcessStartInfo.ArgumentListはこれをやってくれる。5 6 - 中身のある引数の内側で引用符を2つ隣接させる形(
"ab""c"のような書き方)は受け取り側で解釈が割れるので生成しない。空の引数を表す""は別物で、これは正しい書き方。cmd.exe とバッチファイルはこの規則の外にいるので、信頼できない値を通さない。6 7 - 上限は
lpCommandLineが32,767 UTF-16コード単位(終端のnull文字を含む。絵文字などサロゲートペアの文字は2つと数える)、cmd.exe が8,191文字。超えそうなら、相手が@fileのような応答ファイルを読める(または読めるように直せる)場合に限り、応答ファイルに切り替える。1 8
この記事の知識マップ
Windowsのプロセスには引数の配列は渡らず、CreateProcessに渡した1本のコマンドライン文字列が新しいプロセスに届き(先頭の実行ファイル名はOSがフルパスを補うことがある)、子はGetCommandLineWでそれを取り出す。argvの配列は受け取り側が自分で作るもので、Cランタイムの起動コード、CommandLineToArgvW、.NETランタイムの分割コードが、空白とタブで区切る・二重引用符で囲んだ範囲は区切らない・バックスラッシュは直後に二重引用符が来るときだけ特別扱いする(2n個ならn個と囲みの開閉、2n+1個ならn個と文字としての引用符)という同じ骨格の規則を別々に実装している。先頭のargv[0]だけは引用符で囲めるがエスケープの効かない別規則で、lpApplicationNameを省略すると空白を含むパスの解釈が曖昧になりC:\Program.exeのような別の実行ファイルが起動し得る。この規則の帰結として、末尾がバックスラッシュのパスを引用符で囲むと閉じ引用符が文字に化けて次の引数を巻き込む。組み立て側は、空白か引用符を含むか空の引数を引用符で囲み、引用符の直前のバックスラッシュを2倍+1、末尾のバックスラッシュを2倍にし、引用符にバックスラッシュを前置する1つの関数で足り、.NET Core 2.1以降のProcessStartInfo.ArgumentListがこれを内部で行う。ArgumentListとArgumentsは同時に使えず、.NET Frameworkでは同じ規則の自前関数でArgumentsを組み立てる。中身のある引数の内側で引用符を隣接させる形は受け取り側で解釈が分かれるため生成しない(空の引数を表す引用符2つだけの形は別で、正しい書き方)。往復が保証されるのは、受け取り側がCommandLineToArgvW・Cランタイム・.NETランタイムと同じ分割規則でワイド文字のまま分割し(独自の文法で解釈する相手や途中にシェルのパーサが挟まる場合は対象外)、setargv.obj/wsetargv.objによるワイルドカード展開を有効にしておらず、引数にNUL文字を含まず、組み立てた全体がlpCommandLineの上限に収まる場合で、狭い文字列のmainではコードページで表せない文字が失われる。cmd.exeは独自の構文と引用符の剥がし方を持ち、バッチファイルは生の文字列を受け取るため、外から来た値をバッチに渡すとコマンド注入につながる。コマンドライン長にはCreateProcessの32,767 UTF-16コード単位(終端のnull文字を含む)、cmd.exeの8,191文字などの上限があり、件数に比例して伸びる引数は、相手が読める場合に限り応答ファイルに切り替える。届いた引数は、呼び出し側で組み立てた文字列のログ、Process Explorerや相手の起動時ログで見える相手側の文字列(先頭の実行ファイル名はOSがフルパスを補うことがあり、cmd.exeが挟まればその段が作り直した文字列になる)、相手と同じランタイムで作った引数表示用exeで見る分割後の配列、の3つを順に突き合わせる。呼び出し側・起動時のどちらでコマンドラインをログに残す場合も、機密になり得る引数の値を伏せ字にしてから記録する。
flowchart LR
accTitle: Windowsのコマンドライン引数の規則の知識マップ
accDescr: CreateProcessが引数を1本のコマンドライン文字列として渡し、CommandLineToArgvW・Cランタイム・.NETランタイムがそれぞれ空白・引用符・バックスラッシュの同じ規則で分割すること、その規則が末尾バックスラッシュの巻き込みを生み、組み立て関数とProcessStartInfo.ArgumentListがそれを防ぐこと、lpApplicationNameがProgram.exe問題を防ぐこと、cmd.exeとバッチファイルが引数の変形とコマンド注入の原因になり得ること、応答ファイルが長さの上限を回避すること、引数表示用exeとProcess Explorerで届いた引数を確認できることを含む関係図
windows_command_line_string["コマンドライン文字列(lpCommandLine)"]
command_line_backslash_quote_rule["コマンドライン分割の規則(空白・引用符・バックスラッシュ)"]
argument_quoting_function["引数の引用符付け(組み立て関数)"]
processstartinfo_argumentlist["ProcessStartInfo.ArgumentList"]
createprocess["CreateProcess"]
lpapplicationname["lpApplicationName"]
getcommandlinew["GetCommandLineW"]
commandlinetoargvw["CommandLineToArgvW"]
dotnet_command_line_segmentation[".NETランタイムの引数分割"]
msvc_crt_argument_parsing["Cランタイムの引数分割(argv生成)"]
setargv_wildcard_expansion["setargv.objによるワイルドカード展開"]
argv0_program_name_rule["argv[0](プログラム名)の特別規則"]
trailing_backslash_quote_pitfall["末尾バックスラッシュによる引数の巻き込み"]
doubled_quote_form["囲みの内側の引用符2連続(非空の引数)"]
processstartinfo_arguments_string["ProcessStartInfo.Arguments(手組み文字列)"]
child_process_argument_passing["子プロセスへの引数の受け渡し"]
unquoted_space_path_ambiguity["空白を含む実行ファイルパスの曖昧さ(Program.exe問題)"]
unintended_executable_launch["意図しない実行ファイルの起動"]
bat_file["バッチファイル(bat)"]
batch_file_command_injection["バッチファイル経由のコマンド注入リスク"]
cmd_shell["コマンドシェル(cmd.exe)"]
broken_argument_delivery["引用符消失・空文字列引数の消失"]
response_file["応答ファイル"]
command_line_length_limit["コマンドライン長の上限"]
argument_echo_exe["引数表示用の確認exe"]
process_explorer["Process Explorer"]
command_line_startup_logging["コマンドラインのログ記録(呼び出し側・起動時)"]
secret_disclosure_in_logs["ログへの機密の漏えい"]
credential_in_command_line["コマンドライン引数への機密の混入"]
createprocess -.->|"利用する"| windows_command_line_string
createprocess -.->|"利用する"| lpapplicationname
windows_command_line_string -.->|"で確認できる"| getcommandlinew
commandlinetoargvw -.->|"利用する"| getcommandlinew
dotnet_command_line_segmentation -.->|"利用する"| getcommandlinew
commandlinetoargvw -->|"実装を担う"| command_line_backslash_quote_rule
msvc_crt_argument_parsing -.->|"実装を担う"| command_line_backslash_quote_rule
dotnet_command_line_segmentation -->|"実装を担う"| command_line_backslash_quote_rule
msvc_crt_argument_parsing -->|"で構成できる"| setargv_wildcard_expansion
argv0_program_name_rule -->|"前提とする"| windows_command_line_string
command_line_backslash_quote_rule -.->|"原因になり得る"| trailing_backslash_quote_pitfall
argument_quoting_function -->|"防止する"| trailing_backslash_quote_pitfall
argument_quoting_function -->|"防止する"| doubled_quote_form
processstartinfo_argumentlist -->|"実装を担う"| argument_quoting_function
processstartinfo_arguments_string -.->|"前提とする"| argument_quoting_function
processstartinfo_argumentlist -->|"両立しない"| processstartinfo_arguments_string
processstartinfo_argumentlist -.->|"推奨される対応"| child_process_argument_passing
argument_quoting_function -.->|"推奨される対応"| child_process_argument_passing
lpapplicationname -.->|"防止する"| unquoted_space_path_ambiguity
unquoted_space_path_ambiguity -.->|"原因になり得る"| unintended_executable_launch
bat_file -.->|"原因になり得る"| batch_file_command_injection
cmd_shell -.->|"原因になり得る"| broken_argument_delivery
response_file -.->|"軽減する"| command_line_length_limit
child_process_argument_passing -.->|"で確認できる"| argument_echo_exe
child_process_argument_passing -->|"で確認できる"| process_explorer
command_line_startup_logging -.->|"利用する"| getcommandlinew
command_line_startup_logging -.->|"原因になり得る"| secret_disclosure_in_logs
credential_in_command_line -.->|"原因になり得る"| secret_disclosure_in_logs
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全28件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. 引数の配列は存在しない ── CreateProcessと1本の文字列
CreateProcessW の第2引数 lpCommandLine は、実行ファイル名と引数を空白で並べた1本のnull終端文字列です。長さの上限は終端のnull文字を含めて32,767 UTF-16コード単位(wchar_t の個数。絵文字などサロゲートペアの文字は1文字で2つ消費するので、見た目の文字数で事前チェックしてはいけない)で、Unicode版はこの文字列を書き換えることがあるため、文字列リテラルや const のバッファを渡すとアクセス違反になり得ます。1
この文字列は新しいプロセスのプロセスパラメータとしてそのまま渡り、子プロセスは GetCommandLineW で取り出します。OSは先頭の実行ファイル名にフルパスを補うことがあるので、子が見る文字列は親が渡した文字列と完全には一致しません。2 GUIアプリの WinMain に渡る lpCmdLine は、この文字列からプログラム名を除いたものです。9
flowchart TB
accTitle: 引数が子プロセスに届くまでの経路
accDescr: 呼び出し側の引数の配列はCreateProcessのlpCommandLineで1本の文字列に連結されて新しいプロセスに渡り、子プロセスはGetCommandLineWで取り出した文字列を自分のパーサで分割してargvを作る
arr["呼び出し側の引数の配列"] --> join["1本の文字列に連結(呼び出し側の責任)"]
join --> cp["CreateProcessW の lpCommandLine"]
cp --> peb["新しいプロセスのプロセスパラメータ"]
peb --> gcl["GetCommandLineW が返す文字列"]
gcl --> parse["受け取り側のパーサが分割"]
parse --> argv["argv / args の配列"]
図2: 配列は境界を越えない。連結は呼び出し側、分割は受け取り側の責任で、両者の規則が一致して初めて元の配列が復元される。
ここで押さえておきたいのは、連結と分割が別のプロセス、別のコードで行われることです。呼び出し側は「相手が何で分割するか」を知らなければ正しく連結できず、受け取り側は「どう連結されたか」を知る術がありません。Unix系OSでは execve に配列をそのまま渡せるため、この問題は存在しません。Windows固有の、しかしすべてのプロセス起動に付いて回る前提です。
3. 誰が分割するのか ── 3つのパーサ
受け取り側で文字列を argv に割るコードは、主に3つあります。
| 受け取り側 | 分割するコード | 呼ばれる場面 |
|---|---|---|
C/C++ の main / wmain |
MSVC の Cランタイム起動コード | プログラム開始時に自動で argc / argv を作る4 |
| Win32 API を直接使う場合 | CommandLineToArgvW |
GetCommandLineW の戻り値を渡して argv 形式に変換する3 |
.NET の Main(string[] args) / Environment.GetCommandLineArgs()(apphost / dotnet.exe で起動する通常の構成) |
ホスト(apphost / dotnet.exe)の Cランタイム起動コード |
ホストは Windows では wmain のプログラムで、Cランタイムが作った argv から自分のオプションとアプリのパスを除いた残りを、アプリのパスと一緒にランタイムに渡す。ランタイムは起動時に、先頭にプログラム名(ホストから渡された起動名、無ければアセンブリのパス)を置いた配列を作って GetCommandLineArgs() 用に保持し、Main の args にはプログラム名を除いた引数だけを渡す10 11 12 |
| .NET ランタイムをホストされたライブラリとして読み込み、起動時の引数を受け取らない構成 | .NET ランタイム自身の分割コード(SegmentCommandLine) |
GetCommandLineArgs() がフォールバックとして GetCommandLineW の戻り値を自前で分割する。Cランタイムの規則に合わせて実装され、CommandLineToArgvW は「わずかに挙動が違う」ため使っていない12 |
分割するコードは Cランタイム起動コード、CommandLineToArgvW、.NET ランタイム自身の分割コードの3系統で、同じ骨格の規則を実装していますが、同一のコードではありません。apphost や dotnet.exe で起動した .NET アプリは、ホスト自身が MSVC の Cランタイムで作られた wmain のプログラムなので、実質的には1系統目(Cランタイム起動コード)の規則で分割されています。.NETランタイムのソースには、CommandLineToArgvW は挙動が少し異なるので使わない、というコメントが残っています。12 差が出るのは後述する "" の扱いなどの端の部分で、日常の引数ではまず踏みませんが、「同じ規則だから何でも通る」と考えると端で事故になります。
flowchart TB
accTitle: 受け取り側の3つのパーサ
accDescr: GetCommandLineWが返す1本の文字列は、C/C++ならCランタイム起動コード、Win32直接ならCommandLineToArgvW、ホストされたライブラリとして読み込まれた.NETならランタイム自身の分割コードで分割され、それぞれ同じ骨格の規則だが別実装である。apphostやdotnet.exeで起動した通常の.NETアプリはホストのCランタイム起動コードが分割した配列を受け取る
s["GetCommandLineW の文字列"] --> crt["Cランタイム起動コード"]
s --> api["CommandLineToArgvW"]
s --> net[".NET 自身の分割コード(ホスト読み込み時)"]
crt --> app["apphost / dotnet.exe 経由の .NET も同じ"]
crt --> same["骨格は同じ規則、実装は別"]
api --> same
net --> same
図3: 分割するコードは3系統ある。apphost や dotnet.exe で起動した .NET アプリはホストの Cランタイム起動コードが分割した配列を受け取り、ランタイム自身の分割コードはホストされたライブラリ構成のフォールバック。相手のexeがどれで動いているかは外から見えないので、どれでも同じ結果になる文字列を組み立てるのが実務解になる。
なお .NET の Main(string[] args) の args にはプログラム名が含まれず、Environment.GetCommandLineArgs() の先頭要素にはプログラム名が入ります。C/C++ の argv[0] と同じ位置づけが後者です。13 通常の起動では、dotnet app.dll x のようにホスト用のオプションとアプリのパス(dotnet.exe と app.dll)はホストが取り除き、Main の args には x だけが届きます。14 一方 GetCommandLineArgs() は、ランタイムが起動時に先頭へプログラム名を足した配列(app.dll のパスと x)を返します。11 ランタイム自身の分割コードが GetCommandLineW を分割するのは、起動時の引数を受け取らないホストされたライブラリの構成だけで、ネイティブホストが自前の argc/argv を渡して Main を呼ぶ構成では、Main の args はホストが渡した値になります。
4. 分割の規則 ── 空白、引用符、バックスラッシュ
3つのパーサに共通する規則を、argv[1] 以降について整理します。3 4
- 引数は空白またはタブで区切る。
- 二重引用符で囲まれた範囲は、空白を含んでいても1つの引数になる。引用符そのものは引数に含まれない。引用符は引数の途中から始めてもよく、閉じないまま文字列が終わればそこまでが最後の引数になる。
- バックスラッシュは通常の文字として扱う。ただし直後に二重引用符が来るときだけ、次の規則が働く。
- 二重引用符の直前に2n個のバックスラッシュがあれば、n個のバックスラッシュを出力し、引用符は「囲みの開始/終了」として働く。
- 二重引用符の直前に2n+1個のバックスラッシュがあれば、n個のバックスラッシュと文字としての引用符を出力し、囲みの状態は変わらない。
- キャレット(
^)はエスケープ文字ではない(cmd.exe の規則であって、パーサの規則ではない)。
パーサは「引用符の中にいるか」という1ビットの状態を持ち、引用符でそれを反転させながら文字列を左から右に読みます。空白で区切るかどうかはこの状態で決まります。
flowchart TB
accTitle: 引用符の内外を切り替えながら読む分割の流れ
accDescr: パーサは引用符の外では空白で引数を区切り、引用符に出会うと内側に入って空白を引数の一部として扱い、再び引用符に出会うと外側に戻る。バックスラッシュは直後が引用符のときだけ特別扱いされる
out["引用符の外:空白で区切る"] -->|"引用符に出会う"| inq["引用符の中:空白も引数の一部"]
inq -->|"引用符に出会う"| out
out -->|"バックスラッシュの直後が引用符"| bs["バックスラッシュ規則を適用"]
inq -->|"バックスラッシュの直後が引用符"| bs
bs -->|"2n個:n個出力して開閉"| toggle["囲みの状態を反転"]
bs -->|"2n+1個:n個出力して文字の引用符"| lit["囲みの状態は維持"]
図4: 分割の本体は「引用符の内か外か」の1ビットと、引用符の直前のバックスラッシュの数だけで決まる。
規則を文章で覚えるより、入力と出力の対応で見たほうが確実です。
| コマンドラインの一部(入力) | 得られる引数 | 効いている規則 |
|---|---|---|
a b c |
a, b, c |
空白で区切る |
"a b" c |
a b, c |
引用符で囲んだ範囲は区切らない |
C:\data\ next |
C:\data\, next |
バックスラッシュの直後が引用符でないので通常の文字 |
"C:\data\\" next |
C:\data\, next |
引用符の直前の2個は1個になり、引用符は閉じる |
"C:\data\" next |
C:\data" next |
1個なので文字の引用符になり、囲みが閉じないまま次の引数を巻き込む |
"say \"hi\"" |
say "hi" |
奇数個なので文字の引用符 |
"" |
空文字列 | 空の引数を渡す唯一の書き方 |
'a b' |
'a, b' |
単一引用符に特別な意味はない15 |
5行目が、冒頭の「C:\data\ を渡したら次の引数までまとめて1つになった」の正体です。パスの末尾のバックスラッシュを引用符で囲んだ瞬間、閉じ引用符が文字に化け、囲みが閉じなくなります。
flowchart TB
accTitle: 末尾のバックスラッシュが次の引数を巻き込む仕組み
accDescr: パスの末尾のバックスラッシュを引用符で囲むと、閉じるはずの引用符がバックスラッシュ1個の直後にあるため文字としての引用符と解釈され、囲みが閉じないまま次の引数まで1つの引数として読み込まれる
a["引用符で囲んだパスの末尾にバックスラッシュ1個"] --> b["閉じ引用符の直前が奇数個"]
b --> c["引用符は文字として出力され、囲みは閉じない"]
c --> d["以降の空白は区切りにならない"]
d --> e["次の引数まで1つの引数として届く"]
a -.->|"バックスラッシュを2倍にする"| ok["囲みが閉じ、引数は分かれる"]
図5: 「末尾のバックスラッシュを2倍にする」が必要な理由。規則を知らずに書いた引用符付けは、パスの末尾で壊れる。
実装差が出る「囲みの中の引用符2連続」
MSVC の Cランタイムの規則には、もう1つ「引用符で囲まれた文字列の中の2連続の引用符は、1つの引用符として扱う」という項目があります("ab""c" のような形で、空の引数を表す "" とは別の話です)。4 しかし CommandLineToArgvW の公式の規則にはこの項目がなく、.NETランタイムの組み立てコードも「閉じ引用符に続く引用符は2008年より前と後のVCで解釈が異なる」ため、この形を生成しないよう明示的に避けています。6
受け取り側としては「そういう入力が来ることもある」と知っていれば足ります。組み立てる側は、引用符を文字として渡したいときは \" の形だけを使ってください。どのパーサでも同じ結果になります。
5. argv[0] は別ルール ── lpApplicationName と Program.exe 問題
先頭のトークン、つまり実行ファイル名は、ここまでの規則の対象外です。ファイルシステムのパスとして妥当な文字列であることが前提なので、引用符で囲んで空白を含められる一方、バックスラッシュのエスケープ規則は適用されません。引用符自体を argv[0] に含める方法もありません。4 3 .NETの組み立てコードも、先頭要素については「空白があれば引用符で囲むだけ、引用符を含んでいたら例外」という別の処理をしています。6
呼び出し側で問題になるのは、CreateProcess の lpApplicationName を NULL にしたときの挙動です。この場合、実行するモジュールは lpCommandLine の先頭の空白区切りトークンから推定されます。パスに空白があると候補が複数でき、OSは短いほうから順に試します。1
flowchart TB
accTitle: lpApplicationNameがNULLのときの実行ファイルの推定順
accDescr: 引用符なしでC:\Program Files\MyApp -L -Sを渡すと、CreateProcessはC:\Program.exe、C:\Program Files\MyApp.exeの順に存在を試すため、C:\Program.exeが置かれていればそれが実行される
in["引用符なしのパス(空白を含む)を lpCommandLine に渡す"] --> t1["候補1:C:\Program.exe を試す"]
t1 -->|"存在する"| bad["意図しない実行ファイルが起動する"]
t1 -->|"存在しない"| t2["候補2:C:\Program Files\MyApp.exe を試す"]
t2 --> ok["意図した実行ファイルが起動する"]
in -.->|"lpApplicationName を渡す、または先頭を引用符で囲む"| ok
図6: 空白を含むパスを引用符なしで先頭に置くと、短い候補から順に試される。公式ドキュメントはこれを明確に「危険」と書いている。
公式ドキュメントは、C:\Program.exe を置かれると本来のアプリの代わりにそれが動くと明記し、lpApplicationName に NULL を渡さないこと、渡すなら先頭のパスを引用符で囲むことを求めています。1 実務では両方やります。lpApplicationName に実行ファイルのフルパスを渡し、lpCommandLine の先頭にも引用符で囲んだ同じパスを置く。両方渡した場合、実行されるモジュールは lpApplicationName で決まり、子プロセスの argv[0] は lpCommandLine の先頭トークンになります。慣習として両者を一致させておかないと、argv[0] から自分のパスを求めるコードが壊れます。自分のパスは GetModuleFileNameW で取るのが確実です。4
flowchart TB
accTitle: 実行モジュールとargv[0]の決まり方
accDescr: lpApplicationNameとlpCommandLineの両方を渡すと、実行されるモジュールはlpApplicationNameで決まり、子プロセスのargv[0]はlpCommandLineの先頭トークンになる。両者がずれるとargv[0]から自分のパスを求めるコードが壊れるため、自分のパスはGetModuleFileNameWで取る
app["lpApplicationName"] --> run["実行されるモジュール"]
cl["lpCommandLine の先頭トークン"] --> a0["子の argv[0]"]
a0 -.->|"ずれると壊れる"| self["argv[0] から自分のパスを求めるコード"]
self -.->|"代わりに使う"| gmf["GetModuleFileNameW"]
図7: 「何が実行されるか」と「argv[0]に何が入るか」は別々に決まる。自分のパスをargv[0]から求める設計は、この分離の上では成り立たない。
もう1点、lpApplicationName が NULL のときは lpCommandLine の実行ファイル名部分が MAX_PATH に制限されます。1 長いパスの扱いは「MAX_PATHとWindowsのパス・ファイル名の落とし穴」を参照してください。
6. 組み立て側の規則 ── 1つの関数で足りる
分割規則が分かれば、それを逆にたどるだけで「相手が元どおりに割ってくれる文字列」を作れます。argv[1] 以降の各引数について、次の処理をします。6
- 空文字列でなく、空白も引用符も含まないなら、そのまま並べる。
- それ以外は全体を引用符で囲む。囲みの中では、
- 引用符の直前に並ぶバックスラッシュ k 個を 2k+1 個にしてから引用符を置く(奇数個にして「文字の引用符」にする)。
- 末尾に並ぶバックスラッシュ k 個は 2k 個にする(閉じ引用符の直前なので偶数個にして「囲みの終了」にする)。
- それ以外のバックスラッシュはそのまま。
- 空文字列は
""として並べる。
flowchart TB
accTitle: 引数1つを組み立てる判断の流れ
accDescr: 引数が空でなく空白も引用符も含まなければそのまま並べ、それ以外は引用符で囲み、引用符の直前のバックスラッシュは2倍+1、末尾のバックスラッシュは2倍にし、引用符はバックスラッシュ付きにして閉じる
s["引数を1つ受け取る"] --> q{"空、または空白か引用符を含む?"}
q -->|"いいえ"| raw["そのまま並べる"]
q -->|"はい"| open["先頭に引用符"]
open --> scan["左から走査"]
scan --> bq["引用符の直前のバックスラッシュ k 個 → 2k+1 個"]
scan --> be["末尾のバックスラッシュ k 個 → 2k 個"]
scan --> other["それ以外はそのまま"]
bq --> close["末尾に引用符"]
be --> close
other --> close
図8: 組み立ては分割規則の逆写像。分岐は3つしかなく、末尾と引用符の直前でバックスラッシュの数を調整するだけで、受け取り側が CommandLineToArgvW・Cランタイム・.NET と同じ分割規則(4章)でワイド文字のまま分割し(独自の文法で生のコマンドラインを解釈する相手や、途中にシェルのパーサが挟まる場合は対象外)、かつ wsetargv.obj のようなワイルドカード展開を有効にしていない限り、NUL文字を含まず、組み立てた全体が lpCommandLine の上限(終端のnull文字を含めて32,767 UTF-16コード単位)に収まるあらゆる文字列が往復できる(コマンドラインはnull終端文字列なので、NUL文字だけは原理的に渡せない。ワイルドカード展開が有効な相手では * や ? を含む引数がファイル名に置き換わる。8章参照。上限を超える文字列は CreateProcessW が受け付けない。10章参照)。
この規則は「バックスラッシュは引用符の直前でだけ特別」という非対称性をそのまま反映しています。パス区切りのバックスラッシュを機械的に2倍にする必要はなく、引用符の直前と末尾だけを触ればよいのがポイントです。
7. .NET での実装 ── ArgumentList と Arguments
.NET Core 2.1 以降の ProcessStartInfo には、この組み立てを引き受ける ArgumentList があります。1要素が1引数で、追加した文字列は事前にエスケープする必要がなく、Process.Start の時点で.NETが内部で1本の文字列に組み立ててOSに渡します。5
var psi = new ProcessStartInfo
{
FileName = @"C:\Program Files\MyTool\convert.exe",
UseShellExecute = false,
};
psi.ArgumentList.Add("--input");
psi.ArgumentList.Add(inputPath); // 空白・末尾のバックスラッシュ・引用符を含んでいてよい
psi.ArgumentList.Add("--output");
psi.ArgumentList.Add(outputPath);
psi.ArgumentList.Add("--label");
psi.ArgumentList.Add(""); // 空の引数も "" として正しく渡る
using var proc = Process.Start(psi)
?? throw new InvalidOperationException("Process.Start が null を返しました");
proc.WaitForExit();
if (proc.ExitCode != 0)
throw new InvalidOperationException($"convert.exe が失敗しました (ExitCode={proc.ExitCode})");
Arguments は、自分で組み立てた1本の文字列をそのまま渡すプロパティです。両者は独立していて、片方を使うときはもう片方が空でなければなりません。16 公式ドキュメントも、引用符付けに自信がなければ ArgumentList を選ぶよう案内しています。5
flowchart TB
accTitle: ArgumentListとArgumentsが文字列になる場所
accDescr: ArgumentListは.NETが要素ごとにエスケープして1本の文字列に組み立ててからCreateProcessに渡し、Argumentsは呼び出し側が組み立てた文字列をそのまま渡す。どちらもOSに届く時点では1本の文字列である
al["ArgumentList(1要素=1引数)"] --> esc[".NET が要素ごとにエスケープして連結"]
ar["Arguments(自分で組み立てた1本の文字列)"] --> pass["そのまま"]
esc --> cmd["1本のコマンドライン文字列"]
pass --> cmd
cmd --> cp["CreateProcess"]
図9: どちらを使ってもOSに渡るのは1本の文字列。違いは「誰が組み立てるか」だけで、規則を知っている側に任せるのがArgumentList。
ArgumentList の組み立てコードは、6章の規則そのものです。空でなく空白も引用符も含まなければそのまま、それ以外は引用符で囲み、引用符の直前のバックスラッシュは2倍+1、末尾のバックスラッシュは2倍、引用符には必ずバックスラッシュを前置します。中身のある引数の内側で引用符が隣接する形は生成しません。空の引数だけは "" として並べ、こちらは正しい書き方です。6
.NET Framework では自分で組み立てる
ArgumentList は .NET Core 2.1 以降のAPIで、.NET Framework の ProcessStartInfo にはありません。5 .NET Framework 4.8 のアプリや、それを土台にした社内ツールでは、6章の規則を自分で書いて Arguments に渡します。
// .NET Framework 向け。ProcessStartInfo.Arguments に渡す1本の文字列を組み立てる。
// 規則は ProcessStartInfo.ArgumentList が内部で使うものと同じ。
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("引数に NUL 文字は含められません(コマンドラインは null 終端文字列のため、そこで切れます)");
bool needsQuote = arg.Length == 0 || arg.Any(c => char.IsWhiteSpace(c) || c == '"');
if (!needsQuote)
{
sb.Append(arg); // そのまま
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); // 末尾:閉じ引用符の直前なので2倍
}
else if (arg[i] == '"')
{
sb.Append('\\', backslashes * 2 + 1).Append('"'); // 引用符の直前:2倍+1
i++;
}
else
{
sb.Append('\\', backslashes).Append(arg[i]); // それ以外:そのまま
i++;
}
}
sb.Append('"');
}
入力と出力を並べておきます。
| 渡したい値 | AppendArgument が出力する文字列 |
|---|---|
strict |
strict |
| 空文字列 | "" |
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\ (空白なし) |
C:\data\ |
最後の行に注意してください。空白も引用符も含まない値は囲まないので、末尾のバックスラッシュもそのまま出ます。囲まなければ規則4・5は発動しないため、これで正しく C:\data\ が届きます。
flowchart TB
accTitle: .NETのバージョンによる組み立て手段の選択
accDescr: .NET Core 2.1以降ならProcessStartInfo.ArgumentListに任せ、.NET Frameworkでは同じ規則の自前関数でArguments文字列を組み立てる。どちらも文字列連結で引用符を手書きしない
v{".NET のバージョンは?"}
v -->|"Core 2.1 以降"| al["ArgumentList に1要素ずつ追加"]
v -->|"Framework"| own["自前関数で Arguments を組み立てる"]
al --> no["引用符を手書きしない"]
own --> no
図10: 手段は2つだが、原則は1つ。「引用符を手で書かない」を守れば、パスの末尾で壊れる事故は起きない。
なお UseShellExecute = true の場合は CreateProcess ではなく ShellExecuteEx 経由になり、ArgumentList の内容はシェルに渡すパラメータになります。文書やURLを開く用途では、ファイル関連付けが実際のハンドラのコマンドラインを組み立てるため、こちらで組み立てた文字列がそのまま相手に届くとは限りません。出力をリダイレクトしたり終了コードを確実に取ったりする用途では UseShellExecute = false にし、標準出力と標準エラーを同時に読み出す設計が必要です。この部分は「Windowsアプリで子プロセスを安全に扱うチェックリスト」にまとめています。
8. C++ / Win32 での実装
C++ では、組み立てと分割の両側を自分で書くことになります。組み立ては6章の規則をそのまま関数にします。
#include <windows.h>
#include <string>
#include <stdexcept>
#include <string_view>
#include <vector>
// argv[1] 以降の引数を1つ追加する。規則は CommandLineToArgvW / CRT の分割規則の逆。
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("引数に NUL 文字は含められません(コマンドラインは null 終端文字列のため、そこで切れます)");
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'\\'); // 末尾:2倍
break;
}
if (arg[i] == L'"') {
cmd.append(backslashes * 2 + 1, L'\\'); // 引用符の直前:2倍+1
cmd += L'"';
} else {
cmd.append(backslashes, L'\\'); // それ以外:そのまま
cmd += arg[i];
}
++i;
}
cmd += L'"';
}
// argv[0](実行ファイル)は別規則:空白があれば引用符で囲むだけ。引用符は含められない。
std::wstring QuoteArgv0(std::wstring_view exe)
{
if (exe.find(L'\0') != std::wstring_view::npos)
throw std::invalid_argument("実行ファイルのパスに NUL 文字は含められません(lpApplicationName もコマンドラインもそこで切れ、手前までのパスが起動されかねません)");
if (exe.find(L'"') != std::wstring_view::npos)
throw std::invalid_argument("実行ファイルのパスに引用符は使えません");
if (exe.empty() || exe.find_first_of(L" \t") != std::wstring_view::npos)
return L'"' + std::wstring(exe) + L'"';
return std::wstring(exe);
}
呼び出しでは、lpApplicationName に実行ファイルのフルパスを渡し、lpCommandLine には書き換え可能なバッファを渡します。
const std::wstring exe = LR"(C:\Program Files\MyTool\convert.exe)";
std::wstring cmd = QuoteArgv0(exe); // argv[0] は実行ファイルと一致させる
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 は文字列を書き換えることがある
STARTUPINFOW si{}; si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(exe.c_str(), // lpApplicationName:NULL にしない
buffer.data(), // lpCommandLine:先頭は引用符付きの同じパス
nullptr, nullptr, FALSE, CREATE_UNICODE_ENVIRONMENT,
nullptr, nullptr, &si, &pi)) {
const DWORD err = GetLastError();
// ここで err をログに残して呼び出し元へ返す。握りつぶさない
return;
}
CloseHandle(pi.hThread); // 主スレッドのハンドルは不要なので先に閉じる
switch (WaitForSingleObject(pi.hProcess, INFINITE)) { // 必要ならタイムアウト付きにする
case WAIT_OBJECT_0: { // 終了した。終了コードはこの分岐でだけ読む
DWORD exitCode = 0;
if (!GetExitCodeProcess(pi.hProcess, &exitCode)) {
const DWORD err = GetLastError();
// 取得失敗もログに残し、失敗として呼び出し元へ返す
} else if (exitCode != 0) {
// 相手は起動できたが処理に失敗している。0 と同じ扱いにせず、
// 終了コードをログに残して呼び出し元へ返す(C# 例の ExitCode 判定と同じ)
}
break;
}
case WAIT_TIMEOUT:
// まだ動いている。ここで GetExitCodeProcess を呼んでも STILL_ACTIVE(259) が
// 返るだけで、終了コードではない。この例は「時間超過は失敗として畳む」方針:
// 終了要求が通ったときだけ終了を見届け、それから下の CloseHandle に進む。
// 待ち続ける方針なら、ここで break してハンドルを閉じてはいけない(子が動いた
// まま手放すことになる)。待機に戻ること
if (!TerminateProcess(pi.hProcess, 1)) {
const DWORD err = GetLastError();
// 終了させられなかった(権限不足など)。ここで INFINITE で待つと、時間超過を
// 防ぐために付けた期限が無意味になる。err をログに残し、待たずに失敗として
// 呼び出し元へ返す(子は動いたまま手放すことになるので、そのこともログに残す)
break;
}
WaitForSingleObject(pi.hProcess, INFINITE); // 終了要求が通ったので、終了を見届けてから閉じる
// 時間超過を失敗として呼び出し元へ返す
break;
default: { // WAIT_FAILED
const DWORD err = GetLastError();
// 待機自体の失敗もログに残す
break;
}
}
CloseHandle(pi.hProcess); // 閉じ忘れると起動のたびにハンドルが1つ漏れる
flowchart TB
accTitle: CreateProcessWに渡す2つの引数の役割分担
accDescr: lpApplicationNameは実行するモジュールを確定させ、lpCommandLineは子プロセスがGetCommandLineWで受け取る文字列を決める。lpCommandLineは書き換え可能なバッファで渡し、先頭のargv[0]はlpApplicationNameと一致させる
app["lpApplicationName:実行ファイルのフルパス"] --> mod["実行するモジュールが確定する"]
cl["lpCommandLine:書き換え可能なバッファ"] --> child["子が GetCommandLineW で受け取る文字列"]
child --> a0["先頭トークン = argv[0]"]
a0 -.->|"一致させる"| app
child --> rest["以降 = 6章の規則で組み立てた引数"]
図11: 「何を実行するか」と「何を渡すか」は別の引数で決まる。両方を明示すれば、Program.exe問題も書き換え不可バッファのアクセス違反も起きない。
受け取り側では、GetCommandLineW の戻り値を CommandLineToArgvW に渡して argv 形式にします。戻り値は1回の LocalFree で解放します。lpCmdLine が空文字列だと現在の実行ファイルのパスが返り、先頭に空白があると最初の引数が空文字列になる、という端の挙動があります。3
int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (argv == nullptr) {
const DWORD err = GetLastError();
// 解析失敗もログに残す
return 1;
}
for (int i = 0; i < argc; ++i) {
// argv[0] は実行ファイル名。OS がフルパスを補っていることがある
}
LocalFree(argv);
main / wmain を使うなら Cランタイムが同じことを起動時にやってくれます。ただし main の argv は現在のコードページに変換された狭い文字列なので、コードページで表せない文字(日本語環境以外のPCに置かれた日本語パスなど)はここで失われます。6章の組み立て関数が「往復できる」のは、wmain・CommandLineToArgvW・.NET のようにワイド文字のまま分割する受け取り側に対してです。既定ではワイルドカードを展開しませんが、setargv.obj(wmain なら wsetargv.obj)をリンクすると * や ? を展開するようになります。4 ファイル名に * を含む引数を渡す相手がこの設定だと、こちらの意図と違う引数が届くことになります。
9. cmd.exe とバッチファイルが挟まるとき
ここまでの規則は、CreateProcess から相手のexeに直接届く場合のものです。間に cmd.exe が挟まると、もう1段階の解釈が入ります。
cmd.exe は &、|、(、) を構文として扱い、これらを引数として渡すには ^ でエスケープするか引用符で囲む必要があります。/c や /k に続く文字列の引用符の扱いには独自の規則があり、/s の有無や引用符の数、特殊文字の有無で「外側の引用符を剥がすかどうか」が変わります。17 さらにバッチファイルは、引数を分割せず生のコマンドライン文字列として受け取ります。PowerShell の公式ドキュメントは、信頼できない入力をバッチファイルに渡さないよう明確に警告しています。7 CreateProcess のドキュメントは、バッチを起動するには lpApplicationName に cmd.exe を指定して /c とバッチ名を渡すよう書いたうえで、MSRC のエンジニアリングチームがこれを勧めていない旨と MS14-019 の解説へのリンクを注記しています。1 MS14-019 が直したのは、バッチファイルを直接 CreateProcess に渡したときに cmd.exe がカレントディレクトリから先に探されて乗っ取られる問題で、MSRC の勧告は「cmd.exe の完全修飾パスを渡し、バッチはその引数にする」ことです。18 つまり問題なのは cmd.exe を完全なパスで明示せずにバッチを起動する形(lpApplicationName を NULL にしてバッチ名から起動させる形)で、完全なパスの cmd.exe を lpApplicationName に指定した /c 起動そのものを否定するものではありません。
flowchart TB
accTitle: cmd.exeが挟まると解釈の段数が増える
accDescr: 相手のexeを直接起動すれば分割は相手のパーサの1回だけだが、cmd.exe /c を経由するとcmd.exeの構文解釈が加わり、さらにバッチファイルは生の文字列を受け取るため、引用符付けの規則が一段ごとに変わる
direct["自分のプロセス → 相手の exe"] --> p1["分割は相手のパーサの1回だけ"]
p1 ~~~ via
via["自分のプロセス → cmd.exe /c → 相手の exe"] --> p2["cmd.exe の構文解釈が加わる(アンパサンド・パイプ・丸括弧・キャレット)"]
p2 --> p3["相手のパーサで分割"]
p3 ~~~ bat
bat["自分のプロセス → cmd.exe /c → バッチ"] --> p4["バッチは生の文字列を受け取る"]
p4 --> danger["信頼できない値を通すとコマンド注入になる"]
図12: 段数が増えるほど規則が混ざる。直接起動できるものは直接起動し、バッチには外から来た値を渡さない。
実務の判断は単純です。相手が exe なら cmd.exe を挟まない。 .bat を呼ぶしかないなら、外から来た値をバッチに解釈させないことが原則です。値をファイルに書き、バッチはそのファイルのパスを固定の文字列として下流の exe に渡すだけにし、ファイルの中身は exe 側で読みます。環境変数に入れる方法は、バッチの中で %VAR% と展開した時点で & や | が cmd.exe に再解釈されるため、境界になりません。環境変数で渡してよいのは、バッチを経由せずに下流の exe が環境変数を直接読む場合だけです。それも難しければ、バッチの中身を PowerShell や自前の exe に移します(「そのバッチファイル、PowerShellに移行すべき?」)。
10. 長さの上限
上限も経路ごとに違います。
| 経路 | 上限 | 出典 |
|---|---|---|
CreateProcess の lpCommandLine |
32,767 UTF-16コード単位(終端のnull含む。サロゲートペアは2つ) | 1 |
lpApplicationName が NULL のときの実行ファイル名部分 |
MAX_PATH |
1 |
| cmd.exe のコマンドライン(バッチ内の行を含む) | 8,191文字 | 8 |
.NET の ProcessStartInfo.Arguments |
文字列長(UTF-16コード単位)が32,699未満 | 16 |
ファイル一覧のような可変長の値を引数に並べる設計は、件数が増えた日に上限を踏みます。上限に近づく用途は、引数を1つのファイルに書き出してそのファイルのパスだけを渡す「応答ファイル」方式に切り替えてください。cmd.exe の制限に関する公式の回避策も同じ方法です。8 ただし CreateProcess も cmd.exe もファイルを勝手に展開してはくれません。この方式が成り立つのは、相手のプログラムが @file のような構文で応答ファイルを読める場合か、読めるように相手を直せる場合だけです。手を入れられない既製の exe が相手なら、上限内に収まるよう呼び出しを分割するしかありません。
flowchart TB
accTitle: 可変長の値を引数で渡す設計の限界と回避
accDescr: ファイル一覧などの可変長の値を引数に並べると件数の増加でcmd.exeの8191文字やCreateProcessの32767UTF-16コード単位の上限に達する。相手が応答ファイルを読める(または読めるように直せる)場合は値をファイルに書き出してパスだけを渡す応答ファイル方式に切り替え、読めない既製のexeが相手なら呼び出しを分割する
list["可変長の値(ファイル一覧など)を引数に並べる"] --> grow["件数が増えると文字列が伸びる"]
grow --> lim["上限に達する(cmd.exe 8,191 / CreateProcess 32,767)"]
lim --> fail["ある日突然起動に失敗する"]
fail -.->|"相手が応答ファイルを読める"| resp["値をファイルに書き、パスだけを渡す(応答ファイル)"]
fail -.->|"読めない既製の exe"| split["呼び出しを分割する"]
図13: 上限は「今日は大丈夫」な種類の問題。件数に比例して伸びる引数は、相手が応答ファイルを読める(または読めるように直せる)なら最初からそうしておく。
11. 実際に何が届いたかを確認する
引用符付けを推測で増やす前に、相手に届いた引数を見るのが最短です。見るべきものは「呼び出し側で組み立てた文字列」「相手側に届いた文字列」「分割後の配列」の3つで、手段は4つあります。その前に1つ約束があります。どの手段でも、コマンドラインをログに残すときは機密を伏せてから記録します。 引数にパスワード・APIキー・トークンが含まれる設計なら、呼び出し側のログでも相手側の起動時ログでも、そのまま書けば機密がログに残ります。ログはプロセスより長く保持され、より多くの人の目に触れます。そもそもコマンドラインは、後述の Process Explorer のように同じマシンの他のプロセスから読めるものなので、パスワードやトークンは引数で渡さず、標準入力や保護された設定ストアのような別の経路で渡す設計が根本の対策で、ログの伏せ字はその上での備えです。分割後の引数(呼び出し側なら組み立てる前の要素)を解釈して、機密になり得るオプションの値を伏せ字にしてから記録するか、生の文字列の記録は限定した診断モードでだけ有効にしてください。
- 呼び出し側で、組み立てた文字列をログに残す。
CreateProcessに渡す直前のlpCommandLineです。この突き合わせはUseShellExecute = falseかCreateProcessを直接呼ぶ起動が前提です。UseShellExecute = trueで文書やURLを開く場合はShellExecuteEx経由でファイル関連付けが実際のコマンドラインを組み立てるため(7章)、呼び出し側の文字列と相手側の文字列が一致しないのは cmd.exe やバッチがなくても起こり、9章の問題ではありません。.NET のArgumentListを使っている場合、要素の並びをそのまま残しても比較には使えません。要素は引用符も末尾のバックスラッシュの倍加もされる前の値で、OS に渡るのはそれを .NET が整形した文字列だからです。7章のBuildArgumentsと同じ規則で要素から1本の文字列を再構成して残すか(ArgumentListが内部で行う整形と同じ結果になります)、要素の並びを分割後の配列と直接比べてください。これだけが「呼び出し側の元のバッファ」を見る手段で、後述の Process Explorer や相手側のログは、途中に cmd.exe やバッチが挟まっていればその段が作り直した文字列しか見せません。記録するときは冒頭の約束どおり、機密になり得る要素の値を伏せてから残します(伏せた要素は相手側の文字列と一致しなくなるので、比較はその要素を除いて行います)。 - 引数を表示するだけの exe を用意する。 相手の exe の代わりに起動して、届いた
argsを1件1行で出力させます。値をそのまま書くと、改行や制御文字を含む引数が複数行に見えたり前後の行を上書きしたりして数え間違えるので、JSON 文字列としてエスケープした形と長さを出します(エスケープは可逆なので元の値に戻せます)。ただし3章のとおりパーサは3系統あり、囲みの中の引用符2連続のような端の形では解釈が分かれます。相手と同じランタイムで作った表示用 exe を使ってください(相手が MSVC の C/C++ ならwmainの C++ で、.NET なら .NET で)。相手が自分たちのプログラムなら、表示用 exe を挟まず相手自身の起動時にargvを(次項の伏せ字ルールで)ログに残すのが最も確実です。.NET 向けなら次の数行で足ります。
using System.Text.Encodings.Web;
using System.Text.Json;
// 改行・制御文字・引用符・バックスラッシュはエスケープし、日本語はそのまま出す
var json = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
Console.WriteLine("CommandLine: " + JsonSerializer.Serialize(Environment.CommandLine, json)); // 1本の文字列
for (int i = 0; i < args.Length; i++)
Console.WriteLine($"[{i}] len={args[i].Length} {JsonSerializer.Serialize(args[i], json)}");
// 分割後。1件が必ず1行に収まり、空文字列は len=0 と "" で見える。len は UTF-16 コード単位
- Process Explorer で子プロセスのコマンドラインを見る。 プロセスのプロパティに、子プロセスが持っているコマンドライン文字列が表示されます。「相手側に届いた文字列」を確認する手段で、「分割後の配列」は分かりません。表示されるのは子プロセス側が保持している文字列なので、2章で触れたとおり先頭の実行ファイル名はOSがフルパスを補っていることがあり、また cmd.exe やバッチが挟まっていれば見えるのは cmd.exe が作り直した文字列です。先頭トークンの違いだけで慌てないこと、そして呼び出し側の元の文字列は項目1のログでしか分からないことが要点です。使い方は「Sysinternals Process Explorer / Handle / VMMap 実践ガイド」にまとめています。
- 自分のアプリの起動時に、受け取ったコマンドラインをログに残す。 現場で「起動しない」と言われたとき、どんな文字列で起動されたかが残っていれば、引数の問題かどうかを最初に切り分けられます。ここでも
GetCommandLineWの戻り値をそのまま保存してはいけません。冒頭の約束どおり、分割後の引数を解釈して機密になり得る値を伏せ字にしてから記録するか、生の文字列の記録は限定した診断モードでだけ有効にしてください。
突き合わせの順序は次のとおりです。まず呼び出し側の文字列(項目1)と相手側の文字列(項目3または4)を比べます。先頭の実行ファイル名を除いて一致しなければ、途中の段が変形しています。直接起動なら cmd.exe やバッチ(9章)、UseShellExecute = true ならシェルの関連付け(7章)です。6章の関数に置き換えても直りません。一致していれば、その文字列と分割後の配列(項目2)を突き合わせます。規則どおりに割れているのに欲しい配列でなければ組み立て側の問題、規則どおりに割れていなければ受け取り側のパーサの問題です。
flowchart TB
accTitle: 引数の不具合を切り分ける順序
accDescr: まず呼び出し側で組み立てた文字列のログと、Process Explorerや相手の起動時ログで見える相手側の文字列を比べる。先頭の実行ファイル名を除いて一致しなければ途中の段(直接起動ならcmd.exeやバッチ、UseShellExecute=trueならシェルの関連付け)が変形している。一致していれば分割後の配列と突き合わせ、規則どおりに割れているのに欲しい配列でなければ組み立て側の問題、規則どおりに割れていなければ受け取り側のパーサの問題と判断する
s["引数がおかしい"] --> caller["呼び出し側で組み立てた文字列を見る(呼び出し側のログ)"]
caller --> target["相手側の文字列を見る(Process Explorer / 相手の起動時ログ)"]
target --> same{"先頭の実行ファイル名を除いて一致する?"}
same -->|"いいえ"| mid["途中の段が変形している(9章・7章を参照)"]
same -->|"はい"| arr["分割後の配列を見る(相手と同じランタイムの表示用 exe)"]
arr --> cmp{"文字列と配列は規則どおりに対応している?"}
cmp -->|"はい:割れているのに欲しい配列でない"| build["組み立て側の問題:6章の関数に置き換える"]
cmp -->|"いいえ:規則どおりに割れていない"| recv["受け取り側のパーサの問題"]
図14: 「呼び出し側の文字列」「相手側の文字列」「配列」の3つを順に比べれば、責任が途中の段・組み立て側・受け取り側のどこにあるかは機械的に決まる。推測でエスケープを足すのは、この確認をしてからでよい。
12. ざっくり使い分け(判断表)
| 状況 | やること |
|---|---|
| .NET Core 2.1 以降 / .NET 5 以降から exe を起動する | ProcessStartInfo.ArgumentList に1要素ずつ追加する |
| .NET Framework から exe を起動する | 6章の規則の関数で Arguments を組み立てる。引用符を手書きしない |
| C++ から起動する | lpApplicationName を渡し、lpCommandLine は書き換え可能なバッファに規則どおり組み立てる |
| 引数の値に引用符を含めたい | \" の形だけを使う。中身のある引数の内側で引用符を隣接させない |
| パスの末尾がバックスラッシュ | 囲むなら末尾を2倍にする。空白がなければ囲まない |
| 空の引数を渡したい | "" を置く。省略すると引数ごと消える |
| 実行ファイルのパスに空白がある | lpApplicationName を渡し、先頭のトークンも引用符で囲む |
.bat を呼ぶしかない |
外から来た値をバッチに解釈させない。ファイルに書いて下流の exe に読ませる(バッチ内で %VAR% 展開する環境変数は境界にならない) |
| 引数が長くなる | 相手が応答ファイルを読める(または直せる)なら応答ファイルに切り替える。既製の exe なら呼び出しを分割する |
| 何が届いているか分からない | 呼び出し側のログ・相手側の文字列(Process Explorer / 起動時ログ)・分割後の配列(相手と同じランタイムの表示用 exe)の3つを順に突き合わせる |
13. まとめ
Windowsのコマンドライン引数は、配列ではなく1本の文字列として境界を越えます。連結するのは呼び出し側、分割するのは受け取り側で、分割規則は「空白で区切る」「引用符で囲む」「引用符の直前のバックスラッシュだけ特別」の3つに集約されます。先頭の実行ファイル名だけは別規則で、lpApplicationName を省略すると空白を含むパスの解釈が曖昧になります。
組み立て側でやるべきことは1つの関数に収まり、.NET Core 2.1 以降なら ArgumentList がそれを担います。実行ファイルは lpApplicationName にフルパスを渡し、lpCommandLine の先頭にも引用符で囲んだ同じパスを置く(.NET なら FileName に任せる)。中身のある引数の内側で引用符を隣接させる形は生成せず(空の引数を表す "" は別)、cmd.exe とバッチファイルには外から来た値を通さず、件数に比例して伸びる引数は、相手が応答ファイルを読める(または読めるように直せる)場合に限り応答ファイルにし、そうでなければ呼び出しを分割する。この5点を守れば、「空白のあるPCでだけ起動しない」「末尾のバックスラッシュで次の引数が消える」という事故は起きません。
flowchart TB
accTitle: 引数の事故を防ぐ5つの約束
accDescr: 実行ファイルはlpApplicationNameにフルパスを渡して先頭のトークンも引用符で囲み、引用符付けは規則どおりの関数かArgumentListに任せ、中身のある引数の内側で引用符を隣接させる形を生成せず、cmd.exeとバッチに外から来た値を通さず、件数に比例して伸びる引数は相手が読める場合に限り応答ファイルにする。相手が公開された分割規則で解釈しワイルドカード展開を有効にしていないことを前提に、この5点で空白のあるパスや末尾のバックスラッシュによる事故を防ぐ
r0["lpApplicationName にフルパスを渡し、先頭トークンも引用符で囲む"]
r1["引用符付けは規則どおりの関数か ArgumentList に任せる"]
r2["囲みの内側で引用符を隣接させる形を生成しない"]
r3["cmd.exe とバッチに外から来た値を通さない"]
r4["伸びる引数は応答ファイルにする(相手が読める場合)"]
goal["空白や末尾バックスラッシュの事故が起きない"]
r0 ~~~ r1 ~~~ r2 ~~~ r3 ~~~ r4
r0 --> goal
r1 --> goal
r2 --> goal
r3 --> goal
r4 --> goal
図15: 5つの約束はどれも「実行するモジュールを確定させ、相手のパーサが割れる文字列だけを渡す」の言い換えである。相手が公開された分割規則で解釈し、ワイルドカード展開を有効にしていないこと(6章・8章)が前提で、その上でこの5点が空白と末尾のバックスラッシュの事故を防ぐ。
うまくいかないときは、推測でエスケープを増やす前に、呼び出し側で組み立てた文字列、相手側に届いた文字列、分割後の配列の3つを見てください。呼び出し側と相手側の文字列が違えば途中の段(cmd.exe やバッチ、UseShellExecute = true ならシェルの関連付け)、同じなら文字列と配列の対応で組み立て側か受け取り側かが決まります。
関連記事
- PowerShellから外部exeを正しく呼ぶ ── 引数のクォート・終了コード・文字化けの落とし穴
- Windowsアプリで子プロセスを安全に扱うチェックリスト ── Job Object・終了伝播・標準入出力・watchdogのベストプラクティス
- 親が落ちたあとに何が残るか ── Job Objectで子プロセスを飼う
- MAX_PATHとWindowsのパス・ファイル名の落とし穴 ── 260文字制限、予約名、末尾ドット、大文字小文字
- Windowsシェル統合の今 ── 右クリックメニュー・ファイル関連付け・Windows 11の変化
- Windowsアプリの多重起動防止 ── 名前付きMutexと二重起動時のアクティブ化
- C#からWin32 APIを安全に呼ぶ ── P/Invoke実務ガイド(DllImport / LibraryImport / CsWin32)
- そのバッチファイル、PowerShellに移行すべき? ── cmd/bat資産の棚卸しと移行判断
関連する相談領域
合同会社小村ソフトでは、外部ツールや社内EXEを組み合わせたWindowsアプリの設計、「環境によって起動したりしなかったりする」子プロセス起動の原因調査、.NET Framework から .NET への移行に伴うプロセス起動まわりの見直しを扱っています。「引数が化ける」の一件からでもご相談ください。
参考リンク
-
Microsoft Learn, CreateProcessW function (processthreadsapi.h).
lpCommandLineが最大32,767文字(終端のnull含む。ワイド文字列なのでUTF-16コード単位)の1本の文字列であること、Unicode版がその内容を書き換え得るため読み取り専用メモリを渡せないこと、lpApplicationNameがNULLのとき先頭の空白区切りトークンがモジュール名になり、空白を含むパスがc:\program.exeから順に解釈されること、Program.exeを置かれると別の実行ファイルが動く危険と、NULLを避けるか引用符で囲むべきこと、両方指定時にargv[0]がモジュール名と一致しない場合があること、NULL時のモジュール名部分がMAX_PATHに制限されること、バッチファイルの起動には cmd.exe /c が必要なことについて。あわせて CreateProcessA function の、この方法を MSRC のエンジニアリングチームが勧めていない旨の注記(MS14-019 の解説へのリンク付き)も参照。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 -
Microsoft Learn, GetCommandLineW function (processenv.h). 現在のプロセスのコマンドライン文字列を返すこと、戻り値を解放・変更してはならないこと、
CommandLineToArgvWに渡して argv 形式に変換できること、OSが実行ファイル名にフルパスを補うため親がCreateProcessに渡した文字列と一致しない場合があることについて。 ↩ ↩2 -
Microsoft Learn, CommandLineToArgvW function (shellapi.h). 二重引用符の直前のバックスラッシュの特別扱い(2n個でn個+囲みの開閉、2n+1個でn個+文字の引用符、引用符が続かなければそのまま)、「引用符の中」モードで空白が引数の一部になること、先頭のプログラム名を引用符で囲んでも囲まなくてもよいこと、
lpCmdLineが先頭空白で始まると最初の引数が空文字列になること、空文字列を渡すと現在の実行ファイルのパスが返ること、戻り値をLocalFree1回で解放することについて。 ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn,
mainfunction and command-line arguments. Microsoft C/C++ の起動コードがコマンドラインを解釈する規則(空白とタブで区切る、argv[0]は引用符で囲めるが後続の規則は適用されない、引用符で囲んだ文字列は1引数、キャレットはエスケープ文字ではない、引用符内の2連続引用符は1つの引用符、閉じ引用符がなければ末尾までが最後の引数、偶数個/奇数個のバックスラッシュの扱い)、入力とargvの対応表、setargv.objによるワイルドカード展開、lpApplicationNameとlpCommandLineの両方を指定するとargv[0]が実行ファイル名でない場合がありGetModuleFileNameで取得すべきことについて。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 -
Microsoft Learn, ProcessStartInfo.ArgumentList Property. 追加した文字列を事前にエスケープする必要がないこと、
ArgumentListとArgumentsが独立していて同時に使えないこと、ArgumentListが引数をエスケープして内部で1本の文字列を組み立てProcess.Start時にOSへ渡すこと、引用符付けに自信がなければArgumentListを選ぶべきこと、信頼できないデータと併用する危険、適用対象が .NET Core 2.1 以降であることについて。 ↩ ↩2 ↩3 ↩4 -
dotnet/runtime (GitHub), PasteArguments.cs および PasteArguments.Windows.cs.
ArgumentListの内部で使われる組み立てコード。空でなく空白も引用符も含まない引数はそのまま、それ以外は引用符で囲み、末尾のバックスラッシュを2倍、引用符の直前のバックスラッシュを2倍+1にし、引用符には必ずバックスラッシュを前置すること、閉じ引用符に続く引用符が2008年より前と後のVCで異なって解釈されるためその形を生成しないこと、argv[0]については空白があれば引用符で囲むだけで引用符を含む場合は例外にすることについて。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
Microsoft Learn, about_Parsing. バッチファイルへの引数が cmd.exe に生のコマンドライン文字列として渡るため、信頼できない入力を渡さないよう警告していることについて。 ↩ ↩2
-
Microsoft Learn, Command prompt (Cmd.exe) command-line string limitation. コマンドプロンプトで使える文字列の最大長が8,191文字であること、バッチファイル内のコマンドラインにも適用されること、回避策として引数をファイルに書きそのファイル名を渡す方法について。 ↩ ↩2 ↩3
-
Microsoft Learn, WinMain function (winbase.h).
lpCmdLineがプログラム名を除いたコマンドラインであること、コマンドライン全体はGetCommandLineで取得すること、Unicode のエントリポイントとしてwWinMainがあることについて。 ↩ -
dotnet/runtime (GitHub), apphost.c と dotnet.cpp. apphost と
dotnet.exeのエントリポイントが Windows ではwmain(int argc, wchar_t* argv[])で、Cランタイムが作ったargvをそのままホストの起動処理に渡していることについて。 ↩ -
dotnet/runtime (GitHub), corhost.cpp.
ExecuteAssemblyがSetCommandLineArgs(pwzAssemblyPath, argc, argv)でEnvironment.GetCommandLineArgs()の配列を作り、先頭要素がホストから渡された起動名(無ければアセンブリのパス)、その後ろにargvが続くこと、Mainにはそのargvだけが渡ることについて。 ↩ ↩2 -
dotnet/runtime (GitHub), Environment.cs と Environment.Windows.cs.
GetCommandLineArgsが起動時に初期化された配列(s_commandLineArgs)を返し、それが無いホストされたライブラリではフォールバックとしてGetCommandLineWの戻り値をランタイム自身のSegmentCommandLineで分割すること、その規則が MSVC のmain関数のドキュメントに従っていること、CommandLineToArgvWは挙動がわずかに異なるため使っていないことについて。 ↩ ↩2 ↩3 -
Microsoft Learn, Main() and command-line arguments.
Mainのargsが null にならないこと、C/C++ と異なりプログラム名がargsの先頭に含まれずGetCommandLineArgs()の先頭要素であることについて。 ↩ -
Microsoft Learn, dotnet command. アプリの実行が
dotnet [ランタイムオプション] <アプリのパス> [引数]の形で、アプリのパスより後ろがアプリに渡る引数であることについて。 ↩ -
Microsoft Learn, Environment.GetCommandLineArgs Method. 先頭要素が実行ファイル名であること、引数が空白で区切られ二重引用符で空白を含められること、単一引用符にその機能がないこと、偶数個/奇数個のバックスラッシュと引用符の規則、入力と結果の対応表について。 ↩
-
Microsoft Learn, ProcessStartInfo.Arguments Property. 文字列長が32,699未満であること、引数は対象アプリケーションが解釈するため相手の期待に合わせる必要があること、空白を含む引数を引用符で囲むと引用符自体は相手に渡らないこと、
ArgumentListと独立していることについて。 ↩ ↩2 -
Microsoft Learn, cmd.
&、|、( )が特殊文字であり^か引用符が必要なこと、引用符で囲むべき特殊文字の一覧、/c/k指定時に引用符が保持される条件(/sを使わない、引用符が1組、特殊文字を含まない、空白を含む、実行ファイル名である)と、条件を満たさない場合の先頭引用符の剥がし方について。 ↩ -
Microsoft Security Response Center, MS14-019 – Fixing a binary hijacking via .cmd or .bat file と Microsoft Security Bulletin MS14-019.
CreateProcessが .cmd / .bat を直接渡されたときに cmd.exe をカレントディレクトリから先に探していたため乗っ取られ得たこと、修正後は常にシステムの cmd.exe を使うこと、アプリケーションは cmd.exe の完全修飾パスを渡してバッチを引数にすべきという勧告について。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
親が落ちたあとに何が残るか ── Job Objectで子プロセスを飼う
UIを強制終了してもSDKのヘルパーが残り、カメラやCOMポートを握ったままになるのはなぜか。Job Objectでプロセスツリーを一つの単位にし、KillOnJobCloseと完了ポートで子プロセスの寿命を設計する方法を計測アプリ目線で解説します。
名前付きパイプの実務 ── Windowsプロセス間通信の定番を設計からセキュリティまで
Windowsのプロセス間通信の定番・名前付きパイプを実務目線で解説します。バイト/メッセージモードの選択、複数クライアントを捌くサーバー設計、ACLと偽装のセキュリティ、.NETのNamedPipeStreamまで、一次情報にもとづき整理します。
Time Travel Debugging ── 長期稼働で再現しない不具合を「録画」して巻き戻す
月に一度しか出ない不具合は、クラッシュダンプでは結果しか写りません。WinDbgのTime Travel Debugging(TTD)で実行を録画して巻き戻す方法を、TTD.exeの録画設計、リングバッファ、TTD.Callsクエリ、ダンプとの使い分けまで解説します。
Win32スレッドプールAPI ── CreateThreadpoolWorkで「スレッドを作らない」並行処理
ネイティブコードでCreateThreadを乱立させていませんか。Vistaで刷新されたWin32スレッドプールAPIのwork・timer・wait・ioの4オブジェクト、クリーンアップグループ、コールバックでの禁止事項までを一次情報にもとづいて解説します。
DllMainとローダーロック ── 「DLLの初期化で何もするな」と言われる本当の理由
DllMainでLoadLibraryやスレッド同期をしてはいけないのはなぜか。全DLL通知を直列化するローダーロックの仕組みから、デッドロックが成立する典型シナリオ、遅延初期化などの正しい設計、ハング調査の手順までを一次情報で解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- Windowsには引数の配列を渡すAPIはないのですか?
- ありません。CreateProcessが受け取るのはlpCommandLineという1本の文字列で、新しいプロセスにもその文字列が渡ります(先頭の実行ファイル名だけはOSがフルパスを補うことがあります)。argvの配列に見えるものは、受け取り側のプロセスの中でCランタイムの起動コード、CommandLineToArgvW、あるいは.NETランタイムが文字列を分割して作ったものです。したがって「引数を渡す」とは「相手のパーサが元どおりに分割してくれる文字列を組み立てる」ことと同じです。
- バックスラッシュはいつエスケープ文字になりますか?
- 直後に二重引用符が来るときだけです。二重引用符が続かないバックスラッシュは何個並んでいてもそのまま残ります。二重引用符の直前に2n個並んでいればn個のバックスラッシュと引用符の開閉、2n+1個ならn個のバックスラッシュと文字としての引用符になります。この非対称性のため、パスの末尾のバックスラッシュを引用符で囲むときだけ2倍にする必要があります。
- ProcessStartInfo.ArgumentListとArgumentsはどちらを使えばよいですか?
- 値が変数から来るならArgumentListです。1要素が1引数になり、必要な引用符付けとエスケープを.NETが行い、内部で1本の文字列に組み立ててからOSに渡します。Argumentsは自分で組み立てた文字列をそのまま渡すプロパティで、両者は独立していて同時には使えません。ただしArgumentListは.NET Core 2.1以降のAPIで、.NET Frameworkにはありません。.NET Frameworkでは本記事の組み立て関数でArgumentsを作ってください。
- 引用符で囲んだ引数の中で引用符を2つ並べる書き方は使えますか?
- 受け取り側で解釈が分かれるので、組み立てる側では使わないでください。ここで言うのは、中身のある引数を引用符で囲み、その内側で引用符を2つ隣接させる形です。空の引数を表す""(引用符2つだけ)は別物で、こちらは空文字列を渡す正しい書き方です。MSVCのCランタイムの規則では、引用符で囲まれた文字列の中の2連続の引用符は1つの引用符として扱われますが、CommandLineToArgvWの公式の規則にはこの扱いが書かれておらず、.NETランタイムのソースも「2008年より前と後のVCで解釈が異なるため生成しない」と明記しています。引用符を文字として渡したいときは、バックスラッシュを前に置く形にすれば、どのパーサでも同じ結果になります。
- 実行ファイルのパスに空白があるとき、CreateProcessに何を渡せば安全ですか?
- lpApplicationNameに実行ファイルのフルパスを渡し、lpCommandLineの先頭にも引用符で囲んだ同じパスを置くのが確実です。lpApplicationNameをNULLにすると、CreateProcessはlpCommandLineの先頭から空白区切りで実行ファイル名を推定します。C:\Program Files\MyApp -L -Sという文字列なら、まずC:\Program.exeの存在を試すため、そこに悪意のあるファイルがあればそちらが実行されます。公式ドキュメントもこの危険性を明記し、NULLを避けるか、パスを引用符で囲むよう求めています。
- バッチファイルに引数を渡すときも同じ規則ですか?
- 違います。バッチファイルはcmd.exeが解釈し、cmd.exeは引数を分割せずに生のコマンドライン文字列として扱います。&、|、丸括弧、^などの記号はcmd.exeの構文として動作するため、CommandLineToArgvWの規則で引用符付けしても安全にはなりません。公式ドキュメントは、信頼できない入力をバッチファイルに渡さないよう警告しています。値はファイルに書いてバッチではなく下流のexeに読ませるか、バッチの中身をPowerShellや自前のexeに移してください。環境変数に入れても、バッチの中で%VAR%と展開すれば記号がcmd.exeに再解釈されるので境界にはなりません。