From 99be176c3c4be470748e27293bcb097414d941a7 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Sun, 21 Jun 2026 23:47:30 +0800 Subject: [PATCH 1/3] gh-151862: Fix NULL deref on non-str AttributeError when unpickling An object crossing interpreters via a queue or channel is unpickled on the receive side. When that unpickling raised an ``AttributeError`` whose first argument was not a UTF-8-encodable string, ``PyUnicode_AsUTF8()`` returned ``NULL`` and the subsequent ``strncmp(NULL, ...)`` in ``check_missing___main___attr()`` dereferenced it, crashing the interpreter. Guard against the ``NULL`` result and treat it as not a missing-``__main__``-attribute error. --- Lib/test/test_interpreters/test_queues.py | 47 +++++++++++++++++++ ...-06-21-12-00-00.gh-issue-151862.Xq7vTm.rst | 3 ++ Python/crossinterp.c | 5 ++ 3 files changed, 55 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-21-12-00-00.gh-issue-151862.Xq7vTm.rst diff --git a/Lib/test/test_interpreters/test_queues.py b/Lib/test/test_interpreters/test_queues.py index 77334aea3836b98..47dbfcfdc14ce4d 100644 --- a/Lib/test/test_interpreters/test_queues.py +++ b/Lib/test/test_interpreters/test_queues.py @@ -381,6 +381,53 @@ def test_put_get_full_fallback(self): self.assertEqual(obj, obj2) self.assertIsNot(obj, obj2) + def _check_unpickle_attributeerror_arg(self, arg): + # Put an object whose class is then removed so that get() must + # unpickle it via a module __getattr__ that raises AttributeError + # with the given arg, then confirm get() raises NotShareableError + # without crashing. + source = dedent(""" + _attrerr_arg = None + + class Thing: + pass + + def break_module(mod, arg): + del mod.Thing + mod._attrerr_arg = arg + def __getattr__(name): + raise AttributeError(mod._attrerr_arg) + mod.__getattr__ = __getattr__ + """) + with import_helper.ready_to_import('_xi_attrerr', source) as (name, _): + mod = importlib.import_module(name) + queue = queues.create() + queue.put(mod.Thing()) + mod.break_module(mod, arg) + with self.assertRaises(interpreters.NotShareableError): + queue.get() + + def test_get_unpickle_fails_with_bad_attributeerror_arg(self): + # gh-151862: getting an object that fails to unpickle with an + # AttributeError whose first argument cannot be encoded to UTF-8 + # used to crash (NULL dereference in check_missing___main___attr()). + # Two distinct branches NULL the PyUnicode_AsUTF8() result: a + # non-str arg (fails the type check) and a surrogate str (is unicode + # but fails UTF-8 encoding). + for arg in [42, b'x', None, '\ud800']: + with self.subTest(arg=arg): + self._check_unpickle_attributeerror_arg(arg) + + def test_get_unpickle_fails_with_str_attributeerror_arg(self): + # Positive control: a normal str arg (including the genuine missing + # __main__ attribute message shape) must not crash and is handled + # normally. This locks in the non-NULL strncmp() path so a future + # "skip the guard when the arg is a str" change cannot silently + # reintroduce the NULL dereference. + for arg in ['boom', "module '__main__' has no attribute 'Thing'"]: + with self.subTest(arg=arg): + self._check_unpickle_attributeerror_arg(arg) + def test_put_get_same_interpreter(self): interp = interpreters.create() interp.exec(dedent(""" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-21-12-00-00.gh-issue-151862.Xq7vTm.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-21-12-00-00.gh-issue-151862.Xq7vTm.rst new file mode 100644 index 000000000000000..95b2a89a4a3aa05 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-21-12-00-00.gh-issue-151862.Xq7vTm.rst @@ -0,0 +1,3 @@ +Fixed a crash (``NULL`` dereference) when an object passed between +interpreters via :mod:`concurrent.interpreters` fails to unpickle with an +:exc:`AttributeError` whose first argument is not a string. diff --git a/Python/crossinterp.c b/Python/crossinterp.c index 6b489bf03f86ecd..2d11d7442cf283c 100644 --- a/Python/crossinterp.c +++ b/Python/crossinterp.c @@ -664,6 +664,11 @@ check_missing___main___attr(PyObject *exc) } } const char *err = PyUnicode_AsUTF8(msgobj); + if (err == NULL) { + PyErr_Clear(); + Py_DECREF(msgobj); + return 0; + } // Check if it's a missing __main__ attr. int cmp = strncmp(err, "module '__main__' has no attribute '", 36); From 48ad5c4fcbef715557447881e821f067766b4905 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Mon, 22 Jun 2026 00:53:34 +0800 Subject: [PATCH 2/3] Trim verbose test comments --- Lib/test/test_interpreters/test_queues.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/Lib/test/test_interpreters/test_queues.py b/Lib/test/test_interpreters/test_queues.py index 47dbfcfdc14ce4d..e5761d4ad8b5bda 100644 --- a/Lib/test/test_interpreters/test_queues.py +++ b/Lib/test/test_interpreters/test_queues.py @@ -382,10 +382,8 @@ def test_put_get_full_fallback(self): self.assertIsNot(obj, obj2) def _check_unpickle_attributeerror_arg(self, arg): - # Put an object whose class is then removed so that get() must - # unpickle it via a module __getattr__ that raises AttributeError - # with the given arg, then confirm get() raises NotShareableError - # without crashing. + # Cross an object through a queue where get() must re-import its + # class via a module __getattr__ that raises AttributeError(arg). source = dedent(""" _attrerr_arg = None @@ -408,22 +406,15 @@ def __getattr__(name): queue.get() def test_get_unpickle_fails_with_bad_attributeerror_arg(self): - # gh-151862: getting an object that fails to unpickle with an - # AttributeError whose first argument cannot be encoded to UTF-8 - # used to crash (NULL dereference in check_missing___main___attr()). - # Two distinct branches NULL the PyUnicode_AsUTF8() result: a - # non-str arg (fails the type check) and a surrogate str (is unicode - # but fails UTF-8 encoding). + # gh-151862: an AttributeError arg that can't be UTF-8 encoded used + # to crash (NULL deref); covers both non-str and surrogate-str args. for arg in [42, b'x', None, '\ud800']: with self.subTest(arg=arg): self._check_unpickle_attributeerror_arg(arg) def test_get_unpickle_fails_with_str_attributeerror_arg(self): - # Positive control: a normal str arg (including the genuine missing - # __main__ attribute message shape) must not crash and is handled - # normally. This locks in the non-NULL strncmp() path so a future - # "skip the guard when the arg is a str" change cannot silently - # reintroduce the NULL dereference. + # Positive control: a normal str arg (incl. the real missing-__main__ + # message) must not crash, locking in the non-NULL strncmp() path. for arg in ['boom', "module '__main__' has no attribute 'Thing'"]: with self.subTest(arg=arg): self._check_unpickle_attributeerror_arg(arg) From 7cadf94257a6a3d92570d4d4c4d44724e3afda26 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Sun, 16 Aug 2026 23:31:57 +0800 Subject: [PATCH 3/3] Make the test self-contained and stop it re-running __main__ The positive-control arg crafted a "module '__main__' has no attribute" message, which the cross-interpreter path matched and re-executed __main__ (the test runner) in-process. Drop that case and build the module in memory instead of writing a file, so test_interpreters.test_queues runs in isolation again. --- Lib/test/test_interpreters/test_queues.py | 52 +++++++++++------------ 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/Lib/test/test_interpreters/test_queues.py b/Lib/test/test_interpreters/test_queues.py index e5761d4ad8b5bda..0ea4384d27c4b74 100644 --- a/Lib/test/test_interpreters/test_queues.py +++ b/Lib/test/test_interpreters/test_queues.py @@ -1,6 +1,8 @@ import importlib import pickle +import sys import threading +import types from textwrap import dedent import unittest @@ -382,28 +384,25 @@ def test_put_get_full_fallback(self): self.assertIsNot(obj, obj2) def _check_unpickle_attributeerror_arg(self, arg): - # Cross an object through a queue where get() must re-import its - # class via a module __getattr__ that raises AttributeError(arg). - source = dedent(""" - _attrerr_arg = None - - class Thing: - pass - - def break_module(mod, arg): - del mod.Thing - mod._attrerr_arg = arg - def __getattr__(name): - raise AttributeError(mod._attrerr_arg) - mod.__getattr__ = __getattr__ - """) - with import_helper.ready_to_import('_xi_attrerr', source) as (name, _): - mod = importlib.import_module(name) - queue = queues.create() - queue.put(mod.Thing()) - mod.break_module(mod, arg) - with self.assertRaises(interpreters.NotShareableError): - queue.get() + # Put an object through a queue where get() must re-import its class + # via a module __getattr__ that raises AttributeError(arg). + modname = '_test_xi_attrerr' + mod = types.ModuleType(modname) + class Thing: + pass + Thing.__module__ = modname + Thing.__qualname__ = 'Thing' + mod.Thing = Thing + sys.modules[modname] = mod + self.addCleanup(sys.modules.pop, modname, None) + queue = queues.create() + queue.put(Thing()) + del mod.Thing + def raise_attributeerror(name): + raise AttributeError(arg) + mod.__getattr__ = raise_attributeerror + with self.assertRaises(interpreters.NotShareableError): + queue.get() def test_get_unpickle_fails_with_bad_attributeerror_arg(self): # gh-151862: an AttributeError arg that can't be UTF-8 encoded used @@ -413,11 +412,10 @@ def test_get_unpickle_fails_with_bad_attributeerror_arg(self): self._check_unpickle_attributeerror_arg(arg) def test_get_unpickle_fails_with_str_attributeerror_arg(self): - # Positive control: a normal str arg (incl. the real missing-__main__ - # message) must not crash, locking in the non-NULL strncmp() path. - for arg in ['boom', "module '__main__' has no attribute 'Thing'"]: - with self.subTest(arg=arg): - self._check_unpickle_attributeerror_arg(arg) + # Positive control: a normal str arg must not crash, locking in the + # non-NULL strncmp() path. + with self.subTest(arg='boom'): + self._check_unpickle_attributeerror_arg('boom') def test_put_get_same_interpreter(self): interp = interpreters.create()