Skip to content
All writing

Go os/exec and .cmd files: a Windows injection trap

Go quotes arguments for normal programs, not for batch files. Passing one hostile argument to an npm .cmd launcher from Go created a file on disk and no error. Here is the test, the fix, and what the fix costs.

Devpit starts other people's tools for you, so it has to hand your arguments over untouched. On Windows most of those tools are .cmd files. I planned on Go's os/exec quoting arguments safely for them. It doesn't, and nothing tells you.

Why a .cmd file is in the way

Install Vercel, Firebase or Claude Code from npm on Windows and what lands on your PATH isn't a program. It's vercel.cmd, a few lines of batch that start node with the real script.

Devpit's shim, a small Go program named claude.exe or vercel.exe, finds the real tool and starts it with the right account for the folder you're in. So somewhere it ends up calling exec.Command("vercel.cmd", args...).

Go's docs list batch files as an exception to its quoting: "Notable exceptions are msiexec.exe and cmd.exe (and thus, all batch files), which have a different unquoting algorithm." Go builds the command line for programs that read it the way the Microsoft C runtime does. cmd.exe reads the line itself first, and has opinions about %, ^, < and &.

What I measured

Before writing the shim I ran a spike. A copy of the npm launcher, its last line pointed at a tiny program that prints its arguments as JSON, called with exec.Command("tool.cmd", "before", arg, "after") on Go 1.27.1. Spaces, quotes, accents, Japanese, (x) and !PATH! all came through exact. These didn't:

  • %PATH% was expanded and split into many arguments
  • ^ vanished
  • <x> became a redirection: "The system cannot find the file specified"
  • a newline cut the line off, and after never arrived
  • "&echo INJECTED2>pwned.txt&" ran the echo, created pwned.txt and exited 1

Go reported none of it. That last row is the one that matters: a commit message, a branch name or a pasted flag becomes a command.

Escaping wasn't the fix

The obvious answer is to quote the argument yourself. Go's docs even say you can, by building the whole command line. I didn't want to. The table above is a list of characters cmd.exe treats specially, and I'd need to be right about every one of them, on every Windows version, forever. I'd rather take cmd.exe out of the path.

What worked

An npm launcher is small and regular. The line that matters ends in %*:

"%_prog%"  "%dp0%\node_modules\vercel\dist\vc.js" %*

The shim reads that line instead of running the file. It swaps %dp0% for the launcher's own folder, then starts node.exe with that script and your arguments directly. No cmd.exe, so nothing to parse. I ran the same hostile arguments plus two with trailing backslashes: 19 of 19 came through exact, and no pwned.txt appeared. It also drops the "Terminate batch job (Y/N)?" question after Ctrl+C.

A hostile argument through cmd.exe, and the same argument straight to node.exe

Two columns. Before: the Go shim calls exec.Command with vercel.cmd and the argument "&echo INJECTED2>pwned.txt&". cmd.exe parses the line itself, treating the ampersand and the redirect as commands. The result is that echo ran, pwned.txt was created and the exit code was 1. After: step 1, the shim reads vercel.cmd and never runs it. Step 2, it takes the launcher's last line, the one that ends in percent-star, and refuses tokens containing percent, exclamation mark, caret, ampersand, pipe or angle brackets. Step 3, it starts node.exe with the script and the argument arrives as one exact argument.

123Go shimcmd.exeResultGo shimThe launcher's last linenode.exe vc.jsBEFORE · THROUGH cmd.exeAFTER · STRAIGHT TO node.exeexec.Command("vercel.cmd", arg)parses the line itself% ^ < > & mean something hereexit code 1reads vercel.cmd, never runs itrefuses % ! ^ & | < > in a tokenarg = "&echo INJECTED2>pwned.txt&"arg = "&echo INJECTED2>pwned.txt&""%_prog%" "%dp0%\…\vc.js" %*"&echo INJECTED2>pwned.txt&"arrives as one argument, exactecho ran: pwned.txt created
A hostile argument through cmd.exe, and the same argument straight to node.exe

Before: the Go shim calls exec.Command with vercel.cmd and the argument "&echo INJECTED2>pwned.txt&". cmd.exe parses the line itself. The result is that echo ran, pwned.txt was created and the exit code was 1. After: step 1, the shim reads vercel.cmd and never runs it. Step 2, it takes the launcher's last line, the one that ends in percent-star, and refuses tokens with special characters. Step 3, it starts node.exe with the script and the argument arrives as one exact argument.

123Go shimcmd.exeResultGo shimThe launcher's last linenode.exe vc.jsBEFORE · THROUGH cmd.exeAFTER · STRAIGHT TO node.exeexec.Command("vercel.cmd", arg)parses the line itself% ^ < > & mean something hereexit code 1reads vercel.cmd, never runs itrefuses % ! ^ & | < > in a tokenarg = "&echo INJECTED2>pwned.txt&"arg = "&echo INJECTED2>pwned.txt&""%_prog%" "%dp0%\…\vc.js" %*"&echo INJECTED2>pwned.txt&"arrives as one argument, exactecho ran: pwned.txt created

The shim reads the launcher at ① and ② instead of running it, so at ③ cmd.exe never sees your arguments.

The tradeoff

Some batch files aren't npm launchers, and those still need cmd.exe. For them the shim refuses. An argument may contain only letters, digits, a space and _ - . , : / \ = @ +:

func SafeBatchArg(a string) bool {
	for i := 0; i < len(a); i++ {
		c := a[i]
		switch {
		case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
		case strings.IndexByte(" _-.,:/\\=@+", c) >= 0:
		default:
			return false
		}
	}
	return true
}

Anything else stops before the tool starts, with an error that names the argument. That will refuse an argument someone meant literally, a message with an & in it for instance. I chose a loud refusal over a quiet rewrite.

The parser also only knows two launcher layouts, the current npm one and the older one. Any other .cmd, including whatever pnpm, yarn and bun write, which I haven't checked, takes the strict path. And the check is now code I maintain: the hostile list is pinned as tests, and each argument has to come out exact or refused, never changed. One more test just runs plain os/exec against a batch file and logs what happens, so a Go upgrade that fixes this will show up.

The takeaway

If a Go program on Windows passes text it didn't write to a .cmd or .bat through os/exec, treat that text as shell input. Either run what the batch file runs, or allow a short list of safe characters and refuse the rest. Then test with the ugly list before you trust it: %PATH%, ^, <x>, &, a newline, a trailing backslash. Anything that shells out to a CLI on Windows has this exposure, and that covers a lot of workflow automation.

This is the second Go-on-Windows trap I hit building Devpit. The first was a Windows call reading memory Go had already freed.