Prototype pollution has a Python cousin: class pollution and the road to __globals__
Python has no prototypes, so it cannot have prototype pollution, except it can. A walkthrough of class pollution: the vulnerable merge function, the four attribute chains that matter, and how to spot it in a code review.
7 min read0 views
Prototype pollution is well understood in JavaScript. An attacker who can set an arbitrary attribute path on an object reaches __proto__, writes onto Object.prototype, and every object in the process inherits their value. It has produced a long run of high-severity CVEs, and most Node developers can now describe it.
The usual next sentence is: "Python is class-based, not prototype-based, so it is not affected."
That sentence is wrong, and Abdulraheem Khaled's 2023 research showed exactly how. Python objects expose a mutable __class__, classes expose a mutable __bases__, functions expose a live, writable __globals__ dictionary. Chain those and you can write into another module's global namespace from a merge function. The researcher called it class pollution, and it is the same bug with different plumbing.
This post is about recognising it. It is a good bug to know because the vulnerable code is short, ordinary-looking, and appears in exactly the places people do not audit.
The vulnerable shape
Class pollution needs one thing: a function that sets attributes from a user-controlled path, recursively.
That sounds exotic. It is not. It is the "merge this config into that config" helper that exists in most codebases over a certain age:
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, "__getitem__"):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
Read it charitably and it is a helpful utility for applying a JSON body onto an object. Read it as an attacker and the important line is setattr(dst, k, v) with k from the input, plus the recursion, which means the input controls a path and not just a key.
Anywhere a JSON request body is merged onto a model instance, a settings object, a form, or a config, look for this shape. Common hosts:
- "Update my profile" endpoints that apply a partial JSON body onto an ORM object
- Config loaders that overlay a user file onto defaults
- Deep-merge helpers copied from a blog post or an LLM
- Plugin and template systems that let a caller set options by dotted path
- Anything named
deep_update,apply_config,patch_object,set_by_path
The chains that matter
Once you can set an attribute path, the question is what to reach. Four chains carry most of the impact.
1. __class__, pollute the class, hit every instance.
{"__class__": {"some_attribute": "value"}}
You are no longer editing one user's object. You are editing the class, so every instance that does not shadow the attribute now reads your value. If the class has a role, is_admin, price or verified attribute with a class-level default, this is an authorisation or business-logic bug immediately.
2. __class__.__base__ (or __bases__), climb to a shared ancestor.
Most classes eventually inherit from something shared. Reaching a base class widens the blast radius from "this class" to "every class beneath it". In an application with a common BaseModel, that is the whole data layer.
3. __class__.__init__.__globals__, leave the object graph entirely.
This is the interesting one. __init__ is a function; every Python function carries __globals__, which is the actual module-level namespace dictionary, not a copy. Write to it and you have changed a module global that other code reads:
{"__class__": {"__init__": {"__globals__": {"AUTH_REQUIRED": false}}}}
Now you are not modifying a user object at all. You are reaching into the module that defined the class and rewriting its globals. Module-level flags, cached configuration, a DEBUG boolean, an allowlist, a secret key, a function name that is later called: all reachable.
4. __globals__ into subprocess or os internals.
The escalation everyone eventually asks about. If the polluted module imports os or subprocess, those module objects are in __globals__, and their attributes are writable too. Overwriting an attribute that is later called with attacker-influenced arguments turns a merge function into code execution. Depending on the application you may also reach __builtins__, at which point you are rewriting the language for that process.
The practical severity ranking for a report: class-level attribute overwrite is usually a logic or authorisation bug (high); reaching __globals__ is usually critical, because it escapes the object model and its effects are process-wide and persistent until restart.
A lab you can run in two minutes
class User:
role = "user" # class-level default -- the target
def __init__(self, name):
self.name = name
SECRET_MODE = "off" # module global -- the second target
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, "__getitem__"):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
alice = User("alice")
print(User.role, SECRET_MODE) # user off
# 1. Pollute the class through one instance
merge({"__class__": {"role": "admin"}}, alice)
print(User("bob").role) # admin -- bob was created AFTER, and is admin
# 2. Leave the object graph
merge({"__class__": {"__init__": {"__globals__": {"SECRET_MODE": "on"}}}}, alice)
print(SECRET_MODE) # on
Run it. The second print is the moment the bug stops being abstract: a brand-new object, never touched by the attacker, is an admin, because the class was changed, not the instance.
Finding it
grep -rn "setattr(" --include="*.py" . | grep -v "setattr(self"
grep -rn "def merge\|deep_merge\|deep_update\|_update_recursive\|apply_config" --include="*.py" .
grep -rn "__setitem__\|__getattr__\|__setattr__" --include="*.py" .
Then read each hit against three questions:
- Does the key come from user input, or only the value? Only the value is fine. The key is the bug.
- Is it recursive, or does it apply one level? One level limits you to attributes on the target object, still potentially a mass-assignment bug, but not class pollution.
- Is there a denylist, and does it cover
__class__,__bases__,__globals__,__init__,__builtins__,__code__,__dict__,__mro__and__subclasses__? Partial denylists are the norm and they are what makes this bug survive a first review.
The related bug to check at the same time is mass assignment: if the endpoint applies a JSON body onto an ORM model, can the caller set is_admin, balance, role or user_id? That does not require any dunder attribute at all and is far more common.
Fixing it
1. Do not set attributes from user-controlled names. Explicit is better than implicit, and here that stock advice is literally the fix:
ALLOWED = {"display_name", "bio", "timezone"}
for key in ALLOWED & payload.keys():
setattr(user, key, payload[key])
Boring, four lines, and there is no chain to walk because there is no path to control.
2. Use a schema. Pydantic, marshmallow, attrs or a dataclass with explicit fields gives you validation and an implicit allowlist in one step. Unknown keys are rejected rather than assigned.
3. If you genuinely need a deep merge, merge dictionaries, not objects. Restrict the operation to plain dict values and reject any key starting and ending with __. Then apply the merged dictionary through an allowlist as in (1). A dict has no __class__ route to a class body. The danger comes specifically from setattr on an object.
4. Use __slots__ on models that do not need dynamic attributes. A class with __slots__ refuses attributes it did not declare, which turns a silent pollution into an AttributeError. It is a defence in depth rather than a fix, but it is cheap and it fails loudly.
Why it matters beyond Python
The reason to learn this bug is not that you will meet it every week. It is that it teaches the shape, and the shape is language-independent:
Whenever an attacker controls the path of a write, the reachable object graph is the attack surface, not the object you thought you were writing to.
JavaScript spells it __proto__. Python spells it __class__ and __globals__. Ruby has instance_variable_set and send. Java has reflection and the deserialization gadget chains that come with it. PHP has $$variable and dynamic property assignment. Same bug, four accents.
Further reading
- Abdulraheem Khaled: Prototype Pollution in Python. The original research
- HackTricks: Class Pollution
- PortSwigger: Prototype pollution. The JavaScript lineage
Was this useful?
Comments
Loading comments…