Flask · Code injection

Code injection in Flask

Flask stays out of your way, which means nothing stands between a query string and eval() except the view you wrote, and one Flask development feature runs Python from the browser by design.

Class
Code injection in Python
CWE
CWE-94, CWE-95, CWE-470
web framework
Flask documentation
On this page

How it arises in Flask

The debugger. Running with --debug enables Werkzeug's interactive debugger. Flask's documentation is plain about it: the debugger allows executing arbitrary Python code from the browser, it is protected by a PIN that should not be relied on for security, and it must not be enabled in a production environment. It still reaches production through a copied app.run(debug=True) or an environment variable left in a container image.

eval() behind small features. A unit converter, a spreadsheet-style formula field, a "filter" parameter such as ?where=price<10. Python's own evaluator is the shortest implementation. request.args["expr"] goes straight in.

Import by name. werkzeug.utils.import_string(import_name) imports an object from a dotted path or a module:object string; Werkzeug describes it as useful for import paths used as endpoints. Flask's app.config.from_object() accepts the same kind of string. Both are meant for names the developer writes. A plugin system that loads import_string(request.json["handler"]) imports whatever module the caller names, and importing runs that module's top level.

Template context helpers. A helper registered with app.jinja_env.globals that evaluates a string turns any template variable into code.

Vulnerable and fixed

from flask import Flask, jsonify, request
from werkzeug.utils import import_string

app = Flask(__name__)


@app.get("/calc")
def calc():
    return jsonify(result=eval(request.args["expr"]))


@app.post("/export")
def export():
    exporter = import_string("exporters." + request.json["format"])
    return exporter(request.json["rows"])

Arithmetic does not need Python's evaluator. A walk over the parsed syntax tree accepts numbers and four operators and rejects everything else. The exporter is chosen from a dictionary:

import ast
import operator

from flask import Flask, abort, jsonify, request

import exporters

app = Flask(__name__)

OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
EXPORTERS = {"csv": exporters.to_csv, "json": exporters.to_json}


def arithmetic(node):
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.BinOp) and type(node.op) in OPS:
        return OPS[type(node.op)](arithmetic(node.left), arithmetic(node.right))
    raise ValueError("unsupported expression")


@app.get("/calc")
def calc():
    try:
        tree = ast.parse(request.args["expr"][:200], mode="eval")
        return jsonify(result=arithmetic(tree.body))
    except (ValueError, SyntaxError, ZeroDivisionError):
        abort(400)


@app.post("/export")
def export():
    exporter = EXPORTERS.get(request.json.get("format"))
    if exporter is None:
        abort(400)
    return exporter(request.json["rows"])

What Flask already does for you

Debug mode is off unless you ask for it, and the documentation warns against it in production in two separate places. The debugger is PIN-protected as a second line. Jinja templates rendered by render_template() cannot run arbitrary Python statements. Route converters keep path segments typed. Flask does not offer an "evaluate this" helper; every eval() in a Flask app was added by hand, so a search finds them.

What es-python adds

The Flask-specific event here is interactive console. es-python reports it when a console runs input that came from the network, which is what a reachable debugger looks like at runtime. It carries the rce impact tag, and the Block network code execution template blocks it. If that event ever appears on a production machine, the debugger is on and someone used it.

For the view code, eval, exec and compile are reported when their argument contains request data, graded Critical for data from the network. An import by computed name is reported as __import__.

One thing to expect from the fixed version: ast.parse() cannot run code, but it does compile its input to a syntax tree, so es-python reports it as compile when the input is untrusted. That is accurate and, here, intended. Record the decision with a rule for that call site so it stays out of your default view. This is also why the Block network code execution template raises on eval and exec but leaves compile alone: under it, the vulnerable /calc gets a RuntimeError before the expression runs, and the fixed one keeps working.

Review checklist

  1. Confirm debug mode is off in every deployed configuration and image.
  2. Search for eval(, exec(, import_string( and __import__(.
  3. Replace expression evaluation with a syntax-tree walk or a small parser.
  4. Load plugins from a dictionary or from entry points you control.
  5. Keep evaluators out of Jinja globals and filters.

References

See it on your own Flask code

es-python runs your application unchanged and reports when request data reaches the operation, with where the data came from and the line that used it. A live demo walks through it on a real application.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo