GeezerVison is showing github.com — heap-use-after-free in signaldict_repr: signaldict outlives its parent Context after explicit del · Issue #146011 · python/cpython
heap-use-after-free in signaldict_repr: signaldict outlives its parent Context after explicit del #146011DescriptionBug report
Bug description:
Description
A decimal.Context object's flags (and traps) attribute returns a signaldict that holds a borrowed reference to the parent Context. When the parent Context is explicitly deleted, its refcount drops to zero and context_dealloc frees it. However, the signaldict object remains alive and accessible. Any subsequent operation that calls signaldict_repr — such as print(), str(), or repr() — reads through the now-freed Context pointer, causing a heap-use-after-free.
Reproducer
import decimal
ctx = decimal.Context(prec=7)
mapping = ctx.flags
del ctx
print(mapping)
Expected behavior
Either signaldict holds a strong reference to its parent Context (keeping it alive as long as the signaldict is alive), or accessing a signaldict whose parent has been freed raises a RuntimeError or similar safe error. Under no circumstances should signaldict_repr read through a freed pointer.
Actual behavior
=================================================================
==ERROR: AddressSanitizer: heap-use-after-free on address ... in signaldict_repr
READ of size 4 at ... thread T0
#0 signaldict_repr Include/internal/pycore_moduleobject.h
#1 PyObject_Str Objects/object.c:823
#2 PyFile_WriteObject
#3 builtin_print_impl
...
freed by thread T0 here:
#0 free
#1 context_dealloc Modules/_decimal/_decimal.c:1512
#2 _Py_Dealloc
#3 Py_DECREF / Py_XDECREF
#4 insertdict Objects/dictobject.c:1998
Root cause
signaldict is implemented as a view over its parent Context's internal C struct fields. It stores a raw pointer (or borrowed reference) to the Context but does not increment the Context's refcount. When the user holds the last Python reference to the Context and deletes it, context_dealloc immediately frees the underlying C struct. The signaldict is left with a dangling pointer. Any call to signaldict_repr (or other signaldict methods that dereference the parent pointer) then constitutes a use-after-free.
The fix should ensure signaldict holds a strong (Py_INCREF) reference to its parent Context, releasing it only in signaldict's own dealloc.
CPython versions tested on:
CPython main branch
Operating systems tested on:
No response
Linked PRs
Activitybrijkapadia commented on Mar 17, 2026 I'm not entirely sure if signaldict holding a strong reference is the correct solution. The reason why signaldict currently holds a borrowed reference is because it should live as long as the Context object. Therefore, I think that the best solution would be something that enforces this lifetime.
One potential solution would be to change context_clear to this:
static int
context_clear(PyObject *op)
{
PyDecContextObject *self = _PyDecContextObject_CAST(op);
/* Start of added code */
PyDecSignalDictObject *traps = _PyDecSignalDictObject_CAST(self->traps);
PyDecSignalDictObject *flags = _PyDecSignalDictObject_CAST(self->flags);
traps->flags = NULL;
flags->flags = NULL;
/* End of added code */
Py_CLEAR(self->traps);
Py_CLEAR(self->flags);
return 0;
}
as this would result in a ValueError (invalid signal dict) if you try to call repr. Raising an exception seems to be the best strategy as this is a state you would never want to be in.
Can open a PR if this is an acceptable fix. picnixz commented on Mar 17, 2026 cc @skirpichev (I don't have time to triage this one sorry) skirpichev commented on Mar 19, 2026
Can open a PR if this is an acceptable fix.
I think this might be a good solution. brijkapadia commented on Mar 19, 2026 One subtle problem I realized is that the C implementation of decimal and the pure python implementation diverge slightly: in the C version, mapping is stored in a signaldict while in the python version, they are stored in a normal dict. This divergence only became apparent when trying to write a test using the reproducer. Wondering if it would be worthwhile to make both of these implementations have the same behavior or if I should just change the C implementation (i.e. what I suggested above). skirpichev commented on Mar 21, 2026
Wondering if it would be worthwhile to make both of these implementations have the same behavior
I don't think, that making behavior compatible is an easy task. There are also a lot of other differences here, e.g.:
>>> import decimal
>>> import _pydecimal
>>> ctx_py = _pydecimal.Context(prec=7)
>>> ctx_c = decimal.Context(prec=7)
>>> del ctx_py.traps[_pydecimal.Clamped]
>>> del ctx_c.traps[decimal.Clamped]
Traceback (most recent call last):
File "<python-input-7>", line 1, in <module>
del ctx_c.traps[decimal.Clamped]
~~~~~~~~~~~^^^^^^^^^^^^^^^^^
ValueError: signal keys cannot be deleted
or if I should just change the C implementation (i.e. what I suggested above).
You also could check if traps/flags are uniquely referenced (and skip Py_CLEAR, if not).
CC @serhiy-storchaka brijkapadia commented on Mar 22, 2026 The main issue I faced was with finding a way to write a test that covers both the C and Python implementations. As you explained, these implementations differ particularly using a signaldict vs a dict. I could write a test that covers just the C implementation; however, I feel like we should make both implementations as similar as possible. But this might end up being a big change and so I don't know where to go from here.
You also could check if traps/flags are uniquely referenced (and skip Py_CLEAR, if not).
Good idea!
MetadataMetadataDevelopmentNo branches or pull requestsIssue actions
|