“测试时明明通过了,可在路径含空白的电脑上外部工具就是起不来”“传了 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 - 切分规则的主体只有三条:用空白和制表符分隔、双引号括起来的范围不分隔、反斜杠只有紧跟着双引号时才特殊处理(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
- 参数用空白或制表符分隔。
- 被双引号括起来的范围,即使含空白也算一个参数。引号本身不进入参数。引号可以从参数中途开始;如果没有闭合就到了字符串末尾,那么到末尾为止就是最后一个参数。
- 反斜杠按普通字符处理。只有紧跟着双引号时,下面的规则才生效。
- 双引号紧前面有 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,运行的就会是它而不是本来的应用,并要求不要给 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] 上的设计,在这种分离之上是站不住脚的。
还有一点,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 的文档写明,启动批处理需要给 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 时切分只有对方解析器的那一次,经由 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 项的日志里得知。用法整理在“Sysinternals 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++ 的启动代码解释命令行的规则(用空白和制表符分隔、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 重新解释,所以它不构成边界。