「테스트에서는 통과했는데 경로에 공백이 들어 있는 PC에서 외부 도구가 실행되지 않는다」「C:\data\ 를 전달했더니 다음 인수까지 한데 묶여 하나가 되었다」「JSON을 인수로 전달했더니 따옴표가 사라져서 상대가 파싱에 실패했다」── 자식 프로세스를 시작하는 코드에서 반복해서 일어나는 문제입니다. 원인의 대부분은 로직이 아니라, Windows에는 「인수의 배열」을 전달하는 구조가 존재하지 않는다는 것을 전제로 삼지 않은 작성 방식에 있습니다.
Windows에서 프로세스를 만드는 CreateProcess가 받는 것은 lpCommandLine이라는 한 줄의 문자열입니다. 호출하는 쪽이 아무리 꼼꼼하게 배열을 준비해도, OS의 경계를 넘을 때는 반드시 한 줄로 이어 붙여지고 받은 쪽이 다시 한 번 분할합니다. 분할 규칙은 받는 쪽의 런타임이 정하는 것이며, 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["한 줄의 명령줄 문자열"]
str --> parser["상대 exe의 파서"]
net -.->|"이 글의 범위"| parser
그림 1: PowerShell 아래에는 .NET과 Win32 계층이 있고, 어느 쪽에서 시작해도 마지막에는 한 줄의 문자열이 됩니다. 이 글은 그 계층의 규칙을 다룹니다.
1. 우선 결론
- Windows의 프로세스에는 인수의 배열이 전달되지 않습니다.
CreateProcess에 전달한 한 줄의 문자열이 새 프로세스에 도착하고 (맨 앞의 실행 파일 이름만은 OS가 전체 경로를 채워 넣는 경우가 있습니다),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의 두 번째 매개 변수 lpCommandLine은 실행 파일 이름과 인수를 공백으로 나열한 한 줄의 null 종료 문자열입니다. 길이의 상한은 끝의 null 문자를 포함해 32,767 UTF-16 코드 단위 (wchar_t의 개수. 이모지 등 서로게이트 쌍 문자는 한 글자로 두 개를 소비하므로 눈에 보이는 글자 수로 사전 검사를 해서는 안 됩니다)이고, Unicode 버전은 이 문자열을 다시 쓰는 경우가 있으므로 문자열 리터럴이나 const 버퍼를 전달하면 액세스 위반이 발생할 수 있습니다.1
이 문자열은 새 프로세스의 프로세스 매개 변수로 그대로 전달되고, 자식 프로세스는 GetCommandLineW로 꺼냅니다. OS는 맨 앞의 실행 파일 이름에 전체 경로를 채워 넣는 경우가 있으므로, 자식이 보는 문자열은 부모가 전달한 문자열과 완전히 일치하지는 않습니다.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 계열 OS에서는 execve에 배열을 그대로 전달할 수 있으므로 이 문제는 존재하지 않습니다. Windows 고유의, 그러나 모든 프로세스 시작에 따라다니는 전제입니다.
3. 누가 분할하는가 ── 세 개의 파서
받는 쪽에서 문자열을 argv로 자르는 코드는 크게 세 가지가 있습니다.
| 받는 쪽 | 분할하는 코드 | 호출되는 상황 |
|---|---|---|
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 런타임 자신의 분할 코드의 세 계통이고, 같은 골격의 규칙을 구현하고 있지만 동일한 코드는 아닙니다. 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의 규칙이지 파서의 규칙이 아닙니다).
파서는 「따옴표 안에 있는가」라는 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\ 를 전달했더니 다음 인수까지 한데 묶여 하나가 되었다」의 정체입니다. 경로 끝의 백슬래시를 따옴표로 감싼 순간, 닫는 따옴표가 문자로 변하고 감싸기가 닫히지 않게 됩니다.
flowchart TB
accTitle: 끝의 백슬래시가 다음 인수를 끌어들이는 구조
accDescr: 경로 끝의 백슬래시를 따옴표로 감싸면 닫혀야 할 따옴표가 백슬래시 1개 바로 뒤에 있기 때문에 문자로서의 따옴표로 해석되고, 감싸기가 닫히지 않은 채 다음 인수까지 하나의 인수로 읽힙니다
a["따옴표로 감싼 경로 끝에 백슬래시 1개"] --> b["닫는 따옴표의 바로 앞이 홀수 개"]
b --> c["따옴표는 문자로 출력되고 감싸기는 닫히지 않음"]
c --> d["이후의 공백은 구분자가 되지 않음"]
d --> e["다음 인수까지 하나의 인수로 도착"]
a -.->|"백슬래시를 두 배로 늘린다"| ok["감싸기가 닫히고 인수가 나뉨"]
그림 5: 「끝의 백슬래시를 두 배로 늘린다」가 필요한 이유. 규칙을 모른 채 작성한 따옴표 처리는 경로의 끝에서 깨집니다.
구현 차이가 드러나는 「감싸기 안의 따옴표 2연속」
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의 맨 앞의 공백 구분 토큰에서 추정됩니다. 경로에 공백이 있으면 후보가 여러 개 생기고, 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]에서 구하는 설계는 이 분리 위에서는 성립하지 않습니다.
한 가지 더, lpApplicationName이 NULL일 때는 lpCommandLine의 실행 파일 이름 부분이 MAX_PATH로 제한됩니다.1 긴 경로의 처리는 「MAX_PATH와 Windows 경로·파일 이름의 함정」을 참조해 주십시오.
6. 조립하는 쪽의 규칙 ── 하나의 함수로 충분
분할 규칙을 알면 그것을 거꾸로 따라가는 것만으로 「상대가 원래대로 잘라 주는 문자열」을 만들 수 있습니다. argv[1] 이후의 각 인수에 대해 다음 처리를 합니다.6
- 빈 문자열이 아니고 공백도 따옴표도 포함하지 않으면 그대로 나열합니다.
- 그 외에는 전체를 따옴표로 감쌉니다. 감싸기 안에서는,
- 따옴표 바로 앞에 늘어선 백슬래시 k개를 2k+1개로 만든 다음 따옴표를 둡니다 (홀수 개로 만들어 「문자로서의 따옴표」가 되게 합니다).
- 끝에 늘어선 백슬래시 k개는 2k개로 만듭니다 (닫는 따옴표 바로 앞이므로 짝수 개로 만들어 「감싸기의 종료」가 되게 합니다).
- 그 외의 백슬래시는 그대로 둡니다.
- 빈 문자열은
""로 나열합니다.
flowchart TB
accTitle: 인수 하나를 조립하는 판단의 흐름
accDescr: 인수가 비어 있지 않고 공백도 따옴표도 포함하지 않으면 그대로 나열하고, 그 외에는 따옴표로 감싸고 따옴표 바로 앞의 백슬래시는 두 배 더하기 1, 끝의 백슬래시는 두 배로 만들고 따옴표는 백슬래시를 붙여서 닫습니다
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장)으로 와이드 문자인 채 분할하고 (독자적인 문법으로 원시 명령줄을 해석하는 상대나 중간에 셸의 파서가 끼어드는 경우는 대상 밖입니다), 또한 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이 내부에서 한 줄의 문자열로 조립해 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는 직접 조립한 한 줄의 문자열을 그대로 전달하는 속성입니다. 둘은 서로 독립적이며, 한쪽을 쓸 때는 다른 쪽이 비어 있어야 합니다.16 공식 문서도 따옴표 처리에 자신이 없으면 ArgumentList를 고르라고 안내하고 있습니다.5
flowchart TB
accTitle: ArgumentList와 Arguments가 문자열이 되는 위치
accDescr: ArgumentList는 .NET이 요소마다 이스케이프해 한 줄의 문자열로 조립한 뒤 CreateProcess에 전달하고, Arguments는 호출하는 쪽이 조립한 문자열을 그대로 전달합니다. 어느 쪽이든 OS에 도착하는 시점에는 한 줄의 문자열입니다
al["ArgumentList (요소 1개 = 인수 1개)"] --> esc[".NET이 요소마다 이스케이프해 연결"]
ar["Arguments (직접 조립한 한 줄의 문자열)"] --> pass["그대로"]
esc --> cmd["한 줄의 명령줄 문자열"]
pass --> cmd
cmd --> cp["CreateProcess"]
그림 9: 어느 쪽을 써도 OS에 전달되는 것은 한 줄의 문자열입니다. 차이는 「누가 조립하는가」뿐이고, 규칙을 아는 쪽에 맡기는 것이 ArgumentList입니다.
ArgumentList의 조립 코드는 6장의 규칙 그 자체입니다. 비어 있지 않고 공백도 따옴표도 포함하지 않으면 그대로, 그 외에는 따옴표로 감싸고, 따옴표 바로 앞의 백슬래시는 두 배 더하기 1, 끝의 백슬래시는 두 배, 따옴표에는 반드시 백슬래시를 앞에 붙입니다. 내용이 있는 인수의 안쪽에서 따옴표가 인접하는 형태는 생성하지 않습니다. 빈 인수만은 ""로 나열하며, 이쪽은 올바른 표기입니다.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('"'); // 따옴표 바로 앞: 두 배 더하기 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에 요소를 하나씩 추가"]
v -->|"Framework"| own["직접 만든 함수로 Arguments를 조립"]
al --> no["따옴표를 손으로 쓰지 않는다"]
own --> no
그림 10: 수단은 두 가지이지만 원칙은 하나입니다. 「따옴표를 손으로 쓰지 않는다」를 지키면 경로의 끝에서 깨지는 문제는 일어나지 않습니다.
참고로 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] 이후의 인수를 하나 추가합니다. 규칙은 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'\\'); // 따옴표 바로 앞: 두 배 더하기 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); // 닫는 것을 잊으면 시작할 때마다 핸들이 하나씩 샌다
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]은 실행 파일 이름. 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가 끼어들면 한 단계의 해석이 더 들어갑니다.
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를 쓰고 있는 경우, 요소의 나열을 그대로 남겨도 비교에는 쓸 수 없습니다. 요소는 따옴표도 끝의 백슬래시 배가도 되기 전의 값이고, OS에 전달되는 것은 그것을 .NET이 정형한 문자열이기 때문입니다. 7장의BuildArguments와 같은 규칙으로 요소에서 한 줄의 문자열을 다시 구성해 남기거나 (ArgumentList가 내부에서 하는 정형과 같은 결과가 됩니다), 요소의 나열을 분할 후의 배열과 직접 비교해 주십시오. 이것만이 「호출하는 쪽의 원래 버퍼」를 보는 수단이고, 뒤에서 설명할 Process Explorer나 상대 쪽의 로그는 중간에 cmd.exe나 배치가 끼어 있으면 그 단계가 다시 만든 문자열밖에 보여 주지 않습니다. 기록할 때는 서두의 약속대로 기밀이 될 수 있는 요소의 값을 가린 뒤에 남깁니다 (가린 요소는 상대 쪽의 문자열과 일치하지 않게 되므로, 비교는 그 요소를 제외하고 합니다). - 인수를 표시하기만 하는 exe를 준비합니다. 상대의 exe 대신 시작해 도착한
args를 한 건에 한 행으로 출력하게 합니다. 값을 그대로 쓰면 줄바꿈이나 제어 문자를 포함한 인수가 여러 행으로 보이거나 앞뒤의 행을 덮어써서 잘못 세게 되므로, JSON 문자열로 이스케이프한 형태와 길이를 출력합니다 (이스케이프는 가역이므로 원래 값으로 되돌릴 수 있습니다). 다만 3장에서 본 대로 파서는 세 계통이 있고, 감싸기 안의 따옴표 2연속 같은 가장자리 형태에서는 해석이 갈립니다. 상대와 같은 런타임으로 만든 표시용 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장에서 언급한 대로 맨 앞의 실행 파일 이름은 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: 「호출하는 쪽의 문자열」「상대 쪽의 문자열」「배열」의 세 가지를 순서대로 비교하면, 책임이 중간의 단계·조립하는 쪽·받는 쪽 중 어디에 있는지는 기계적으로 정해집니다. 추측으로 이스케이프를 더하는 것은 이 확인을 하고 나서 해도 됩니다.
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와 배치 파일에는 밖에서 온 값을 통과시키지 않으며, 건수에 비례해 늘어나는 인수는 상대가 응답 파일을 읽을 수 있는 (또는 읽을 수 있게 고칠 수 있는) 경우에 한해 응답 파일로 하고, 그렇지 않으면 호출을 나눕니다. 이 다섯 가지를 지키면 「공백이 있는 PC에서만 시작되지 않는다」「끝의 백슬래시로 다음 인수가 사라진다」는 문제는 일어나지 않습니다.
flowchart TB
accTitle: 인수 문제를 막는 다섯 가지 약속
accDescr: 실행 파일은 lpApplicationName에 전체 경로를 전달하고 맨 앞의 토큰도 따옴표로 감싸며, 따옴표 처리는 규칙대로의 함수나 ArgumentList에 맡기고, 내용이 있는 인수의 안쪽에서 따옴표를 인접시키는 형태를 생성하지 않으며, cmd.exe와 배치에 밖에서 온 값을 통과시키지 않고, 건수에 비례해 늘어나는 인수는 상대가 읽을 수 있는 경우에 한해 응답 파일로 합니다. 상대가 공개된 분할 규칙으로 해석하고 와일드카드 확장을 활성화하지 않았다는 것을 전제로, 이 다섯 가지로 공백이 있는 경로나 끝의 백슬래시로 인한 문제를 막습니다
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: 다섯 가지 약속은 모두 「실행할 모듈을 확정시키고, 상대의 파서가 자를 수 있는 문자열만 전달한다」를 바꿔 말한 것입니다. 상대가 공개된 분할 규칙으로 해석하고 와일드카드 확장을 활성화하지 않았다는 것 (6장·8장)이 전제이며, 그 위에서 이 다섯 가지가 공백과 끝의 백슬래시로 인한 문제를 막습니다.
잘 되지 않을 때는 추측으로 이스케이프를 늘리기 전에, 호출하는 쪽에서 조립한 문자열, 상대 쪽에 도착한 문자열, 분할 후의 배열의 세 가지를 봐 주십시오. 호출하는 쪽과 상대 쪽의 문자열이 다르면 중간의 단계 (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 코드 단위)의 한 줄 문자열이라는 점, 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이 맨 앞 공백으로 시작하면 첫 인수가 빈 문자열이 된다는 점, 빈 문자열을 전달하면 현재 실행 파일의 경로가 반환된다는 점, 반환값을LocalFree한 번으로 해제한다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn,
mainfunction and command-line arguments. Microsoft C/C++의 시작 코드가 명령줄을 해석하는 규칙 (공백과 탭으로 구분,argv[0]은 따옴표로 감쌀 수 있지만 이후의 규칙은 적용되지 않음, 따옴표로 감싼 문자열은 하나의 인수, 캐럿은 이스케이프 문자가 아님, 따옴표 안의 2연속 따옴표는 하나의 따옴표, 닫는 따옴표가 없으면 끝까지가 마지막 인수, 짝수 개/홀수 개 백슬래시의 처리), 입력과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시에 OS에 전달한다는 점, 따옴표 처리에 자신이 없으면ArgumentList를 골라야 한다는 점, 신뢰할 수 없는 데이터와 함께 쓰는 위험, 적용 대상이 .NET Core 2.1 이후라는 점에 대해. ↩ ↩2 ↩3 ↩4 -
dotnet/runtime (GitHub), PasteArguments.cs 및 PasteArguments.Windows.cs.
ArgumentList의 내부에서 쓰이는 조립 코드. 비어 있지 않고 공백도 따옴표도 포함하지 않는 인수는 그대로, 그 외에는 따옴표로 감싸고 끝의 백슬래시를 두 배, 따옴표 바로 앞의 백슬래시를 두 배 더하기 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를 쓰지 않음, 따옴표가 한 쌍, 특수 문자를 포함하지 않음, 공백을 포함함, 실행 파일 이름임)과 조건을 만족하지 않는 경우 맨 앞 따옴표를 벗기는 방식에 대해. ↩ -
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와 완료 포트로 자식 프로세스의 수명을 설계하는 방법을 ...
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 설명합니다. 바이트/메시지 모드의 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .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 네 가지 객체와 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 자료를 ...
DllMain과 로더 락 ── 「DLL 초기화에서는 아무것도 하지 말라」는 말의 진짜 이유
DllMain에서 LoadLibrary나 스레드 동기화를 해서는 안 되는 이유는 무엇인가. 모든 DLL 알림을 직렬화하는 로더 락의 구조부터, 데드락이 성립하는 전형적인 시나리오, 지연 초기화 같은 올바른 설계, hang 조사 절차까지를 1차 정...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- Windows에는 인수의 배열을 전달하는 API가 없나요?
- 없습니다. CreateProcess가 받는 것은 lpCommandLine이라는 한 줄의 문자열이고, 새 프로세스에도 그 문자열이 전달됩니다 (맨 앞의 실행 파일 이름만은 OS가 전체 경로를 채워 넣는 경우가 있습니다). argv 배열처럼 보이는 것은 받는 쪽 프로세스 안에서 C 런타임의 시작 코드, CommandLineToArgvW, 또는 .NET 런타임이 문자열을 분할해서 만든 것입니다. 따라서 「인수를 전달한다」는 것은 「상대의 파서가 원래대로 분할해 주는 문자열을 조립한다」는 것과 같습니다.
- 백슬래시는 언제 이스케이프 문자가 되나요?
- 바로 뒤에 큰따옴표가 올 때뿐입니다. 큰따옴표가 이어지지 않는 백슬래시는 몇 개가 늘어서 있어도 그대로 남습니다. 큰따옴표 바로 앞에 2n개가 있으면 n개의 백슬래시와 따옴표의 열고 닫음이 되고, 2n+1개이면 n개의 백슬래시와 문자로서의 따옴표가 됩니다. 이 비대칭성 때문에 경로 끝의 백슬래시를 따옴표로 감쌀 때만 두 배로 늘려야 합니다.
- ProcessStartInfo.ArgumentList와 Arguments 중 어느 쪽을 써야 하나요?
- 값이 변수에서 온다면 ArgumentList입니다. 요소 하나가 인수 하나가 되고, 필요한 따옴표 처리와 이스케이프를 .NET이 해 주며, 내부에서 한 줄의 문자열로 조립한 뒤 OS에 전달합니다. 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에 다시 해석되므로 경계가 되지 않습니다.