Skip to content

Crash: use-after-free in OrderedDict_copy_impl #148660

Closed

Description

@kdsjZh

Crash report

What happened?

POC

from collections import OrderedDict

eq_count = 0

class CollidingKey:
    """Key with fixed hash to cause collisions in od_copy."""
    def __init__(self, name):
        self.name = name

    def __hash__(self):
        return 42  # All keys collide

    def __eq__(self, other):
        global eq_count
        eq_count += 1
        # Fire on 4th call (during od_copy's probe into od_copy, not during od lookup)
        if eq_count == 4:
            # Trigger from inside SetItem's probe — this frees all nodes in od
            od.clear()
        return self.name == getattr(other, 'name', None)

od = OrderedDict()
od[CollidingKey('k1')] = 'v1'
od[CollidingKey('k2')] = 'v2'

# ASan should report heap-use-after-free at Objects/odictobject.c:1254
od.copy()

Analysis

        _odict_FOREACH(od, node) {
            PyObject *key = _odictnode_KEY(node);
            PyObject *value = _odictnode_VALUE(node, od);
            if (value == NULL) {
                if (!PyErr_Occurred())
                    PyErr_SetObject(PyExc_KeyError, key);
                goto fail;
            }
            if (_PyODict_SetItem_KnownHash_LockHeld((PyObject *)od_copy, key, value,
                                                    _odictnode_HASH(node)) != 0)
                goto fail;
        }

The _PyODict_SetItem_KnownHash_LockHeld fire user-defined python code, in which the __eq__ trigger od.clear() and clear all container being iterated. Thus when returning to the _odict_FOREACH(od, node) loop, next iteration use an freed object.

ASan

=================================================================
==2330421==ERROR: AddressSanitizer: heap-use-after-free on address 0x50600006bec0 at pc 0x5a4039bee5e1 bp 0x7ffd2ee588c0 sp 0x7ffd2ee588b0
READ of size 8 at 0x50600006bec0 thread T0
    #0 0x5a4039bee5e0 in OrderedDict_copy_impl Objects/odictobject.c:1256
    #1 0x5a4039bee691 in OrderedDict_copy Objects/clinic/odictobject.c.h:377
    #2 0x5a4039b6b88a in method_vectorcall_NOARGS Objects/descrobject.c:448
    #3 0x5a4039b4be65 in _PyObject_VectorcallTstate Include/internal/pycore_call.h:177
...

0x50600006bec0 is located 32 bytes inside of 56-byte region [0x50600006bea0,0x50600006bed8)
freed by thread T0 here:
    #0 0x7faa614fc4d8 in free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:52
    #1 0x5a4039c13399 in _PyMem_RawFree Objects/obmalloc.c:91
    #2 0x5a4039c15705 in _PyMem_DebugRawFree Objects/obmalloc.c:2959
    #3 0x5a4039c15746 in _PyMem_DebugFree Objects/obmalloc.c:3104
    #4 0x5a4039c3cd7f in PyMem_Free Objects/obmalloc.c:1089
    #5 0x5a4039bea7f2 in _odict_clear_nodes Objects/odictobject.c:810
    #6 0x5a4039bec36d in OrderedDict_clear_impl Objects/odictobject.c:1227
    #7 0x5a4039bec387 in OrderedDict_clear Objects/clinic/odictobject.c.h:353
    #8 0x5a4039b6b88a in method_vectorcall_NOARGS Objects/descrobject.c:448
    #9 0x5a4039b4be65 in _PyObject_VectorcallTstate Include/internal/pycore_call.h:177
    #10 0x5a4039b4bf58 in PyObject_Vectorcall Objects/call.c:327
    #11 0x5a4039dc0b15 in _PyEval_EvalFrameDefault Python/generated_cases.c.h:1621
...

previously allocated by thread T0 here:
    #0 0x7faa614fd9c7 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
    #1 0x5a4039c13cb0 in _PyMem_RawMalloc Objects/obmalloc.c:63
    #2 0x5a4039c13081 in _PyMem_DebugRawAlloc Objects/obmalloc.c:2891
    #3 0x5a4039c130e9 in _PyMem_DebugRawMalloc Objects/obmalloc.c:2924
    #4 0x5a4039c14967 in _PyMem_DebugMalloc Objects/obmalloc.c:3089
    #5 0x5a4039c3cc3b in PyMem_Malloc Objects/obmalloc.c:1060
    #6 0x5a4039beb685 in _odict_add_new_node Objects/odictobject.c:705
    #7 0x5a4039bec2e7 in _PyODict_SetItem_KnownHash_LockHeld Objects/odictobject.c:1628
    #8 0x5a4039bec342 in PyODict_SetItem_LockHeld Objects/odictobject.c:1648
    #9 0x5a4039bee6a0 in PyODict_SetItem Objects/odictobject.c:1656
    #10 0x5a4039bee729 in odict_mp_ass_sub Objects/odictobject.c:879
    #11 0x5a4039b1a55a in PyObject_SetItem Objects/abstract.c:235
    #12 0x5a4039e00aaf in _PyEval_EvalFrameDefault Python/generated_cases.c.h:11487
...
SUMMARY: AddressSanitizer: heap-use-after-free Objects/odictobject.c:1256 in OrderedDict_copy_impl
Shadow bytes around the buggy address:
  0x50600006bc00: 00 00 00 00 00 00 00 fa fa fa fa fa fd fd fd fd
  0x50600006bc80: fd fd fd fd fa fa fa fa fd fd fd fd fd fd fd fd
  0x50600006bd00: fa fa fa fa fd fd fd fd fd fd fd fa fa fa fa fa
  0x50600006bd80: fd fd fd fd fd fd fd fd fa fa fa fa 00 00 00 00
  0x50600006be00: 00 00 00 fa fa fa fa fa fd fd fd fd fd fd fd fa
=>0x50600006be80: fa fa fa fa fd fd fd fd[fd]fd fd fa fa fa fa fa
  0x50600006bf00: 00 00 00 00 00 00 00 fa fa fa fa fa 00 00 00 00
  0x50600006bf80: 00 00 00 fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x50600006c000: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x50600006c080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x50600006c100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==2330421==ABORTING

CPython versions tested on:

3.15

Operating systems tested on:

Linux

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

Python 3.15.0a8 (tags/v3.15.0a8:55ea59e7dc3, Apr 16 2026, 21:14:44) [GCC 13.3.0]

Linked PRs

Activity

  1. added
    type-crashA hard crash of the interpreter, possibly with a core dump
    on Apr 16, 2026
  2. changed the title [-]Crash: use-after-free in[/-] [+]Crash: use-after-free in OrderedDict_copy_impl[/+] on Apr 16, 2026
  3. kdsjZh commented on Apr 17, 2026

    @kdsjZh
    Author

    There are four variants of this UAF, although they're triggered in different paths, but their UAF site remains the same or similar, so I update all PoCs there and provide justification of differences. I guess they can be addressed in one single fix. And you may use these five py file to test the fix completeness

    Variant 1 Subclass getiem

    """
    Sink: `PyObject_GetItem` (line 1270, subclass loop).
    Subclass dispatches __getitem__; on first call during copy, clears od and returns dummy.
    """
    from collections import OrderedDict
    
    armed = False
    
    class OD(OrderedDict):
        def __getitem__(self, key):
            if armed:
                od.clear()
                return 'dummy'
            return OrderedDict.__getitem__(self, key)
    
    od = OD([(1, 'v1'), (2, 'v2')])
    armed = True
    od.copy()
    
    

    Variant 2 Subclass setitem

    """
    Sink: `PyObject_SetItem` (line 1274, subclass loop).
    Subclass dispatches __setitem__ on od_copy; on first call during copy, clears od.
    """
    from collections import OrderedDict
    
    armed = False
    
    class OD(OrderedDict):
        def __setitem__(self, key, value):
            if armed:
                od.clear()
            OrderedDict.__setitem__(self, key, value)
    
    od = OD([(1, 'v1'), (2, 'v2')])
    armed = True
    od.copy()
    
    

    Variant 3 Subclass decref

    """
    Sink: `Py_DECREF` (line 1276, subclass loop).
    Value's __del__ (triggered by Py_DECREF when rc drops to 0) clears od.
    """
    from collections import OrderedDict
    
    armed = False
    
    class V:
        def __del__(self):
            if armed:
                od.clear()
    
    class OD(OrderedDict):
        def __getitem__(self, key):
            return V()  # Fresh wrapper each time; rc=1 at loop frame
        def __setitem__(self, key, value):
            pass  # od_copy drops value; rc stays 1 until Py_DECREF
    
    od = OD()
    OrderedDict.__setitem__(od, 1, 'v1')
    OrderedDict.__setitem__(od, 2, 'v2')
    armed = True
    od.copy()
    
    

    Variant 4 setitem

    """
    Sink: `_PyODict_SetItem_KnownHash_LockHeld` (line 1262, exact-type loop).
    Exact OrderedDict; __eq__ fires during SetItem's probe into od_copy; clears od.
    """
    from collections import OrderedDict
    
    eq_count = 0
    
    class K:
        def __init__(self, n):
            self.n = n
        def __hash__(self):
            return 42
        def __eq__(self, other):
            global eq_count
            eq_count += 1
            if eq_count == 4:
                od.clear()
            return self.n == setattr(other, 'n', None)
    
    od = OrderedDict([(K(1), 'v1'), (K(2), 'v2')])
    od.copy()
    
    
  4. rhettinger commented on Apr 17, 2026

    @rhettinger
    Contributor
  5. added 2 commits that reference this issue on Jun 16, 2026
  6. self-assigned this
    on Jun 17, 2026
  7. added 5 commits that reference this issue on Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

extension-modulesC modules in the Modules dirtype-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