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.

eval and exec

print(eval("2 + 3 * 4"))                  # 14
exec("total = sum(range(10))")
print(total)                               # 45

Both 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 directory
Restricting the globals passed to eval does not make it safe. Sandboxing Python inside Python has been attempted many times and defeated every time. The only correct answer is not to call eval on 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)                          # None
import 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

DangerousBecauseUse instead
eval(user_input)Runs arbitrary Pythonast.literal_eval, a dispatch dict, an AST allow list
exec(user_input)Runs arbitrary PythonThe same
shell=TrueThe shell reinterprets the stringA list of arguments
f"... {value} ..." in SQLThe value becomes query textParameters, and an allow list for identifiers
pickle.loads(untrusted)Unpickling executes codeJSON, or a verified signature
re.compile(user_input)Catastrophic backtrackingre.escape, or a fixed pattern
template.format(obj=x)Reaches attributesExplicit substitution
import_module(user_input)Runs module level codeAn allow list

A checklist

  1. Does any user supplied text become code, a command, a query or a path? Fix that first.
  2. Is every input validated against an allow list, with a length limit?
  3. Are all SQL values parameters, and all identifiers checked against a fixed set?
  4. Is every subprocess call a list of arguments with shell=False and a timeout?
  5. Is anything unpickled that did not come from your own program?
  6. Are secrets read from the environment and kept out of logs and error messages?
  7. Do security checks fail closed?
  8. Are passwords salted and stretched, and compared with compare_digest?

Common mistakes

  • Believing a restricted eval is safe.
  • Using shell=True because 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_eval for 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

  1. Write a calculator that evaluates arithmetic safely with an AST allow list, and show it rejecting an import.
  2. Replace an eval based command dispatcher with a dictionary.
  3. Take a subprocess call using shell=True and rewrite it as an argument list.
  4. Demonstrate an SQL injection against a string built query, then fix it with parameters.
  5. 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.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Trees and Graphs

A tree is a graph with no cycles and one root. Both are walked with the same two strategies - depth first with a stack, breadth first with a queue.

Read more
Python

Sorting Algorithms

Python sorts for you in n log n. Implementing bubble, insertion, merge and quick sort is still worth doing, because it teaches how algorithms are comp...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.