From db87281be75b349ffb3b08b0db212f05b804c7c6 Mon Sep 17 00:00:00 2001 From: jg Date: Sun, 25 Oct 2026 12:47:32 +0000 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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) {