From 3ad27dc5b0e7fc648ebd54af98f2436472284c9d Mon Sep 17 00:00:00 2001 From: devdanzin <74280297+devdanzin@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:49:24 -0300 Subject: [PATCH] Don't fuzz a module's own mutex: fusil was releasing it PyPy implements `grp` in Python over cffi with `_lock = _thread.allocate_lock()`, and `getgrall()` holds that lock across its loop over libc's STATIC group buffer. The lock is the only thing making the iteration safe. fusil selected `grp._lock` as a module-level object -- it is just an attribute -- and fuzzed it: --- Fuzzing instance: fuzz_target_module._lock (type hint: lock, prefix: obj0m) --- [obj0m11] lock.release_lock() [obj0m12] lock.release_lock() Releasing it mid-`getgrall()` drops the mutual exclusion, a second thread re-enters and calls setgrent()/getgrnam(), libc reuses the static buffer under the first thread's feet, and `while res.gr_mem[i]:` walks freed memory. Measured: 6/6 SIGSEGV with the release thread, 6/6 clean without it -- necessary and sufficient. That is the `grp` crash open since the fleet_08 triage, where it was left INCONCLUSIVE because 22 of 24 replays timed out; releasing a lock hangs the harness as readily as it crashes it, which is why replay never worked. METHOD_BLACKLIST was no defence, and the shape of the gap is worth recording: it blocks every way to TAKE a lock (acquire, acquire_lock, _acquire_lock, _acquire_restore, wait) and none of the ways to DROP one (release, release_lock, _release_lock, _release_save, _at_fork_reinit). fusil had blocked the methods that hang IT and left the ones that corrupt the TARGET. So the filter is on the object's TYPE, at selection time, and only for module attributes: a lock fusil instantiated itself is nobody's mutex, so fuzzing `threading` still covers Lock/RLock/Condition through the class path. A name-based ban on "release" would instead have cost `memoryview.release()`, which is the surface PYPY-FUZZ-011 came from. It has to hold on both discovery paths. In `--discover-in-target` mode the runner only ever sees a proxy carrying the type's NAME, so the target subprocess filters too, mirroring how TRIVIAL_TYPES is already split across the two. Verified against the real module: introspecting PyPy's `grp` now yields ['__loader__', '__spec__', 'ffi'] -- no `_lock`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WPBSmN87d2BqUnrDbojUT1 --- fusil/python/target_introspect.py | 7 +++ fusil/python/write_python_code.py | 23 ++++++++ tests/python/test_target_introspect.py | 79 ++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/fusil/python/target_introspect.py b/fusil/python/target_introspect.py index e553079..762e035 100644 --- a/fusil/python/target_introspect.py +++ b/fusil/python/target_introspect.py @@ -46,6 +46,11 @@ # Mirror WritePythonCode.TRIVIAL_TYPES (top-level object branch skips these). _TRIVIAL = {int, str, float, bool, bytes, tuple, list, dict, set, type(None)} +# Mirror WritePythonCode.SYNC_PRIMITIVE_MODULES: a live lock held as a module attribute is that +# module's own mutex, and releasing it corrupts the module (PyPy grp._lock -> getgrall walking +# libc's static group buffer without exclusion). The runner cannot check this in metadata mode -- +# the proxy carries only the type NAME -- so the filter has to happen here, in the target. +_SYNC_MODULES = {"_thread", "thread", "threading"} _MAX_METHODS = 300 @@ -132,6 +137,8 @@ def main(): else: if isinstance(attr, ModuleType) or type(attr) in _TRIVIAL: continue # not fuzzable (matches _get_module_members object branch) + if type(attr).__module__ in _SYNC_MODULES: + continue # the module's own mutex (matches is_sync_primitive) is_exc = isinstance(attr, BaseException) members.append({"name": name, "kind": "object", "is_module": False, "is_exception": is_exc, diff --git a/fusil/python/write_python_code.py b/fusil/python/write_python_code.py index 22c7c3e..6ea9c4d 100644 --- a/fusil/python/write_python_code.py +++ b/fusil/python/write_python_code.py @@ -61,6 +61,27 @@ } TRIVIAL_TYPES_STR = "{int, str, float, bool, bytes, tuple, list, dict, set, type(None),}" +# Modules whose types are synchronisation primitives. A LIVE INSTANCE of one held as a module +# attribute is that module's own mutex -- PyPy's `grp._lock = _thread.allocate_lock()` is the +# exemplar -- and fusil must not touch it: `grp.getgrall()` holds `_lock` across its loop over +# libc's static group buffer, so calling `_lock.release_lock()` drops the mutual exclusion, a +# second thread clobbers the buffer, and the first walks freed memory. Measured: 6/6 SIGSEGV with +# the release, 6/6 clean without it. METHOD_BLACKLIST is no defence -- it blocks every way to +# TAKE a lock (acquire / acquire_lock / _acquire_lock / _acquire_restore / wait) and none of the +# ways to DROP one, i.e. it blocked what hangs fusil and left what corrupts the target. +# +# Keyed on the TYPE's defining module, and applied only when selecting MODULE-LEVEL objects: +# a lock fusil instantiated itself is nobody's mutex and stays fuzzable, so fuzzing `threading` +# still covers Lock/RLock/Condition through the class path. A name-based ban on "release" would +# instead have cost `memoryview.release()`, which is the surface PYPY-FUZZ-011 came from. +SYNC_PRIMITIVE_MODULES = frozenset({"_thread", "thread", "threading"}) + + +def is_sync_primitive(obj): + """True for a live synchronisation primitive (lock, RLock, Condition, Event, ...).""" + return type(obj).__module__ in SYNC_PRIMITIVE_MODULES + + # Process-lifecycle calls the --tsan stress region must never make: forking a fuzzer worker # thread (os.fork/forkpty, pty.fork/spawn, os.spawn*/posix_spawn without an immediate exec) # leaves the child with an inconsistent runtime -- and under ThreadSanitizer that is an @@ -283,6 +304,8 @@ def _get_module_members(self) -> tuple[list[str], list[str], list[str]]: else: if isinstance(attr, ModuleType) or type(attr) in TRIVIAL_TYPES: continue + if is_sync_primitive(attr): + continue # the module's own mutex -- see SYNC_PRIMITIVE_MODULES if ( not self.options.fuzz_exceptions and isinstance(attr, BaseException) # and attr.__class__.__name__ in _EXCEPTION_NAMES diff --git a/tests/python/test_target_introspect.py b/tests/python/test_target_introspect.py index e0368eb..93e0a26 100644 --- a/tests/python/test_target_introspect.py +++ b/tests/python/test_target_introspect.py @@ -161,6 +161,85 @@ def test_live_vs_subprocess_name_lists_match(self): self.assertEqual(live, got) +class TestSyncPrimitiveObjectsAreSkipped(unittest.TestCase): + """A live lock held as a module attribute is that module's own mutex -- never a fuzz target. + + PyPy implements ``grp`` in Python over cffi with ``_lock = _thread.allocate_lock()``, and + ``getgrall()`` holds it across its loop over libc's *static* group buffer. fusil selected + ``grp._lock`` as a module object and called ``lock.release_lock()`` on it, dropping the + mutual exclusion: a second thread clobbered the buffer and the first walked freed memory. + Measured 6/6 SIGSEGV with the release, 6/6 clean without it. + + ``METHOD_BLACKLIST`` was no defence -- it blocks every way to TAKE a lock and none of the + ways to DROP one -- so the filter is on the object's TYPE, at selection time, and has to + hold on BOTH discovery paths: the runner cannot check it in ``--discover-in-target`` mode, + where the proxy carries only the type's name. + + These modules need ``test_private=True`` (as the PyPy fleets run) or the underscore-prefixed + lock names are filtered before the type is ever looked at, and the test proves nothing. + """ + + class _PrivateOptions(_Options): + test_private = True + + def _live_objects(self, module): + parent = _Parent() + parent.options = self._PrivateOptions() # private names reach the type check + fd, path = tempfile.mkstemp(suffix=".py") + os.close(fd) + try: + w = WritePythonCode(parent, path, module, module.__name__, + threads=False, _async=False, plugin_manager=None) # fmt: skip + return sorted(w.module_objects) + finally: + os.unlink(path) + + def test_live_path_skips_a_module_level_lock(self): + import tempfile as tempfile_mod + + objs = self._live_objects(tempfile_mod) + self.assertNotIn("_once_lock", objs, "tempfile._once_lock is tempfile's own mutex") + + def test_live_path_skips_a_module_level_rlock(self): + import logging as logging_mod + + self.assertNotIn("_lock", self._live_objects(logging_mod)) + + def test_subprocess_path_skips_it_too(self): + """Metadata mode must agree: the proxy carries only the type name, so the target filters.""" + meta = introspect_module(PYEXE, "tempfile") + self.assertIsNotNone(meta, "discovery failed outright") + names = [m["name"] for m in meta["members"] if m.get("kind") == "object"] + self.assertNotIn("_once_lock", names) + + def test_a_lock_fusil_made_itself_is_still_fuzzable(self): + """The filter is on module ATTRIBUTES only -- the class path keeps lock coverage.""" + import threading as threading_mod + + from fusil.python.write_python_code import is_sync_primitive + + self.assertTrue(is_sync_primitive(threading_mod.Lock()), "a live lock is a primitive") + self.assertFalse( + is_sync_primitive(threading_mod.Lock), + "the CLASS is not a live mutex: instantiating it yields fusil's own lock", + ) + self.assertFalse(is_sync_primitive(object()), "an ordinary object must not be skipped") + + def test_filter_does_not_swallow_ordinary_module_objects(self): + """Guard against over-filtering: a normal module still yields its objects.""" + import json as jsonmod + + parent = _Parent() + fd, path = tempfile.mkstemp(suffix=".py") + os.close(fd) + try: + w = WritePythonCode(parent, path, jsonmod, "json", + threads=False, _async=False, plugin_manager=None) # fmt: skip + self.assertTrue(w.module_functions or w.module_classes) + finally: + os.unlink(path) + + class TestPackageEnumeration(unittest.TestCase): def test_enumerate_script_is_valid_python(self): ast.parse(_ENUMERATE_SRC)