CWE-676: Use of Potentially Dangerous Function - Python
Overview
Python's standard library includes a handful of functions that are dangerous specifically because of how flexible they are: eval() and exec() run arbitrary Python source, and os.system() plus subprocess with shell=True hand a string straight to a shell. None of these are deprecated - they're documented, supported API - but each has a safe, narrow replacement for nearly every legitimate use case.
Common Vulnerable Patterns
Dynamic Code Evaluation
# VULNERABLE - arbitrary code execution
user_expression = request.form["expression"]
result = eval(user_expression) # attacker input runs as Python code
# Attacker input: "__import__('os').system('rm -rf /')"
Why this is vulnerable: eval() compiles and runs its argument as a full Python expression, with access to builtins like __import__ unless explicitly restricted. There is no way to make eval() safe for untrusted input short of building an allowlist-based sandbox, which is exactly what a purpose-built parser already does more reliably.
Shell Commands Built From Untrusted Text
import subprocess
# VULNERABLE - command injection
filename = request.args.get("file")
subprocess.run(f"cat {filename}", shell=True) # shell parses filename
# Attacker-controlled filename: "a.txt; rm -rf /"
Why this is vulnerable: shell=True (and os.system(), which always uses a shell) hands the whole string to /bin/sh, which interprets ;, |, &, and ` as command separators and substitutions. Building that string with an f-string or concatenation means the attacker controls part of what the shell executes.
Secure Patterns
Parsing Instead of Evaluating
import ast
# For structured data - use a real parser, not eval()
import json
data = json.loads(user_supplied_text)
# For simple literal Python values (numbers, strings, lists, dicts of literals)
value = ast.literal_eval(user_supplied_text)
Why this works: json.loads() and ast.literal_eval() parse text into data, not code - there's no execution step, so there's nothing for attacker-controlled text to run. ast.literal_eval() specifically rejects anything that isn't a literal (no function calls, no attribute access, no imports), so it can't be used to reach __import__ or any other escape hatch.
Parameterized Process Execution
import subprocess
filename = request.args.get("file")
result = subprocess.run(
["cat", "--", filename], # list form: no shell; "--" stops cat reading it as an option
capture_output=True,
text=True,
check=True,
timeout=10, # a child that never exits otherwise blocks the request forever
)
Why this works: Passing arguments as a list means Python invokes the program directly, without a shell in between to parse metacharacters in filename. The value is delivered to the program as a single argument no matter what characters it contains, so a.txt; rm -rf / reaches cat as one improbable filename rather than as two commands.
Removing the shell does not make the argument safe, only inert as shell syntax. cat still parses its own options, so a filename of --help or -v changes what the command does; the -- separator ends that. And the value is still a path - ../../etc/passwd is not a shell metacharacter problem, it is CWE-22, and it needs the path resolved against a known base directory rather than a different process API.
Considerations
shell=True is not always the finding. A subprocess call whose command
string is entirely application-controlled - a fixed pipeline in a build script, a
command assembled only from constants - is not injectable, and the list form is
awkward where a genuine shell pipeline is what is wanted. The question is whether
any part of the string can be influenced from outside the process. Where it
cannot, record why next to the call; where even one interpolated value comes from
a request, a database row, a filename on disk or an environment variable, it is
real.
eval() on trusted input is still usually the wrong tool. Configuration
loaded from a file the operator controls is not an injection finding, but
ast.literal_eval or a real config format reads the same data without an
execution step, so the replacement costs nothing. Reserve the judgement call for
the cases where genuine expression evaluation is the requirement - a formula
field, a rules engine - and there the answer is a purpose-built expression
parser, not a hardened eval().
Restricting eval() is not a control. Passing {"__builtins__": {}} as the
globals argument is the usual attempt and it does not hold. Every object the
expression can name still carries its type, so an expression can walk from a
bare tuple up to object and back down through __subclasses__() to a class
that imports modules - ().__class__.__base__.__subclasses__() reaches
BuiltinImporter on a stock CPython 3.13 and gets os back from it, with no
builtins in scope at any point. Treat an eval() on untrusted input as
unfixable in place.
Testing
- For replaced
eval()/exec()calls, test with payloads like__import__('os').system('id')and confirm the replacement rejects or safely parses them instead of executing them. - For replaced shell calls, test with shell metacharacters (
| & $() \) in the untrusted argument and confirm they reach the target program as literal characters, not as shell syntax. - Test the same call with an argument that begins with
-(--help,-v) and confirm the target program treats it as an operand rather than an option - this is the failure the list form alone does not prevent, and it passes only if the--separator or an equivalent guard is present. - Re-run the security scanner or static analysis (e.g.
bandit) to confirm no remainingeval,exec, orshell=Truefindings.banditreports the shape of the call: it flagsshell=Trueandeval, and stays silent on a list-formsubprocess.runwhose argument is an unvalidated path, so a clean run is not evidence the argument is safe.