Skip to content

sentinel() construction races func.__module__ reassignment in caller() under free-threading #153531

Description

@devdanzin

Crash report

What happened?

On a free-threaded build, constructing a PEP 661 sentinel() in one thread while another thread reassigns the enclosing function's __module__ causes a heap use-after-free and SIGSEGV.

Objects/sentinelobject.c's caller() reads func->func_module as a borrowed reference and then Py_NewRefs it, with no protection against a concurrent reassignment freeing that module in the window between the read and the incref. The freed pointer is baked into the new sentinel's self->module; when the sentinel is later collected, sentinel_clear's Py_CLEAR(self->module) decrefs the dangling pointer and _Py_Dealloc reads a freed type slot → crash.

Reproducer

import sys, threading, time

class M:
    __slots__ = ("payload",)
    def __init__(self):
        self.payload = bytearray(128)

def make():
    return sentinel("X")          # sentinel() -> caller() reads make.__module__

make.__module__ = M()
stop = False

def churn():                       # reassign make.__module__ rapidly
    while not stop:
        make.__module__ = M()

def build():                       # construct + drop sentinels
    while not stop:
        s = make(); del s

ts  = [threading.Thread(target=churn) for _ in range(4)]
ts += [threading.Thread(target=build) for _ in range(8)]
for t in ts: t.start()
time.sleep(5); stop = True
for t in ts: t.join()
print("survived (no crash this run)")

Crashes reliably, only on a free-threaded build.

Root cause

Objects/sentinelobject.c, caller() (≈ lines 41–46):

PyFunctionObject *func = _PyFrame_GetFunction(f);
assert(PyFunction_Check(func));
PyObject *r = PyFunction_GetModule((PyObject *)func);   /* BORROWED reference */
if (!r) {
    assert(!PyErr_Occurred());
    Py_RETURN_NONE;
}
return Py_NewRef(r);                                    /* increfs the borrowed ref */

PyFunction_GetModule returns a borrowed reference to func->func_module. Between that read and Py_NewRef(r), a concurrent func.__module__ = ... can drop the previous module object's last reference to 0 and free it; Py_NewRef(r) then operates on freed memory.

The result is stored into the sentinel by sentinel_new_implsentinel_new_with_module (≈ line 59):

self->module = Py_NewRef(module);   /* dangling pointer baked into the sentinel */

and later released by sentinel_clear (line 129):

Py_CLEAR(self->module);             /* Py_DECREF on the dangling pointer -> UAF */

Backtrace

Debug free-threaded build, faulting thread:

#0  _Py_Dealloc (op=<freed module>) at ./Include/internal/pycore_pystate.h:339
#1  Py_DECREF (op=<freed module>)   at ./Include/refcount.h:363
#2  sentinel_clear (op=<sentinel>)  at Objects/sentinelobject.c:129
#3  sentinel_dealloc (op=<freed module>) at Objects/sentinelobject.c:138

Release free-threaded build (same repro), faulting thread lands one step later, in the allocator freeing the corrupted object:

#0  mi_free                at Objects/mimalloc/alloc.c:576
#1  _PyObject_MiFree       at Objects/obmalloc.c:381
#2  subtype_dealloc        at Objects/typeobject.c:2876
#3  _Py_Dealloc            at Objects/object.c:3319

Related

Same borrowed-reference-under-free-threading class as #153297 ("double-decref in func_set_qualname under free-threading") and the broader funcobject.c getset FT-unsafety. The shared root is that func->func_module (and the other function fields) can be reassigned concurrently, so any consumer that reads them borrowed and then increfs is racy on the free-threaded build. Here the unsafe consumer is caller() in sentinelobject.c rather than a funcobject.c getset.


Found, investigated, and drafted with assistance from Claude Code (Opus 4.8).

CPython versions tested on:

CPython main branch

Operating systems tested on:

Linux

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

Python 3.16.0a0 free-threading build (heads/main:bcf98ddbc40, Jul 4 2026, 15:09:31) [Clang 21.1.8 (6ubuntu1)]

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)type-crashA hard crash of the interpreter, possibly with a core dump

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions