「測試時明明過了,可是在路徑含空白的電腦上外部工具就是啟動不了」「傳了 C:\data\ 之後,連下一個引數都被併成一個」「用引數傳 JSON,引號卻不見了,對方剖析失敗」── 這些都是啟動子處理程序的程式碼裡反覆出現的問題。原因大多不在邏輯,而在於寫法沒有把 Windows 根本不存在傳遞「引數陣列」的機制當成前提。
在 Windows 上建立處理程序的 CreateProcess 接收的是 lpCommandLine 這一條字串。無論呼叫端多麼仔細地準備好陣列,跨越作業系統邊界時一定會被串成一條,接收端再重新分割一次。分割規則由接收端的執行階段決定,C 執行階段、CommandLineToArgvW、.NET 執行階段、cmd.exe 各自是不同的程式碼。所謂傳引數,就是組裝一條讓對方的剖析器能原樣分割回來的字串。
本文站在從 Win32 與 .NET 的程式碼(而不是 PowerShell 指令碼)啟動子處理程序的立場,整理字串在哪裡被串接、在哪裡被分割、遵循哪些規則。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["一條命令列字串"]
str --> parser["對方 exe 的剖析器"]
net -.->|"本文的範圍"| parser
圖 1: PowerShell 之下還有 .NET 與 Win32 兩層,不論從哪一層啟動,最後都會變成一條字串。本文處理的就是這一層的規則。
1. 先講結論
- Windows 的處理程序收不到引數陣列。傳給
CreateProcess的那一條字串會送到新的處理程序(只有開頭的執行檔名稱可能由作業系統補上完整路徑),GetCommandLineW傳回的就是它。argv由接收端自己建立。1 2 - 分割規則的主體只有三條:以空白和 Tab 分隔、用雙引號括起來的範圍不分隔、反斜線只有緊接著雙引號時才特別處理(2n 個就是 n 個加上引號的開合,2n+1 個就是 n 個加上一個當作字元的引號)。3 4
- 只有開頭的符記(
argv[0],也就是執行檔名稱)走另一套規則:可以用引號括起來,但反斜線的跳脫不生效。把lpApplicationName設為NULL,含空白的路徑就變得可以有多種解讀,C:\Program.exe會先被嘗試。1 4 - 組裝的一方只需要一條規則:「含空白或引號、或者是空字串就用引號括起來,把引號緊鄰前面與結尾的反斜線加倍,引號寫成
\"」。.NET Core 2.1 以後的ProcessStartInfo.ArgumentList會替你做這件事。5 6 - 在有內容的引數內部讓兩個引號相鄰的形式(
"ab""c"這樣的寫法)在接收端會有不同解讀,所以不要產生。表示空引數的""是另一回事,那是正確的寫法。cmd.exe 與批次檔在這套規則之外,所以不要讓不可信的值通過它們。6 7 - 上限方面,
lpCommandLine是 32,767 個 UTF-16 字碼單位(含結尾的 null 字元;表情符號等代理字元對算兩個),cmd.exe 是 8,191 個字元。快要超過時,只有在對方能讀@file這類回應檔(或者能改成讀得了)的前提下,才切換到回應檔。1 8
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 28 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
2. 引數陣列並不存在 ── CreateProcess 與一條字串
CreateProcessW 的第 2 個參數 lpCommandLine,是把執行檔名稱與引數用空白排開的一條以 null 結尾的字串。長度上限是含結尾 null 字元在內的 32,767 個 UTF-16 字碼單位(也就是 wchar_t 的個數;表情符號等代理字元對一個字就消耗兩個,所以不能用肉眼看到的字數做事前檢查);而且 Unicode 版本可能改寫這條字串,所以傳字串常值或 const 緩衝區可能造成存取違規。1
這條字串會原樣當成新處理程序的處理程序參數傳過去,子處理程序用 GetCommandLineW 取出它。作業系統有時會替開頭的執行檔名稱補上完整路徑,所以子處理程序看到的字串和父處理程序傳入的並不完全一致。2 GUI 應用程式的 WinMain 收到的 lpCmdLine,是從這條字串裡去掉程式名稱之後的部分。9
flowchart TB
accTitle: 引數送到子處理程序為止的路徑
accDescr: 呼叫端的引數陣列在 CreateProcess 的 lpCommandLine 處被串成一條字串傳給新的處理程序,子處理程序用 GetCommandLineW 取出字串後再用自己的剖析器分割出 argv
arr["呼叫端的引數陣列"] --> join["串成一條字串(呼叫端的責任)"]
join --> cp["CreateProcessW 的 lpCommandLine"]
cp --> peb["新處理程序的處理程序參數"]
peb --> gcl["GetCommandLineW 傳回的字串"]
gcl --> parse["接收端的剖析器分割"]
parse --> argv["argv / args 陣列"]
圖 2: 陣列不會跨越邊界。串接是呼叫端的責任,分割是接收端的責任,只有兩邊的規則一致,原本的陣列才能被還原。
這裡要記住的是,串接與分割發生在不同的處理程序、不同的程式碼裡。呼叫端不知道「對方用什麼來分割」就無法正確串接,而接收端無從得知「它是怎麼被串接的」。在 Unix 系作業系統上可以把陣列直接交給 execve,所以不存在這個問題。這是 Windows 特有、卻伴隨每一次處理程序啟動的前提。
3. 由誰來分割 ── 三個剖析器
在接收端把字串切成 argv 的程式碼主要有三處。
| 接收端 | 負責分割的程式碼 | 被呼叫的情境 |
|---|---|---|
C/C++ 的 main / wmain |
MSVC 的 C 執行階段啟動程式碼 | 程式開始時自動建立 argc / argv4 |
| 直接使用 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 執行階段本身的分割程式碼這三條線,它們實作的是同一套骨架的規則,但並不是同一份程式碼。用 apphost 或 dotnet.exe 啟動的 .NET 應用程式,主機本身就是用 MSVC 的 C 執行階段建置的 wmain 程式,所以實際上是依第一條線(C 執行階段啟動程式碼)的規則分割的。.NET 執行階段的原始碼裡還留著一段註解,說 CommandLineToArgvW 的行為稍有不同,所以不使用它。12 差異出現在後面會談的 "" 的處理這類邊緣情形上,日常的引數幾乎踩不到,但如果以為「反正規則一樣,什麼都能通過」,就會在邊緣處出問題。
flowchart TB
accTitle: 接收端的三個剖析器
accDescr: GetCommandLineW 傳回的一條字串,在 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: 負責分割的程式碼有三條線。用 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. 分割的規則 ── 空白、引號、反斜線
以下就 argv[1] 之後的部分,整理三個剖析器共通的規則。3 4
- 引數以空白或 Tab 分隔。
- 被雙引號括起來的範圍,即使含空白也算一個引數。引號本身不會進入引數。引號可以從引數中途開始;如果沒有閉合就到了字串結尾,那麼到結尾為止就是最後一個引數。
- 反斜線視為一般字元。只有緊接著雙引號時,下面的規則才會生效。
- 雙引號緊鄰前面有 2n 個反斜線時,輸出 n 個反斜線,引號發揮「開始/結束括起來」的作用。
- 雙引號緊鄰前面有 2n+1 個反斜線時,輸出 n 個反斜線與一個當作字元的引號,括起來的狀態不變。
- 插入號(
^)不是跳脫字元(那是 cmd.exe 的規則,不是剖析器的規則)。
剖析器持有「是否在引號內」這一位元的狀態,一邊在遇到引號時翻轉它,一邊由左而右讀取字串。要不要以空白分隔,就由這個狀態決定。
flowchart TB
accTitle: 一邊切換引號內外一邊讀取的分割流程
accDescr: 剖析器在引號外以空白分隔引數,遇到引號就進入內側,把空白也當成引數的一部分,再遇到引號又回到外側。反斜線只有在緊接著引號時才會被特別處理
out["引號外:以空白分隔"] -->|"遇到引號"| inq["引號內:空白也是引數的一部分"]
inq -->|"遇到引號"| out
out -->|"反斜線後面緊接著引號"| bs["套用反斜線規則"]
inq -->|"反斜線後面緊接著引號"| bs
bs -->|"2n 個:輸出 n 個並開合"| toggle["翻轉括起來的狀態"]
bs -->|"2n+1 個:輸出 n 個與當作字元的引號"| lit["括起來的狀態維持不變"]
圖 4: 分割的主體只由「在引號內還是引號外」這一位元,以及引號緊鄰前面的反斜線個數決定。
與其用文字去記規則,不如按輸入與輸出的對應關係來看更可靠。
| 命令列的一部分(輸入) | 得到的引數 | 生效的規則 |
|---|---|---|
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\ 之後連下一個引數都被併成一個」的真相。用引號把路徑結尾的反斜線括進去的那一刻,閉合引號就變成了字元,括起來的範圍再也不會閉合。
flowchart TB
accTitle: 結尾的反斜線把下一個引數捲進來的機制
accDescr: 用引號括起結尾是反斜線的路徑時,本該閉合的引號因為緊鄰前面有一個反斜線而被解讀成當作字元的引號,括起來沒有閉合,於是連下一個引數也被當成同一個引數讀了進來
a["用引號括起來的路徑結尾有 1 個反斜線"] --> b["閉合引號緊鄰前面是奇數個"]
b --> c["引號被當成字元輸出,括起來沒有閉合"]
c --> d["之後的空白不再是分隔符"]
d --> e["連下一個引數也當成同一個引數送達"]
a -.->|"把反斜線加倍"| ok["括起來閉合,引數被分開"]
圖 5: 「把結尾的反斜線加倍」之所以必要的理由。不知道規則就寫出來的加引號,會在路徑結尾壞掉。
會顯現實作差異的「括起來內部的兩個連續引號」
MSVC 的 C 執行階段的規則裡還有一項:「被引號括起來的字串中連續的兩個引號,視為一個引號」(指 "ab""c" 這樣的形式,和表示空引數的 "" 是兩回事)。4 但 CommandLineToArgvW 的官方規則裡沒有這一項,.NET 執行階段的組裝程式碼也因為「閉合引號後面緊接的引號,在 2008 年之前與之後的 VC 上解讀不同」,而明確避免產生這種形式。6
站在接收端,知道「也可能收到這樣的輸入」就夠了。站在組裝端,想把引號當作字元傳過去時,請只用 \" 這種形式。任何剖析器都會得到相同的結果。
5. argv[0] 走另一套規則 ── lpApplicationName 與 Program.exe 問題
開頭的符記,也就是執行檔名稱,不在前面這些規則的適用範圍內。前提是它必須是檔案系統上有效的路徑字串,所以可以用引號括起來以包含空白,但反斜線的跳脫規則不適用,也沒有辦法把引號本身放進 argv[0]。4 3 .NET 的組裝程式碼對第一個元素也是另一套處理:「有空白就只用引號括起來,含引號就擲回例外」。6
在呼叫端會出問題的,是把 CreateProcess 的 lpApplicationName 設為 NULL 時的行為。這種情況下,要執行的模組是從 lpCommandLine 的開頭那個以空白分隔的符記推測出來的。路徑裡有空白就會產生多個候選,作業系統依由短到長的順序逐一嘗試。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,跑起來的就會是它而不是原本的應用程式,並要求不要傳 NULL 給 lpApplicationName;如果要傳,就得把開頭的路徑用引號括起來。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] 上的設計,在這種分離之上是站不住腳的。
還有一點,lpApplicationName 為 NULL 時,lpCommandLine 中執行檔名稱的部分會被限制在 MAX_PATH。1 長路徑的處理請參閱「MAX_PATH 與 Windows 路徑・檔案名稱的陷阱」。
6. 組裝端的規則 ── 一個函式就夠
知道了分割規則,只要反過來走一遍,就能造出「對方能原樣分割回來的字串」。對 argv[1] 之後的每個引數做下面的處理。6
- 不是空字串,而且既不含空白也不含引號,就原樣排上去。
- 其餘情況把整體用引號括起來。括起來的內部:
- 引號緊鄰前面排著的 k 個反斜線改成 2k+1 個,然後再放引號(弄成奇數個,讓它成為「當作字元的引號」)。
- 結尾排著的 k 個反斜線改成 2k 個(它在閉合引號緊鄰前面,所以弄成偶數個,讓引號發揮「結束括起來」的作用)。
- 其餘的反斜線維持原樣。
- 空字串排成
""。
flowchart TB
accTitle: 組裝一個引數的判斷流程
accDescr: 引數不為空且既不含空白也不含引號時就原樣排上去,其餘情況用引號括起來,引號緊鄰前面的反斜線加倍再加一,結尾的反斜線加倍,引號前面加上反斜線,最後閉合
s["接收一個引數"] --> 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: 組裝是分割規則的反向映射。分支只有三個,只要在結尾與引號緊鄰前面調整反斜線的個數,那麼只要接收端依 CommandLineToArgvW・C 執行階段・.NET 相同的分割規則(第 4 章)以寬字元原樣分割(用自己的語法解讀原始命令列的對方,或者中途夾著 shell 的剖析器的情況不在此列),並且沒有啟用 wsetargv.obj 那樣的萬用字元展開,那麼只要不含 NUL 字元、組裝出來的整體不超過 lpCommandLine 的上限(含結尾 null 字元在內的 32,767 個 UTF-16 字碼單位),任何字串都能往返(命令列是以 null 結尾的字串,所以只有 NUL 字元原理上傳不了。在啟用了萬用字元展開的對方那裡,含 * 或 ? 的引數會被換成檔案名稱。參見第 8 章。超過上限的字串 CreateProcessW 不接受。參見第 10 章)。
這條規則原樣反映了「反斜線只在引號緊鄰前面才特別」這種不對稱性。沒必要機械地把路徑分隔用的反斜線都加倍,重點在於只動引號緊鄰前面與結尾。
7. 在 .NET 上的實作 ── ArgumentList 與 Arguments
.NET Core 2.1 以後的 ProcessStartInfo 提供了替你完成這項組裝的 ArgumentList。一個元素就是一個引數,加入的字串不需要事先跳脫,到了 Process.Start 的時刻由 .NET 在內部組裝成一條字串再交給作業系統。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 是把你自己組裝好的一條字串原樣傳過去的屬性。兩者互相獨立,使用其中一個時另一個必須是空的。16 官方文件也建議,如果對加引號沒有把握就選 ArgumentList。5
flowchart TB
accTitle: ArgumentList 與 Arguments 變成字串的位置
accDescr: ArgumentList 由 .NET 逐個元素跳脫並組裝成一條字串後再交給 CreateProcess,Arguments 則把呼叫端組裝好的字串原樣傳過去。兩者送到作業系統時都是一條字串
al["ArgumentList(1 個元素 = 1 個引數)"] --> esc[".NET 逐個元素跳脫並串接"]
ar["Arguments(自己組裝的一條字串)"] --> pass["原樣傳遞"]
esc --> cmd["一條命令列字串"]
pass --> cmd
cmd --> cp["CreateProcess"]
圖 9: 不論用哪一個,送到作業系統的都是一條字串。差別只在於「由誰來組裝」,而 ArgumentList 就是把它交給懂規則的一方。
ArgumentList 的組裝程式碼就是第 6 章的規則本身:不為空且既不含空白也不含引號就原樣,其餘情況用引號括起來,引號緊鄰前面的反斜線加倍再加一,結尾的反斜線加倍,引號前面一定加上反斜線。它不會產生在有內容的引數內部讓引號相鄰的形式。只有空引數會排成 "",而這是正確的寫法。6
在 .NET Framework 上要自己組裝
ArgumentList 是 .NET Core 2.1 以後的 API,.NET Framework 的 ProcessStartInfo 裡沒有。5 在 .NET Framework 4.8 的應用程式,或以它為基礎的公司內部工具裡,要自己寫出第 6 章的規則並交給 Arguments。
// 給 .NET Framework 用。組裝要交給 ProcessStartInfo.Arguments 的一條字串。
// 規則與 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); // 結尾:在閉合引號緊鄰前面,所以加倍
}
else if (arg[i] == '"')
{
sb.Append('\\', backslashes * 2 + 1).Append('"'); // 引號緊鄰前面:加倍再加一
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"]
v -->|"Framework"| own["用自寫函式組裝 Arguments"]
al --> no["不手寫引號"]
own --> no
圖 10: 手段有兩種,但原則只有一條。守住「不手寫引號」,就不會發生在路徑結尾壞掉的問題。
另外,UseShellExecute = true 時走的是 ShellExecuteEx 而不是 CreateProcess,ArgumentList 的內容會成為交給 shell 的參數。在開啟文件或 URL 的用途裡,實際處理常式的命令列是由檔案關聯組裝的,所以這邊組裝的字串未必會原樣送到對方。需要重新導向輸出或確實取得結束代碼時,請設 UseShellExecute = false,並採用同時讀取標準輸出與標準錯誤的設計。這部分整理在「Windows 應用程式安全處理子處理程序的檢查清單」裡。
8. 在 C++ / Win32 上的實作
在 C++ 裡,組裝與分割兩邊都要自己寫。組裝就是把第 6 章的規則原樣寫成函式。
#include <windows.h>
#include <string>
#include <stdexcept>
#include <string_view>
#include <vector>
// 追加一個 argv[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'\\'); // 結尾:加倍
break;
}
if (arg[i] == L'"') {
cmd.append(backslashes * 2 + 1, L'\\'); // 引號緊鄰前面:加倍再加一
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); // 忘了關閉的話,每啟動一次就漏掉一個控制代碼
flowchart TB
accTitle: 傳給 CreateProcessW 的兩個參數的分工
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 形式。傳回值用一次 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] 是執行檔名稱。作業系統有時會補上完整路徑
}
LocalFree(argv);
如果用 main / wmain,C 執行階段會在啟動時替你做同樣的事。不過 main 的 argv 是轉換成目前字碼頁的窄字串,所以字碼頁表示不了的字元(例如放在非日文環境的電腦上的日文路徑)會在這裡遺失。第 6 章的組裝函式之所以「能往返」,針對的是 wmain・CommandLineToArgvW・.NET 這類以寬字元原樣分割的接收端。預設不展開萬用字元,但連結了 setargv.obj(wmain 則是 wsetargv.obj)之後就會展開 * 和 ?。4 如果要把檔案名稱含 * 的引數傳給採用了這種設定的對方,送達的引數就會和這邊的意圖不同。
9. 中間夾著 cmd.exe 與批次檔時
到這裡為止的規則,適用於從 CreateProcess 直接送到對方 exe 的情況。中間夾進 cmd.exe 之後,就多了一層解讀。
cmd.exe 把 &、|、(、) 當成語法處理,要把它們當成引數傳過去,就需要用 ^ 跳脫或用引號括起來。/c 與 /k 後面的字串對引號的處理有自己的規則,是否剝掉外側的引號會隨 /s 的有無、引號的個數、是否含特殊字元而改變。17 而且批次檔不分割引數,它把命令列當成原始字串接收。PowerShell 的官方文件明確警告不要把不可信的輸入傳給批次檔。7 CreateProcess 的文件寫明,要啟動批次檔就得把 cmd.exe 指定給 lpApplicationName 並傳入 /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 時分割只有對方剖析器的那一次,經由 cmd.exe /c 就加上了 cmd.exe 的語法解讀,而批次檔收到的是原始字串,所以加引號的規則每多一段就變一次
direct["自己的處理程序 → 對方的 exe"] --> p1["分割只有對方剖析器的那一次"]
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。代理字元對算兩個) | 1 |
lpApplicationName 為 NULL 時的執行檔名稱部分 |
MAX_PATH |
1 |
| cmd.exe 的命令列(含批次檔內的行) | 8,191 個字元 | 8 |
.NET 的 ProcessStartInfo.Arguments |
字串長度(UTF-16 字碼單位)小於 32,699 | 16 |
把檔案清單那樣長度可變的值排在引數裡的設計,總有一天會在數量增加時踩到上限。接近上限的用途,請切換成把引數寫進一個檔案、只傳那個檔案的路徑的「回應檔」方式。cmd.exe 限制的官方因應辦法也是同一個方法。8 不過 CreateProcess 與 cmd.exe 都不會擅自展開檔案。這種方式能成立,只限於對方的程式能用 @file 這類語法讀回應檔,或者能把對方改成讀得了。如果對方是動不了的現成 exe,就只能把呼叫拆分到上限之內。
flowchart TB
accTitle: 用引數傳遞可變長度的值的設計的極限與因應
accDescr: 把檔案清單等可變長度的值排在引數裡,數量增加就會達到 cmd.exe 的 8191 個字元或 CreateProcess 的 32767 個 UTF-16 字碼單位的上限。對方讀得了回應檔(或者能改成讀得了)時就切換成把值寫進檔案、只傳路徑的回應檔方式,對方是讀不了的現成 exe 時就拆分呼叫
list["把可變長度的值(檔案清單等)排在引數裡"] --> grow["數量增加,字串就變長"]
grow --> lim["達到上限(cmd.exe 8,191 / CreateProcess 32,767)"]
lim --> fail["某一天突然啟動失敗"]
fail -.->|"對方讀得了回應檔"| resp["把值寫進檔案,只傳路徑(回應檔)"]
fail -.->|"讀不了的現成 exe"| split["拆分呼叫"]
圖 13: 上限屬於「今天還沒事」的那類問題。會隨數量成比例變長的引數,只要對方讀得了回應檔(或者能改成讀得了),一開始就該那樣做。
11. 確認實際送到了什麼
在憑猜測增加引號之前,看一眼送到對方的引數才是最短的路。要看的有「呼叫端組裝出來的字串」「送到對方那邊的字串」「分割後的陣列」這三樣,手段有四種。在此之前先有一個約定:不論用哪種手段,把命令列寫入記錄時都要先把機密遮蔽掉。 如果設計上引數裡含有密碼、API 金鑰、權杖,那麼不論是呼叫端的記錄還是對方的啟動時記錄,照原樣寫下去機密就會留在記錄裡。記錄保存得比處理程序更久,也會被更多人看到。而且命令列本身就是後面要談的 Process Explorer 那樣、同一台機器上的其他處理程序也讀得到的東西,所以根本的對策是不用引數傳密碼與權杖,改用標準輸入或受保護的設定儲存區等別的途徑,記錄裡的遮蔽是在此之上的一層防備。請解讀分割後的引數(在呼叫端則是組裝之前的元素),把可能成為機密的選項的值遮蔽掉之後再記錄;或者把原始字串的記錄只在受限的診斷模式下啟用。
- 在呼叫端,把組裝出來的字串寫入記錄。 就是交給
CreateProcess之前的lpCommandLine。這項比對的前提是UseShellExecute = false或直接呼叫CreateProcess的啟動方式。如果用UseShellExecute = true開啟文件或 URL,實際的命令列是經由ShellExecuteEx由檔案關聯組裝的(第 7 章),所以就算沒有 cmd.exe 與批次檔,呼叫端的字串與對方的字串也會不一致,那不是第 9 章的問題。如果用的是 .NET 的ArgumentList,把元素的排列原樣記下來是不能拿來比對的。元素是尚未加引號、也尚未把結尾反斜線加倍的值,而交給作業系統的是 .NET 整理之後的字串。請用與第 7 章的BuildArguments相同的規則,從元素重新建構出一條字串再記錄(結果與ArgumentList內部所做的整理相同),或者把元素的排列直接與分割後的陣列比較。只有這一項能看到「呼叫端原本的緩衝區」,後面談的 Process Explorer 與對方那一側的記錄,只要中間夾著 cmd.exe 或批次檔,看到的就只是那一段重新造出來的字串。記錄時依開頭的約定,把可能成為機密的元素的值遮蔽掉之後再留下(遮蔽掉的元素會和對方的字串不一致,所以比對時要把那個元素排除在外)。 - 準備一個只用來顯示引數的 exe。 用它代替對方的 exe 啟動,讓它把收到的
args一行一筆地輸出。直接把值寫出來的話,含換行或控制字元的引數會看起來像多行、或者覆寫前後的行,導致數錯,所以要輸出跳脫成 JSON 字串的形式與長度(跳脫是可逆的,可以還原成原值)。不過如第 3 章所說剖析器有三條線,在括起來內部兩個連續引號這類邊緣形式上解讀會有分歧。請使用與對方同一執行階段建置的顯示用 exe(對方是 MSVC 的 C/C++ 就用wmain的 C++,是 .NET 就用 .NET)。如果對方就是自己團隊的程式,那麼不夾顯示用 exe,直接在對方自己啟動時把argv依下一項的遮蔽規則寫入記錄最為可靠。給 .NET 用的話,下面幾行就夠了。
using System.Text.Encodings.Web;
using System.Text.Json;
// 換行、控制字元、引號、反斜線都跳脫,日文等非 ASCII 字元原樣輸出
var json = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
Console.WriteLine("CommandLine: " + JsonSerializer.Serialize(Environment.CommandLine, json)); // 一條字串
for (int i = 0; i < args.Length; i++)
Console.WriteLine($"[{i}] len={args[i].Length} {JsonSerializer.Serialize(args[i], json)}");
// 分割之後。一筆一定佔一行,空字串以 len=0 與 "" 的形式可見。len 是 UTF-16 字碼單位
- 用 Process Explorer 看子處理程序的命令列。 在處理程序的屬性裡會顯示子處理程序持有的命令列字串。這是確認「送到對方那邊的字串」的手段,看不到「分割後的陣列」。顯示的是子處理程序那一側持有的字串,所以如第 2 章所述,開頭的執行檔名稱可能已被作業系統補上完整路徑;而且如果夾著 cmd.exe 或批次檔,看到的就是 cmd.exe 重新造出來的字串。重點是不要只因為開頭符記不同就慌張,以及呼叫端原本的字串只能從第 1 項的記錄裡得知。用法整理在「Process Explorer / Handle / VMMap 實戰指南」裡。
- 在自己的應用程式啟動時,把收到的命令列寫入記錄。 現場說「啟動不了」的時候,只要留著是用什麼字串啟動的,就能第一時間釐清是不是引數的問題。這裡同樣不能把
GetCommandLineW的傳回值原樣保存。請依開頭的約定,解讀分割後的引數,把可能成為機密的值遮蔽掉之後再記錄;或者把原始字串的記錄只在受限的診斷模式下啟用。
比對的順序如下。先比較呼叫端的字串(第 1 項)與對方的字串(第 3 項或第 4 項)。除了開頭的執行檔名稱之外還不一致,就表示中間某一段做了變形。直接啟動的話就是 cmd.exe 或批次檔(第 9 章),UseShellExecute = true 的話就是 shell 的檔案關聯(第 7 章)。換成第 6 章的函式也修不好。如果一致,就把那條字串與分割後的陣列(第 2 項)對上。如果依規則分割了卻不是想要的陣列,問題在組裝端;如果沒有依規則分割,問題在接收端的剖析器。
flowchart TB
accTitle: 釐清引數問題的順序
accDescr: 先比較呼叫端組裝出來的字串的記錄,和用 Process Explorer 或對方的啟動時記錄看到的對方那一側的字串。除了開頭的執行檔名稱之外還不一致,就表示中間某一段(直接啟動就是 cmd.exe 或批次檔,UseShellExecute=true 就是 shell 的檔案關聯)做了變形。一致的話就與分割後的陣列對上,如果依規則分割了卻不是想要的陣列就判斷為組裝端的問題,沒有依規則分割就判斷為接收端剖析器的問題
s["引數不對勁"] --> caller["看呼叫端組裝出來的字串(呼叫端的記錄)"]
caller --> target["看對方那一側的字串(Process Explorer / 對方的啟動時記錄)"]
target --> same{"除了開頭的執行檔名稱之外是否一致?"}
same -->|"否"| mid["中間某一段做了變形(參見第 9 章・第 7 章)"]
same -->|"是"| arr["看分割後的陣列(與對方同一執行階段的顯示用 exe)"]
arr --> cmp{"字串與陣列是否依規則對應?"}
cmp -->|"是:分割了卻不是想要的陣列"| build["組裝端的問題:換成第 6 章的函式"]
cmp -->|"否:沒有依規則分割"| recv["接收端剖析器的問題"]
圖 14: 把「呼叫端的字串」「對方的字串」「陣列」這三樣依序比過去,責任在中間某一段、在組裝端、還是在接收端,就能機械地判定出來。憑猜測增加跳脫,等做完這項確認再說也不遲。
12. 大致的取捨(判斷表)
| 情況 | 該做的事 |
|---|---|
| 從 .NET Core 2.1 以後 / .NET 5 以後啟動 exe | 逐個元素加入 ProcessStartInfo.ArgumentList |
| 從 .NET Framework 啟動 exe | 用第 6 章規則的函式組裝 Arguments。不手寫引號 |
| 從 C++ 啟動 | 傳入 lpApplicationName,lpCommandLine 依規則組裝到可寫入的緩衝區裡 |
| 想讓引數的值裡含引號 | 只用 \" 這種形式。不要在有內容的引數內部讓引號相鄰 |
| 路徑結尾是反斜線 | 要括起來就把結尾加倍。沒有空白就不要括起來 |
| 想傳空引數 | 放一個 ""。省略的話整個引數都會消失 |
| 執行檔的路徑含有空白 | 傳入 lpApplicationName,開頭的符記也用引號括起來 |
只能呼叫 .bat |
不讓批次檔去解讀外面來的值。寫進檔案讓下游的 exe 去讀(在批次檔裡用 %VAR% 展開的環境變數不構成邊界) |
| 引數會變長 | 對方讀得了回應檔(或者能改成讀得了)就切換成回應檔。是現成的 exe 就拆分呼叫 |
| 不知道送到了什麼 | 把呼叫端的記錄、對方那一側的字串(Process Explorer / 啟動時記錄)、分割後的陣列(與對方同一執行階段的顯示用 exe)這三樣依序對上 |
13. 總結
Windows 的命令列引數不是以陣列、而是以一條字串的形式跨越邊界的。串接的是呼叫端,分割的是接收端,而分割規則歸結為三條:「以空白分隔」「用引號括起來」「只有引號緊鄰前面的反斜線特別」。只有開頭的執行檔名稱走另一套規則,省略 lpApplicationName 就會讓含空白的路徑變得可以有多種解讀。
組裝端要做的事裝得進一個函式,.NET Core 2.1 以後由 ArgumentList 來承擔。執行檔要把完整路徑傳給 lpApplicationName,並在 lpCommandLine 的開頭也放上用引號括起來的同一條路徑(.NET 的話交給 FileName)。不要產生在有內容的引數內部讓引號相鄰的形式(表示空引數的 "" 是另一回事),不要讓外面來的值通過 cmd.exe 與批次檔,會隨數量成比例變長的引數,只有在對方讀得了回應檔(或者能改成讀得了)時才改用回應檔,否則就拆分呼叫。守住這 5 點,就不會發生「只有在有空白的電腦上啟動不了」「結尾的反斜線讓下一個引數消失」這類問題。
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 點可以防止空白與結尾反斜線造成的問題。
不順利的時候,在憑猜測增加跳脫之前,請看呼叫端組裝出來的字串、送到對方那邊的字串、分割後的陣列這三樣。呼叫端與對方的字串不同就是中間某一段(cmd.exe 或批次檔,UseShellExecute = true 的話就是 shell 的檔案關聯),相同的話就由字串與陣列的對應關係決定是組裝端還是接收端。
相關文章
- 從 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 字碼單位)的一條字串、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 形式、以及因為作業系統會替執行檔名稱補上完整路徑而可能與父處理程序傳給CreateProcess的字串不一致等內容。 ↩ ↩2 -
Microsoft Learn, CommandLineToArgvW function (shellapi.h). 關於雙引號緊鄰前面的反斜線的特別處理(2n 個是 n 個加上括起來的開合,2n+1 個是 n 個加上當作字元的引號,後面沒接引號就原樣)、在「引號內」模式下空白會成為引數的一部分、開頭的程式名稱加不加引號都可以、
lpCmdLine以空白開頭時第一個引數會是空字串、傳空字串時會傳回目前執行檔的路徑、以及傳回值用一次LocalFree釋放等內容。 ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn,
mainfunction and command-line arguments. 關於 Microsoft C/C++ 的啟動程式碼解讀命令列的規則(以空白和 Tab 分隔、argv[0]可以用引號括起來但後續規則不適用、引號括起來的字串是一個引數、插入號不是跳脫字元、引號內兩個連續的引號是一個引號、沒有閉合引號時到結尾為止是最後一個引數、偶數個/奇數個反斜線的處理)、輸入與argv的對應表、setargv.obj帶來的萬用字元展開、以及同時指定lpApplicationName與lpCommandLine時argv[0]可能不是執行檔名稱因而應當用GetModuleFileName取得等內容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 -
Microsoft Learn, ProcessStartInfo.ArgumentList Property. 關於加入的字串不需要事先跳脫、
ArgumentList與Arguments互相獨立且不能同時使用、ArgumentList會跳脫引數並在內部組裝成一條字串於Process.Start時交給作業系統、對加引號沒有把握時應選ArgumentList、與不可信資料一起使用的危險、以及適用範圍是 .NET Core 2.1 以後等內容。 ↩ ↩2 ↩3 ↩4 -
dotnet/runtime (GitHub), PasteArguments.cs 與 PasteArguments.Windows.cs.
ArgumentList內部使用的組裝程式碼。關於不為空且既不含空白也不含引號的引數原樣保留、其餘情況用引號括起來並把結尾的反斜線加倍、引號緊鄰前面的反斜線加倍再加一、引號前面一定加上反斜線、因為閉合引號後面緊接的引號在 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),而在沒有這個陣列的被主機載入的程式庫裡作為後備用執行階段本身的SegmentCommandLine分割GetCommandLineW的傳回值、其規則遵循 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、引號只有一組、不含特殊字元、含空白、是執行檔名稱),以及不滿足條件時開頭引號的剝除方式等內容。 ↩ -
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 的具名管道串流。
Time Travel Debugging ── 把長期運轉中無法重現的問題「錄下來」再倒回去
一個月才出現一次的問題,當機傾印只拍得到結果。本文說明如何用 WinDbg 的 Time Travel Debugging(TTD) 錄下執行並倒回,涵蓋 TTD.exe 的錄製設計、環形緩衝區、TTD.Calls 查詢,以及和傾印的分工。
Win32 執行緒集區 API ── 用 CreateThreadpoolWork 做「不自己建立執行緒」的並行
原生程式碼裡是不是到處都在呼叫 CreateThread?本文依據一手資料解說 Vista 全面重新設計的 Win32 執行緒集區 API:work、timer、wait、io 四種物件、清理群組,以及回呼裡禁止做的事。
DllMain 與載入器鎖定 ── 「DLL 初始化什麼都別做」真正的理由
為什麼不能從 DllMain 呼叫 LoadLibrary,也不能和其他執行緒同步。本文依一次資訊說明載入器鎖定如何序列化每個 DLL 通知、結構上必定成立的死結場景、延遲初始化的正確設計,以及無回應的調查方式。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- Windows 上沒有可以傳入引數陣列的 API 嗎?
- 沒有。CreateProcess 接收的是名為 lpCommandLine 的一條字串,傳給新處理程序的也是這條字串(只有開頭的執行檔名稱可能由作業系統補上完整路徑)。看起來像 argv 陣列的東西,是接收端的處理程序內部由 C 執行階段的啟動程式碼、CommandLineToArgvW 或 .NET 執行階段把字串分割出來的。因此「傳引數」就等於「組裝一條讓對方的剖析器能原樣分割回來的字串」。
- 反斜線什麼時候才是跳脫字元?
- 只有緊接著雙引號時才是。後面沒有跟著雙引號的反斜線,排幾個都會原樣保留。雙引號緊鄰前面排了 2n 個,就得到 n 個反斜線加上引號的開合;排了 2n+1 個,就得到 n 個反斜線加上一個當作字元的引號。正因為這種不對稱,只有在用引號把結尾是反斜線的路徑括起來時,才需要把它加倍。
- ProcessStartInfo.ArgumentList 和 Arguments 該用哪一個?
- 如果值來自變數,就用 ArgumentList。一個元素對應一個引數,需要的引號與跳脫由 .NET 完成,它在內部組裝成一條字串後再交給作業系統。Arguments 是把你自己組裝好的字串原樣傳過去的屬性,兩者互相獨立,不能同時使用。不過 ArgumentList 是 .NET Core 2.1 以後才有的 API,.NET Framework 裡沒有。在 .NET Framework 上請用本文的組裝函式來產生 Arguments。
- 在用引號括起來的引數裡連寫兩個引號,這種寫法可以用嗎?
- 接收端對它的解讀不一致,所以組裝的一方請不要使用。這裡說的是把有內容的引數用引號括起來,並在括起來的內部讓兩個引號相鄰的形式。表示空引數的""(只有兩個引號)是另一回事,那是傳空字串的正確寫法。依 MSVC 的 C 執行階段的規則,被引號括起來的字串內部連續的兩個引號會被當成一個引號,但 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 重新解讀,所以它不構成邊界。