From 77987564d8abe1b5c5e20f6044b4ac3b94ab8e8f Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" <68491+gpshead@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:09:04 -0700 Subject: [PATCH] [3.14] gh-159185: Fix marshalling of a set modified while its elements are marshalled (GH-159195) Marshalling a set element can run Python code (__buffer__, an audit hook) which changes the set. The number of elements was read before the loop, so a set which grew was written past the end of the list and one which shrank left NULL items in the list which was then sorted. Raise RuntimeError("set changed size during iteration") instead. (cherry picked from commit cd71799c47802f87e1469cc97cc99f5ce6cea27c) --- Lib/test/test_marshal.py | 23 +++++++++++++++++++ ...-10-11-08-40-00.gh-issue-159185.Sq7cZd.rst | 4 ++++ Python/marshal.c | 21 ++++++++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-11-08-40-00.gh-issue-159185.Sq7cZd.rst diff --git a/Lib/test/test_marshal.py b/Lib/test/test_marshal.py index 2e8e0ea388639a8..d917e9339995547 100644 --- a/Lib/test/test_marshal.py +++ b/Lib/test/test_marshal.py @@ -562,6 +562,29 @@ def test_unmarshallable(self): with self.assertRaisesRegex(ValueError, "unmarshallable object"): marshal.dumps((arg, memoryview(b''))) + def test_set_changed_size(self): + # A set is modified while its items are being marshalled. + class Item: + def __hash__(self): + return 0 + def __buffer__(self, flags): + change(sample) + return memoryview(b'abc') + def grow(sample): + sample.update(range(1000, 1400)) + def shrink(sample): + sample.difference_update(range(1, 60)) + for change in grow, shrink: + for version in range(marshal.version + 1): + with self.subTest(change=change.__name__, version=version): + # The first item of the set modifies it. + sample = {Item()} + sample.update(range(1, 60)) + self.assertIsInstance(next(iter(sample)), Item) + with self.assertRaisesRegex(RuntimeError, + "set changed size"): + marshal.dumps(sample, version) + LARGE_SIZE = 2**31 pointer_size = 8 if sys.maxsize > 0xFFFFFFFF else 4 diff --git a/Misc/NEWS.d/next/Library/2026-10-11-08-40-00.gh-issue-159185.Sq7cZd.rst b/Misc/NEWS.d/next/Library/2026-10-11-08-40-00.gh-issue-159185.Sq7cZd.rst new file mode 100644 index 000000000000000..493cd78d6cf015d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-11-08-40-00.gh-issue-159185.Sq7cZd.rst @@ -0,0 +1,4 @@ +Fix a crash in :func:`marshal.dumps` and :func:`marshal.dump` when a set is +modified while its elements are serialized, for example by the +:meth:`~object.__buffer__` method of one of its elements. +:exc:`RuntimeError` is now raised. diff --git a/Python/marshal.c b/Python/marshal.c index d5107c4975ca11b..a05305f72cf51af 100644 --- a/Python/marshal.c +++ b/Python/marshal.c @@ -104,6 +104,7 @@ module marshal #define WFERR_NESTEDTOODEEP 2 #define WFERR_NOMEMORY 3 #define WFERR_CODE_NOT_ALLOWED 4 +#define WFERR_SET_CHANGED_SIZE 5 typedef struct { FILE *fp; @@ -635,9 +636,19 @@ w_complex_object(PyObject *v, char flag, WFILE *p) p->error = WFERR_NOMEMORY; return; } + // Marshalling an element can run arbitrary code (an audit hook, or + // the __buffer__() method of the element), which can add elements + // to the set or remove some: n is not trusted to still be the size + // of the set. Py_ssize_t i = 0; + bool changed = false; Py_BEGIN_CRITICAL_SECTION(v); while (_PySet_NextEntryRef(v, &pos, &value, &hash)) { + if (i == n) { + changed = true; + Py_DECREF(value); + break; + } PyObject *dump = _PyMarshal_WriteObjectToString(value, p->version, p->allow_code); if (dump == NULL) { @@ -659,7 +670,11 @@ w_complex_object(PyObject *v, char flag, WFILE *p) Py_DECREF(pairs); return; } - assert(i == n); + if (changed || i != n) { + p->error = WFERR_SET_CHANGED_SIZE; + Py_DECREF(pairs); + return; + } if (PyList_Sort(pairs)) { p->error = WFERR_NOMEMORY; Py_DECREF(pairs); @@ -1909,6 +1924,10 @@ _PyMarshal_WriteObjectToString(PyObject *x, int version, int allow_code) PyErr_SetString(PyExc_ValueError, "marshalling code objects is disallowed"); break; + case WFERR_SET_CHANGED_SIZE: + PyErr_SetString(PyExc_RuntimeError, + "set changed size during iteration"); + break; default: case WFERR_UNMARSHALLABLE: PyErr_SetString(PyExc_ValueError,