Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions fusil/python/target_introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions fusil/python/write_python_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions tests/python/test_target_introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading