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.