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)