skip to content

How can Python code with no builtins reach os.system by walking __class__ and __subclasses__?

level: middleimportance: must knowfreq 45%

answer

  1. The object graph stays connected
  2. Attributes reach past the namespace
  3. Every type traces back to object
  4. object.__subclasses__() lists loaded classes
  5. Find a gadget, use its __globals__

basics

~20 s

Every object exposes class, and from there bases reaches object, whose subclasses() lists every class the interpreter has already loaded. An attacker scans that list for a gadget class whose method or globals reaches os or subprocess, so stripping builtins never removes the path back to dangerous code.

solid answer

~50 s

The object graph is fully connected, so a single literal is enough. From `()` you take `.__class__` (tuple), then `.__bases__[0]` or `.__mro__[-1]` to reach `object`, then call `object.__subclasses__()` — a list of every class loaded anywhere in the process. The attacker searches that list by `__name__` for a useful gadget: a class whose constructor or a bound function's `__globals__` already holds `os`, `subprocess`, or `builtins`. Once found, they call the gadget's method to spawn a process or import a module. None of this needs `__builtins__`, `import`, or any name you left in the namespace — it is pure attribute access, and attribute access reaches everything the interpreter has. That is why hiding names is not containment: the edges of the object graph are still there. The only boundary that holds is an operating-system one — a separate process, container, or VM.

code

python · 4 lines
python
# From a bare literal, with no builtins in scope, reach the root type.
root = ().__class__.__bases__[0]
print(root)                          # <class 'object'>
print(len(root.__subclasses__()) > 100)  # True: the whole class tree is reachable

go deeper

for a junior

Recall that any Python object can reach its class, and classes chain back to object. The takeaway to state is that hiding names does not stop reflection from reaching them again.

for a middle

Be ready to walk the exact chain out loud: literal -> class -> bases/mro -> object.subclasses() -> a gadget's globals, and explain why emptying builtins leaves every edge intact.

for a senior

Explain why this makes namespace restriction a non-boundary in production, and that the redundancy of the graph (multiple paths to the same modules) defeats any attribute blacklist. Point to the OS-level boundary as the fix.

for a principal

Own the architectural conclusion: reflection is total in CPython, so the decision is not 'how to lock down the namespace' but 'which process/container/VM boundary and what resource limits', and how to keep the trusted host out of the untrusted graph entirely.

## The claim A common instinct is that you can run untrusted Python safely by handing it a namespace with the dangerous names removed — no `open`, no `__import__`, an emptied `__builtins__`. This leaf's job is to show *why that fails*, and the flagship technique is attribute traversal back to the builtins through `object.__subclasses__()`. ## Everything traces back to `object` Python's object model is a single connected graph. Every value has a type, reachable as `value.__class__`. Every type has bases, reachable as `type.__bases__` (or the full linearization `type.__mro__`). The root of every method-resolution order is the builtin `object`. So from *any* value the untrusted code can touch — a literal `()`, `''`, `0`, or an object you deliberately passed in — it can climb to `object`: ```python root = ().__class__.__bases__[0] # -> <class 'object'> # equivalently: ().__class__.__mro__[-1] ``` ## `object.__subclasses__()` is a menu of gadgets `object.__subclasses__()` returns a list of every class currently registered as a subclass of `object` anywhere in the interpreter — hundreds of them, including internal machinery from modules the host application imported. This list is a menu. The attacker walks it looking for a *gadget*: a class whose `__init__`, a method, or an attached function object has a `__globals__` mapping that already contains something dangerous — `os`, `subprocess`, `sys`, or `builtins` itself. A function object exposes its defining module's globals through `__globals__`, and those globals almost always include `__builtins__`. So a single gadget re-opens the entire builtins namespace you thought you had removed. The exact gadget varies by which modules are loaded, which is why real exploits enumerate rather than hard-code: ```python for cls in ().__class__.__bases__[0].__subclasses__(): g = getattr(getattr(cls, '__init__', None), '__globals__', {}) if 'os' in g or 'system' in g: pass # a usable gadget; call it to run a command ``` ## Why removing names does not help Restricting the execution namespace only controls which *names* resolve. It does nothing to the *attributes* already reachable from the objects in scope. `().__class__` does not consult your namespace — it is a slot on the object. `__bases__`, `__mro__`, `__subclasses__`, and `__globals__` are the same. Because the graph is redundant, blocking one edge is pointless: if you somehow removed `__subclasses__`, the attacker reaches the same modules through any function's `__globals__`, through `gc.get_objects()`, or through a frame's `f_globals`. There is no finite blacklist of attributes that seals the graph, because the graph is what the language *is*. ## What actually contains it The correct conclusion is architectural: you cannot sandbox untrusted Python inside the interpreter that runs your trusted code. Containment must be an operating-system boundary — a short-lived separate process with dropped privileges and resource limits, a container, or a virtual machine — so that even a full breakout inside the untrusted interpreter reaches nothing valuable and can be killed. Everything else (emptying `__builtins__`, auditing hooks, name blacklists) raises the effort bar slightly but is not a security boundary. ## Interview framing Interviewers ask this to see whether you understand that Python's dynamism is total: reflection is not an opt-in feature you can switch off, it is intrinsic. A candidate who says 'just delete the dangerous builtins' has not internalized the object model. The strong answer names the traversal (`__class__` -> `__bases__`/`__mro__` -> `object.__subclasses__()` -> a gadget's `__globals__`), explains why every mitigation at the namespace layer is bypassable, and lands on the process/container/VM boundary as the real answer.

  • Once the attacker has object.__subclasses__(), how do they actually run a command?
    They scan the list for a gadget — a class whose __init__ or a bound function has a __globals__ mapping already holding os or subprocess. Reading gadget.__init__.__globals__ gives back the module's namespace, from which they call system() or construct a process. The subclass list is effectively a directory of every module's functions, so a single usable entry restores full capability.
  • Would deleting __subclasses__ from object close the hole?
    No. You cannot reliably remove attributes from builtin types, and even if you could the graph is redundant: any function's __globals__, gc.get_objects(), or a frame's f_globals reach the same modules. Blocking one edge leaves equivalent paths, because reflection is intrinsic to the object model, not a removable feature.

saying these in an interview costs you the question

  • Deleting __builtins__ makes exec safe
  • Untrusted code cannot reach os if you never import it
  • Removing dangerous names from globals sandboxes the code
  • A literal like () exposes nothing useful
  • You can blacklist the few dunder attributes that matter

context