Skip to content

← Config, Logs and Untrusted Input step 8 of 16

Easy Primitives

Run a Command Without a Shell

Your script runs a command-line tool on a file the user uploaded. The tempting way builds one command string and hands it to a shell. On Linux or macOS:

import subprocess

filename = "my report.csv; echo pwned"
command = f"printf '[%s]\\n' {filename}"
print(subprocess.run(command, shell=True, capture_output=True, text=True).stdout)
[my]
[report.csv]
pwned

The shell split the name at the space and ran echo pwned as a second command. When a user's text can change which commands run, that is called injection. Pass the command as a list instead. Then no shell is involved, and each item reaches the program as one argument, exactly as written:

subprocess.run(["printf", "[%s]\\n", filename], capture_output=True, text=True)
# stdout: [my report.csv; echo pwned]

What to write

Fill in run_tool(args). It runs the supplied tool with args as its arguments and returns what the tool prints.

  • The tool is a short Python program, TOOL_CODE. Run it as python -c TOOL_CODE arg1 arg2 .... In real code the first item would be a program such as git. Here it is Python itself, because every machine that grades your code has it.
  • Build the command as a list: [sys.executable, "-c", TOOL_CODE, *args]. sys.executable is the path of the Python that is running your code.
  • Call subprocess.run with that list and three options: capture_output=True collects what the tool prints instead of showing it, text=True decodes the output to str, and check=True raises subprocess.CalledProcessError if the tool exits with a nonzero status.
  • Do not pass shell=True. Return the result's stdout.

The tool prints each argument on its own line, in brackets. It stops with unknown option ... on stderr and exit status 1 when an argument starts with --.

Edge cases

  • Spaces, ;, $(...), quotes and * are ordinary characters in a list item. The tool prints them as they are.
  • An empty string is still an argument, and the tool prints [] for it.
  • With no arguments, the tool prints nothing.

What the driver reports

solve(args) calls run_tool(args). On success it returns "ok": True and the output split into lines. If run_tool raises CalledProcessError, solve returns "ok": False, the tool's returncode, and the last line the tool wrote to stderr as error. That line is there only if you captured the output.

Going further

The child process also gets a copy of your program's environment variables, as they are when you start the child. If you set os.environ["MODE"] = "test" first, the child sees MODE. The shell that started your program does not, and neither does a child that was already running. Pass env= to subprocess.run to choose exactly which variables the child gets.