Flask · Insecure deserialization

Insecure deserialization in Flask

Flask itself does not unpickle anything a client sends, so every unsafe deserialization in a Flask application was added above the framework: a YAML import endpoint, an object stored in a cookie, or a cache of pickled results.

Class
Insecure deserialization in Python
CWE
CWE-502
web framework
Flask documentation
On this page

How it arises in Flask

Start with what is not a problem. Flask's default session is SecureCookieSessionInterface, which stores the session in a signed cookie through the itsdangerous module, and its serializer is a compact JSON-derived format with support for a few extra Python types. A client can read that cookie. Without the secret key a client cannot change it, and even with the key it is parsed as JSON, not as pickle.

The findings are elsewhere.

YAML import endpoints. "Upload your config", "import a pipeline definition", "paste a Kubernetes manifest". The handler calls yaml.load(request.data, Loader=yaml.Loader) or yaml.unsafe_load(). PyYAML's documentation warns that it is not safe to call yaml.load with data from an untrusted source, and that it is as powerful as pickle.load.

Objects in cookies or hidden fields. Wizard-style forms keep state between steps by pickling a Python object, base64-encoding it and sending it to the browser. It comes back on the next request and is loaded.

Pickled values in a shared store. A view caches a computed object in Redis with pickle.dumps() and loads it on the next hit. Whoever can write to that Redis key controls what the web process unpickles.

Model and data files. Small ML demos built on Flask accept a model file upload and load it with a pickle-based loader.

Python's documentation for pickle is unambiguous: the module is not secure, and malicious pickle data can execute arbitrary code during unpickling.

Vulnerable and fixed

import base64
import pickle

import yaml
from flask import Flask, jsonify, request

app = Flask(__name__)


@app.post("/pipelines/import")
def import_pipeline():
    spec = yaml.load(request.get_data(), Loader=yaml.Loader)
    return jsonify(name=spec["name"], steps=len(spec["steps"]))


@app.post("/wizard/next")
def wizard_next():
    state = pickle.loads(base64.b64decode(request.form["state"]))
    state["step"] += 1
    return jsonify(state=base64.b64encode(pickle.dumps(state)).decode())

yaml.safe_load() builds only plain scalars, lists and dictionaries. The wizard state belongs in Flask's session, which is already signed and already JSON:

import yaml
from flask import Flask, abort, jsonify, request, session

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024


@app.post("/pipelines/import")
def import_pipeline():
    spec = yaml.safe_load(request.get_data())
    if not isinstance(spec, dict) or not isinstance(spec.get("steps"), list):
        abort(400)
    return jsonify(name=str(spec.get("name", "")), steps=len(spec["steps"]))


@app.post("/wizard/next")
def wizard_next():
    state = session.get("wizard", {"step": 0})
    state["step"] += 1
    session["wizard"] = state
    return jsonify(step=state["step"])

What Flask already does for you

The session design is the main protection: signed, JSON-based and on by default, so the obvious place to keep per-user state is also the safe one. request.get_json() parses JSON and nothing more. MAX_CONTENT_LENGTH bounds how much data a request can make the app read. Flask ships no helper that unpickles request data, which is why a search for pickle and yaml.load in a Flask codebase finds every candidate.

What es-python adds

The label depends on the loader. An unsafe PyYAML loader fed with request bytes is reported as yaml unsafe load; the pickle cookie as pickle loads; json.loads() with an object_hook as object hook; jsonpickle as unsafe deserialization. The YAML and jsonpickle events carry the unsafe deserialization impact tag, the pickle events carry rce, and data from the network is graded Critical.

Correct code is quiet, which keeps the list short. yaml.safe_load() and a plain json.loads() are not treated as deserialization sinks, and Flask's session handling does not unpickle, so the fixed endpoints above produce no event in this class. What remains is exactly the set of places where bytes from outside reach a loader that can construct arbitrary objects. EyalSec does not suppress these on the value, since no encoding makes a pickle harmless.

For the shared-store case, the bytes are read from Redis over a socket, so with the socket source on those pickle loads events are reported as well, and they tell you which views trust that store. The Block network code execution template raises on yaml unsafe load and unsafe deserialization; a Raise rule on pickle loads stops the cookie case with a RuntimeError before the payload runs.

Review checklist

  1. Search for yaml.load(, yaml.unsafe_load(, pickle.loads( and pickle.load(.
  2. Replace YAML loading with yaml.safe_load() and validate the result's shape.
  3. Keep per-user state in session, or server-side keyed by a session id.
  4. Store JSON, not pickles, in shared caches.
  5. Rotate SECRET_KEY if it has ever been committed.

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