How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
Exploit a shell=True call with a harmless file canary, compare how cmd.exe, a POSIX shell and an argv list treat 13 hostile strings, reproduce Git option injection and the Windows .cmd trap, then...
Command injection happens when a program builds a command line out of text it did not write, and that text contains something the operating system treats as an instruction instead of as data. In this tutorial we reproduce it safely on our own machine, find out exactly where it comes from, and fix it. Then we go after the two versions that survive the obvious fix: option injection, where a value that starts with a dash becomes a flag, and the Windows batch-file trap, where a .cmd file quietly puts cmd.exe back between your arguments and the program.
Table Of Content
- Why this matters
- Words we will use
- Prerequisites
- The idea: up to three parsers sit between your text and the program
- Step 1: Create the lab folder and a stand-in program
- Step 2: Watch a shell=True call get injected
- Why it happens
- Step 3: See what each layer receives
- Step 4: Pass an argv list, and learn why shlex.quote is not the Windows fix
- Step 5: Stop option injection, the bug that survives an argv list
- What the output shows
- The rules that come out of this
- Step 6: Handle the Windows batch-file trap
- What the output shows
- What the docs say, and what I measured
- What to do instead
- Option 1: do not run a batch file with untrusted arguments
- Option 2: allow only characters cmd.exe does not care about
- Option 3: keep batch files off PATH
- Step 7: Build saferun.py so the rules live in one place
- Step 8: Test it, then break it on purpose
- Step 9: Add a lint gate that keeps raw subprocess calls out
- What the output shows
- Step 10: Confirm everything end to end
- Common mistakes
- Where to go next
- Sources and further reading
By the end you will have a short wrapper called saferun.py, a suite of 107 tests that attack it, a mutation check that proves the tests notice when each rule is removed (all 10 planted bugs were caught), and a lint rule that stops anyone from bypassing the wrapper. Every attack in this post only writes a harmless file named canary something, so nothing here can damage your machine.
Why this matters
MITRE files this bug class twice. CWE-78 is “Improper Neutralization of Special Elements used in an OS Command”, which is the classic shell injection. CWE-88 is “Improper Neutralization of Argument Delimiters in a Command”, which is what happens when the shell is gone but the value still gets read as an option. The OWASP cheat sheet on the topic puts the relationship in one sentence: “Every OS Command Injection is also an Argument Injection.” You need to defend against both.
It also matters more than it used to. Plenty of tools that AI agents call are thin wrappers around a command line program, and the arguments come from a model that has just read an untrusted web page or file. Treat those arguments exactly like a form field. The agent harness tutorial on this site builds the surrounding sandbox; this post fixes the layer underneath it.
Words we will use
- A shell is a program such as
cmd.exeorshthat reads a line of text, splits it into words, and decides which characters mean run another command, write to a file, or insert a variable. - argv is the list of separate arguments a program receives.
['git', 'show', 'HEAD']is three arguments and no shell is involved in reading it. - A metacharacter is a character a shell treats as syntax, such as
&,;,|,>,$or a backtick. - A canary is a file our attacks create. If a canary appears, the attack worked.
- An allowlist describes what a value may look like and rejects everything else. A blocklist tries to list what is bad, and always misses something.
Prerequisites
- Windows 11. The
cmd.exeand batch-file results in Steps 2, 3 and 6 are Windows behavior. The lab was run on Python 3.13.14, and Steps 1 to 7 were repeated on Python 3.15.1 with identical output apart from the version line and commit hashes. - Git for Windows 2.55 (
giton your PATH). It also ships a POSIX shell,sh.exe. The “POSIX shell” rows in this post come from that shell (bash 5.3.15 started assh), not from a Linux or macOS machine, which I did not have for this run. On Linux and macOS,shell=Truealready means/bin/sh -c, and the lab helper picks/bin/shautomatically. - Python 3.13 or newer, and for Steps 8 and 9 the packages
pytest==9.1.1,ruff==0.17.1andbandit==1.9.4. - You should be comfortable running a Python script from a terminal and reading a function. You do not need to know anything about security.
The idea: up to three parsers sit between your text and the program
The mistake behind almost every injection is forgetting how many things read a command line before the program does any work. Here is the full path:
text you did not write
|
v
[1] a shell parses it only if you ask for one: shell=True, os.system
|
v
[2] cmd.exe parses it again only on Windows, only when the target is a .bat or .cmd file
|
v
[3] the program parses options every program, with or without a shell
|
v
the program does its work
Each stage has its own attack and its own defense. Stage 1 is stopped by not using a shell, which means passing an argv list. Stage 2 is stopped by not handing untrusted text to batch files, or by letting through only characters cmd.exe does not care about. Stage 3 is stopped by validating the value and by telling the program where its options end. We will meet them in that order.
Step 1: Create the lab folder and a stand-in program
Make an empty folder, open a terminal in it, and create two files. The first is a tiny program that stands in for ping, git, ffmpeg or any other tool. All it does is print the arguments it received, which lets us see exactly what crossed each boundary. It also behaves the same on every operating system and needs no network.
# argv_echo.py - stands in for ping, git or ffmpeg: it shows the arguments it received
import sys
print("argv:", sys.argv[1:])
The second file is a helper module used by every later step. It builds the sandbox folder, defines the 13 hostile strings we will try, and provides attempt(), which runs the stand-in tool with one string in one of three ways and reports what the program saw and what else happened. Read it once; the comments say what each part is for.
# labkit.py - shared helpers for the lab steps
import os
import pathlib
import shutil
import subprocess
import sys
HERE = pathlib.Path(__file__).resolve().parent
os.chdir(HERE)
PY = sys.executable
PY_POSIX = PY.replace("\\", "/")
SH = "/bin/sh" if os.name != "nt" else r"C:\Program Files\Git\usr\bin\sh.exe" # Git for Windows ships a POSIX shell
SANDBOX = HERE / "sandbox"
os.environ["DEMO_SECRET"] = "s3cr3t-token"
os.environ["PATH"] = os.pathsep.join([str(HERE / "tools"), os.path.dirname(PY), os.environ["PATH"]])
SANDBOX.mkdir(exist_ok=True)
for name in ("a.txt", "b.txt"):
(SANDBOX / name).write_text("", encoding="utf-8")
shutil.copy(HERE / "argv_echo.py", SANDBOX / "argv_echo.py")
PAYLOADS = [
("ampersand", "example.org & echo INJECTED> canary_m.txt"),
("double ampersand", "example.org && echo INJECTED> canary_m.txt"),
("semicolon", "example.org ; echo INJECTED> canary_m.txt"),
("newline", "example.org\necho INJECTED> canary_m.txt"),
("dollar-paren", "$(echo INJECTED> canary_m.txt)"),
("backticks", "`echo INJECTED> canary_m.txt`"),
("redirect", "example.org > canary_m.txt"),
("percent variable", "%DEMO_SECRET%"),
("dollar variable", "$DEMO_SECRET"),
("caret", "a^b"),
("double quote", 'a"b'),
("star", "*"),
("space", "two words"),
]
BATCH_CASES = [
("plain word", "world"),
("space", "two words"),
("ampersand, no quote", "a & echo INJECTED> canary_bat.txt"),
("quote, then ampersand", 'x" & echo INJECTED> canary_bat.txt & "y'),
("percent variable", "%DEMO_SECRET%"),
("caret ampersand", "a^&b"),
]
def canaries():
found = []
for folder in (HERE, HERE / "app", SANDBOX):
found += [p for p in folder.glob("canary*") if p.is_file()]
return sorted(p.name for p in found)
def where(name):
for folder in (HERE, HERE / "app", SANDBOX):
if (folder / name).is_file():
return folder / name
return None
def reset():
for name in canaries():
where(name).unlink()
def show_files():
return {name: where(name).read_text(errors="replace").strip() for name in canaries()}
def attempt(mode, payload):
"""Run the stand-in tool with one payload in one mode; return what the child saw and what else happened."""
reset()
tool = f'"{PY}" argv_echo.py'
if mode == "cmd.exe":
r = subprocess.run(f"{tool} {payload}", shell=True, cwd=SANDBOX, capture_output=True, text=True)
elif mode == "sh -c":
r = subprocess.run([SH, "-c", f'"{PY_POSIX}" argv_echo.py {payload}'], cwd=SANDBOX, capture_output=True, text=True)
else:
r = subprocess.run([PY, "argv_echo.py", payload], cwd=SANDBOX, capture_output=True, text=True)
text = (r.stdout + r.stderr).strip()
saw = text.splitlines()[0] if text else "(nothing on screen)"
if text and not saw.startswith("argv:"):
saw = "error text: " + saw[:44]
files = show_files()
notes = [f"wrote {n}" + (" (the child's output)" if c.startswith("argv:") else "") for n, c in files.items()]
if "s3cr3t-token" in text or any("s3cr3t-token" in c for c in files.values()):
notes.append("secret expanded")
return saw, "; ".join(notes) or "-"
Check that the stand-in works:
python argv_echo.py hello
You should see this single line:
argv: ['hello']
If you see it, move on. If Python is not found, make sure the python command works in your terminal first. The helper adds the interpreter that runs each step to the front of PATH, so a script run with a specific Python also finds that Python when a batch file calls python later.
Step 2: Watch a shell=True call get injected
Here are three ways a developer might write a host-check function. The first two are the vulnerable shapes; the third is the one we will end up recommending.
# vulnerable.py - three ways to build a command line from text you did not write
import os
import subprocess
import sys
TOOL = f'"{sys.executable}" argv_echo.py'
def check_host_system(host):
return os.system(f"{TOOL} {host}")
def check_host_shell(host):
return subprocess.run(f"{TOOL} {host}", shell=True, capture_output=True, text=True)
def check_host_argv(host):
return subprocess.run([sys.executable, "argv_echo.py", host], capture_output=True, text=True)
First, run them with a harmless host name to confirm they work and to record which interpreter and shell we are using:
# step01_normal.py - versions, then the three functions on a harmless host name
import platform
import subprocess
import sys
import vulnerable
from labkit import SH, reset
print("python :", sys.version.split()[0], "on", platform.system(), platform.release())
print("git :", subprocess.run(["git", "--version"], capture_output=True, text=True).stdout.strip())
print("sh :", subprocess.run([SH, "-c", "echo $0 $BASH_VERSION"], capture_output=True, text=True).stdout.strip())
reset()
print("os.system ->", end=" ", flush=True)
vulnerable.check_host_system("example.org")
print("shell=True ->", vulnerable.check_host_shell("example.org").stdout.strip())
print("argv list ->", vulnerable.check_host_argv("example.org").stdout.strip())
python step01_normal.py
python : 3.13.14 on Windows 11
git : git version 2.55.0.windows.3
sh : /usr/bin/sh 5.3.15(1)-release
os.system -> argv: ['example.org']
shell=True -> argv: ['example.org']
argv list -> argv: ['example.org']
All three print the same thing, which is why this bug survives code review: with friendly input, the vulnerable and the safe version are indistinguishable. Now give them a hostile host name. The payload is a host name followed by a second command that writes the word INJECTED into a file. We also try the same idea in a POSIX shell, where the command separator is a semicolon.
# step02_inject.py - the same functions with a hostile host name (the payload only writes a harmless file)
import functools
import subprocess
import vulnerable
from labkit import PY_POSIX, SH, reset, show_files
print = functools.partial(print, flush=True) # keep our lines in order with output written by child programs
AMP = "example.org & echo INJECTED> canary_amp.txt"
SEMI = "example.org; echo INJECTED > canary_semi.txt"
reset()
print("=== os.system with an ampersand")
vulnerable.check_host_system(AMP)
print("files created:", show_files())
reset()
print("=== subprocess.run(shell=True) with an ampersand")
r = vulnerable.check_host_shell(AMP)
print("child printed:", r.stdout.strip())
print("files created:", show_files())
reset()
print("=== a POSIX shell (sh -c) with a semicolon")
r = subprocess.run([SH, "-c", f'"{PY_POSIX}" argv_echo.py {SEMI}'], capture_output=True, text=True)
print("child printed:", r.stdout.strip())
print("files created:", show_files())
reset()
print("=== the argv list with the ampersand text")
r = vulnerable.check_host_argv(AMP)
print("child printed:", r.stdout.strip())
print("files created:", show_files())
python step02_inject.py
=== os.system with an ampersand
argv: ['example.org']
files created: {'canary_amp.txt': 'INJECTED'}
=== subprocess.run(shell=True) with an ampersand
child printed: argv: ['example.org']
files created: {'canary_amp.txt': 'INJECTED'}
=== a POSIX shell (sh -c) with a semicolon
child printed: argv: ['example.org']
files created: {'canary_semi.txt': 'INJECTED'}
=== the argv list with the ampersand text
child printed: argv: ['example.org & echo INJECTED> canary_amp.txt']
files created: {}
Read the output carefully, because each line teaches something.
os.systemandshell=Trueboth created the canary. The stand-in tool still printed['example.org'], so from the application’s point of view the call succeeded. The attack is invisible unless you look for its side effect. The Python documentation describesos.systemas executing the command “in a subshell”, implemented by the Csystem()function.- A POSIX shell does the same with a semicolon. The Python docs say that on POSIX,
shell=Trueis the equivalent of running['/bin/sh', '-c', ...]. On Windows, CPython 3.13.14’sLib/subprocess.pybuilds the linecmd.exe /c "..."instead, and adds no escaping forcmd.exesyntax. - The argv list kept the whole string as one argument. The last block shows the stand-in receiving a single odd-looking host name and no canary appearing.
Why it happens
A shell is an interpreter. When we write f"{TOOL} {host}" we are not building a command; we are building a program in the shell’s language, with the user’s text pasted into the middle of it. Anything in that text that is valid shell syntax becomes part of the program. That is the same shape as SQL injection, and the cure is the same: keep code and data separate. The argv list is Python’s version of a parameterized query.
Step 3: See what each layer receives
Before we fix anything, let us measure. We try 13 hostile strings against three ways of starting the stand-in program: cmd.exe through shell=True, the POSIX shell through sh -c, and a plain argv list. For each, we record what the program received and whether anything else happened. The script also prints the command line that Python builds on Windows from a list, because on Windows CreateProcess takes a single string, and Python assembles it with subprocess.list2cmdline using, in the words of its docstring, “the same rules as the MS C runtime”.
# step03_matrix.py - what the child program receives, mode by mode
import subprocess
from labkit import PAYLOADS, attempt
print("list2cmdline:", subprocess.list2cmdline(["argv_echo.py", "example.org & echo INJECTED> canary_m.txt"]))
for number, (name, payload) in enumerate(PAYLOADS, 1):
print(f"\n{number:>2}. {name}: {payload!r}")
for mode in ("cmd.exe", "sh -c", "argv list"):
saw, notes = attempt(mode, payload)
print(f" {mode:<10} {saw:<50} {notes}")
python step03_matrix.py
list2cmdline: argv_echo.py "example.org & echo INJECTED> canary_m.txt"
1. ampersand: 'example.org & echo INJECTED> canary_m.txt'
cmd.exe argv: ['example.org'] wrote canary_m.txt
sh -c argv: ['example.org'] wrote canary_m.txt
argv list argv: ['example.org & echo INJECTED> canary_m.txt'] -
2. double ampersand: 'example.org && echo INJECTED> canary_m.txt'
cmd.exe argv: ['example.org'] wrote canary_m.txt
sh -c argv: ['example.org'] wrote canary_m.txt
argv list argv: ['example.org && echo INJECTED> canary_m.txt'] -
3. semicolon: 'example.org ; echo INJECTED> canary_m.txt'
cmd.exe (nothing on screen) wrote canary_m.txt (the child's output)
sh -c argv: ['example.org'] wrote canary_m.txt
argv list argv: ['example.org ; echo INJECTED> canary_m.txt'] -
4. newline: 'example.org\necho INJECTED> canary_m.txt'
cmd.exe argv: ['example.org'] -
sh -c argv: ['example.org'] wrote canary_m.txt
argv list argv: ['example.org\necho INJECTED> canary_m.txt'] -
5. dollar-paren: '$(echo INJECTED> canary_m.txt)'
cmd.exe (nothing on screen) wrote canary_m.txt) (the child's output)
sh -c argv: [] wrote canary_m.txt
argv list argv: ['$(echo INJECTED> canary_m.txt)'] -
6. backticks: '`echo INJECTED> canary_m.txt`'
cmd.exe (nothing on screen) wrote canary_m.txt` (the child's output)
sh -c argv: [] wrote canary_m.txt
argv list argv: ['`echo INJECTED> canary_m.txt`'] -
7. redirect: 'example.org > canary_m.txt'
cmd.exe (nothing on screen) wrote canary_m.txt (the child's output)
sh -c (nothing on screen) wrote canary_m.txt (the child's output)
argv list argv: ['example.org > canary_m.txt'] -
8. percent variable: '%DEMO_SECRET%'
cmd.exe argv: ['s3cr3t-token'] secret expanded
sh -c argv: ['%DEMO_SECRET%'] -
argv list argv: ['%DEMO_SECRET%'] -
9. dollar variable: '$DEMO_SECRET'
cmd.exe argv: ['$DEMO_SECRET'] -
sh -c argv: ['s3cr3t-token'] secret expanded
argv list argv: ['$DEMO_SECRET'] -
10. caret: 'a^b'
cmd.exe argv: ['ab'] -
sh -c argv: ['a^b'] -
argv list argv: ['a^b'] -
11. double quote: 'a"b'
cmd.exe argv: ['ab'] -
sh -c error text: /usr/bin/sh: -c: line 1: unexpected EOF whil -
argv list argv: ['a"b'] -
12. star: '*'
cmd.exe argv: ['*'] -
sh -c argv: ['a.txt', 'argv_echo.py', 'b.txt'] -
argv list argv: ['*'] -
13. space: 'two words'
cmd.exe argv: ['two', 'words'] -
sh -c argv: ['two', 'words'] -
argv list argv: ['two words'] -
This table is worth studying slowly, because it shows that the two shells disagree about almost everything:
- Chaining.
&and&&chain commands in both shells. A semicolon chains in the POSIX shell, but incmd.exeit is just another separator between words, so the program received extra arguments. Notice, though, that the>redirect in the same string still worked: the file holds the stand-in’s own output. - Newlines. The POSIX shell ran the second line.
cmd.exeran only the first. - Substitution.
$(...)and backticks run commands in the POSIX shell.cmd.exeignores them as syntax, yet its redirect still created a file with an odd name such ascanary_m.txt). - Variables leak without any chaining.
%DEMO_SECRET%expanded incmd.exeand$DEMO_SECRETexpanded in the POSIX shell, and each one handed the secret to the program. A host name is a surprising place to find an environment variable, which is the point. - Silent rewriting.
cmd.exedeleted the caret froma^band the double quote froma"b, and the POSIX shell refused the lone quote with a syntax error. The star expanded to the three file names in the folder, and a space split one value into two arguments in both shells. - The argv list is boring, which is what we want. All 13 strings arrived as exactly one argument, unchanged.
Step 4: Pass an argv list, and learn why shlex.quote is not the Windows fix
The fix for stage 1 is to stop using a shell. Pass a list, leave shell at its default of False, and the operating system never sees a command line that something else has to parse. The Python documentation states the consequence plainly: “all characters, including shell metacharacters, can safely be passed to child processes.” We now check that claim against all 13 strings, and we also test the fix many developers reach for first, shlex.quote.
# step04_fix.py - the argv list, and why shlex.quote is not the fix on Windows
import shlex
import subprocess
from labkit import PAYLOADS, PY, PY_POSIX, SANDBOX, SH, attempt, reset, show_files
print("=== argv list: every payload from step 3")
clean = 0
for name, payload in PAYLOADS:
saw, notes = attempt("argv list", payload)
ok = saw == f"argv: {[payload]}" and notes == "-"
clean += ok
print(f"{'ok ' if ok else 'FAIL'} {name:<18} {saw[:60]}")
print(f"{clean} of {len(PAYLOADS)} payloads arrived as one unchanged argument and wrote nothing")
print("\n=== shlex.quote, then a shell")
payload = "example.org & echo INJECTED> canary_q.txt"
quoted = shlex.quote(payload)
print("shlex.quote gives:", quoted)
reset()
r = subprocess.run(f'"{PY}" argv_echo.py {quoted}', shell=True, cwd=SANDBOX, capture_output=True, text=True)
print("cmd.exe child printed:", (r.stdout + r.stderr).strip().replace("\n", " | "), "| files:", show_files())
reset()
r = subprocess.run([SH, "-c", f'"{PY_POSIX}" argv_echo.py {quoted}'], cwd=SANDBOX, capture_output=True, text=True)
print("sh -c child printed:", (r.stdout + r.stderr).strip().replace("\n", " | "), "| files:", show_files())
reset()
python step04_fix.py
=== argv list: every payload from step 3
ok ampersand argv: ['example.org & echo INJECTED> canary_m.txt']
ok double ampersand argv: ['example.org && echo INJECTED> canary_m.txt']
ok semicolon argv: ['example.org ; echo INJECTED> canary_m.txt']
ok newline argv: ['example.org\necho INJECTED> canary_m.txt']
ok dollar-paren argv: ['$(echo INJECTED> canary_m.txt)']
ok backticks argv: ['`echo INJECTED> canary_m.txt`']
ok redirect argv: ['example.org > canary_m.txt']
ok percent variable argv: ['%DEMO_SECRET%']
ok dollar variable argv: ['$DEMO_SECRET']
ok caret argv: ['a^b']
ok double quote argv: ['a"b']
ok star argv: ['*']
ok space argv: ['two words']
13 of 13 payloads arrived as one unchanged argument and wrote nothing
=== shlex.quote, then a shell
shlex.quote gives: 'example.org & echo INJECTED> canary_q.txt'
cmd.exe child printed: argv: ["'example.org"] | files: {"canary_q.txt'": 'INJECTED'}
sh -c child printed: argv: ['example.org & echo INJECTED> canary_q.txt'] | files: {}
Two results matter here.
First, all 13 payloads arrived as one unchanged argument and wrote nothing. That is the fix for shell injection. If you take only one habit from this post, make it this one: build a list, never a string.
Second, shlex.quote protected the POSIX shell, where the whole string stayed one argument, and failed completely on cmd.exe: the canary was created, with a stray single quote glued to its name. cmd.exe does not treat single quotes as quoting at all. The shlex documentation says so directly: “The shlex module is only designed for Unix shells.” The subprocess docs hedge the same way, saying shlex.quote() works “on some platforms”. There is no equivalent escape function for cmd.exe in the standard library, so on Windows the honest answer to the question of how to escape text for the shell is not to use the shell.
If you think you need a shell feature, you almost certainly do not. Use glob for wildcards, os.path.expanduser for a leading ~, shutil.which for finding a program, and two Popen objects joined by a pipe for |. Each of those keeps data as data.
Step 5: Stop option injection, the bug that survives an argv list
With the shell gone, one parser is still reading our text: the program’s own option parser. Almost every command line tool treats an argument that starts with a dash as a flag, and some flags do dangerous things. This is the CWE-88 half of the problem, and it works with shell=False.
To see it, we need something realistic that is not a toy. Imagine a code review bot or an agent tool that can list the tags of a remote and show a commit. Both are one-line wrappers around git. First, a script that builds a tiny origin repository called repo and a clone of it called app:
# setup_repo.py - build a tiny origin repository called repo and a clone of it called app
import os
import pathlib
import shutil
import stat
import subprocess
HERE = pathlib.Path(__file__).resolve().parent
GIT = ["git", "-c", "user.name=Lab", "-c", "[email protected]",
"-c", "init.defaultBranch=main", "-c", "core.autocrlf=false"]
def force_remove(func, path, _error):
os.chmod(path, stat.S_IWRITE) # git object files are read-only on Windows
func(path)
for name in ("repo", "app"):
if (HERE / name).exists():
shutil.rmtree(HERE / name, onexc=force_remove)
subprocess.run(GIT + ["init", "-q", "repo"], cwd=HERE, check=True)
(HERE / "repo" / "a.txt").write_text("one\n", encoding="utf-8")
subprocess.run(GIT + ["-C", "repo", "add", "a.txt"], cwd=HERE, check=True)
subprocess.run(GIT + ["-C", "repo", "commit", "-q", "-m", "first"], cwd=HERE, check=True)
subprocess.run(GIT + ["-C", "repo", "tag", "v1"], cwd=HERE, check=True)
subprocess.run(GIT + ["clone", "-q", "repo", "app"], cwd=HERE, check=True)
print("created repo and app (a clone whose origin is repo)")
python setup_repo.py
created repo and app (a clone whose origin is repo)
Now the two helpers. Neither uses a shell, and both pass the caller’s text straight to git as one list element:
# git_tools.py - two helpers that pass the caller's text straight to git
import subprocess
def list_tags(remote):
return subprocess.run(["git", "ls-remote", "--tags", remote], cwd="app", capture_output=True, text=True)
def show_commit(ref):
return subprocess.run(["git", "show", "--stat", "--format=%s", ref], cwd="app", capture_output=True, text=True)
The next script calls them normally, then with two hostile values, then tries two ways of telling Git where options end. The first hostile value uses --upload-pack, which the Git documentation describes as “Specify the full path of git-upload-pack on the remote host.” That is a legitimate feature for SSH remotes, and for a local repository the “remote host” is this machine. The second uses --output, which the diff options page describes as “Output to a specific file instead of stdout.”
# step05_git.py - option injection: text that starts with a dash is an option, with or without a shell
import subprocess
import git_tools
from labkit import reset, show_files
def tags(r):
return [line.split("\t")[1] for line in r.stdout.splitlines() if "\t" in line]
print("=== normal use")
print("list_tags('origin') ->", tags(git_tools.list_tags("origin")))
print(git_tools.show_commit("HEAD").stdout.strip())
reset()
print("\n=== option injection in git ls-remote: --upload-pack names a program to run")
remote_payload = "--upload-pack=python -c \"open('canary_up.txt','w').write('INJECTED')\" #"
r = git_tools.list_tags(remote_payload)
print("remote argument:", remote_payload)
print("git exit status:", r.returncode, "| git said:", r.stderr.strip().splitlines()[0])
print("files created:", show_files())
reset()
print("\n=== option injection in git show: --output writes a file")
ref_payload = "--output=../canary_show.txt"
r = git_tools.show_commit(ref_payload)
print("git exit status:", r.returncode, "| text on screen:", repr(r.stdout.strip()))
print("files created:", {k: v.splitlines()[0] for k, v in show_files().items()})
print("\n=== separators: -- versus --end-of-options")
CASES = [
("ls-remote --", ["git", "ls-remote", "--tags", "--", "{}"], remote_payload, "origin"),
("ls-remote --end-of-options", ["git", "ls-remote", "--tags", "--end-of-options", "{}"], remote_payload, "origin"),
("show --", ["git", "show", "--stat", "--format=%s", "--", "{}"], ref_payload, "HEAD"),
("show --end-of-options", ["git", "show", "--stat", "--format=%s", "--end-of-options", "{}"], ref_payload, "HEAD"),
]
for label, template, hostile, benign in CASES:
for kind, text in (("hostile", hostile), ("benign", benign)):
reset()
argv = [text if part == "{}" else part for part in template]
r = subprocess.run(argv, cwd="app", capture_output=True, text=True)
lines = (r.stdout.strip() or r.stderr.strip()).splitlines()
shown = lines[0].strip()[:70]
files = list(show_files()) or "-"
print(f"{label:<27} {kind:<8} exit {r.returncode:<3} {len(lines):>2} lines {shown:<72} files: {files}")
reset()
python step05_git.py
=== normal use
list_tags('origin') -> ['refs/tags/v1']
first
a.txt | 1 +
1 file changed, 1 insertion(+)
=== option injection in git ls-remote: --upload-pack names a program to run
remote argument: --upload-pack=python -c "open('canary_up.txt','w').write('INJECTED')" #
git exit status: 128 | git said: fatal: Could not read from remote repository.
files created: {'canary_up.txt': 'INJECTED'}
=== option injection in git show: --output writes a file
git exit status: 0 | text on screen: ''
files created: {'canary_show.txt': 'first'}
=== separators: -- versus --end-of-options
ls-remote -- hostile exit 128 1 lines fatal: strange pathname '--upload-pack=python -c "open('canary_up.txt' files: -
ls-remote -- benign exit 0 1 lines e01a54040168422ed24ed42fa5b3d63dda279c63 refs/tags/v1 files: -
ls-remote --end-of-options hostile exit 128 1 lines fatal: strange pathname '--upload-pack=python -c "open('canary_up.txt' files: -
ls-remote --end-of-options benign exit 0 1 lines e01a54040168422ed24ed42fa5b3d63dda279c63 refs/tags/v1 files: -
show -- hostile exit 0 1 lines first files: -
show -- benign exit 0 1 lines first files: -
show --end-of-options hostile exit 128 1 lines fatal: option '--output=../canary_show.txt' must come before non-optio files: -
show --end-of-options benign exit 0 4 lines first files: -
What the output shows
- Normal use works. The tag list contains
refs/tags/v1and the commit view shows the subject and the diffstat. - The
--upload-packvalue ran a program. Git exited with status 128 and said it could not read from the remote, because our program is not a Git server. Butcanary_up.txtexists, so the program ran first. The only evidence an application would see is an error that looks like a network failure. - The
--outputvalue wrote a file and hid it. Exit status 0, nothing on screen, and a file with a name the caller chose. That is an arbitrary file write with a polite success code. - The separator table is the useful part. For
ls-remote, both--and--end-of-optionsturned the hostile value into a harmless error about a strange pathname whileoriginstill worked. Forshow, the two behave differently: after--, the hostile value was silently ignored but the benignHEADlost its diffstat (one line of output instead of four), because Git now readHEADas a file name. With--end-of-optionsthe hostile value was refused with an error andHEADdisplayed normally.
The Git CLI documentation explains the difference: “Because -- disambiguates revisions and paths in some commands, it cannot be used for those commands to separate options and revisions. You can use --end-of-options for this”. It also gives the general advice we are following: “When writing a script that is expected to handle random user-input, it is a good practice to make it explicit which arguments are which by placing disambiguating -- at appropriate places”.
The rules that come out of this
- Validate with an allowlist shaped like the thing you expect: a remote name, a revision, a host name. Reject everything else before it reaches a process.
- Reject a leading dash in any value that is not supposed to be an option. It costs nothing and stops this whole class.
- Use the separator that fits the tool.
--for file and operand arguments,--end-of-optionsfor Git revisions. Read the tool’s documentation rather than assuming. The OWASP cheat sheet warns that for curl, “later arguments are still URL operands:--does not prevent additional transfers”. - Do not try to blocklist dangerous flags. Every tool has its own list, and the list grows.
Step 6: Handle the Windows batch-file trap
This step is Windows only. A batch file is a text file ending in .bat or .cmd. Windows cannot run one without cmd.exe, so when you pass a batch file to CreateProcess, the operating system starts cmd.exe for you. In the words of the researcher who named this problem BatBadBut, “CreateProcess() implicitly spawns cmd.exe when executing batch files (.bat, .cmd, etc.), even if the application didn’t specify them in the command line.” So a call that looks like stage 3 of our diagram, an argv list and no shell, silently grows a stage 2.
Many command line tools on Windows ship a small batch file in front of the real program. Ours is a stand-in. Create a folder called tools and put this file in it:
@echo off
python "%~dp0..\argv_echo.py" %*
Save it as tools\export_report.cmd. It passes every argument on to the stand-in program. Now the script that attacks it. It tries six values, each with shell=False and with shell=True, because Python’s own documentation suggests the second one. It also checks how Python finds the file.
# step06_batch.py - an argv list and shell=False are not enough for a .cmd file
import pathlib
import shutil
import subprocess
from labkit import BATCH_CASES, HERE, reset, show_files
REPORT = str(HERE / "tools" / "export_report.cmd")
print("shutil.which finds:", pathlib.Path(shutil.which("export_report")).name)
print("command line Python builds for the quote case:",
subprocess.list2cmdline(["export_report.cmd", 'x" & echo INJECTED> canary_bat.txt & "y']))
try:
subprocess.run(["export_report", "world"])
except FileNotFoundError as error:
print("subprocess.run without the extension:", type(error).__name__)
def go(text, shell):
reset()
r = subprocess.run([REPORT, text], shell=shell, capture_output=True, text=True)
out = (r.stdout + r.stderr).strip()
saw = out.splitlines()[0] if out else "(nothing printed)"
notes = []
if show_files():
notes.append("wrote " + ", ".join(show_files()))
if "s3cr3t-token" in out:
notes.append("secret expanded")
if "is not recognized" in out:
notes.append("cmd.exe tried to run a second command")
return saw[:42], "; ".join(notes) or "-"
for name, text in BATCH_CASES:
print(f"\n{name}: {text!r}")
for shell in (False, True):
saw, notes = go(text, shell)
print(f" shell={str(shell):<5} {saw:<44} {notes}")
reset()
python step06_batch.py
shutil.which finds: export_report.CMD
command line Python builds for the quote case: export_report.cmd "x\" & echo INJECTED> canary_bat.txt & \"y"
subprocess.run without the extension: FileNotFoundError
plain word: 'world'
shell=False argv: ['world'] -
shell=True argv: ['world'] -
space: 'two words'
shell=False argv: ['two words'] -
shell=True argv: ['two words'] -
ampersand, no quote: 'a & echo INJECTED> canary_bat.txt'
shell=False argv: ['a & echo INJECTED> canary_bat.txt' -
shell=True argv: ['a & echo INJECTED> canary_bat.txt' -
quote, then ampersand: 'x" & echo INJECTED> canary_bat.txt & "y'
shell=False argv: ['x"'] wrote canary_bat.txt; cmd.exe tried to run a second command
shell=True argv: ['x"'] wrote canary_bat.txt; cmd.exe tried to run a second command
percent variable: '%DEMO_SECRET%'
shell=False argv: ['s3cr3t-token'] secret expanded
shell=True argv: ['s3cr3t-token'] secret expanded
caret ampersand: 'a^&b'
shell=False argv: ['a'] cmd.exe tried to run a second command
shell=True argv: ['a'] cmd.exe tried to run a second command
What the output shows
- Finding the file.
shutil.which("export_report")returnedexport_report.CMDbecause it honorsPATHEXT. A plainsubprocess.run(["export_report", "world"])failed withFileNotFoundError, becauseCreateProcessonly appends.exeon its own. Python will not pick a batch file by accident, but code that resolves a name withshutil.whichwill, and our wrapper in Step 7 does exactly that, so it has to check. - Spaces and a quoted ampersand are safe. The first three cases arrived intact. Python wraps a value that contains a space in double quotes, and inside quotes
cmd.exeleaves&alone. - A double quote breaks out. Python escapes a double quote with a backslash, which is the C runtime rule. The line it built is printed above: the value ends up as
"x\" & echo INJECTED> canary_bat.txt & \"y".cmd.exedoes not treat a backslash as an escape character, so tocmd.exethat backslash-quote closes the string, and what follows runs as commands. The canary appeared. The BatBadBut write-up explains the root cause: “the command prompt doesn’t use the backslash as an escape character”, and it uses the caret instead. - A percent variable leaks a secret with no quote or ampersand.
cmd.exeexpands%DEMO_SECRET%while reading the command line, even inside quotes, and the program received the secret. - A caret re-arms an ampersand. In
a^&b,cmd.execonsumed the caret during its first pass, and then the batch file’s%*handeda&bto a second pass, where the ampersand separated commands andcmd.exetried to run a program namedb. That explanation is my reading of this output; your batch file may quote its arguments differently, which is exactly why the next section refuses to guess.
What the docs say, and what I measured
The subprocess documentation has a Windows paragraph under “Security Considerations”: batch files “may be launched by the operating system in a system shell regardless of the arguments passed to this library”, which “could result in arguments being parsed according to shell rules, but without any escaping added by Python.” It then suggests: “If you are intentionally launching a batch file with arguments from untrusted sources, consider passing shell=True to allow Python to escape special characters.”
In the output above, shell=True made no difference at all: the same canary, the same leaked secret, the same second command. The docs page itself also says, a few screens earlier, “You do not need shell=True to run a batch file or console-based executable.” The CPython issue behind the note (gh-114539, opened in January 2024 and closed that April) shows the disagreement: a commenter named Ry0taK wrote that using shell=True “doesn’t escape arguments properly”, and in August 2024 another commenter reported that the page contradicts itself. The issue text records the Python Security Response Team’s conclusion that “we can’t safely detect and handle this situation without causing new issues”, so the outcome was a documentation note rather than new escaping. The sentence is still in the 3.13 and 3.15 docs I fetched on 2026-11-08, and the BatBadBut write-up lists Python’s status as “Documentation update”. The practical lesson: on Windows, Python does not make batch-file arguments safe, with or without shell=True, so your code has to.
What to do instead
Option 1: do not run a batch file with untrusted arguments
Call the real program (tool.exe, or python script.py, or node cli.js) instead of the shim. This removes stage 2 entirely and is the only option that needs no clever rules.
Option 2: allow only characters cmd.exe does not care about
If you must run the batch file, accept arguments that match [A-Za-z0-9._-]{1,64} and nothing else. No space, quote, percent sign, caret, ampersand, pipe, angle bracket, parenthesis, exclamation mark or newline can get through. The BatBadBut article describes how to escape arbitrary text for cmd.exe, and it takes several steps, including rewriting % as %%cd:~,%. Refusing is easier to get right than escaping.
Option 3: keep batch files off PATH
The same article advises users to consider “moving the batch files to a directory that is not included in the PATH environment variable”, so that they only run when someone gives the full path. Option 3 prevents the accidental case and does nothing about the deliberate one, so combine it with Option 1 or 2.
Step 7: Build saferun.py so the rules live in one place
Fixing each call site by hand does not scale, and the next developer will write shell=True again. We put every rule from Steps 4 to 6 in one small module, and the rest of the codebase calls it. The rules, in the order the code applies them:
- argv lists only. A single string is refused before anything starts.
- The program is never untrusted. A subclass of
strnamedUntrustedmarks text from outside. The program name may not be one. The Python team’s own view, recorded in the CPython issue, is that an untrusted program name is already vulnerable. - Untrusted values pass an allowlist.
value(text, pattern)usesre.fullmatch, notre.matchwith a$at the end, because the Python documentation says$“matches the end of the string or just before the newline at the end of the string”, so a pattern ending in$accepts a trailing newline. - Untrusted values may not start with a dash. That is the Step 5 rule, enforced again at the last moment.
- Batch files are refused unless the caller passes
allow_batch=True, and even then every argument must match the Step 6 character set. - Housekeeping. The program is resolved with
shutil.which, standard input is closed, a timeout applies,shellisFalse, and the child receives only a short list of environment variables so a secret in our environment stays here.
Part one defines the exception, the marker class, the constants and value():
# saferun.py
"""A guard around subprocess.run: argv lists only, resolved programs, and no surprises from cmd.exe."""
import os
import re
import shutil
import subprocess
from pathlib import Path
class UnsafeCommand(ValueError):
"""Raised before anything starts when a command breaks a rule."""
class Untrusted(str):
"""Text that came from a user, a file, the network, or a model."""
BATCH_SUFFIXES = {".bat", ".cmd"}
BATCH_SAFE_ARG = re.compile(r"[A-Za-z0-9._-]{1,64}") # nothing cmd.exe treats as special
KEEP_ENV = {"PATH", "SYSTEMROOT", "TEMP", "TMP", "HOME", "USERPROFILE", "LANG", "LC_ALL"}
def value(text, pattern):
"""Accept untrusted text only if all of it matches an allowlist pattern."""
if not isinstance(text, str) or re.fullmatch(pattern, text) is None:
raise UnsafeCommand(f"value does not match {pattern}")
return Untrusted(text)
Part two finds the program, refuses batch files by default, and builds the minimal environment:
# saferun.py (continued)
def resolve(program, allow_batch=False):
"""Find the program on PATH and refuse batch files unless the caller opted in."""
found = shutil.which(program)
if found is None:
raise UnsafeCommand(f"program not found: {program}")
if Path(found).suffix.lower() in BATCH_SUFFIXES and not allow_batch:
raise UnsafeCommand(f"{Path(found).name} is a batch file; cmd.exe would re-read its arguments")
return found
def minimal_env():
"""Pass the child only what it needs, so a stray secret in our environment stays here."""
return {k: v for k, v in os.environ.items() if k.upper() in KEEP_ENV}
Part three is the function the rest of the code calls. Append it to the same file:
# saferun.py (continued)
def run(argv, *, allow_batch=False, timeout=10.0, cwd=None):
if isinstance(argv, (str, bytes)) or not argv:
raise UnsafeCommand("argv must be a non-empty list, not one string")
if not all(isinstance(arg, str) for arg in argv):
raise UnsafeCommand("every argument must be a string")
if isinstance(argv[0], Untrusted):
raise UnsafeCommand("the program itself must not come from untrusted text")
program = resolve(argv[0], allow_batch)
batch = Path(program).suffix.lower() in BATCH_SUFFIXES
for arg in argv[1:]:
if isinstance(arg, Untrusted) and arg.startswith("-"):
raise UnsafeCommand(f"untrusted value looks like an option: {arg}")
if batch and BATCH_SAFE_ARG.fullmatch(arg) is None:
raise UnsafeCommand(f"batch file argument outside the safe set: {arg!r}")
return subprocess.run([program, *argv[1:]], shell=False, stdin=subprocess.DEVNULL, capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=cwd,
env=minimal_env(), check=False)
Now rewrite the four helpers on top of it. Each one has an allowlist pattern shaped like what it expects, and the developer, not the user, places --end-of-options. Note that REMOTE accepts the name of a remote that is already configured in the clone, not a URL. Accepting URLs is a separate problem with its own tutorial: preventing server-side request forgery.
# tools_safe.py - the same four jobs, now routed through saferun
import sys
import saferun
from saferun import value
HOST = r"(?!-)[A-Za-z0-9.-]{1,253}"
REMOTE = r"(?!-)[A-Za-z0-9_-]{1,40}" # the name of a remote already configured in the clone
REVISION = r"(?!-)[A-Za-z0-9._/~^-]{1,100}"
REPORT = r"[A-Za-z0-9_-]{1,40}"
def check_host(host):
return saferun.run([sys.executable, "argv_echo.py", value(host, HOST)])
def list_tags(remote):
return saferun.run(["git", "ls-remote", "--tags", "--end-of-options", value(remote, REMOTE)], cwd="app")
def show_commit(ref):
return saferun.run(["git", "show", "--stat", "--format=%s", "--end-of-options", value(ref, REVISION)], cwd="app")
def export_report(name):
return saferun.run(["export_report", value(name, REPORT)], allow_batch=True)
Finally, rerun every attack against the new helpers, then probe the second layer on purpose. The second block uses a sloppy pattern, .*, which lets anything through the allowlist, to prove that saferun.run still refuses the dangerous shapes on its own.
# step07_safe.py - the same attacks against the guarded tools, then the legitimate calls
import saferun
import tools_safe
from labkit import BATCH_CASES, PAYLOADS, PY_POSIX, reset, show_files
from saferun import UnsafeCommand, value
REMOTE_PAYLOAD = "--upload-pack=python -c \"open('canary_up.txt','w').write('INJECTED')\" #"
ATTACKS = (
[("check_host: " + name, tools_safe.check_host, text) for name, text in PAYLOADS]
+ [("list_tags: upload-pack", tools_safe.list_tags, REMOTE_PAYLOAD),
("list_tags: a URL", tools_safe.list_tags, "https://example.org/x.git"),
("show_commit: --output", tools_safe.show_commit, "--output=../canary_show.txt"),
("show_commit: semicolon", tools_safe.show_commit, "HEAD; echo INJECTED")]
+ [("export_report: " + name, tools_safe.export_report, text) for name, text in BATCH_CASES[1:]]
)
refused = 0
for label, func, text in ATTACKS:
reset()
try:
result = func(text)
verdict = f"RAN, child printed {result.stdout.strip()[:40]!r}"
except UnsafeCommand as error:
refused += 1
verdict = f"refused: {str(error).replace(PY_POSIX, 'python')[:52]}"
print(f"{label:<34} {verdict:<66} files: {list(show_files()) or '-'}")
print(f"\n{refused} of {len(ATTACKS)} attack calls refused before anything started")
print("\n=== second layer: what saferun.run refuses even when the allowlist is sloppy (pattern .*)")
SLOPPY = r".*"
LAYER_TWO = [
("one string as argv", "git status", {}),
("option as the remote", ["git", "ls-remote", "--tags", value(REMOTE_PAYLOAD, SLOPPY)], {"cwd": "app"}),
("--output as the ref", ["git", "show", "--stat", value("--output=../canary_show.txt", SLOPPY)], {"cwd": "app"}),
("batch file, no opt-in", ["export_report", "world"], {}),
("batch file, quote and &", ["export_report", value(BATCH_CASES[3][1], SLOPPY)], {"allow_batch": True}),
("batch file, %VAR%", ["export_report", value("%DEMO_SECRET%", SLOPPY)], {"allow_batch": True}),
("untrusted program name", [value("git", SLOPPY), "status"], {}),
]
for label, argv, kw in LAYER_TWO:
reset()
try:
saferun.run(argv, **kw)
verdict = "RAN"
except UnsafeCommand as error:
verdict = "refused: " + str(error)[:72]
print(f"{label:<26} {verdict:<84} files: {list(show_files()) or '-'}")
print("\nlegitimate calls:")
reset()
print("check_host('example.org') ->", tools_safe.check_host("example.org").stdout.strip())
print("list_tags('origin') ->", [l.split("\t")[1] for l in tools_safe.list_tags("origin").stdout.splitlines()])
print("show_commit('HEAD') ->", tools_safe.show_commit("HEAD").stdout.strip().replace("\n", " | "))
print("export_report('world') ->", tools_safe.export_report("world").stdout.strip())
python step07_safe.py
check_host: ampersand refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: double ampersand refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: semicolon refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: newline refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: dollar-paren refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: backticks refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: redirect refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: percent variable refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: dollar variable refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: caret refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: double quote refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: star refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
check_host: space refused: value does not match (?!-)[A-Za-z0-9.-]{1,253} files: -
list_tags: upload-pack refused: value does not match (?!-)[A-Za-z0-9_-]{1,40} files: -
list_tags: a URL refused: value does not match (?!-)[A-Za-z0-9_-]{1,40} files: -
show_commit: --output refused: value does not match (?!-)[A-Za-z0-9._/~^-]{1,100} files: -
show_commit: semicolon refused: value does not match (?!-)[A-Za-z0-9._/~^-]{1,100} files: -
export_report: space refused: value does not match [A-Za-z0-9_-]{1,40} files: -
export_report: ampersand, no quote refused: value does not match [A-Za-z0-9_-]{1,40} files: -
export_report: quote, then ampersand refused: value does not match [A-Za-z0-9_-]{1,40} files: -
export_report: percent variable refused: value does not match [A-Za-z0-9_-]{1,40} files: -
export_report: caret ampersand refused: value does not match [A-Za-z0-9_-]{1,40} files: -
22 of 22 attack calls refused before anything started
=== second layer: what saferun.run refuses even when the allowlist is sloppy (pattern .*)
one string as argv refused: argv must be a non-empty list, not one string files: -
option as the remote refused: untrusted value looks like an option: --upload-pack=python -c "open('can files: -
--output as the ref refused: untrusted value looks like an option: --output=../canary_show.txt files: -
batch file, no opt-in refused: export_report.CMD is a batch file; cmd.exe would re-read its arguments files: -
batch file, quote and & refused: batch file argument outside the safe set: 'x" & echo INJECTED> canary_ba files: -
batch file, %VAR% refused: batch file argument outside the safe set: '%DEMO_SECRET%' files: -
untrusted program name refused: the program itself must not come from untrusted text files: -
legitimate calls:
check_host('example.org') -> argv: ['example.org']
list_tags('origin') -> ['refs/tags/v1']
show_commit('HEAD') -> first | | a.txt | 1 + | 1 file changed, 1 insertion(+)
export_report('world') -> argv: ['world']
All 22 attack calls were refused before any process started, and no canary appeared. The second block shows each rule firing by itself with a different message, which makes failures easy to diagnose. The legitimate calls at the bottom still work, which matters as much: a guard that blocks everything is not a fix.
Step 8: Test it, then break it on purpose
A guard that nobody tests slowly stops guarding. We write attack tests, which assert that hostile input is refused, rather than only checking that good input works. A few design choices make the suite short and hard to fool:
- An autouse fixture fails any test that leaves a canary file behind. One assertion therefore covers every attack in every test.
- The 13 hostile strings from Step 3 run through the wrapper and through the helper, so both layers are tested.
- For the batch rule, the test tries every printable character that is not a letter, a digit, a dot, an underscore or a hyphen, which is 35 characters including every kind of whitespace. All must be refused.
- Refusal tests pass
match=, so they check why a call was refused, not only that it was. The first test shows why this matters: with onlypytest.raises(UnsafeCommand), a version ofsaferunthat wrongly accepts one string would still pass, because the first letter of the string is not a program andsaferunraisesUnsafeCommand: program not found: panyway. I checked that by running the mutant.
# test_saferun.py - attack tests for saferun and the guarded tools
import ast
import string
import subprocess
import pytest
import saferun
import tools_safe
from labkit import BATCH_CASES, HERE, PAYLOADS, PY, canaries, reset
from saferun import UnsafeCommand, Untrusted, value
SAFE_CHARS = set(string.ascii_letters + string.digits + "._-")
SPECIALS = [c for c in string.printable if c not in SAFE_CHARS] # punctuation and every kind of whitespace
OPTIONS = ["--output=../canary_show.txt", "--upload-pack=touch canary_up.txt #", "-n", "--help", "-oProxyCommand=x"]
@pytest.fixture(autouse=True)
def no_files_left_behind():
reset()
yield
leftovers = canaries()
reset()
assert not leftovers, f"an attack left files behind: {leftovers}"
def test_one_string_is_refused():
with pytest.raises(UnsafeCommand, match="not one string"):
saferun.run("python argv_echo.py hello")
def test_missing_program_is_refused():
with pytest.raises(UnsafeCommand, match="not found"):
saferun.run(["no-such-program-xyz"])
def test_program_cannot_be_untrusted():
with pytest.raises(UnsafeCommand, match="program itself"):
saferun.run([Untrusted(PY), "argv_echo.py"])
@pytest.mark.parametrize("name,text", PAYLOADS)
def test_metacharacters_stay_inert(name, text):
result = saferun.run([PY, "argv_echo.py", text], cwd=HERE / "sandbox")
assert ast.literal_eval(result.stdout.strip().removeprefix("argv: ")) == [text]
@pytest.mark.parametrize("text", OPTIONS)
def test_untrusted_option_is_refused(text):
with pytest.raises(UnsafeCommand, match="looks like an option"):
saferun.run([PY, "argv_echo.py", Untrusted(text)])
def test_value_accepts_and_marks():
assert isinstance(value("origin", r"[a-z]+"), Untrusted)
@pytest.mark.parametrize("text", ["", "a b", "a;b", "origin\n", "../x", "--x"])
def test_value_rejects(text):
with pytest.raises(UnsafeCommand):
value(text, r"(?!-)[A-Za-z0-9._-]{1,20}")
def test_batch_file_is_refused_by_default():
with pytest.raises(UnsafeCommand, match="batch file"):
saferun.run(["export_report", "world"])
def test_batch_file_runs_with_a_plain_name():
result = saferun.run(["export_report", "world"], allow_batch=True)
assert result.stdout.strip() == "argv: ['world']"
@pytest.mark.parametrize("char", SPECIALS)
def test_batch_refuses_every_special_character(char):
with pytest.raises(UnsafeCommand, match="safe set"):
saferun.run(["export_report", "a" + char + "b"], allow_batch=True)
@pytest.mark.parametrize("name,text", BATCH_CASES[1:])
def test_batch_refuses_hostile_arguments(name, text):
with pytest.raises(UnsafeCommand, match="safe set"):
saferun.run(["export_report", text], allow_batch=True)
def test_timeout_stops_a_slow_child():
with pytest.raises(subprocess.TimeoutExpired):
saferun.run([PY, "-c", "import time; time.sleep(5)"], timeout=0.5)
def test_environment_is_scrubbed():
result = saferun.run([PY, "-c", "import os; print(os.environ.get('DEMO_SECRET', 'absent'))"])
assert result.stdout.strip() == "absent"
@pytest.mark.parametrize("name,text", PAYLOADS)
def test_check_host_refuses_every_payload(name, text):
with pytest.raises(UnsafeCommand):
tools_safe.check_host(text)
def test_check_host_accepts_a_host_name():
assert tools_safe.check_host("example.org").stdout.strip() == "argv: ['example.org']"
@pytest.mark.parametrize("text", OPTIONS + ["origin\n", "https://example.org/x.git"])
def test_list_tags_refuses_hostile_remotes(text):
with pytest.raises(UnsafeCommand):
tools_safe.list_tags(text)
def test_list_tags_finds_the_tag():
assert "refs/tags/v1" in tools_safe.list_tags("origin").stdout
@pytest.mark.parametrize("text", OPTIONS + ["HEAD; echo INJECTED"])
def test_show_commit_refuses_hostile_refs(text):
with pytest.raises(UnsafeCommand):
tools_safe.show_commit(text)
def test_show_commit_shows_head():
assert tools_safe.show_commit("HEAD").stdout.startswith("first")
@pytest.mark.parametrize("name,text", BATCH_CASES[1:])
def test_export_report_refuses_hostile_names(name, text):
with pytest.raises(UnsafeCommand):
tools_safe.export_report(text)
def test_export_report_runs():
assert tools_safe.export_report("world").stdout.strip() == "argv: ['world']"
python -m pip install pytest==9.1.1
python -m pytest -q -p no:cacheprovider
............................................................................................ [ 85%]
............... [100%]
107 passed in 1.64s
Green tests only prove the code and the tests agree. To find out whether the tests would notice a regression, we plant bugs. The script below copies the lab into a scratch folder ten times, deletes or weakens one rule in each copy, and counts how many tests fail. A mutant that no test notices is a rule nobody is guarding.
# mutations.py - break saferun.py on purpose, one line at a time, and count the tests that notice
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile
HERE = pathlib.Path(__file__).resolve().parent
MUTANTS = [
("shell=False becomes shell=True", "shell=False", "shell=True"),
("batch files always allowed", " and not allow_batch", " and False"),
("batch allowlist widened", 'r"[A-Za-z0-9._-]{1,64}"', 'r"[A-Za-z0-9._&%^\\" -]{1,64}"'),
("leading-dash rule removed", 'isinstance(arg, Untrusted) and arg.startswith("-")', "False"),
("one string accepted as argv", "isinstance(argv, (str, bytes)) or not argv", "not argv"),
("untrusted program allowed", "isinstance(argv[0], Untrusted)", "False"),
("whole environment passed on", "env=minimal_env()", "env=None"),
("timeout dropped", "timeout=timeout, ", ""),
("PATH lookup skipped", "found = shutil.which(program)", "found = program"),
("fullmatch becomes match with $", "re.fullmatch(pattern, text) is None", "re.match(pattern + '$', text) is None"),
]
FILES = ["saferun.py", "tools_safe.py", "labkit.py", "argv_echo.py", "setup_repo.py", "test_saferun.py"]
def trial(old, new):
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as scratch:
folder = pathlib.Path(scratch)
for file in FILES:
shutil.copy(HERE / file, folder / file)
shutil.copytree(HERE / "tools", folder / "tools")
if old:
text = (folder / "saferun.py").read_text(encoding="utf-8")
assert text.count(old) == 1, f"{old!r} occurs {text.count(old)} times"
(folder / "saferun.py").write_text(text.replace(old, new), encoding="utf-8", newline="\n")
subprocess.run([sys.executable, "setup_repo.py"], cwd=folder, capture_output=True, check=True)
result = subprocess.run([sys.executable, "-m", "pytest", "-q", "--tb=no", "-p", "no:cacheprovider"],
cwd=folder, capture_output=True, text=True, timeout=600)
summary = result.stdout.strip().splitlines()[-1]
failed = re.search(r"(\d+) failed", summary)
return int(failed.group(1)) if failed else 0, summary
failing, summary = trial("", "")
print(f"baseline: {summary}")
caught = 0
for name, old, new in MUTANTS:
failing, summary = trial(old, new)
caught += failing > 0
print(f"{'caught ' if failing else 'SURVIVED'} {name:<34} {failing:>3} tests fail")
print(f"\n{caught} of {len(MUTANTS)} planted bugs were caught")
python mutations.py
baseline: 107 passed in 1.62s
caught shell=False becomes shell=True 2 tests fail
caught batch files always allowed 1 tests fail
caught batch allowlist widened 8 tests fail
caught leading-dash rule removed 5 tests fail
caught one string accepted as argv 1 tests fail
caught untrusted program allowed 1 tests fail
caught whole environment passed on 1 tests fail
caught timeout dropped 1 tests fail
caught PATH lookup skipped 44 tests fail
caught fullmatch becomes match with $ 2 tests fail
10 of 10 planted bugs were caught
All 10 planted bugs were caught, and the counts are informative. Skipping the PATH lookup fails 44 tests, because without shutil.which the name export_report has no extension and Windows reports FileNotFoundError; a large part of the suite depends on resolving the program properly. Widening the batch allowlist fails 8 tests and removing the leading-dash rule fails 5. The timeout, the environment, the untrusted program name, the one-string check and the batch switch each have exactly one test pointing at them, which is fine for a rule with exactly one behavior.
The most interesting result is the first one. Changing shell=False to shell=True was caught by only two tests: the newline payload, which cmd.exe truncated, and the caret payload, which it rewrote from a^b to ab. The ampersand payloads all passed, because Python quotes any value that contains a space and cmd.exe leaves a quoted ampersand alone. A suite that only tried ampersands would have called shell=True safe on Windows. That is the argument for trying many kinds of hostile text rather than one famous one.
Step 9: Add a lint gate that keeps raw subprocess calls out
The wrapper only helps if people use it. Linters can enforce that, but we should measure what they actually catch rather than assume. The sample file below holds twelve ways of starting a process, each labeled with a number in a trailing comment. The script runs Ruff’s security rules (the S family) and Bandit over it, prints what each tool says about each line, then tries a different approach: Ruff’s TID251 rule (named banned-api), configured to forbid the process launchers outright.
# lint_samples/samples.py
import asyncio
import os
import subprocess
def shapes(host, remote, name):
os.system(f"ping {host}") # 1 os.system with an f-string
os.popen("ping " + host) # 2 os.popen with concatenation
subprocess.run(f"ping {host}", shell=True) # 3 shell=True with an f-string
subprocess.check_output("ping " + host, shell=True) # 4 shell=True with concatenation
subprocess.run(["ping", host]) # 5 list with a bare program name
subprocess.run(["git", "ls-remote", remote]) # 6 list, option injection possible
subprocess.run(["git", "ls-remote", "--", remote]) # 7 list, hardened with a separator
subprocess.run(["C:/tools/export_report.cmd", name]) # 8 list, batch file
asyncio.create_subprocess_shell(f"ping {host}") # 9 asyncio through a shell
os.spawnl(os.P_WAIT, "/bin/ping", "ping", host) # 10 os.spawnl
subprocess.Popen(["ping", host], shell=False) # 11 Popen
os.startfile(name) # 12 os.startfile, Windows only
# step09_lint.py - what Ruff and Bandit say about twelve call shapes, then a Ruff ban that covers them all
import json
import pathlib
import re
import shutil
import subprocess
import sys
HERE = pathlib.Path(__file__).resolve().parent
SAMPLES = HERE / "lint_samples" / "samples.py"
SCRIPTS = pathlib.Path(sys.executable).parent
print("ruff", subprocess.run([str(SCRIPTS / "ruff"), "--version"], capture_output=True, text=True).stdout.strip(),
"| bandit", subprocess.run([str(SCRIPTS / "bandit"), "--version"], capture_output=True, text=True).stdout.split()[1])
ruff = subprocess.run([str(SCRIPTS / "ruff"), "check", "--isolated", "--select", "S", "--output-format", "json", str(SAMPLES)],
capture_output=True, text=True)
ruff_hits = {}
for item in json.loads(ruff.stdout):
ruff_hits.setdefault(item["location"]["row"], []).append(item["code"])
bandit = subprocess.run([str(SCRIPTS / "bandit"), "-q", "-f", "json", str(SAMPLES)], capture_output=True, text=True)
bandit_hits = {}
for item in json.loads(bandit.stdout)["results"]:
bandit_hits.setdefault(item["line_number"], []).append(item["test_id"])
print("\n=== Ruff S rules and Bandit on twelve call shapes")
for lineno, line in enumerate(SAMPLES.read_text(encoding="utf-8").splitlines(), 1):
m = re.search(r"# (\d+) (.*)", line)
if m:
ruff_codes = " ".join(sorted(ruff_hits.get(lineno, []))) or "-"
bandit_codes = " ".join(sorted(bandit_hits.get(lineno, []))) or "-"
print(f"{int(m.group(1)):>2} {m.group(2):<38} Ruff: {ruff_codes:<10} Bandit: {bandit_codes}")
print("\n=== Ruff TID251 ban on subprocess and friends")
BAN = HERE / "lint_ban"
BAN.mkdir(exist_ok=True)
shutil.copy(HERE / "saferun.py", BAN / "saferun.py")
shutil.copy(SAMPLES, BAN / "samples.py")
(BAN / "ruff.toml").write_text("""\
[lint]
select = ["TID251"]
[lint.flake8-tidy-imports.banned-api]
"subprocess".msg = "Use saferun.run()."
"os.system".msg = "Use saferun.run()."
"os.popen".msg = "Use saferun.run()."
"os.spawnl".msg = "Use saferun.run()."
"os.startfile".msg = "Use saferun.run()."
"asyncio.create_subprocess_shell".msg = "Use saferun.run()."
[lint.per-file-ignores]
"saferun.py" = ["TID251"]
""", encoding="utf-8", newline="\n")
print((BAN / "ruff.toml").read_text(encoding="utf-8").rstrip())
out = subprocess.run([str(SCRIPTS / "ruff"), "check", "--config", str(BAN / "ruff.toml"), "--output-format", "concise",
str(BAN / "saferun.py"), str(BAN / "samples.py")], capture_output=True, text=True, cwd=HERE)
print("\n$ ruff check --config lint_ban/ruff.toml lint_ban/saferun.py lint_ban/samples.py")
for line in (out.stdout + out.stderr).replace(str(HERE) + "\\", "").replace("\\", "/").strip().splitlines():
print(line)
print("exit status:", out.returncode)
python -m pip install ruff==0.17.1 bandit==1.9.4
python step09_lint.py
ruff ruff 0.17.1 | bandit 1.9.4
=== Ruff S rules and Bandit on twelve call shapes
1 os.system with an f-string Ruff: S605 Bandit: B605
2 os.popen with concatenation Ruff: S605 Bandit: B605
3 shell=True with an f-string Ruff: S602 Bandit: B602
4 shell=True with concatenation Ruff: S602 Bandit: B602
5 list with a bare program name Ruff: S603 S607 Bandit: B603 B607
6 list, option injection possible Ruff: S603 S607 Bandit: B603 B607
7 list, hardened with a separator Ruff: S603 S607 Bandit: B603 B607
8 list, batch file Ruff: S603 Bandit: B603
9 asyncio through a shell Ruff: - Bandit: -
10 os.spawnl Ruff: S606 Bandit: B606
11 Popen Ruff: S603 S607 Bandit: B603 B607
12 os.startfile, Windows only Ruff: S606 Bandit: B606
=== Ruff TID251 ban on subprocess and friends
[lint]
select = ["TID251"]
[lint.flake8-tidy-imports.banned-api]
"subprocess".msg = "Use saferun.run()."
"os.system".msg = "Use saferun.run()."
"os.popen".msg = "Use saferun.run()."
"os.spawnl".msg = "Use saferun.run()."
"os.startfile".msg = "Use saferun.run()."
"asyncio.create_subprocess_shell".msg = "Use saferun.run()."
[lint.per-file-ignores]
"saferun.py" = ["TID251"]
$ ruff check --config lint_ban/ruff.toml lint_ban/saferun.py lint_ban/samples.py
lint_ban/samples.py:4:8: TID251 `subprocess` is banned: Use saferun.run().
lint_ban/samples.py:8:5: TID251 `os.system` is banned: Use saferun.run().
lint_ban/samples.py:9:5: TID251 `os.popen` is banned: Use saferun.run().
lint_ban/samples.py:16:5: TID251 `asyncio.create_subprocess_shell` is banned: Use saferun.run().
lint_ban/samples.py:17:5: TID251 `os.spawnl` is banned: Use saferun.run().
lint_ban/samples.py:19:5: TID251 `os.startfile` is banned: Use saferun.run().
Found 6 errors.
exit status: 1
What the output shows
- Ruff and Bandit agree, row by row. Both flag shell use (
S602andS605againstB602andB605). Both flag every list call, withS603orB603, and addS607orB607when the program name has no path. - They cannot tell a vulnerable list call from a hardened one. Rows 6 and 7 get identical findings even though row 7 has the separator. The Ruff documentation for
S603says so openly: “Prone to false positives as it is difficult to determine whether the passed arguments have been validated.” Row 8, the batch file, gets the same single finding and no hint aboutcmd.exe. - Row 9 slipped through both tools. In this run (Ruff 0.17.1, Bandit 1.9.4),
asyncio.create_subprocess_shellproduced no finding from the security rules. - The ban list does not need to classify anything. The Ruff documentation describes
TID251like this: “Projects may want to ensure that specific modules or module members are not imported or accessed.” Our configuration forbidssubprocessas a whole (flagged once, at the import on line 4, which covers rows 3 to 8 and 11) and names the other launchers separately: rows 1, 2, 9, 10 and 12. That is six findings, all insamples.py.saferun.pyitself is exempted by theper-file-ignoresentry and has no findings. The exit status is 1, which fails a CI job.
A ban list is a policy, not a proof. If your code base can start processes in other ways (for example os.exec*, os.posix_spawn or pty.spawn), add those names to the list; I only tested the entries shown here. Run ruff check --config with this file in CI on your whole project, and every process launch has to go through saferun.run or carry a visible exemption that a reviewer can question.
Step 10: Confirm everything end to end
Run these checks in order from the lab folder. Each one should match what you saw above.
python step02_inject.pyshows the vulnerable functions creating canary files, and the argv list creating none. This proves the lab still attacks what it claims to attack.python step07_safe.pyreports22 of 22 attack calls refused before anything started, seven refusals from the second layer, and four legitimate calls that still work.python -m pytest -q -p no:cacheproviderreports107 passed.python mutations.pyreports10 of 10 planted bugs were caught.python step09_lint.pylists sixTID251findings insamples.pyand none insaferun.py.- Finally, look for stray files. Running
dir canary*in the lab folder, inappand insandboxshould find nothing, because every step cleans up after itself.
I also repeated Steps 1 to 7 on Python 3.15.1, the python.org embeddable build, and the output matched line for line apart from the Python version and the commit hash that Git prints. In particular, shell=True still made no difference for the batch file on 3.15.1. The pytest, mutation and lint steps ran on 3.13.14 only.
Common mistakes
- Passing a list with
shell=Trueon Linux or macOS. The Python documentation says that with a sequence, “any additional items will be treated as additional arguments to the shell itself”, not to your command. The call is both wrong and still shell-parsed. - Believing
shell=Truemakes batch files safe. The documentation suggests it; the measurements above show it changes nothing. - Using
shlex.quoteon Windows. It is for Unix shells and failed againstcmd.exein Step 4. - Building argv by splitting user text.
text.split()orshlex.split(text)lets the user choose the arguments, including options. - Blocklisting metacharacters. The two shells treat different characters as special, and
cmd.exeadds%and^. Describe what is allowed instead. - Ending an allowlist pattern with
$and callingre.match.$also matches before a trailing newline. Usere.fullmatch. - Letting the child inherit your environment. A host name was enough to read
DEMO_SECRETin Step 3. Pass only what the program needs. - Forgetting the timeout. A child that waits forever ties up a worker; see the tutorial on timeouts and deadlines.
- Validating the shape but not the meaning. A well-formed remote name can still point somewhere you did not intend, and a well-formed path can still climb out of its folder. Those are separate problems; see path traversal and server-side request forgery.
Where to go next
- Replace the command with a library call where you can. The OWASP cheat sheet calls this the primary defense: “The primary defense is to avoid calling OS commands directly. Built-in library functions are a very good alternative to OS Commands, as they cannot be manipulated to perform tasks other than those it is intended to do.”
- Watch process launches at run time. CPython raises an audit event named
subprocess.Popenwhenever thesubprocessmodule starts a process, which an audit hook can log or block. The audit hooks tutorial shows how hooks work. - Apply the same separation of code and data elsewhere. Parameterized SQL and Python t-strings are the same idea in other languages. Windows has a sibling of Step 6 in argument injection in a custom URI protocol handler.
- Run the tool with the least privilege it needs. A separate low-privilege account or container limits the damage if a rule is ever missed.
Sources and further reading
- Python documentation: subprocess (Security Considerations; the 3.15 page carries the same Windows batch-file paragraph), shlex, re and os.
- CPython issue gh-114539, “subprocess security considerations needs a Windows-specific exception”.
- RyotaK, BatBadBut: You can’t securely execute commands on Windows, GMO Flatt Security, April 9, 2024.
- OWASP, OS Command Injection Defense Cheat Sheet.
- MITRE, CWE-78 and CWE-88.
- Git documentation: gitcli, git-ls-remote and diff options.
- Ruff rules S602, S603, S606, S607 and TID251.








No Comment! Be the first one.