Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix code injection in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Never evaluate user input as code. Parse the expression with ast.parse and walk the tree, allowing only numeric literals, the supplied variables and arithmetic operators; refuse everything else. eval(expr, {"__builtins__": {}}, vars) is not a sandbox: attribute access still reaches any object. If code must run, use a separate process with limits.

On this page

Steps

  1. Find every eval, exec, compile or eval-equivalent that touches request data (a formula field, a filter, a rules expression).
  2. If the need is arithmetic or a small fixed grammar, replace eval with ast.parse(expression, mode="eval") and a walker that accepts only the node types you intend.
  3. Allow numeric literals, the names that appear in your variables dict, the arithmetic operators you need and parentheses; raise for attribute access, calls, subscripts, comprehensions, strings and any other node.
  4. Reject bool and non-numeric values (bool is a subclass of int), catch ZeroDivisionError, and cap ** so a huge exponent cannot hang the process.
  5. Return a 400 for anything refused and keep the error generic; log the expression server-side if you need to.
  6. Add regression tests with code payloads -- a bare secret name, a builtin call, attribute access, object-graph traversal -- and assert each is refused.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.pyPython
def compute(expression: str, variables: dict):
    # eval() compiles and runs the string. Passing globals() exposes BUILD_SECRET and the
    # builtins; the variables are the locals. This is remote code execution.
    return eval(expression, globals(), dict(variables))

With IMPL=vulnerable, 5 of 10 tests fail (4 exploit tests).

The fix: parse and walk, never eval

Fixedimpl/fixed.pyPython
def compute(expression: str, variables: dict):
    try:
        tree = ast.parse(expression, mode="eval")
    except SyntaxError as exc:
        raise CalcError("could not parse the expression") from exc
    return _eval(tree.body, variables)


def _number(value):
    # bool is a subclass of int; reject it so "True" is not treated as 1.
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise CalcError("values must be numbers")
    return value


def _eval(node, variables):
    if isinstance(node, ast.Constant):
        return _number(node.value)
    if isinstance(node, ast.Name):
        if node.id not in variables:
            raise CalcError(f"unknown variable {node.id!r}")
        return _number(variables[node.id])
    if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARYOPS:
        return _UNARYOPS[type(node.op)](_eval(node.operand, variables))
    if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS:
        left = _eval(node.left, variables)
        right = _eval(node.right, variables)
        if isinstance(node.op, ast.Pow) and abs(right) > 100:
            raise CalcError("exponent too large")
        try:
            return _BINOPS[type(node.op)](left, right)
        except ZeroDivisionError as exc:
            raise CalcError("division by zero") from exc
    raise CalcError("only arithmetic over the supplied variables is allowed")

With IMPL=fixed, 10 of 10 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
def compute(expression: str, variables: dict):
    # Looks locked down: no builtins, only the caller's variables. But eval still runs the
    # code, and (42).__class__ ... traverses the object graph from a bare literal.
    return eval(expression, {"__builtins__": {}}, dict(variables))

With IMPL=wrong, 3 of 10 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/test_compute.pyPython
# Exploit: an "expression" that is really code.

def test_exploit_module_secret_is_not_leaked(client):
    status, _, text = compute(client, "BUILD_SECRET")
    assert BUILD_SECRET not in text, "the process secret leaked through the expression"
    assert status == 400


def test_exploit_builtin_call_is_refused(client):
    # A builtin call proves the evaluator runs code with builtins in scope.
    status, _, _ = compute(client, "abs(-1)")
    assert status == 400


def test_exploit_attribute_access_is_refused(client):
    # Reaching an object's type is the first step of an eval sandbox escape.
    status, _, _ = compute(client, "(42).__class__.__name__")
    assert status == 400


def test_exploit_object_graph_traversal_is_refused(client):
    status, _, _ = compute(client, '"".__class__.__base__.__name__')
    assert status == 400

Pitfalls

  • The Python documentation warns that eval() "executes arbitrary code" and that overriding __builtins__ "is not a security mechanism". eval(expr, {"__builtins__": {}}, vars) is the classic non-sandbox: clearing builtins hides names, but attribute access still works, so (42).__class__.__bases__[0].__subclasses__() walks from a literal to the whole object graph and from there to os and subprocess.
  • Passing globals() (or omitting the globals argument) to eval exposes the module's own names -- here BUILD_SECRET -- and the builtins to the expression.
  • Blocklisting substrings like __ , import or os is bypassable (getattr via a different spelling, chr()-built strings) and breaks legitimate input; allowlist AST nodes instead.
  • ast.literal_eval is safe for literals but does not evaluate operators, so it will not compute revenue - cost; you still need a small walker for arithmetic.
  • bool is a subclass of int, so a Constant of True passes an isinstance(x, int) check; reject bool explicitly if you only mean numbers.
  • ** with a large exponent is a cheap denial of service (huge integer allocation); cap it. Even a safe evaluator needs resource limits.
  • If users genuinely must supply code, an in-process 'sandbox' is not enough; run it in a separate process or container with CPU, memory and syscall limits, and no secrets in its environment.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs code-injection/python-flask does this for you).
  2. Against your own endpoint, send expressions that are code -- a bare secret name, abs(-1), (42).__class__.__name__ -- and confirm each is refused with 400 and nothing is leaked; confirm legitimate arithmetic still computes.
  3. Semgrep's eval-detected and Bandit B307 flag the eval() in the vulnerable and the wrong code but do not tell the two apart, so they do not prove a fix; the regression tests are the control (scannerRules records what each run found).

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix code injection in Python and Flask
ToolRuleWhat it found here
Semgreppython.lang.security.audit.eval-detected.eval-detectedTwo findings: the eval() in impl/vulnerable.py and in impl/wrong.py; none in impl/fixed.py. The rule matches any eval() call, so it cannot tell the cleared-builtins 'sandbox' from the open one -- both are flagged, which is right here. python.flask.security.injection.user-eval.eval-injection found nothing: it needs the eval() inside the @app.route function or directly on flask.request data, and this card's eval() is in the implementation module. exec-detected and the packs found nothing else.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3; semgrep scan --metrics=off --config p/python --config p/owasp-top-ten --config p/security-audit --config p/flask --config r/python.lang.security.audit.eval-detected.eval-detected --config r/python.lang.security.audit.exec-detected.exec-detected --config r/python.flask.security.injection.user-eval.eval-injection --exclude tests .
CodeQLpy/code-injectionCodeInjectionCustomizations.qll treats the code argument of any modelled code execution (eval) as a sink, and Flask.qll makes the request body a remote source, so the vulnerable and wrong eval(expression, ...) should be reported if CodeQL follows the importlib IMPL loader; the fixed ast walker has no sink.Checked: github/codeql main at b5b8165; not executed
BanditB307, B105blacklist: eval; hardcoded_password_stringB307 (MEDIUM severity, HIGH confidence) on the eval() in impl/vulnerable.py and impl/wrong.py; nothing in impl/fixed.py. B105 (LOW) on BUILD_SECRET in secret.py, the stand-in secret the exploit tries to read.Checked: Bandit 1.9.4 (pip install bandit==1.9.4 in a scratch venv, Python 3.14.8); bandit -r . -x ./.venv,./tests

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
ast, operator (standard library)Python 3.14.8
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the code-injection/python-flaskfolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs code-injection/python-flask runs all three modes and checks the expected results.

Download the project ZIP, 18 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • flask 3.1.3
  • werkzeug 3.1.9
  • pytest 9.1.1
Matrix
fixed: 10 of 10 tests pass; vulnerable: 5 of 10 tests fail (4 exploit tests); wrong: 3 of 10 tests fail (2 exploit tests)
Test results
IMPL=fixed 10/10 pass; IMPL=vulnerable 5/10 fail (4 exploit: the secret leak, the builtin call, attribute access, object-graph traversal; plus the non-numeric-variable functional check, which eval accepts); IMPL=wrong 3/10 fail (2 exploit: attribute access and object-graph traversal, which work with cleared builtins; plus the non-numeric-variable check).
Dependency freshness
flask 3.1.3 (2026-02-19), werkzeug 3.1.9 (2026-09-27, a security release) and pytest 9.1.1 (2026-06-19) are the latest. Python 3.14.8 is the 30 September 2026 security release.
Scanners
Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); CodeQL read from github/codeql main at b5b8165, not executed.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-94: Improper Control of Generation of Code ('Code Injection')
  2. CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
  3. Python ast module (ast.parse, node types)
  4. Python functions: eval (security note)
  5. OWASP Code Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.