Skip to content

Commit e649453

Browse files
Bryan C. Millsgopherbot
Bryan C. Mills
authored andcommitted
windows: convert TestCommandLineRecomposition to a fuzz test and fix discrepancies
Notably, this fixes the escaping of the first argument when it contains quoted spaces, and fixes a panic in DecomposeCommandLine when it contains more than 8192 arguments. Fixes golang/go#58817. For golang/go#17149. For golang/go#63236. Change-Id: Ib72913b8182998adc1420d73ee0f9dc017dfbf32 Reviewed-on: https://go-review.googlesource.com/c/sys/+/530275 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Quim Muntal <[email protected]> Reviewed-by: Than McIntosh <[email protected]> Auto-Submit: Bryan Mills <[email protected]>
1 parent 8858c72 commit e649453

File tree

2 files changed

+192
-75
lines changed

2 files changed

+192
-75
lines changed

windows/exec_windows.go

+65-13
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import (
2222
// but only if there is space or tab inside s.
2323
func EscapeArg(s string) string {
2424
if len(s) == 0 {
25-
return "\"\""
25+
return `""`
2626
}
2727
n := len(s)
2828
hasSpace := false
@@ -35,7 +35,7 @@ func EscapeArg(s string) string {
3535
}
3636
}
3737
if hasSpace {
38-
n += 2
38+
n += 2 // Reserve space for quotes.
3939
}
4040
if n == len(s) {
4141
return s
@@ -82,20 +82,68 @@ func EscapeArg(s string) string {
8282
// in CreateProcess's CommandLine argument, CreateService/ChangeServiceConfig's BinaryPathName argument,
8383
// or any program that uses CommandLineToArgv.
8484
func ComposeCommandLine(args []string) string {
85-
var commandLine string
86-
for i := range args {
87-
if i > 0 {
88-
commandLine += " "
85+
if len(args) == 0 {
86+
return ""
87+
}
88+
89+
// Per https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-commandlinetoargvw:
90+
// “This function accepts command lines that contain a program name; the
91+
// program name can be enclosed in quotation marks or not.”
92+
//
93+
// Unfortunately, it provides no means of escaping interior quotation marks
94+
// within that program name, and we have no way to report them here.
95+
prog := args[0]
96+
mustQuote := len(prog) == 0
97+
for i := 0; i < len(prog); i++ {
98+
c := prog[i]
99+
if c <= ' ' || (c == '"' && i == 0) {
100+
// Force quotes for not only the ASCII space and tab as described in the
101+
// MSDN article, but also ASCII control characters.
102+
// The documentation for CommandLineToArgvW doesn't say what happens when
103+
// the first argument is not a valid program name, but it empirically
104+
// seems to drop unquoted control characters.
105+
mustQuote = true
106+
break
107+
}
108+
}
109+
var commandLine []byte
110+
if mustQuote {
111+
commandLine = make([]byte, 0, len(prog)+2)
112+
commandLine = append(commandLine, '"')
113+
for i := 0; i < len(prog); i++ {
114+
c := prog[i]
115+
if c == '"' {
116+
// This quote would interfere with our surrounding quotes.
117+
// We have no way to report an error, so just strip out
118+
// the offending character instead.
119+
continue
120+
}
121+
commandLine = append(commandLine, c)
122+
}
123+
commandLine = append(commandLine, '"')
124+
} else {
125+
if len(args) == 1 {
126+
// args[0] is a valid command line representing itself.
127+
// No need to allocate a new slice or string for it.
128+
return prog
89129
}
90-
commandLine += EscapeArg(args[i])
130+
commandLine = []byte(prog)
91131
}
92-
return commandLine
132+
133+
for _, arg := range args[1:] {
134+
commandLine = append(commandLine, ' ')
135+
// TODO(bcmills): since we're already appending to a slice, it would be nice
136+
// to avoid the intermediate allocations of EscapeArg.
137+
// Perhaps we can factor out an appendEscapedArg function.
138+
commandLine = append(commandLine, EscapeArg(arg)...)
139+
}
140+
return string(commandLine)
93141
}
94142

95143
// DecomposeCommandLine breaks apart its argument command line into unescaped parts using CommandLineToArgv,
96144
// as gathered from GetCommandLine, QUERY_SERVICE_CONFIG's BinaryPathName argument, or elsewhere that
97145
// command lines are passed around.
98-
// DecomposeCommandLine returns error if commandLine contains NUL.
146+
// DecomposeCommandLine returns an error if commandLine contains NUL.
99147
func DecomposeCommandLine(commandLine string) ([]string, error) {
100148
if len(commandLine) == 0 {
101149
return []string{}, nil
@@ -105,14 +153,18 @@ func DecomposeCommandLine(commandLine string) ([]string, error) {
105153
return nil, errorspkg.New("string with NUL passed to DecomposeCommandLine")
106154
}
107155
var argc int32
108-
argv, err := CommandLineToArgv(&utf16CommandLine[0], &argc)
156+
argv8192, err := CommandLineToArgv(&utf16CommandLine[0], &argc)
109157
if err != nil {
110158
return nil, err
111159
}
112-
defer LocalFree(Handle(unsafe.Pointer(argv)))
160+
defer LocalFree(Handle(unsafe.Pointer(argv8192)))
161+
113162
var args []string
114-
for _, v := range (*argv)[:argc] {
115-
args = append(args, UTF16ToString((*v)[:]))
163+
// Note: CommandLineToArgv hard-codes an incorrect return type
164+
// (see https://go.dev/issue/63236).
165+
// We use an unsafe.Pointer conversion here to work around it.
166+
for _, p := range unsafe.Slice((**uint16)(unsafe.Pointer(argv8192)), argc) {
167+
args = append(args, UTF16PtrToString(p))
116168
}
117169
return args, nil
118170
}

windows/syscall_windows_test.go

+127-62
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"debug/pe"
1111
"errors"
1212
"fmt"
13-
"math/rand"
1413
"os"
1514
"path/filepath"
1615
"runtime"
@@ -19,6 +18,7 @@ import (
1918
"syscall"
2019
"testing"
2120
"time"
21+
"unicode/utf8"
2222
"unsafe"
2323

2424
"golang.org/x/sys/windows"
@@ -562,78 +562,143 @@ func TestResourceExtraction(t *testing.T) {
562562
}
563563
}
564564

565-
func TestCommandLineRecomposition(t *testing.T) {
566-
const (
567-
maxCharsPerArg = 35
568-
maxArgsPerTrial = 80
569-
doubleQuoteProb = 4
570-
singleQuoteProb = 1
571-
backSlashProb = 3
572-
spaceProb = 1
573-
trials = 1000
574-
)
575-
randString := func(l int) []rune {
576-
s := make([]rune, l)
577-
for i := range s {
578-
s[i] = rand.Int31()
579-
}
580-
return s
581-
}
582-
mungeString := func(s []rune, char rune, timesInTen int) {
583-
if timesInTen < rand.Intn(10)+1 || len(s) == 0 {
584-
return
585-
}
586-
s[rand.Intn(len(s))] = char
587-
}
588-
argStorage := make([]string, maxArgsPerTrial+1)
589-
for i := 0; i < trials; i++ {
590-
args := argStorage[:rand.Intn(maxArgsPerTrial)+2]
591-
args[0] = "valid-filename-for-arg0"
592-
for j := 1; j < len(args); j++ {
593-
arg := randString(rand.Intn(maxCharsPerArg + 1))
594-
mungeString(arg, '"', doubleQuoteProb)
595-
mungeString(arg, '\'', singleQuoteProb)
596-
mungeString(arg, '\\', backSlashProb)
597-
mungeString(arg, ' ', spaceProb)
598-
args[j] = string(arg)
565+
func FuzzComposeCommandLine(f *testing.F) {
566+
f.Add(`C:\foo.exe /bar /baz "-bag qux"`)
567+
f.Add(`"C:\Program Files\Go\bin\go.exe" env`)
568+
f.Add(`C:\"Program Files"\Go\bin\go.exe env`)
569+
f.Add(`C:\"Program Files"\Go\bin\go.exe env`)
570+
f.Add(`C:\"Pro"gram Files\Go\bin\go.exe env`)
571+
f.Add(``)
572+
f.Add(` `)
573+
f.Add(`W\"0`)
574+
f.Add("\"\f")
575+
f.Add("\f")
576+
f.Add("\x16")
577+
f.Add(`"" ` + strings.Repeat("a", 8193))
578+
f.Add(strings.Repeat(`"" `, 8193))
579+
580+
f.Add("\x00abcd")
581+
f.Add("ab\x00cd")
582+
f.Add("abcd\x00")
583+
f.Add("\x00abcd\x00")
584+
f.Add("\x00ab\x00cd\x00")
585+
f.Add("\x00\x00\x00")
586+
f.Add("\x16\x00\x16")
587+
f.Add(`C:\Program Files\Go\bin\go.exe` + "\x00env")
588+
f.Add(`"C:\Program Files\Go\bin\go.exe"` + "\x00env")
589+
f.Add(`C:\"Program Files"\Go\bin\go.exe` + "\x00env")
590+
f.Add(`C:\"Pro"gram Files\Go\bin\go.exe` + "\x00env")
591+
f.Add("\x00" + strings.Repeat("a", 8192))
592+
f.Add(strings.Repeat("\x00"+strings.Repeat("a", 8192), 4))
593+
594+
f.Fuzz(func(t *testing.T, s string) {
595+
// DecomposeCommandLine is the “control” for our experiment:
596+
// if it returns a particular list of arguments, then we know
597+
// it must be possible to create an input string that produces
598+
// exactly those arguments.
599+
//
600+
// However, DecomposeCommandLine returns an error if the string
601+
// contains a NUL byte. In that case, we will fall back to
602+
// strings.Split, and be a bit more permissive about the results.
603+
args, err := windows.DecomposeCommandLine(s)
604+
argsFromSplit := false
605+
606+
if err == nil {
607+
if testing.Verbose() {
608+
t.Logf("DecomposeCommandLine(%#q) = %#q", s, args)
609+
}
610+
} else {
611+
t.Logf("DecomposeCommandLine: %v", err)
612+
if !strings.Contains(s, "\x00") {
613+
// The documentation for CommandLineToArgv takes for granted that
614+
// the first argument is a valid file path, and doesn't describe any
615+
// specific behavior for malformed arguments. Empirically it seems to
616+
// tolerate anything we throw at it, but if we discover cases where it
617+
// actually returns an error we might need to relax this check.
618+
t.Fatal("(error unexpected)")
619+
}
620+
621+
// Since DecomposeCommandLine can't handle this string,
622+
// interpret it as the raw arguments to ComposeCommandLine.
623+
args = strings.Split(s, "\x00")
624+
argsFromSplit = true
625+
for i, arg := range args {
626+
if !utf8.ValidString(arg) {
627+
// We need to encode the arguments as UTF-16 to pass them to
628+
// CommandLineToArgvW, so skip inputs that are not valid: they might
629+
// have one or more runes converted to replacement characters.
630+
t.Skipf("skipping: input %d is not valid UTF-8", i)
631+
}
632+
if len(arg) > 8192 {
633+
// CommandLineToArgvW seems to truncate each argument after 8192
634+
// UTF-16 code units, although this behavior is not documented. Since
635+
// it isn't documented, we shouldn't rely on it one way or the other,
636+
// so skip the input to tell the fuzzer to try a different approach.
637+
enc, _ := windows.UTF16FromString(arg)
638+
if len(enc) > 8192 {
639+
t.Skipf("skipping: input %d encodes to more than 8192 UTF-16 code units", i)
640+
}
641+
}
642+
}
643+
if testing.Verbose() {
644+
t.Logf("using input: %#q", args)
645+
}
599646
}
647+
648+
// It's ok if we compose a different command line than what was read.
649+
// Just check that we are able to compose something that round-trips
650+
// to the same results as the original.
600651
commandLine := windows.ComposeCommandLine(args)
601-
decomposedArgs, err := windows.DecomposeCommandLine(commandLine)
652+
t.Logf("ComposeCommandLine(_) = %#q", commandLine)
653+
654+
got, err := windows.DecomposeCommandLine(commandLine)
602655
if err != nil {
603-
t.Errorf("Unable to decompose %#q made from %v: %v", commandLine, args, err)
604-
continue
656+
t.Fatalf("DecomposeCommandLine: unexpected error: %v", err)
605657
}
606-
if len(decomposedArgs) != len(args) {
607-
t.Errorf("Incorrect decomposition length from %v to %#q to %v", args, commandLine, decomposedArgs)
608-
continue
658+
if testing.Verbose() {
659+
t.Logf("DecomposeCommandLine(_) = %#q", got)
609660
}
610-
badMatches := make([]int, 0, len(args))
661+
662+
var badMatches []int
611663
for i := range args {
612-
if args[i] != decomposedArgs[i] {
664+
if i >= len(got) {
665+
badMatches = append(badMatches, i)
666+
continue
667+
}
668+
want := args[i]
669+
if got[i] != want {
670+
if i == 0 && argsFromSplit {
671+
// It is possible that args[0] cannot be encoded exactly, because
672+
// CommandLineToArgvW doesn't unescape that argument in the same way
673+
// as the others: since the first argument is assumed to be the name
674+
// of the program itself, we only have the option of quoted or not.
675+
//
676+
// If args[0] contains a space or control character, we must quote it
677+
// to avoid it being split into multiple arguments.
678+
// If args[0] already starts with a quote character, we have no way
679+
// to indicate that that character is part of the literal argument.
680+
// In either case, if the string already contains a quote character
681+
// we must avoid misinterpriting that character as the end of the
682+
// quoted argument string.
683+
//
684+
// Unfortunately, ComposeCommandLine does not return an error, so we
685+
// can't report existing quote characters as errors.
686+
// Instead, we strip out the problematic quote characters from the
687+
// argument, and quote the remainder.
688+
// For paths like C:\"Program Files"\Go\bin\go.exe that is arguably
689+
// what the caller intended anyway, and for other strings it seems
690+
// less harmful than corrupting the subsequent arguments.
691+
if got[i] == strings.ReplaceAll(want, `"`, ``) {
692+
continue
693+
}
694+
}
613695
badMatches = append(badMatches, i)
614696
}
615697
}
616698
if len(badMatches) != 0 {
617-
t.Errorf("Incorrect decomposition at indices %v from %v to %#q to %v", badMatches, args, commandLine, decomposedArgs)
618-
continue
699+
t.Errorf("Incorrect decomposition at indices: %v", badMatches)
619700
}
620-
}
621-
622-
// check that windows.DecomposeCommandLine returns error for strings with NUL
623-
testsWithNUL := []string{
624-
"\x00abcd",
625-
"ab\x00cd",
626-
"abcd\x00",
627-
"\x00abcd\x00",
628-
"\x00ab\x00cd\x00",
629-
"\x00\x00\x00",
630-
}
631-
for _, test := range testsWithNUL {
632-
_, err := windows.DecomposeCommandLine(test)
633-
if err == nil {
634-
t.Errorf("Failed to return error while decomposing %#q string with NUL inside", test)
635-
}
636-
}
701+
})
637702
}
638703

639704
func TestWinVerifyTrust(t *testing.T) {

0 commit comments

Comments
 (0)