cpython icon indicating copy to clipboard operation
cpython copied to clipboard

Crash in OrderedDict similiar to #83771

Open chibinz opened this issue 1 year ago • 6 comments

Bug report

Bug description:

import collections

global count
count = 0

class Evil():
    def __eq__(self, other):
        global count
        print(count)

        if count == 1:
            l.clear()
            print("cleared l")

        count += 1
        return True

    def __hash__(self):
        return 3

l = collections.OrderedDict({Evil(): 4, 5: 6})
r = collections.OrderedDict({Evil(): 4, 5: 6})

print(l == r)

CPython versions tested on:

3.10, 3.13

Operating systems tested on:

Linux

Linked PRs

  • gh-121329

chibinz avatar May 13 '24 16:05 chibinz

this seems to work correctly in 3.12.3+ and Python 3.13.0a6+ for me (ubuntu Jammy)

 graingert@conscientious  ~/projects  python3.11 crash.py
0
1
cleared l
[1]    742219 segmentation fault (core dumped)  python3.11 crash.py
 ✘  graingert@conscientious  ~/projects  python3.12 crash.py
0
1
cleared l
False
 graingert@conscientious  ~/projects  python3.13 crash.py
0
1
cleared l
False
 graingert@conscientious  ~/projects  

it also works correctly on main: Python 3.14.0a0 (heads/main:7d7eec595a, May 13 2024, 17:38:47)

graingert avatar May 13 '24 16:05 graingert

I can reproduce locally on 7d7eec595a47a5cd67ab420164f0059eb8b9aa28 with --with-pydebug

Output on macos:

» PYTHONFAULTHANDLER=1 ./python.exe ex.py
Fatal Python error: Segmentation fault

Current thread 0x00000001fafabac0 (most recent call first):
  File "/Users/sobolev/Desktop/cpython2/ex.py", line 24 in <module>
[2]    61910 segmentation fault  PYTHONFAULTHANDLER=1 ./python.exe ex.py

The problem happens here: https://github.com/python/cpython/blob/7d7eec595a47a5cd67ab420164f0059eb8b9aa28/Objects/odictobject.c#L819-L822

sobolevn avatar May 13 '24 16:05 sobolevn

I think we can add something like

    if (di->di_odict->od_state != di->di_state) {
        PyErr_SetString(PyExc_RuntimeError,
                        "OrderedDict mutated during iteration");
        goto done;
    }

🤔

sobolevn avatar May 13 '24 17:05 sobolevn

I came up with something like

» git patch
diff --git Objects/odictobject.c Objects/odictobject.c
index 53f64fc81e7..df5fe259d39 100644
--- Objects/odictobject.c
+++ Objects/odictobject.c
@@ -806,6 +806,11 @@ _odict_keys_equal(PyODictObject *a, PyODictObject *b)
 {
     _ODictNode *node_a, *node_b;
 
+    size_t state_a = a->od_state;
+    size_t state_b = b->od_state;
+    Py_ssize_t size_a = PyODict_SIZE(a);
+    Py_ssize_t size_b = PyODict_SIZE(b);
+
     node_a = _odict_FIRST(a);
     node_b = _odict_FIRST(b);
     while (1) {
@@ -820,10 +825,22 @@ _odict_keys_equal(PyODictObject *a, PyODictObject *b)
                                             (PyObject *)_odictnode_KEY(node_a),
                                             (PyObject *)_odictnode_KEY(node_b),
                                             Py_EQ);
-            if (res < 0)
+            if (res < 0) {
                 return res;
-            else if (res == 0)
+            }
+            else if (a->od_state != state_a || b->od_state != state_b) {
+                PyErr_SetString(PyExc_RuntimeError,
+                                "OrderedDict mutated during iteration");
+                return -1;
+            }
+            else if (size_a != PyODict_SIZE(a) || size_b != PyODict_SIZE(b)) {
+                PyErr_SetString(PyExc_RuntimeError,
+                                "OrderedDict changed size during iteration");
+                return -1;
+            }
+            else if (res == 0) {
                 return 0;
+            }
 
             /* otherwise it must match, so move on to the next one */
             node_a = _odictnode_NEXT(node_a);
                                                 

I am not sure that this is a correct and/or optimal solution though.

It raises an error like so:

» PYTHONFAULTHANDLER=1 ./python.exe ex.py
0
1
cleared l
Traceback (most recent call last):
  File "/Users/sobolev/Desktop/cpython2/ex.py", line 24, in <module>
    print(l == r)
          ^^^^^^
RuntimeError: OrderedDict changed size during iteration

sobolevn avatar May 13 '24 17:05 sobolevn

@sobolevn I think your solution is the only one that is correct. There's a known issues with clearing during the comparison: https://github.com/python/cpython/issues/82769 Unfortunately, the nature of OrderedDict does not allow us to apply the same fix as for #82769 (Just incref the reference counter of values to compare before PyObject_RichCompareBool). The root cause is in how is clear method is implemented for OrderedDict. It's decref's keys in nodes and then frees the node. So, we cannot "incref" keys in the _odict_keys_equal. Let's imagine we implemented this:

    while (1) {
        if (node_a == NULL && node_b == NULL)
            /* success: hit the end of each at the same time */
            return 1;
        else if (node_a == NULL || node_b == NULL)
            /* unequal length */
            return 0;
        else {
            Py_INCREF(_odictnode_KEY(node_a));
            Py_INCREF(_odictnode_KEY(node_b));
            int res = PyObject_RichCompareBool(
                                            (PyObject *)_odictnode_KEY(node_a),
                                            (PyObject *)_odictnode_KEY(node_b),
                                            Py_EQ);

            if (res < 0)
                return res;
            else if (res == 0)
                return 0;

            /* otherwise it must match, so move on to the next one */
            node_a = _odictnode_NEXT(node_a);
            node_b = _odictnode_NEXT(node_b);
        }
    }

The first iteration of this loop will complete without any errors (actually clearing our OrderedDict). So, after this iteration our node_a and node_b become the next nodes (see the last lines of the cycle above). But... some of these nodes actually freed! (in our example it's the node_a but it's not hard to clear both of our OrderedDicts :)) Now node_a points to something like 0xdddddddddddddddd and accessing its field key leads to a segfault.

Eclips4 avatar May 13 '24 17:05 Eclips4

@Eclips4 yeap, this was the first thing I tried. node_a->key caused the crash on the second loop.

sobolevn avatar May 13 '24 18:05 sobolevn