Skip to content

Commit db87281

Browse files
committed
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()`.
1 parent 036d309 commit db87281

3 files changed

Lines changed: 90 additions & 10 deletions

File tree

‎Lib/test/test_gc.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,68 @@ def test_saveall(self):
353353
obj = gc.garbage.pop()
354354
self.assertEqual(id(obj), id_L)
355355

356+
@unittest.skipIf(Py_GIL_DISABLED,
357+
"the free-threaded GC prints while the world is stopped")
358+
def test_debug_collectable_write_frees_object(self):
359+
class Node:
360+
def __init__(self):
361+
self.cycle = [self]
362+
363+
class Stderr:
364+
def write(self, text):
365+
node = wr()
366+
if node is not None:
367+
node.cycle.clear()
368+
return len(text)
369+
370+
gc.collect()
371+
node = Node()
372+
wr = weakref.ref(node)
373+
del node
374+
debug = gc.get_debug()
375+
with support.swap_attr(sys, 'stderr', Stderr()):
376+
gc.set_debug(gc.DEBUG_COLLECTABLE)
377+
try:
378+
gc.collect()
379+
finally:
380+
gc.set_debug(debug)
381+
self.assertIsNone(wr())
382+
383+
def test_debug_uncollectable_write_frees_object(self):
384+
@with_tp_del
385+
class Legacy:
386+
def __tp_del__(self):
387+
pass
388+
389+
class Child:
390+
pass
391+
392+
class Stderr:
393+
def write(self, text):
394+
legacy = wr()
395+
if legacy is not None and '<Child ' in text:
396+
del legacy.child
397+
return len(text)
398+
399+
gc.collect()
400+
legacy = Legacy()
401+
legacy.cycle = legacy
402+
legacy.child = Child()
403+
wr = weakref.ref(legacy)
404+
del legacy
405+
debug = gc.get_debug()
406+
with support.swap_attr(sys, 'stderr', Stderr()):
407+
gc.set_debug(gc.DEBUG_UNCOLLECTABLE)
408+
try:
409+
gc.collect()
410+
finally:
411+
gc.set_debug(debug)
412+
legacy = wr()
413+
self.assertFalse(hasattr(legacy, 'child'))
414+
self.assertIn(legacy, gc.garbage)
415+
del legacy.cycle
416+
gc.garbage.remove(legacy)
417+
356418
def test_del(self):
357419
# __del__ methods can trigger collection, make this to happen
358420
thresholds = gc.get_threshold()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix a crash in the garbage collector when :data:`gc.DEBUG_COLLECTABLE` or
2+
:data:`gc.DEBUG_UNCOLLECTABLE` is set and writing the debug output to
3+
:data:`sys.stderr` frees one of the objects being reported.

‎Python/gc.c‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,27 @@ debug_cycle(const char *msg, PyObject *op)
10031003
msg, Py_TYPE(op)->tp_name, op);
10041004
}
10051005

1006+
/* Call debug_cycle() on each object in `list`. sys.stderr.write() can run
1007+
* Python code that frees any object in the list, including the one just
1008+
* printed, so always take the first object and move it to a temporary
1009+
* `seen` list, as finalize_garbage() does.
1010+
*/
1011+
static void
1012+
debug_cycles(const char *msg, PyGC_Head *list)
1013+
{
1014+
PyGC_Head seen;
1015+
gc_list_init(&seen);
1016+
while (!gc_list_is_empty(list)) {
1017+
PyGC_Head *gc = GC_NEXT(list);
1018+
PyObject *op = FROM_GC(gc);
1019+
gc_list_move(gc, &seen);
1020+
Py_INCREF(op);
1021+
debug_cycle(msg, op);
1022+
Py_DECREF(op);
1023+
}
1024+
gc_list_merge(&seen, list);
1025+
}
1026+
10061027
/* Handle uncollectable garbage (cycles with tp_del slots, and stuff reachable
10071028
* only from such cycles).
10081029
* 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)
14351456
PyGC_Head *old; /* next older generation */
14361457
PyGC_Head unreachable; /* non-problematic unreachable trash */
14371458
PyGC_Head finalizers; /* objects with, & reachable from, __del__ */
1438-
PyGC_Head *gc;
14391459
GCState *gcstate = &tstate->interp->gc;
14401460

14411461
// gc_collect_main() must not be called before _PyGC_Init
@@ -1558,9 +1578,7 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason)
15581578
if (gcstate->debug & _PyGC_DEBUG_COLLECTABLE) {
15591579
/* sys.stderr.write() can resurrect objects. */
15601580
needs_resurrection_check = true;
1561-
for (gc = GC_NEXT(&unreachable); gc != &unreachable; gc = GC_NEXT(gc)) {
1562-
debug_cycle("collectable", FROM_GC(gc));
1563-
}
1581+
debug_cycles("collectable", &unreachable);
15641582
}
15651583

15661584
/* Clear weakrefs and invoke callbacks as necessary. */
@@ -1603,13 +1621,10 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason)
16031621

16041622
/* Collect statistics on uncollectable objects found and print
16051623
* debugging information. */
1606-
Py_ssize_t n = 0;
1607-
for (gc = GC_NEXT(&finalizers); gc != &finalizers; gc = GC_NEXT(gc)) {
1608-
n++;
1609-
if (gcstate->debug & _PyGC_DEBUG_UNCOLLECTABLE)
1610-
debug_cycle("uncollectable", FROM_GC(gc));
1624+
if (gcstate->debug & _PyGC_DEBUG_UNCOLLECTABLE) {
1625+
debug_cycles("uncollectable", &finalizers);
16111626
}
1612-
stats.uncollectable = n;
1627+
stats.uncollectable = gc_list_size(&finalizers);
16131628
(void)PyTime_PerfCounterRaw(&stats.ts_stop);
16141629
stats.duration = PyTime_AsSecondsDouble(stats.ts_stop - stats.ts_start);
16151630
if (gcstate->debug & _PyGC_DEBUG_STATS) {

0 commit comments

Comments
 (0)