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

importsys, threading, timeclassM:
__slots__= ("payload",)
def__init__(self):
self.payload=bytearray(128)
defmake():
returnsentinel("X") # sentinel() -> caller() reads make.__module__make.__module__=M()
stop=Falsedefchurn(): # reassign make.__module__ rapidlywhilenotstop:
make.__module__=M()
defbuild(): # construct + drop sentinelswhilenotstop:
s=make(); delsts= [threading.Thread(target=churn) for_inrange(4)]
ts+= [threading.Thread(target=build) for_inrange(8)]
fortints: t.start()
time.sleep(5); stop=Truefortints: 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;
}
returnPy_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)]

Linked PRs

Metadata

Metadata

Assignees

Labels

interpreter-core(Objects, Python, Grammar, and Parser dirs)topic-free-threadingtype-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