HackBlue

Tools · Python and Bash for security automation · 09 / 20

Running commands safely from Python: subprocess without shell injection

See how shell=True with user input enables command injection, rewrite calls with argument lists, validate inputs, and avoid the same mistake in os.system and similar APIs.

9 min read · practice only with authorization

The problem

Security scripts often wrap tools such as ping, dig, or nmap. If a script builds a command string from user input and runs it through a shell, characters like ; | & $( ) let the input run extra commands. This is command injection, and it turns a helpful internal tool into a remote shell for whoever can reach it.

The fix

Pass an argument list to subprocess.run without shell=True. Each list item becomes exactly one argument, so shell metacharacters have no meaning. Validate inputs against what you expect, such as a hostname or IP pattern, and use -- to stop option parsing where tools support it.

Snippet
import ipaddress, re, subprocess

# unsafe: input becomes shell syntax
# subprocess.run(f'ping -c 1 {host}', shell=True)

HOST = re.compile(r'^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*
#x27;) def valid_target(value: str) -> bool: try: ipaddress.ip_address(value); return True except ValueError: return bool(HOST.match(value)) def ping(host: str) -> str: if not valid_target(host): raise ValueError('not a hostname or IP') r = subprocess.run(['ping', '-c', '1', '--', host], capture_output=True, text=True, timeout=10) return r.stdout

Lab: see it, then fix it

In a local lab script that is not reachable from the network, call the unsafe version with the input 127.0.0.1; id and observe that id runs. Then switch to the list form and see ping fail on the invalid argument instead. Add the validator and confirm the bad input is rejected before any process starts. Finally, run Bandit, a Python security linter, on the file and see it flag the shell=True call.

Snippet
pip install bandit
bandit -q wrapper.py

Diagram

shell=True with input versus argument list

The list form removes the shell from the picture.

shell=True with f-string

Input. 127.0.0.1; id

Shell sees. ping -c 1 127.0.0.1 ; id

Result. Two commands run

Argument list

Input. 127.0.0.1; id

Process gets. ['ping', '-c', '1', '--', '127.0.0.1; id']

Result. ping rejects one odd argument

Check yourself

1. What risk does subprocess.run(f"ping {host}", shell=True) create?

2. Which call is safe against shell injection?

3. Which tool flags risky subprocess usage in Python code?