From 7a5456ca5e3e1fca138dfc7948e02055e3cd5308 Mon Sep 17 00:00:00 2001 From: Brij Date: Sat, 28 Feb 2026 10:51:20 -0500 Subject: [PATCH 1/3] gh-144475: Fix a heap buffer overflow in partial_repr (v2) --- Lib/test/test_functools.py | 52 +++++++++++++++++ ...-02-07-16-37-42.gh-issue-144475.8tFEXw.rst | 6 ++ Modules/_functoolsmodule.c | 56 +++++++++++-------- 3 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 Misc/NEWS.d/next/C_API/2026-02-07-16-37-42.gh-issue-144475.8tFEXw.rst diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 86652b7fa4d6df..79b3d834ee2a27 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -514,6 +514,58 @@ def test_partial_genericalias(self): self.assertEqual(alias.__args__, (int,)) self.assertEqual(alias.__parameters__, ()) + # Issue 144475 + def test_repr_saftey_against_reentrant_mutation(self): + g_partial = None + + class Function: + def __init__(self, name): + self.name = name + + def __call__(self): + return None + + def __repr__(self): + return f"Function({self.name})" + + class EvilObject: + def __init__(self): + self.triggered = False + + def __repr__(self): + if not self.triggered and g_partial is not None: + self.triggered = True + new_args_tuple = (None,) + new_keywords_dict = {"keyword": None} + new_tuple_state = (Function("new_function"), new_args_tuple, new_keywords_dict, None) + g_partial.__setstate__(new_tuple_state) + gc.collect() + return f"EvilObject" + + trigger = EvilObject() + func = Function("old_function") + + g_partial = functools.partial(func, None, trigger=trigger) + self.assertEqual(repr(g_partial),"functools.partial(Function(old_function), None, trigger=EvilObject)") + + trigger.triggered = False + g_partial = functools.partial(func, trigger, arg=None) + self.assertEqual(repr(g_partial),"functools.partial(Function(old_function), EvilObject, arg=None)") + + + trigger.triggered = False + g_partial = functools.partial(func, trigger, None) + self.assertEqual(repr(g_partial),"functools.partial(Function(old_function), EvilObject, None)") + + trigger.triggered = False + g_partial = functools.partial(func, trigger=trigger, arg=None) + self.assertEqual(repr(g_partial),"functools.partial(Function(old_function), trigger=EvilObject, arg=None)") + + trigger.triggered = False + g_partial = functools.partial(func, trigger, None, None, None, None, arg=None) + self.assertEqual(repr(g_partial),"functools.partial(Function(old_function), EvilObject, None, None, None, None, arg=None)") + + @unittest.skipUnless(c_functools, 'requires the C _functools module') class TestPartialC(TestPartial, unittest.TestCase): diff --git a/Misc/NEWS.d/next/C_API/2026-02-07-16-37-42.gh-issue-144475.8tFEXw.rst b/Misc/NEWS.d/next/C_API/2026-02-07-16-37-42.gh-issue-144475.8tFEXw.rst new file mode 100644 index 00000000000000..9f9e866348b088 --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-02-07-16-37-42.gh-issue-144475.8tFEXw.rst @@ -0,0 +1,6 @@ +Fixed a bug in :func:`functools.partial` when calling :func:`repr` on a partial +object that could occur when the ``fn``, ``args``, or ``kw`` arguments are modified +during a call to :func:`repr`. Now, calls to :func:`repr` will use the original +arguments when generating the string representation of the partial object. +Subsequent calls will use the updated arguments instead. + diff --git a/Modules/_functoolsmodule.c b/Modules/_functoolsmodule.c index 5773083ff68b46..5c5cdf55c26cfe 100644 --- a/Modules/_functoolsmodule.c +++ b/Modules/_functoolsmodule.c @@ -688,6 +688,7 @@ partial_repr(PyObject *self) { partialobject *pto = partialobject_CAST(self); PyObject *result = NULL; + PyObject *fn, *args, *kw; PyObject *arglist; PyObject *mod; PyObject *name; @@ -697,56 +698,65 @@ partial_repr(PyObject *self) status = Py_ReprEnter(self); if (status != 0) { - if (status < 0) + if (status < 0) { return NULL; + } return PyUnicode_FromString("..."); } + /* Reference arguments in case they change */ + fn = Py_NewRef(pto->fn); + args = Py_NewRef(pto->args); + kw = Py_NewRef(pto->kw); + assert(PyTuple_Check(args)); + assert(PyDict_Check(kw)); arglist = Py_GetConstant(Py_CONSTANT_EMPTY_STR); - if (arglist == NULL) - goto done; + if (arglist == NULL) { + goto arglist_error; + } /* Pack positional arguments */ - assert(PyTuple_Check(pto->args)); - n = PyTuple_GET_SIZE(pto->args); + n = PyTuple_GET_SIZE(args); for (i = 0; i < n; i++) { Py_SETREF(arglist, PyUnicode_FromFormat("%U, %R", arglist, - PyTuple_GET_ITEM(pto->args, i))); - if (arglist == NULL) - goto done; + PyTuple_GET_ITEM(args, i))); + if (arglist == NULL) { + goto arglist_error; + } } /* Pack keyword arguments */ - assert (PyDict_Check(pto->kw)); - for (i = 0; PyDict_Next(pto->kw, &i, &key, &value);) { + for (i = 0; PyDict_Next(kw, &i, &key, &value);) { /* Prevent key.__str__ from deleting the value. */ Py_INCREF(value); Py_SETREF(arglist, PyUnicode_FromFormat("%U, %S=%R", arglist, key, value)); Py_DECREF(value); - if (arglist == NULL) - goto done; + if (arglist == NULL) { + goto arglist_error; + } } mod = PyType_GetModuleName(Py_TYPE(pto)); if (mod == NULL) { - goto error; + goto mod_error; } + name = PyType_GetQualName(Py_TYPE(pto)); if (name == NULL) { - Py_DECREF(mod); - goto error; + goto name_error; } - result = PyUnicode_FromFormat("%S.%S(%R%U)", mod, name, pto->fn, arglist); - Py_DECREF(mod); + + result = PyUnicode_FromFormat("%S.%S(%R%U)", mod, name, fn, arglist); Py_DECREF(name); + name_error: + Py_DECREF(mod); + mod_error: Py_DECREF(arglist); - - done: + arglist_error: + Py_DECREF(fn); + Py_DECREF(args); + Py_DECREF(kw); Py_ReprLeave(self); return result; - error: - Py_DECREF(arglist); - Py_ReprLeave(self); - return NULL; } /* Pickle strategy: From 7b4955f495962b318dc3e603a495f88a864e0034 Mon Sep 17 00:00:00 2001 From: Brij Date: Sat, 28 Feb 2026 12:44:06 -0500 Subject: [PATCH 2/3] small changes --- Lib/test/test_functools.py | 2 +- ...-02-07-16-37-42.gh-issue-144475.8tFEXw.rst | 9 +++---- Modules/_functoolsmodule.c | 26 +++++++++---------- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 79b3d834ee2a27..33220412337ab9 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -514,7 +514,7 @@ def test_partial_genericalias(self): self.assertEqual(alias.__args__, (int,)) self.assertEqual(alias.__parameters__, ()) - # Issue 144475 + # GH-144475: Tests that the partial object does not change until repr finishes def test_repr_saftey_against_reentrant_mutation(self): g_partial = None diff --git a/Misc/NEWS.d/next/C_API/2026-02-07-16-37-42.gh-issue-144475.8tFEXw.rst b/Misc/NEWS.d/next/C_API/2026-02-07-16-37-42.gh-issue-144475.8tFEXw.rst index 9f9e866348b088..4e1d7d190d01b8 100644 --- a/Misc/NEWS.d/next/C_API/2026-02-07-16-37-42.gh-issue-144475.8tFEXw.rst +++ b/Misc/NEWS.d/next/C_API/2026-02-07-16-37-42.gh-issue-144475.8tFEXw.rst @@ -1,6 +1,3 @@ -Fixed a bug in :func:`functools.partial` when calling :func:`repr` on a partial -object that could occur when the ``fn``, ``args``, or ``kw`` arguments are modified -during a call to :func:`repr`. Now, calls to :func:`repr` will use the original -arguments when generating the string representation of the partial object. -Subsequent calls will use the updated arguments instead. - +Calling :func:`repr` on :func:`functools.partial` will no longer +use a mutated version of the arguments ``fn``, ``args``, or ``kw`` +when generating the string representation of the partial object. diff --git a/Modules/_functoolsmodule.c b/Modules/_functoolsmodule.c index 5c5cdf55c26cfe..8b3d8e13766093 100644 --- a/Modules/_functoolsmodule.c +++ b/Modules/_functoolsmodule.c @@ -689,9 +689,9 @@ partial_repr(PyObject *self) partialobject *pto = partialobject_CAST(self); PyObject *result = NULL; PyObject *fn, *args, *kw; - PyObject *arglist; - PyObject *mod; - PyObject *name; + PyObject *arglist = NULL; + PyObject *mod = NULL; + PyObject *name = NULL; Py_ssize_t i, n; PyObject *key, *value; int status; @@ -712,7 +712,7 @@ partial_repr(PyObject *self) arglist = Py_GetConstant(Py_CONSTANT_EMPTY_STR); if (arglist == NULL) { - goto arglist_error; + goto done; } /* Pack positional arguments */ n = PyTuple_GET_SIZE(args); @@ -720,7 +720,7 @@ partial_repr(PyObject *self) Py_SETREF(arglist, PyUnicode_FromFormat("%U, %R", arglist, PyTuple_GET_ITEM(args, i))); if (arglist == NULL) { - goto arglist_error; + goto done; } } /* Pack keyword arguments */ @@ -731,27 +731,25 @@ partial_repr(PyObject *self) key, value)); Py_DECREF(value); if (arglist == NULL) { - goto arglist_error; + goto done; } } mod = PyType_GetModuleName(Py_TYPE(pto)); if (mod == NULL) { - goto mod_error; + goto done; } name = PyType_GetQualName(Py_TYPE(pto)); if (name == NULL) { - goto name_error; + goto done; } result = PyUnicode_FromFormat("%S.%S(%R%U)", mod, name, fn, arglist); - Py_DECREF(name); - name_error: - Py_DECREF(mod); - mod_error: - Py_DECREF(arglist); - arglist_error: +done: + Py_XDECREF(name); + Py_XDECREF(mod); + Py_XDECREF(arglist); Py_DECREF(fn); Py_DECREF(args); Py_DECREF(kw); From 543d44c9356b4315f14b11235336060cc55ee7f6 Mon Sep 17 00:00:00 2001 From: bkap123 <97006829+bkap123@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:47:29 -0500 Subject: [PATCH 3/3] Update test_functools.py --- Lib/test/test_functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 33220412337ab9..dda42cb33072c3 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -515,7 +515,7 @@ def test_partial_genericalias(self): self.assertEqual(alias.__parameters__, ()) # GH-144475: Tests that the partial object does not change until repr finishes - def test_repr_saftey_against_reentrant_mutation(self): + def test_repr_safety_against_reentrant_mutation(self): g_partial = None class Function: