From db87281be75b349ffb3b08b0db212f05b804c7c6 Mon Sep 17 00:00:00 2001 From: jg Date: Sun, 25 Oct 2026 12:47:32 +0000 Subject: [PATCH 1/9] Fix UAF for gc debug print In both uncollectable and collectables we would iterate through gc items using `GC_NEXT(gc)`. However in the case the current item was cleaned during iteration this would reuslt in UAF when fetching the next step in the loop. The fix is to move each item to a temporary `seen` list before printing it, and always take the next item from the head of the original list, so the loop never reads from a freed item. This takes a similar approach to `finalize_garbage()`. --- Lib/test/test_gc.py | 62 +++++++++++++++++++ ...-10-25-12-30-00.gh-issue-112564.gcDbgW.rst | 3 + Python/gc.c | 35 ++++++++--- 3 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-25-12-30-00.gh-issue-112564.gcDbgW.rst diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 919100f2d5ce041..7535ab6ea8ca816 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -353,6 +353,68 @@ def test_saveall(self): obj = gc.garbage.pop() self.assertEqual(id(obj), id_L) + @unittest.skipIf(Py_GIL_DISABLED, + "the free-threaded GC prints while the world is stopped") + def test_debug_collectable_write_frees_object(self): + class Node: + def __init__(self): + self.cycle = [self] + + class Stderr: + def write(self, text): + node = wr() + if node is not None: + node.cycle.clear() + return len(text) + + gc.collect() + node = Node() + wr = weakref.ref(node) + del node + debug = gc.get_debug() + with support.swap_attr(sys, 'stderr', Stderr()): + gc.set_debug(gc.DEBUG_COLLECTABLE) + try: + gc.collect() + finally: + gc.set_debug(debug) + self.assertIsNone(wr()) + + def test_debug_uncollectable_write_frees_object(self): + @with_tp_del + class Legacy: + def __tp_del__(self): + pass + + class Child: + pass + + class Stderr: + def write(self, text): + legacy = wr() + if legacy is not None and 'tp_name, op); } +/* Call debug_cycle() on each object in `list`. sys.stderr.write() can run + * Python code that frees any object in the list, including the one just + * printed, so always take the first object and move it to a temporary + * `seen` list, as finalize_garbage() does. + */ +static void +debug_cycles(const char *msg, PyGC_Head *list) +{ + PyGC_Head seen; + gc_list_init(&seen); + while (!gc_list_is_empty(list)) { + PyGC_Head *gc = GC_NEXT(list); + PyObject *op = FROM_GC(gc); + gc_list_move(gc, &seen); + Py_INCREF(op); + debug_cycle(msg, op); + Py_DECREF(op); + } + gc_list_merge(&seen, list); +} + /* Handle uncollectable garbage (cycles with tp_del slots, and stuff reachable * only from such cycles). * If _PyGC_DEBUG_SAVEALL, all objects in finalizers are appended to the module @@ -1435,7 +1456,6 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) PyGC_Head *old; /* next older generation */ PyGC_Head unreachable; /* non-problematic unreachable trash */ PyGC_Head finalizers; /* objects with, & reachable from, __del__ */ - PyGC_Head *gc; GCState *gcstate = &tstate->interp->gc; // gc_collect_main() must not be called before _PyGC_Init @@ -1558,9 +1578,7 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) if (gcstate->debug & _PyGC_DEBUG_COLLECTABLE) { /* sys.stderr.write() can resurrect objects. */ needs_resurrection_check = true; - for (gc = GC_NEXT(&unreachable); gc != &unreachable; gc = GC_NEXT(gc)) { - debug_cycle("collectable", FROM_GC(gc)); - } + debug_cycles("collectable", &unreachable); } /* Clear weakrefs and invoke callbacks as necessary. */ @@ -1603,13 +1621,10 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) /* Collect statistics on uncollectable objects found and print * debugging information. */ - Py_ssize_t n = 0; - for (gc = GC_NEXT(&finalizers); gc != &finalizers; gc = GC_NEXT(gc)) { - n++; - if (gcstate->debug & _PyGC_DEBUG_UNCOLLECTABLE) - debug_cycle("uncollectable", FROM_GC(gc)); + if (gcstate->debug & _PyGC_DEBUG_UNCOLLECTABLE) { + debug_cycles("uncollectable", &finalizers); } - stats.uncollectable = n; + stats.uncollectable = gc_list_size(&finalizers); (void)PyTime_PerfCounterRaw(&stats.ts_stop); stats.duration = PyTime_AsSecondsDouble(stats.ts_stop - stats.ts_start); if (gcstate->debug & _PyGC_DEBUG_STATS) { From e548578d755dfcefcf4ed7cc35b225bb937eafdd Mon Sep 17 00:00:00 2001 From: jg Date: Mon, 26 Oct 2026 08:37:59 +0000 Subject: [PATCH 2/9] Remove added test and comment debug_cycles loop --- Lib/test/test_gc.py | 62 --------------------------------------------- Python/gc.c | 3 +++ 2 files changed, 3 insertions(+), 62 deletions(-) diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 7535ab6ea8ca816..919100f2d5ce041 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -353,68 +353,6 @@ def test_saveall(self): obj = gc.garbage.pop() self.assertEqual(id(obj), id_L) - @unittest.skipIf(Py_GIL_DISABLED, - "the free-threaded GC prints while the world is stopped") - def test_debug_collectable_write_frees_object(self): - class Node: - def __init__(self): - self.cycle = [self] - - class Stderr: - def write(self, text): - node = wr() - if node is not None: - node.cycle.clear() - return len(text) - - gc.collect() - node = Node() - wr = weakref.ref(node) - del node - debug = gc.get_debug() - with support.swap_attr(sys, 'stderr', Stderr()): - gc.set_debug(gc.DEBUG_COLLECTABLE) - try: - gc.collect() - finally: - gc.set_debug(debug) - self.assertIsNone(wr()) - - def test_debug_uncollectable_write_frees_object(self): - @with_tp_del - class Legacy: - def __tp_del__(self): - pass - - class Child: - pass - - class Stderr: - def write(self, text): - legacy = wr() - if legacy is not None and ' Date: Mon, 26 Oct 2026 08:50:08 +0000 Subject: [PATCH 3/9] Avoid recalculating gc_list_size in debug case --- Python/gc.c | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Python/gc.c b/Python/gc.c index 8ee934ea477139d..1ab940ce2855db6 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -1006,12 +1006,14 @@ debug_cycle(const char *msg, PyObject *op) /* Call debug_cycle() on each object in `list`. sys.stderr.write() can run * Python code that frees any object in the list, including the one just * printed, so always take the first object and move it to a temporary - * `seen` list, as finalize_garbage() does. + * `seen` list, as finalize_garbage() does. Return the number of seen + * items. */ -static void +static int debug_cycles(const char *msg, PyGC_Head *list) { PyGC_Head seen; + Py_ssize_t n = 0; /* Always take the next object from the head of `list` rather than from * the current one, which may be freed (along with others) by debug_cycle(). */ @@ -1020,11 +1022,13 @@ debug_cycles(const char *msg, PyGC_Head *list) PyGC_Head *gc = GC_NEXT(list); PyObject *op = FROM_GC(gc); gc_list_move(gc, &seen); + n++; Py_INCREF(op); debug_cycle(msg, op); Py_DECREF(op); } gc_list_merge(&seen, list); + return n; } /* Handle uncollectable garbage (cycles with tp_del slots, and stuff reachable @@ -1625,9 +1629,11 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) /* Collect statistics on uncollectable objects found and print * debugging information. */ if (gcstate->debug & _PyGC_DEBUG_UNCOLLECTABLE) { - debug_cycles("uncollectable", &finalizers); + stats.uncollectable = debug_cycles("uncollectable", &finalizers); + } else { + stats.uncollectable = gc_list_size(&finalizers); } - stats.uncollectable = gc_list_size(&finalizers); + (void)PyTime_PerfCounterRaw(&stats.ts_stop); stats.duration = PyTime_AsSecondsDouble(stats.ts_stop - stats.ts_start); if (gcstate->debug & _PyGC_DEBUG_STATS) { From 0a646839be4d761d635eaa1a08cbf4ec50c8aa0b Mon Sep 17 00:00:00 2001 From: jg Date: Mon, 26 Oct 2026 09:28:11 +0000 Subject: [PATCH 4/9] Align debug_cycles with CPython style --- Python/gc.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Python/gc.c b/Python/gc.c index 1ab940ce2855db6..3a556856a53231f 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -1009,7 +1009,7 @@ debug_cycle(const char *msg, PyObject *op) * `seen` list, as finalize_garbage() does. Return the number of seen * items. */ -static int +static Py_ssize_t debug_cycles(const char *msg, PyGC_Head *list) { PyGC_Head seen; @@ -1630,10 +1630,10 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) * debugging information. */ if (gcstate->debug & _PyGC_DEBUG_UNCOLLECTABLE) { stats.uncollectable = debug_cycles("uncollectable", &finalizers); - } else { + } + else { stats.uncollectable = gc_list_size(&finalizers); } - (void)PyTime_PerfCounterRaw(&stats.ts_stop); stats.duration = PyTime_AsSecondsDouble(stats.ts_stop - stats.ts_start); if (gcstate->debug & _PyGC_DEBUG_STATS) { From 8c485ea8a5657372410b4044f4e3702afab95b22 Mon Sep 17 00:00:00 2001 From: jg Date: Fri, 30 Oct 2026 21:59:31 +0000 Subject: [PATCH 5/9] Buffer debug cycles output before printing it via stderr --- Python/gc.c | 61 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/Python/gc.c b/Python/gc.c index 3a556856a53231f..47ad32f6df2a077 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -996,38 +996,53 @@ clear_weakrefs(PyGC_Head *unreachable) } } -static void +static char * debug_cycle(const char *msg, PyObject *op) { - PySys_FormatStderr("gc: %s <%s %p>\n", - msg, Py_TYPE(op)->tp_name, op); + const char *name = Py_TYPE(op)->tp_name; + int len = PyOS_snprintf(NULL, 0, "gc: %s <%s %p>\n", + msg, name, (void *)op); + if (len < 0) { + return NULL; + } + char *line = PyMem_RawMalloc((size_t)len + 1); + if (line == NULL) { + return NULL; + } + PyOS_snprintf(line, (size_t)len + 1, "gc: %s <%s %p>\n", + msg, name, (void *)op); + return line; } -/* Call debug_cycle() on each object in `list`. sys.stderr.write() can run - * Python code that frees any object in the list, including the one just - * printed, so always take the first object and move it to a temporary - * `seen` list, as finalize_garbage() does. Return the number of seen - * items. +/* Format a line for each object in `list`, then print them. + * sys.stderr.write(). Stores into buffers so can iterate + * to avoid calling into Python whilst we iterate the objects */ static Py_ssize_t debug_cycles(const char *msg, PyGC_Head *list) { - PyGC_Head seen; - Py_ssize_t n = 0; - /* Always take the next object from the head of `list` rather than from - * the current one, which may be freed (along with others) by debug_cycle(). - */ - gc_list_init(&seen); - while (!gc_list_is_empty(list)) { - PyGC_Head *gc = GC_NEXT(list); - PyObject *op = FROM_GC(gc); - gc_list_move(gc, &seen); - n++; - Py_INCREF(op); - debug_cycle(msg, op); - Py_DECREF(op); + Py_ssize_t n = gc_list_size(list); + if (n == 0) { + return n; + } + + char **lines = PyMem_RawCalloc(n, sizeof(char *)); + if (lines == NULL) { + return n; + } + + Py_ssize_t i = 0; + for (PyGC_Head *gc = GC_NEXT(list); gc != list; gc = GC_NEXT(gc), i++) { + lines[i] = debug_cycle(msg, FROM_GC(gc)); + } + + for (i = 0; i < n; i++) { + if (lines[i] != NULL) { + PySys_FormatStderr("%s", lines[i]); + } + PyMem_RawFree(lines[i]); } - gc_list_merge(&seen, list); + PyMem_RawFree(lines); return n; } From 93e1bd14a1ac352fa219efe00cb02eb18611fe48 Mon Sep 17 00:00:00 2001 From: jg Date: Fri, 30 Oct 2026 22:04:48 +0000 Subject: [PATCH 6/9] move format string into a variable --- Python/gc.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Python/gc.c b/Python/gc.c index 47ad32f6df2a077..70935eca0575062 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -999,8 +999,9 @@ clear_weakrefs(PyGC_Head *unreachable) static char * debug_cycle(const char *msg, PyObject *op) { + const char fmt[] = "gc: %s <%s %p>\n"; const char *name = Py_TYPE(op)->tp_name; - int len = PyOS_snprintf(NULL, 0, "gc: %s <%s %p>\n", + int len = PyOS_snprintf(NULL, 0, fmt, msg, name, (void *)op); if (len < 0) { return NULL; @@ -1009,7 +1010,7 @@ debug_cycle(const char *msg, PyObject *op) if (line == NULL) { return NULL; } - PyOS_snprintf(line, (size_t)len + 1, "gc: %s <%s %p>\n", + PyOS_snprintf(line, (size_t)len + 1, fmt, msg, name, (void *)op); return line; } From 20872324dc9ccc67eda64404f1d0f15aed0e6126 Mon Sep 17 00:00:00 2001 From: jg Date: Sat, 31 Oct 2026 07:47:17 +0000 Subject: [PATCH 7/9] Size debug_cycle lines from tp_name instead of measuring with snprintf --- Python/gc.c | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Python/gc.c b/Python/gc.c index 70935eca0575062..ef28e17ca05cda8 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -996,22 +996,19 @@ clear_weakrefs(PyGC_Head *unreachable) } } +/* "gc: " + msg (at most "uncollectable") + " <" + " " + %p + ">\n" + NUL */ +#define DEBUG_LINE_FIXED_LEN 64 + static char * debug_cycle(const char *msg, PyObject *op) { - const char fmt[] = "gc: %s <%s %p>\n"; const char *name = Py_TYPE(op)->tp_name; - int len = PyOS_snprintf(NULL, 0, fmt, - msg, name, (void *)op); - if (len < 0) { - return NULL; - } - char *line = PyMem_RawMalloc((size_t)len + 1); + size_t size = strlen(name) + DEBUG_LINE_FIXED_LEN; + char *line = PyMem_RawMalloc(size); if (line == NULL) { return NULL; } - PyOS_snprintf(line, (size_t)len + 1, fmt, - msg, name, (void *)op); + PyOS_snprintf(line, size, "gc: %s <%s %p>\n", msg, name, (void *)op); return line; } From 5771cc4f846089540d41ef2bb3a5ba850cfe54b7 Mon Sep 17 00:00:00 2001 From: jg Date: Sat, 31 Oct 2026 07:48:53 +0000 Subject: [PATCH 8/9] Include msg length when sizing debug_cycle lines --- Python/gc.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Python/gc.c b/Python/gc.c index ef28e17ca05cda8..4e329f5b0ee9230 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -996,14 +996,14 @@ clear_weakrefs(PyGC_Head *unreachable) } } -/* "gc: " + msg (at most "uncollectable") + " <" + " " + %p + ">\n" + NUL */ -#define DEBUG_LINE_FIXED_LEN 64 +/* "gc: " + " <" + " " + %p + ">\n" + NUL, excluding msg and tp_name */ +#define DEBUG_LINE_FIXED_LEN 32 static char * debug_cycle(const char *msg, PyObject *op) { const char *name = Py_TYPE(op)->tp_name; - size_t size = strlen(name) + DEBUG_LINE_FIXED_LEN; + size_t size = strlen(msg) + strlen(name) + DEBUG_LINE_FIXED_LEN; char *line = PyMem_RawMalloc(size); if (line == NULL) { return NULL; From 266e5ee85ea142279287028213b71498951ee0a0 Mon Sep 17 00:00:00 2001 From: jg Date: Sat, 31 Oct 2026 08:03:01 +0000 Subject: [PATCH 9/9] Print debug_cycle address with explicit 0x prefix for Windows --- Python/gc.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Python/gc.c b/Python/gc.c index 4e329f5b0ee9230..90dbb1ced3f90e3 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -996,7 +996,8 @@ clear_weakrefs(PyGC_Head *unreachable) } } -/* "gc: " + " <" + " " + %p + ">\n" + NUL, excluding msg and tp_name */ +/* "gc: " + " <" + " 0x" + 16 hex digits + ">\n" + NUL, excluding msg and + * tp_name */ #define DEBUG_LINE_FIXED_LEN 32 static char * @@ -1008,7 +1009,9 @@ debug_cycle(const char *msg, PyObject *op) if (line == NULL) { return NULL; } - PyOS_snprintf(line, size, "gc: %s <%s %p>\n", msg, name, (void *)op); + /* Not %p: its output is platform-specific (no "0x" prefix on Windows). */ + PyOS_snprintf(line, size, "gc: %s <%s 0x%" PRIxPTR ">\n", + msg, name, (uintptr_t)op); return line; }