https://github.com/python/cpython/commit/404113bd37625215d03545e51f8caa9fb7f1eea8
commit: 404113bd37625215d03545e51f8caa9fb7f1eea8
branch: 3.15
author: Miss Islington (bot) <[email protected]>
committer: gpshead <[email protected]>
date: 2026-06-29T02:34:20Z
summary:

[3.15] gh-148660: Fix use-after-free in OrderedDict.copy() on reentrant 
mutation (GH-151573) (#152540)

gh-148660: Fix use-after-free in OrderedDict.copy() on reentrant mutation 
(GH-151573)

* gh-148660: Fix use-after-free in OrderedDict.copy() on reentrant mutation

OrderedDict.copy() walks the internal linked list while building the new
dict. The loop body can run arbitrary Python (a key's __eq__/__hash__, or
a subclass __getitem__/__setitem__) which can clear the source dict and
free the nodes being iterated.

Detect this the same way OrderedDict.__eq__ already does (gh-119004):
snapshot od_state before the loop, hold a strong reference to the key and
read the hash before any reentrant call, and raise RuntimeError if the
state changed before advancing to the next node.

* gh-148660: fix NEWS nit, suppress undocumented OrderedDict.copy xref
(cherry picked from commit 7d128e319f3730e776a9161a4b5e9de95c802eaf)

Co-authored-by: Gregory P. Smith <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-06-17-00-00-00.gh-issue-148660.odcopy.rst
M Lib/test/test_ordered_dict.py
M Objects/odictobject.c

diff --git a/Lib/test/test_ordered_dict.py b/Lib/test/test_ordered_dict.py
index 642c2722711c7ca..84bb3fdfbaaa6e7 100644
--- a/Lib/test/test_ordered_dict.py
+++ b/Lib/test/test_ordered_dict.py
@@ -879,6 +879,39 @@ def side_effect(self):
         self.assertDictEqual(dict1, dict.fromkeys((0, 4.2)))
         self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))
 
+    def test_issue148660_copy_clear_in_key_eq(self):
+        # gh-148660: od.copy() must not crash when a key's __eq__ clears od
+        # while copy() is inserting into the new dict.
+        armed = False
+        calls = 0
+        class Key:
+            def __hash__(self):
+                return 1
+            def __eq__(self, other):
+                nonlocal calls
+                if armed:
+                    calls += 1
+                    if calls == 2:
+                        od.clear()
+                return self is other
+        od = self.OrderedDict()
+        od[Key()] = "v1"
+        od[Key()] = "v2"
+        armed = True
+        msg = "OrderedDict mutated during iteration"
+        self.assertRaisesRegex(RuntimeError, msg, od.copy)
+
+    def test_issue148660_copy_clear_in_subclass_getitem(self):
+        # gh-148660: od.copy() must not crash when a subclass __getitem__
+        # clears od.
+        class OD(self.OrderedDict):
+            def __getitem__(self, key):
+                od.clear()
+                return "v"
+        od = OD([(1, "v1"), (2, "v2")])
+        msg = "OrderedDict mutated during iteration"
+        self.assertRaisesRegex(RuntimeError, msg, od.copy)
+
 
 @unittest.skipUnless(c_coll, 'requires the C version of the collections 
module')
 class CPythonOrderedDictTests(OrderedDictTests,
diff --git 
a/Misc/NEWS.d/next/Library/2026-06-17-00-00-00.gh-issue-148660.odcopy.rst 
b/Misc/NEWS.d/next/Library/2026-06-17-00-00-00.gh-issue-148660.odcopy.rst
new file mode 100644
index 000000000000000..30c2e366ca2ba07
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-06-17-00-00-00.gh-issue-148660.odcopy.rst
@@ -0,0 +1,3 @@
+Fix a crash in :meth:`!collections.OrderedDict.copy` when a key's
+``__eq__`` or a subclass method mutates the dict during the copy.  Now
+raises :exc:`RuntimeError` instead, as iteration does.
diff --git a/Objects/odictobject.c b/Objects/odictobject.c
index 6f05395b18d781f..33914cdffb1dcc5 100644
--- a/Objects/odictobject.c
+++ b/Objects/odictobject.c
@@ -1251,36 +1251,52 @@ OrderedDict_copy_impl(PyObject *od)
     if (od_copy == NULL)
         return NULL;
 
+    /* The loop body may run arbitrary Python code which could mutate od and
+       free its nodes (gh-148660); detect that the same way __eq__ does. */
+    size_t state = _PyODictObject_CAST(od)->od_state;
+
     if (PyODict_CheckExact(od)) {
         _odict_FOREACH(od, node) {
-            PyObject *key = _odictnode_KEY(node);
-            PyObject *value = _odictnode_VALUE(node, od);
+            PyObject *key = Py_NewRef(_odictnode_KEY(node));
+            Py_hash_t hash = _odictnode_HASH(node);
+            PyObject *value = PyODict_GetItemWithError(od, key);
             if (value == NULL) {
                 if (!PyErr_Occurred())
                     PyErr_SetObject(PyExc_KeyError, key);
+                Py_DECREF(key);
                 goto fail;
             }
-            if (_PyODict_SetItem_KnownHash_LockHeld((PyObject *)od_copy, key, 
value,
-                                                    _odictnode_HASH(node)) != 
0)
+            int res = _PyODict_SetItem_KnownHash_LockHeld((PyObject *)od_copy,
+                                                          key, value, hash);
+            Py_DECREF(key);
+            if (res != 0)
                 goto fail;
+            if (_PyODictObject_CAST(od)->od_state != state)
+                goto mutated;
         }
     }
     else {
         _odict_FOREACH(od, node) {
-            int res;
-            PyObject *value = PyObject_GetItem((PyObject *)od,
-                                               _odictnode_KEY(node));
-            if (value == NULL)
+            PyObject *key = Py_NewRef(_odictnode_KEY(node));
+            PyObject *value = PyObject_GetItem(od, key);
+            if (value == NULL) {
+                Py_DECREF(key);
                 goto fail;
-            res = PyObject_SetItem((PyObject *)od_copy,
-                                   _odictnode_KEY(node), value);
+            }
+            int res = PyObject_SetItem((PyObject *)od_copy, key, value);
             Py_DECREF(value);
+            Py_DECREF(key);
             if (res != 0)
                 goto fail;
+            if (_PyODictObject_CAST(od)->od_state != state)
+                goto mutated;
         }
     }
     return od_copy;
 
+mutated:
+    PyErr_SetString(PyExc_RuntimeError,
+                    "OrderedDict mutated during iteration");
 fail:
     Py_DECREF(od_copy);
     return NULL;

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to