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