Skip to content

ctypes: __class__ assignment between Structure types of different sizes causes out-of-bounds write and corrupts managed dict pointer #145985

@0xCyberstan

Description

@0xCyberstan

Crash report

What happened?

What Happened

ctypes.Structure instances store their field data in an inline buffer called b_value (16 bytes on x86-64), allocated at object creation time. The size of this buffer is recorded in b_size and never changes.

Python's type system allows __class__ assignment between types that share the same tp_basicsize. All ctypes.Structure subclasses have the same tp_basicsize regardless of their field sizes, so assigning __class__ from a small Structure to a large one is permitted without error.

When fields of the new larger type are subsequently written, PyCField_set computes the write destination as b_ptr + byte_offset using the new type's field offsets, but b_ptr still points to the original undersized b_value buffer. There is no bounds check against b_size. A field at offset 16 or greater writes past the end of b_value into adjacent object metadata.

On Python 3.13+ the 16 bytes immediately following b_value (object+96 and
object+104) are managed dict metadata — specifically the inline values pointer
and dict pointer used to store instance attributes. The out-of-bounds write
replaces these with attacker-controlled values.

When s.cycle = cycle subsequently attempts to store an instance attribute,
store_instance_attr_lock_held in dictobject.c reads the corrupted inline
values pointer and fails the internal consistency assertion:

dict == NULL || ((PyDictObject *)dict)->ma_values == values

PoC

import ctypes, gc

class Small(ctypes.Structure):
    _fields_ = [('x', ctypes.c_uint8)]      # 1 byte, uses 16-byte inline b_value

class Big(ctypes.Structure):
    _fields_ = [(f'f{i}', ctypes.c_uint64) for i in range(4)]  # 32 bytes

s = Small()
s.__class__ = Big            # allowed — tp_basicsize matches, b_size not updated
s.f2 = 0xDEADBEEFDEADBEEF  # memcpy to b_ptr+16 — 8 bytes past end of b_value
s.f3 = 0xCAFEBABECAFEBABE  # overwrites managed dict pointer at object+104

# Force cyclic GC to traverse s and dereference the corrupted pointer
cycle = [s]
s.cycle = cycle
gc.collect(2)

Crash Behaviour

Debug build (--with-pydebug): assertion failure in store_instance_attr_lock_held
at Objects/dictobject.c:6866 when s.cycle = cycle attempts to store an instance
attribute into the corrupted managed dict. Reproducible every run.

Release build: silent memory corruption — the OOB write succeeds and the corrupted
pointer persists in the object. The interpreter crashes with SIGSEGV during GC traversal
at shutdown, or non-deterministically depending on heap layout and GC scheduling.
Confirmed via raw memory inspection on 3.13.1: object+96 and object+104 overwritten
with attacker-controlled values.

I have attached the gdb trace below to highlight the oob write.

gdb_output.txt

CPython versions tested on:

3.14

Operating systems tested on:

Linux

Output from running 'python -VV' on the command line:

Python 3.14.2 (tags/v3.14.2:df793163d58, Mar 15 2026, 18:02:18) [GCC 13.3.0]

Metadata

Metadata

Assignees

No one assigned

    Labels

    extension-modulesC modules in the Modules dirtopic-ctypestype-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions