Hacking Python
Published on 23.06.2025
I came across this document on github which talks about the _PyRuntime structure in cpython and how it can be used to profile or debug python code. I never knew about this structure before, so I thought it would be fun to mess around with it.
typedef struct pyruntimestate _PyRuntimeState;
So i compiled cpython 3.12.3 with debug flags and hooked it up with GDB.
(gdb) ptype _PyRuntime
type = struct pyruntimestate {
int _initialized;
int preinitializing;
int preinitialized;
int core_initialized;
int initialized;
_Py_atomic_address _finalizing;
struct pyinterpreterstate interpreters;
...
PyInterpreterState _main_interpreter;
}
Every python binary has a _PyRuntime section which contains the runtime state. This structure is placed in a dedicated .PyRuntime ELF section rather than the standard .data or .bss sections. This makes it easier to locate the runtime state directly through the section's virtual memory address (VMA).
$ objdump -h /usr/bin/python3d
/usr/bin/python3d: file format elf64-littleaarch64
Sections:
Idx Name Size VMA LMA File off Algn
0 .interp 0000001b 0000000000400270 0000000000400270 00000270 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
...
26 .PyRuntime 00074348 0000000000bf9150 0000000000bf9150 007e9150 2**3
CONTENTS, ALLOC, LOAD, DATA
...
To find the _PyRuntime struct, one has to add the base address of the python binary to the offset of the .PyRuntime section but GDB makes it easy by providing the _PyRuntime structure directly because it has the debug symbols loaded.
Every python process can have one or more interpreters, and each interpreter is represented by the PyInterpreterState structure. The main interpreter is always present in the runtime state. Each interpreter has its own state, including the bytecode execution state.
(gdb) ptype _PyRuntime.interpreters.main
type = struct _is {
PyInterpreterState *next;
int64_t id;
...
struct pythreads threads;
struct pyruntimestate *runtime;
...
PyObject *sysdict;
PyObject *builtins;
struct _ceval_state ceval;
struct _import_state imports;
struct _gil_runtime_state _gil;
...
} *
The pattern is that there are a lot of structures that represent the state of the interpreter, threads, bytecode execution, etc. The _PyRuntime structure is the top-level structure that contains all these states.
Now consider the following python code:
import os
import time
import random
def main():
print(f"pid = {os.getpid()}")
salt = random.random()
rand = random.random()
secret = salt + rand
print(secret)
halt()
def halt():
while True:
pass
if __name__ == '__main__':
main()
The goal is to read the secret variable from the main() function while the program is running.
The hierarchy of structures for the current thread looks like this:
_PyRuntime
└─ interpreters
└─ main
└─ threads
└─ head (current thread)
└─ cframe
└─ current_frame
Once we have the current frame which will be the halt() function, we can use previous to go back to the main() function.
(gdb) p _PyRuntime.interpreters.main->threads.head->cframe.current_frame.
f_builtins f_funcobj f_locals localsplus prev_instr return_offset
f_code f_globals frame_obj owner previous stacktop
We can inspect a few things like the builtins, locals, globals, etc.
(gdb) p _PyRuntime.interpreters.main->threads.head->cframe.current_frame.f_builtins
$6 = {'__name__': 'builtins', '__doc__': "Built-in ...
(gdb) p _PyRuntime.interpreters.main->threads.head->cframe.current_frame.f_globals
$7 = {'__name__': '__main__', ... , 'main': <function at remote 0xe29140a24f50>, 'halt': <function at remote 0xe2914095b410>}
(gdb) p _PyRuntime.interpreters.main->threads.head->cframe.current_frame.f_funcobj.ob_refcnt
$11 = 2
The f_code field contains the metadata and the bytecode for the function. So in our case we can inspect a few things about the current function halt().
(gdb) p _PyRuntime.interpreters.main->threads.head->cframe.current_frame.f_code.
co_argcount co_localsplusnames
co_code_adaptive co_name
co_consts co_names
co_exceptiontable co_ncellvars
co_extra co_nfreevars
co_filename co_nlocals
co_firstlineno co_nlocalsplus
co_flags co_posonlyargcount
co_framesize co_qualname
co_kwonlyargcount co_stacksize
co_linetable co_version
co_localspluskinds co_weakreflist
(gdb) p _PyRuntime.interpreters.main->threads.head->cframe.current_frame.f_code.co_name
$12 = 'halt'
So let's try to understand how the main() is creating the secret variable.
salt = random.random()
rand = random.random()
secret = salt + rand
A simple salt added with a random number, if we inspect the bytecode using the dis module, we can see that the secret variable is created by adding the two random numbers.
>>> import dis
>>> dis.dis(main)
5 0 RESUME 0
...
8 66 LOAD_GLOBAL 7 (NULL + random)
76 LOAD_ATTR 6 (random)
96 CALL 0
104 STORE_FAST 0 (salt)
9 106 LOAD_GLOBAL 7 (NULL + random)
116 LOAD_ATTR 6 (random)
136 CALL 0
144 STORE_FAST 1 (rand)
11 146 LOAD_FAST 0 (salt)
148 LOAD_FAST 1 (rand)
150 BINARY_OP 0 (+)
154 STORE_FAST 2 (secret)
...
198 RETURN_CONST 0 (None)
Here the last STORE_FAST is telling us that the secret variable is likely stored on the stack. The f_localsplus field in the frame structure contains the local variables and their values.
You can also inspect the co_code_adaptive to get the adaptive bytecode, which is a more optimized version of the bytecode that is used by the interpreter.
x/200xb _PyRuntime.interpreters.main->threads.head->cframe.current_frame->previous->f_code.co_code_adaptive
0xe29140b836a0: 0x97 0x00 0x74 ...
>>> import dis
>>>
>>> addresses = [
... 0x97, 0x00, ... , 0x79, 0x00
... ]
>>>
>>> for i, addr in enumerate(addresses):
... base = 0xe29140b836a0 + i
... print(f"0x{base:x}: 0x{addr:02x} -> {dis.opname[addr]}")
...
# 0xe29140b836a0: 0x97 -> RESUME
# 0xe29140b836a1: 0x00 -> CACHE
# 0xe29140b836a2: 0x74 -> LOAD_GLOBAL
# ...
# 0xe29140b83731: 0x01 -> POP_TOP
# 0xe29140b83732: 0x58 -> <88>
# 0xe29140b83733: 0x00 -> CACHE
# 0xe29140b83734: 0x7c -> LOAD_FAST
# 0xe29140b83735: 0x01 -> POP_TOP
# 0xe29140b83736: 0x7a -> BINARY_OP
# 0xe29140b83737: 0x00 -> CACHE
# 0xe29140b83738: 0x01 -> POP_TOP
# 0xe29140b83739: 0x00 -> CACHE
# 0xe29140b8373a: 0x7d -> STORE_FAST
...
# 0xe29140b83766: 0x79 -> RETURN_CONST
# 0xe29140b83767: 0x00 -> CACHE
Now we can read the secret variable from the main() function using the localsplus field in the frame structure.
(gdb) p *((PyFloatObject*)_PyRuntime.interpreters.main->threads.head->cframe.current_frame->previous->localsplus[0])
$25 = { ... , ob_fval = 0.34013542952956677}
(gdb) p *((PyFloatObject*)_PyRuntime.interpreters.main->threads.head->cframe.current_frame->previous->localsplus[1])
$26 = { ... , ob_fval = 0.099622000445300007}
(gdb) p *((PyFloatObject*)_PyRuntime.interpreters.main->threads.head->cframe.current_frame->previous->localsplus[2])
$27 = { ... , ob_fval = 0.43975742997486678}
You can verify that the secret variable is the sum of the salt and rand variables.
>>> 0.34013542952956677 + 0.099622000445300007
0.4397574299748668
Noice.
I didn't try to inject bytecode into interpreter frame directly, if you did let me know how it went (or) if you wanna discuss more on this stuff, DM me - @pwnfunction.