From d69416dd21cca9aae9b8c221de5584c15b837024 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Wed, 17 Dec 2025 04:04:27 +0300 Subject: [PATCH 01/10] gh-142831: Fix UAF in `_json` module --- Modules/_json.c | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/Modules/_json.c b/Modules/_json.c index 14714d4b346546..33b6c99e31b73f 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -1733,15 +1733,14 @@ _encoder_iterate_mapping_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, PyObject *key, *value; for (Py_ssize_t i = 0; i < PyList_GET_SIZE(items); i++) { PyObject *item = PyList_GET_ITEM(items, i); -#ifdef Py_GIL_DISABLED - // gh-119438: in the free-threading build the critical section on items can get suspended + + // GH-142831: The item must be strong-referenced to avoid UAF + // if the user code modifies the list during iteration. Py_INCREF(item); -#endif + if (!PyTuple_Check(item) || PyTuple_GET_SIZE(item) != 2) { PyErr_SetString(PyExc_ValueError, "items must return 2-tuples"); -#ifdef Py_GIL_DISABLED Py_DECREF(item); -#endif return -1; } @@ -1750,14 +1749,10 @@ _encoder_iterate_mapping_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, if (encoder_encode_key_value(s, writer, first, dct, key, value, indent_level, indent_cache, separator) < 0) { -#ifdef Py_GIL_DISABLED Py_DECREF(item); -#endif return -1; } -#ifdef Py_GIL_DISABLED Py_DECREF(item); -#endif } return 0; @@ -1772,24 +1767,20 @@ _encoder_iterate_dict_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, PyObject *key, *value; Py_ssize_t pos = 0; while (PyDict_Next(dct, &pos, &key, &value)) { -#ifdef Py_GIL_DISABLED - // gh-119438: in the free-threading build the critical section on dct can get suspended + // GH-142831: The key and value must be strong-referenced to avoid UAF + // if the user code modifies the dict during iteration. Py_INCREF(key); Py_INCREF(value); -#endif + if (encoder_encode_key_value(s, writer, first, dct, key, value, indent_level, indent_cache, separator) < 0) { -#ifdef Py_GIL_DISABLED Py_DECREF(key); Py_DECREF(value); -#endif return -1; } -#ifdef Py_GIL_DISABLED Py_DECREF(key); Py_DECREF(value); -#endif } return 0; } @@ -1893,28 +1884,23 @@ _encoder_iterate_fast_seq_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, { for (Py_ssize_t i = 0; i < PySequence_Fast_GET_SIZE(s_fast); i++) { PyObject *obj = PySequence_Fast_GET_ITEM(s_fast, i); -#ifdef Py_GIL_DISABLED - // gh-119438: in the free-threading build the critical section on s_fast can get suspended + + // GH-142831: The object must be strong-referenced to avoid UAF + // if the user code modifies the sequence during iteration. Py_INCREF(obj); -#endif + if (i) { if (PyUnicodeWriter_WriteStr(writer, separator) < 0) { -#ifdef Py_GIL_DISABLED Py_DECREF(obj); -#endif return -1; } } if (encoder_listencode_obj(s, writer, obj, indent_level, indent_cache)) { _PyErr_FormatNote("when serializing %T item %zd", seq, i); -#ifdef Py_GIL_DISABLED Py_DECREF(obj); -#endif return -1; } -#ifdef Py_GIL_DISABLED Py_DECREF(obj); -#endif } return 0; } From b153c2ac4b16b5a2f0d059108c04e56f20a55a8e Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Wed, 17 Dec 2025 04:11:02 +0300 Subject: [PATCH 02/10] add news --- .../2025-12-17-04-10-35.gh-issue-142831.ee3t4L.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-12-17-04-10-35.gh-issue-142831.ee3t4L.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-12-17-04-10-35.gh-issue-142831.ee3t4L.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-12-17-04-10-35.gh-issue-142831.ee3t4L.rst new file mode 100644 index 00000000000000..5fa3cd2727a9e5 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-12-17-04-10-35.gh-issue-142831.ee3t4L.rst @@ -0,0 +1,2 @@ +Fix a crash in the :mod:`json` module where a use-after-free could occur if +the object being encoded is modified during serialization. From 64fcd7591015f14e967d70d4e71fa9557f6dfc9e Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Thu, 18 Dec 2025 14:15:09 +0300 Subject: [PATCH 03/10] add test --- Lib/test/test_json/test_dump.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Lib/test/test_json/test_dump.py b/Lib/test/test_json/test_dump.py index 39470754003bb6..fb8803d38122ba 100644 --- a/Lib/test/test_json/test_dump.py +++ b/Lib/test/test_json/test_dump.py @@ -65,6 +65,39 @@ def __lt__(self, o): d[1337] = "true.dat" self.assertEqual(self.dumps(d, sort_keys=True), '{"1337": "true.dat"}') + def test_mutate_items_during_encode(self): + c_make_encoder = getattr(self.json.encoder, 'c_make_encoder', None) + if c_make_encoder is None: + self.skipTest("c_make_encoder not available") + + cache = [] + + class BadDict(dict): + def __init__(self): + super().__init__(real=1) + + def items(self): + entries = [("boom", object())] + cache.append(entries) + return entries + + def encode_str(obj): + if cache: + cache.pop().clear() + return '"x"' + + encoder = c_make_encoder( + None, lambda o: "null", + encode_str, None, + ": ", ", ", False, + False, True + ) + + try: + encoder(BadDict(), 0) + except (ValueError, RuntimeError, SystemError): + pass + class TestPyDump(TestDump, PyTest): pass From e3859583e577510d12c5c44cd140a35c5e28f02b Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Thu, 18 Dec 2025 14:15:19 +0300 Subject: [PATCH 04/10] fix comments --- Modules/_json.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Modules/_json.c b/Modules/_json.c index 33b6c99e31b73f..993ee72d8a0e6f 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -1734,8 +1734,8 @@ _encoder_iterate_mapping_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, for (Py_ssize_t i = 0; i < PyList_GET_SIZE(items); i++) { PyObject *item = PyList_GET_ITEM(items, i); - // GH-142831: The item must be strong-referenced to avoid UAF - // if the user code modifies the list during iteration. + // GH-142831: The item must be strong-referenced to avoid + // use-after-free if the user code modifies the list during iteration. Py_INCREF(item); if (!PyTuple_Check(item) || PyTuple_GET_SIZE(item) != 2) { @@ -1767,8 +1767,8 @@ _encoder_iterate_dict_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, PyObject *key, *value; Py_ssize_t pos = 0; while (PyDict_Next(dct, &pos, &key, &value)) { - // GH-142831: The key and value must be strong-referenced to avoid UAF - // if the user code modifies the dict during iteration. + // GH-142831: The key and value must be strong-referenced to avoid + // use-after-free if the user code modifies the dict during iteration. Py_INCREF(key); Py_INCREF(value); @@ -1885,7 +1885,7 @@ _encoder_iterate_fast_seq_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, for (Py_ssize_t i = 0; i < PySequence_Fast_GET_SIZE(s_fast); i++) { PyObject *obj = PySequence_Fast_GET_ITEM(s_fast, i); - // GH-142831: The object must be strong-referenced to avoid UAF + // GH-142831: The object must be strong-referenced to avoid use-after-free // if the user code modifies the sequence during iteration. Py_INCREF(obj); From 5d6b2a8c9d890107b30c20b27d711ea02082c5d3 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Thu, 18 Dec 2025 14:25:30 +0300 Subject: [PATCH 05/10] fix test --- Lib/test/test_json/test_dump.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_json/test_dump.py b/Lib/test/test_json/test_dump.py index fb8803d38122ba..64291d3fd66e20 100644 --- a/Lib/test/test_json/test_dump.py +++ b/Lib/test/test_json/test_dump.py @@ -95,7 +95,7 @@ def encode_str(obj): try: encoder(BadDict(), 0) - except (ValueError, RuntimeError, SystemError): + except (ValueError, RuntimeError): pass From 66c3af1c9609078b76608ee41f9eba45f6866c93 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Fri, 19 Dec 2025 13:36:31 +0530 Subject: [PATCH 06/10] clean up test --- Lib/test/test_json/test_dump.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/Lib/test/test_json/test_dump.py b/Lib/test/test_json/test_dump.py index 64291d3fd66e20..8c4c9899b1ebf1 100644 --- a/Lib/test/test_json/test_dump.py +++ b/Lib/test/test_json/test_dump.py @@ -66,10 +66,6 @@ def __lt__(self, o): self.assertEqual(self.dumps(d, sort_keys=True), '{"1337": "true.dat"}') def test_mutate_items_during_encode(self): - c_make_encoder = getattr(self.json.encoder, 'c_make_encoder', None) - if c_make_encoder is None: - self.skipTest("c_make_encoder not available") - cache = [] class BadDict(dict): @@ -84,20 +80,9 @@ def items(self): def encode_str(obj): if cache: cache.pop().clear() - return '"x"' - - encoder = c_make_encoder( - None, lambda o: "null", - encode_str, None, - ": ", ", ", False, - False, True - ) - - try: - encoder(BadDict(), 0) - except (ValueError, RuntimeError): - pass + return 'x' + self.assertEqual(self.dumps(BadDict(), default=encode_str), '{"boom": "x"}') class TestPyDump(TestDump, PyTest): pass From adfeee28e5b3fe53fdc776bc9b07c9416e4b1e4d Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Fri, 19 Dec 2025 14:06:50 +0530 Subject: [PATCH 07/10] Revert "clean up test" This reverts commit 66c3af1c9609078b76608ee41f9eba45f6866c93. --- Lib/test/test_json/test_dump.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_json/test_dump.py b/Lib/test/test_json/test_dump.py index 8c4c9899b1ebf1..64291d3fd66e20 100644 --- a/Lib/test/test_json/test_dump.py +++ b/Lib/test/test_json/test_dump.py @@ -66,6 +66,10 @@ def __lt__(self, o): self.assertEqual(self.dumps(d, sort_keys=True), '{"1337": "true.dat"}') def test_mutate_items_during_encode(self): + c_make_encoder = getattr(self.json.encoder, 'c_make_encoder', None) + if c_make_encoder is None: + self.skipTest("c_make_encoder not available") + cache = [] class BadDict(dict): @@ -80,9 +84,20 @@ def items(self): def encode_str(obj): if cache: cache.pop().clear() - return 'x' + return '"x"' + + encoder = c_make_encoder( + None, lambda o: "null", + encode_str, None, + ": ", ", ", False, + False, True + ) + + try: + encoder(BadDict(), 0) + except (ValueError, RuntimeError): + pass - self.assertEqual(self.dumps(BadDict(), default=encode_str), '{"boom": "x"}') class TestPyDump(TestDump, PyTest): pass From a6734fe764dd19838a377fa7ab8233ac2a52493f Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Mon, 19 Jan 2026 19:58:29 +0300 Subject: [PATCH 08/10] Remove outdated comment about Py_INCREF in JSON encoder --- Modules/_json.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/Modules/_json.c b/Modules/_json.c index 993ee72d8a0e6f..a636efded60233 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -1734,8 +1734,6 @@ _encoder_iterate_mapping_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, for (Py_ssize_t i = 0; i < PyList_GET_SIZE(items); i++) { PyObject *item = PyList_GET_ITEM(items, i); - // GH-142831: The item must be strong-referenced to avoid - // use-after-free if the user code modifies the list during iteration. Py_INCREF(item); if (!PyTuple_Check(item) || PyTuple_GET_SIZE(item) != 2) { From facfd5d89993f40b56293cc84d8bacff1fae2d8a Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Mon, 19 Jan 2026 20:00:26 +0300 Subject: [PATCH 09/10] Move test_mutate_items_during_encode to test_speedups.py --- Lib/test/test_json/test_dump.py | 33 ----------------------------- Lib/test/test_json/test_speedups.py | 30 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/Lib/test/test_json/test_dump.py b/Lib/test/test_json/test_dump.py index 64291d3fd66e20..39470754003bb6 100644 --- a/Lib/test/test_json/test_dump.py +++ b/Lib/test/test_json/test_dump.py @@ -65,39 +65,6 @@ def __lt__(self, o): d[1337] = "true.dat" self.assertEqual(self.dumps(d, sort_keys=True), '{"1337": "true.dat"}') - def test_mutate_items_during_encode(self): - c_make_encoder = getattr(self.json.encoder, 'c_make_encoder', None) - if c_make_encoder is None: - self.skipTest("c_make_encoder not available") - - cache = [] - - class BadDict(dict): - def __init__(self): - super().__init__(real=1) - - def items(self): - entries = [("boom", object())] - cache.append(entries) - return entries - - def encode_str(obj): - if cache: - cache.pop().clear() - return '"x"' - - encoder = c_make_encoder( - None, lambda o: "null", - encode_str, None, - ": ", ", ", False, - False, True - ) - - try: - encoder(BadDict(), 0) - except (ValueError, RuntimeError): - pass - class TestPyDump(TestDump, PyTest): pass diff --git a/Lib/test/test_json/test_speedups.py b/Lib/test/test_json/test_speedups.py index 682014cfd5b344..7201c9508dd9c8 100644 --- a/Lib/test/test_json/test_speedups.py +++ b/Lib/test/test_json/test_speedups.py @@ -80,3 +80,33 @@ def test(name): def test_unsortable_keys(self): with self.assertRaises(TypeError): self.json.encoder.JSONEncoder(sort_keys=True).encode({'a': 1, 1: 'a'}) + + def test_mutate_items_during_encode(self): + c_make_encoder = getattr(self.json.encoder, 'c_make_encoder', None) + if c_make_encoder is None: + self.skipTest("c_make_encoder not available") + + cache = [] + + class BadDict(dict): + def items(self): + entries = [("boom", object())] + cache.append(entries) + return entries + + def encode_str(obj): + if cache: + cache.pop().clear() + return '"x"' + + encoder = c_make_encoder( + None, lambda o: "null", + encode_str, None, + ": ", ", ", False, + False, True + ) + + try: + encoder(BadDict(real=1), 0) + except (ValueError, RuntimeError): + pass From 3c29d138886f9630c3ef40eb649e8cd7ab953f12 Mon Sep 17 00:00:00 2001 From: Shamil Abdulaev Date: Mon, 19 Jan 2026 20:35:25 +0300 Subject: [PATCH 10/10] fix lint error --- Lib/test/test_json/test_speedups.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_json/test_speedups.py b/Lib/test/test_json/test_speedups.py index 5267faaa8d2177..20fd3b594d1ddf 100644 --- a/Lib/test/test_json/test_speedups.py +++ b/Lib/test/test_json/test_speedups.py @@ -111,7 +111,7 @@ def test_current_indent_level(self): self.assertEqual(enc(['spam', {'ham': 'eggs'}], 3)[0], expected2) self.assertRaises(TypeError, enc, ['spam', {'ham': 'eggs'}], 3.0) self.assertRaises(TypeError, enc, ['spam', {'ham': 'eggs'}]) - + def test_mutate_items_during_encode(self): c_make_encoder = getattr(self.json.encoder, 'c_make_encoder', None) if c_make_encoder is None: