Django · Insecure deserialization

Insecure deserialization in Django

A typical Django view never calls pickle.loads(), yet pickle is running under most Django sites, in the cache framework and sometimes in the task queue, so the question is who can write to those stores.

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

How it arises in Django

The cache framework. Django's cache documentation says you can cache any Python object that can be pickled safely. Values are pickled on cache.set() and unpickled on cache.get(). That makes the cache server part of your code-execution boundary. For the file-based backend the documentation spells out the consequence: an attacker who gains access to the cache file can not only falsify HTML content but also remotely execute arbitrary code, as the data is serialized using pickle. The same reasoning applies to a Memcached or Redis instance that another application, another tenant or an SSRF bug can write to.

Sessions on old settings. SESSION_SERIALIZER defaults to JSONSerializer. Older projects set PickleSerializer to store richer objects. Django deprecated it in 4.1 because of the risk of remote code execution and removed it in 5.0. With signed-cookie sessions and a leaked SECRET_KEY, the pickle serializer turned a forged cookie into code execution. A project that carries its own copy of that serializer forward keeps the problem.

Celery. Celery's default serializer has been JSON since version 4.0, and by default only JSON is accepted. Projects that pass model instances or datetimes as task arguments switch to pickle and add it to accept_content. From then on, anyone who can publish to the broker can run code in the worker.

Hand-rolled uses. A "remember my filters" cookie, an export token, or a BinaryField that stores pickle.dumps(obj) and is loaded back on every request.

Vulnerable and fixed

import base64
import pickle

from django.http import JsonResponse

CELERY_TASK_SERIALIZER = "pickle"
CELERY_ACCEPT_CONTENT = ["pickle", "json"]


def saved_filters(request):
    raw = request.COOKIES.get("filters", "")
    filters = pickle.loads(base64.b64decode(raw)) if raw else {}
    return JsonResponse({"filters": filters})

Django's signing module is made for this job. signing.loads() verifies the signature and uses JSON underneath, so even a stolen key does not turn the cookie into code. The queue goes back to JSON, and tasks receive primary keys:

from django.core import signing
from django.http import JsonResponse

CELERY_TASK_SERIALIZER = "json"
CELERY_ACCEPT_CONTENT = ["json"]


def saved_filters(request):
    raw = request.COOKIES.get("filters", "")
    try:
        filters = signing.loads(raw, salt="filters", max_age=30 * 86400) if raw else {}
    except signing.BadSignature:
        filters = {}
    return JsonResponse({"filters": filters})

What Django already does for you

Django moved its defaults away from pickle wherever users are involved. Sessions serialize as JSON, and the pickle serializer is gone. The signing documentation explains that dumps() and loads() use JSON so that an attacker who steals SECRET_KEY cannot execute arbitrary commands by exploiting the pickle format. The cache documentation warns about cache locations inside MEDIA_ROOT or STATIC_ROOT. The remaining decision, which networks can reach the cache and the broker, belongs to the deployment.

What es-python adds

es-python reports bytes from an untrusted source reaching a deserializer, at the call: pickle loads here. The dashboard puts the rce impact tag on it, because unpickling attacker bytes is code execution. For the cookie view that is unambiguous: request data, Critical.

The cache is the instructive case. With the socket source on, values Django reads back from Memcached or Redis arrived over a network connection, so unpickling them is reported too. That is not a false alarm. It is an accurate statement that your process executes whatever that server returns. Read those events once as an inventory: which code paths unpickle, from which host. If the cache is on a private network that only this application can write to, record that with a rule for that call site. If the answer is "other things can write there", you have found the real issue.

Deserialization is also the class EyalSec never suppresses based on the value, because a serialized blob has no harmless form. For workers, broker messages arrive over a socket, so with the socket source on pickle loads and yaml unsafe load on a message are reported too. Under a Raise rule, pickle.loads() raises a RuntimeError before the bytes are processed.

Review checklist

  1. Search settings for PickleSerializer, "pickle" and accept_content.
  2. Search code for pickle.loads(, pickle.load( and yaml.load(.
  3. Put the cache and the broker on a network only the application can reach, with authentication.
  4. Keep the file-based cache directory outside any served or uploadable path.
  5. Pass identifiers to tasks, not objects.

References

See it on your own Django 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