Dangerous Execution and Injection
eval, exec, shell commands, string built queries and pickle all take text and turn it into action. Each has a safe alternative that costs almost nothing.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
eval and exec
print(eval("2 + 3 * 4")) # 14
exec("total = sum(range(10))")
print(total) # 45Both take a string and run it as Python. With input you wrote, that is merely unusual. With input from anywhere else, it is complete control of your program.
# A calculator that accepts user input
def calculate(expression):
return eval(expression) # DANGEROUS
# calculate("2 + 3") # 5
# calculate("__import__('os').listdir('.')") # lists your files
# calculate("open('/etc/passwd').read()") # reads any readable file
# calculate("__import__('shutil').rmtree('.')") # deletes the directoryRestricting the globals passed toevaldoes not make it safe. Sandboxing Python inside Python has been attempted many times and defeated every time. The only correct answer is not to callevalon input you did not write.
# This looks safe and is not
# eval("[].__class__.__base__.__subclasses__()", {"__builtins__": {}})
# From `object` you can reach almost every class the interpreter has loaded.The safe alternatives
import ast
# For literal data only: numbers, strings, lists, dicts, tuples, sets, booleans
print(ast.literal_eval("[1, 2, {'a': 3}]"))
print(ast.literal_eval("(1, 2)"))
print(ast.literal_eval("'hello'"))
try:
ast.literal_eval("__import__('os').system('ls')")
except ValueError as error:
print("rejected:", error)import ast
import operator
OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
MAX_POWER = 1000
def evaluate(node):
"""Evaluate a small arithmetic expression safely."""
if isinstance(node, ast.Expression):
return evaluate(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPERATORS:
left, right = evaluate(node.left), evaluate(node.right)
if isinstance(node.op, ast.Pow) and abs(right) > MAX_POWER:
raise ValueError("exponent too large")
return OPERATORS[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and type(node.op) in OPERATORS:
return OPERATORS[type(node.op)](evaluate(node.operand))
raise ValueError("unsupported expression")
def calculate(text):
if len(text) > 200:
raise ValueError("expression too long")
return evaluate(ast.parse(text, mode="eval"))
for expression in ["2 + 3 * 4", "-5 ** 2", "__import__('os')", "2 ** 999999"]:
try:
print(f"{expression:<24}{calculate(expression)}")
except (ValueError, SyntaxError) as error:
print(f"{expression:<24}rejected: {error}")An allow list of node types can be reasoned about completely: anything not explicitly permitted is rejected. That is what makes this safe where a filtered eval is not.
Dispatch instead of eval
# Using eval to pick a function
# result = eval(f"handle_{command}()") # DANGEROUS
HANDLERS = {
"add": lambda payload: f"adding {payload}",
"delete": lambda payload: f"deleting {payload}",
"list": lambda payload: "listing",
}
def dispatch(command, payload=None):
handler = HANDLERS.get(command)
if handler is None:
raise ValueError(f"unknown command: {command!r}")
return handler(payload)
print(dispatch("add", "note-1"))
try:
dispatch("__import__")
except ValueError as error:
print(error)Almost every real use of eval is choosing between a fixed set of behaviours. A dictionary does that, and only those behaviours can ever be reached.
Command injection
import subprocess
filename = "notes.txt"
# DANGEROUS: the shell parses the whole string
# subprocess.run(f"cat {filename}", shell=True)
#
# With filename = "notes.txt; rm -rf ~" the shell runs BOTH commands.
# Safe: a list of arguments, no shell involved
result = subprocess.run(["cat", filename], capture_output=True, text=True)
print(result.returncode)import shlex
import subprocess
user_input = "notes.txt; rm -rf ~"
print(shlex.quote(user_input)) # escaped, if a shell truly is unavoidable
# But the list form remains better: the argument cannot be reinterpreted at all
subprocess.run(["ls", "-l", user_input], capture_output=True)import subprocess
ALLOWED_COMMANDS = {
"disk": ["df", "-h"],
"uptime": ["uptime"],
"date": ["date"],
}
def run_report(name, timeout=5):
"""Run only a command from the allow list."""
command = ALLOWED_COMMANDS.get(name)
if command is None:
raise ValueError(f"unknown report: {name!r}")
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=timeout, # never let a subprocess run forever
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"{name} failed: {result.stderr.strip()}")
return result.stdout
try:
print(run_report("date"))
run_report("rm -rf /")
except ValueError as error:
print("rejected:", error)- Pass a list, never a string, and leave
shell=False, which is the default. - Restrict which commands may run at all, with an allow list.
- Always set a
timeout. - Never build a command line from user input, even with quoting.
SQL injection
import sqlite3
connection = sqlite3.connect(":memory:")
connection.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT)")
connection.executemany("INSERT INTO users (name, role) VALUES (?, ?)",
[("meera", "editor"), ("arun", "viewer")])
connection.commit()
username = "meera"
# DANGEROUS: the value becomes part of the query text
# query = f"SELECT * FROM users WHERE name = '{username}'"
# connection.execute(query)
#
# With username = "' OR '1'='1" every row is returned.
# With username = "'; DROP TABLE users; --" the table is gone.
# Safe: the value is sent separately and is never parsed as SQL
row = connection.execute(
"SELECT id, name, role FROM users WHERE name = ?", (username,)
).fetchone()
print(row)
# Even the classic attack string is just a value that matches nothing
row = connection.execute(
"SELECT id, name FROM users WHERE name = ?", ("' OR '1'='1",)
).fetchone()
print(row) # Noneimport sqlite3
connection = sqlite3.connect(":memory:")
connection.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT, views INTEGER)")
# Named parameters read better with several values
connection.execute(
"INSERT INTO notes (title, views) VALUES (:title, :views)",
{"title": "Regex", "views": 10},
)
# Many rows at once
connection.executemany(
"INSERT INTO notes (title, views) VALUES (?, ?)",
[("Generators", 5), ("Decorators", 12)],
)
connection.commit()
# Column and table names CANNOT be parameters - use an allow list
SORTABLE = {"title", "views", "id"}
def list_notes(sort_by="id", minimum_views=0):
if sort_by not in SORTABLE:
raise ValueError(f"cannot sort by {sort_by!r}")
query = f"SELECT title, views FROM notes WHERE views >= ? ORDER BY {sort_by}"
return connection.execute(query, (minimum_views,)).fetchall()
print(list_notes("views", 5))
try:
list_notes("views; DROP TABLE notes")
except ValueError as error:
print("rejected:", error)Values go in as parameters. Identifiers - table names, column names, sort directions - cannot be parameters, so they must be checked against a fixed allow list. There is no third option.
Unsafe deserialization
import pickle
class Payload:
def __reduce__(self):
import os
return (os.system, ("echo arbitrary command executed",))
data = pickle.dumps(Payload())
# Merely loading it runs the command
pickle.loads(data)import json
import hmac
import hashlib
import pickle
# Safe: JSON cannot execute anything
record = json.loads('{"name": "Meera", "age": 27}')
print(record)
# If pickle must cross a boundary, sign it and verify BEFORE loading
SECRET = b"a key from the environment, not from source"
def sign(obj):
payload = pickle.dumps(obj)
return hmac.new(SECRET, payload, hashlib.sha256).digest() + payload
def load_signed(blob):
signature, payload = blob[:32], blob[32:]
expected = hmac.new(SECRET, payload, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
raise ValueError("signature mismatch; refusing to unpickle")
return pickle.loads(payload)
blob = sign({"a": 1})
print(load_signed(blob))
try:
load_signed(blob[:32] + b"tampered payload")
except ValueError as error:
print(error)Other text that becomes action
import re
# A pattern built from user input can be catastrophically slow
# pattern = re.compile(user_input) # a regex denial of service
# re.match(r"^(a+)+$", "a" * 30 + "b") # exponential backtracking
# Escape it, so it can only match literally
print(re.escape("cost (in Rs) *"))
matches = re.findall(re.escape("cost (in Rs)"), "the cost (in Rs) is 5")
print(matches)# A format string can read attributes of whatever you pass it
class Config:
SECRET = "hunter2"
template = "{config.SECRET}" # if this came from a user...
print(template.format(config=Config)) # ...it just leaked the secret
# Safe: choose the substitutions yourself
def render(template, **values):
result = template
for key, value in values.items():
result = result.replace("{" + key + "}", str(value))
return result
print(render("Hello {name}", name="Meera"))import importlib
# Importing a module named by the user runs its top level code
# module = importlib.import_module(user_input) # DANGEROUS
ALLOWED_MODULES = {"json", "csv", "math"}
def load_module(name):
if name not in ALLOWED_MODULES:
raise ValueError(f"module not permitted: {name!r}")
return importlib.import_module(name)
print(load_module("math").sqrt(16))The summary table
| Dangerous | Because | Use instead |
|---|---|---|
eval(user_input) | Runs arbitrary Python | ast.literal_eval, a dispatch dict, an AST allow list |
exec(user_input) | Runs arbitrary Python | The same |
shell=True | The shell reinterprets the string | A list of arguments |
f"... {value} ..." in SQL | The value becomes query text | Parameters, and an allow list for identifiers |
pickle.loads(untrusted) | Unpickling executes code | JSON, or a verified signature |
re.compile(user_input) | Catastrophic backtracking | re.escape, or a fixed pattern |
template.format(obj=x) | Reaches attributes | Explicit substitution |
import_module(user_input) | Runs module level code | An allow list |
A checklist
- Does any user supplied text become code, a command, a query or a path? Fix that first.
- Is every input validated against an allow list, with a length limit?
- Are all SQL values parameters, and all identifiers checked against a fixed set?
- Is every subprocess call a list of arguments with
shell=Falseand a timeout? - Is anything unpickled that did not come from your own program?
- Are secrets read from the environment and kept out of logs and error messages?
- Do security checks fail closed?
- Are passwords salted and stretched, and compared with
compare_digest?
Common mistakes
- Believing a restricted
evalis safe. - Using
shell=Truebecause it was easier to write. - Building a query with an f-string "just this once".
- Parameterising values but interpolating a column name.
- Unpickling a file that arrived over a network.
- Compiling a regular expression supplied by a user.
- Passing a user supplied template to
str.format.
Best practices
- Never turn untrusted text into code, commands, queries or paths.
- Use
ast.literal_evalfor literals and a dispatch dictionary for behaviour. - Use argument lists for subprocesses and parameters for SQL.
- Use JSON at every trust boundary; keep pickle inside your own program.
- Escape anything that must be used as a pattern.
- Prefer an allow list to a block list, every time.
Practice
- Write a calculator that evaluates arithmetic safely with an AST allow list, and show it rejecting an import.
- Replace an
evalbased command dispatcher with a dictionary. - Take a subprocess call using
shell=Trueand rewrite it as an argument list. - Demonstrate an SQL injection against a string built query, then fix it with parameters.
- Write a function that safely allows sorting by a user chosen column.
Conclusion
Every vulnerability in this note is the same mistake: text from outside became an instruction. Keep data as data - parameters, argument lists, JSON, escaped patterns, allow lists - and the whole category disappears.