diff --git a/fusil/python/arg_numbers.py b/fusil/python/arg_numbers.py index 1af317c..27c9f24 100644 --- a/fusil/python/arg_numbers.py +++ b/fusil/python/arg_numbers.py @@ -11,6 +11,8 @@ import re from random import randint +from fusil.python.meta_proxy import is_meta_proxy + MAX_ARG = 6 MAX_VAR_ARG = 5 PARSE_PROTOTYPE = True @@ -317,7 +319,7 @@ def get_arg_number(func, func_name, min_arg): # Metadata proxy (discovery ran in the target subprocess, no live object): the arity was # already computed there (argspec branch) as `_fusil_arity`, or is unknown (`None`, e.g. a C # builtin) -> fall to the same doc/default the live branch below uses. - if getattr(func, "_fusil_is_meta", False): + if is_meta_proxy(func): ar = getattr(func, "_fusil_arity", None) if ar: return ar[0], ar[1] @@ -349,7 +351,7 @@ def class_arg_number(class_name, cls): if class_name in CLASS_NB_ARG: min_args, max_args = CLASS_NB_ARG[class_name] nb_arg = randint(min_args, max_args) - elif getattr(cls, "_fusil_is_meta", False): + elif is_meta_proxy(cls): # Metadata proxy: ctor arity computed in the target subprocess (or unknown -> 0..3). ca = getattr(cls, "_fusil_ctor_arity", None) nb_arg = randint(ca[0], ca[1]) if ca else randint(0, 3) diff --git a/fusil/python/meta_proxy.py b/fusil/python/meta_proxy.py new file mode 100644 index 0000000..abaac38 --- /dev/null +++ b/fusil/python/meta_proxy.py @@ -0,0 +1,55 @@ +"""The metadata stand-in used when module discovery ran in the target subprocess. + +Lives in its own module so both the generator (``write_python_code``) and the arity code +(``arg_numbers``) can identify a proxy by TYPE. They cannot share it through +``write_python_code`` -- that module imports ``arg_numbers``, so the dependency would be +circular -- and identifying it by a duck-typed attribute instead is not safe here: see +``is_meta_proxy``. +""" + + +class _MetaProxy: + """Stand-in for a live target member when module discovery ran in the target subprocess + (see ``target_introspect``). Carries the serializable metadata the generation path needs. + Never a ``FunctionType`` / ``type`` / ``ModuleType`` -- the classification already happened + in the subprocess.""" + + _fusil_is_meta = True + + def __init__(self, name: str, meta: dict): + self._name = name + self._meta = meta + self._fusil_arity = meta.get("arity") # function/method: [lo, hi] or None (C builtin) + self._fusil_doc = meta.get("doc") + self._fusil_ctor_arity = meta.get("ctor_arity") # class: [lo, hi] or None + self._fusil_is_exception = bool(meta.get("is_exception")) + self._fusil_class_name = meta.get("class_name", name) + + def _fusil_raw_methods(self): + """(name, method-proxy) candidates for ``_get_object_methods`` to filter (blacklist / + private / plugin / exception-``__init__`` filtering stays in the parent).""" + return [(m["name"], _MetaProxy(m["name"], m)) for m in self._meta.get("methods", [])] + + +def is_meta_proxy(obj) -> bool: + """True only for a real ``_MetaProxy``. + + This is an isinstance check on purpose. The previous + ``getattr(obj, "_fusil_is_meta", False)`` was spoofable by any target object with a + catch-all ``__getattr__``, and a real one shipped in CPython 3.16: + + class _ShutdownTheme: # Lib/traceback.py + def __getattr__(self, _): return self + + ``getattr(theme, "_fusil_is_meta", False)`` returns the theme itself -- truthy -- so + generation took the metadata branch and then called ``theme._fusil_raw_methods()``, which + returns the theme again and is not callable. The resulting TypeError escapes into the MAS + and terminates the whole fusil process, not just the session: it ended 15 of 29 runs in one + fleet, roughly half the wall-clock, and it does so even with ``--discover-in-target`` off, + where no proxy can exist at all. + + Duck-typing on ``type(obj)`` instead would fix that one class but not a hostile metaclass + with ``__getattr__``, which fusil injects deliberately (the metaclass bombs). isinstance is + the only check a target cannot forge. + """ + return isinstance(obj, _MetaProxy) diff --git a/fusil/python/write_python_code.py b/fusil/python/write_python_code.py index a322311..ae7d0a6 100644 --- a/fusil/python/write_python_code.py +++ b/fusil/python/write_python_code.py @@ -18,6 +18,7 @@ METHOD_BLACKLIST, OBJECT_BLACKLIST, ) +from fusil.python.meta_proxy import _MetaProxy, is_meta_proxy # noqa: F401 from fusil.write_code import WriteCode if TYPE_CHECKING: @@ -78,30 +79,6 @@ ) # fmt: skip -class _MetaProxy: - """Stand-in for a live target member when module discovery ran in the target subprocess - (see ``target_introspect``). Carries the serializable metadata the generation path needs; the - duck-typed ``_fusil_*`` attributes are recognised by ``get_arg_number`` / ``class_arg_number`` - / ``_get_object_methods``, so the live-object path is unchanged. Never a ``FunctionType`` / - ``type`` / ``ModuleType`` -- the classification already happened in the subprocess.""" - - _fusil_is_meta = True - - def __init__(self, name: str, meta: dict): - self._name = name - self._meta = meta - self._fusil_arity = meta.get("arity") # function/method: [lo, hi] or None (C builtin) - self._fusil_doc = meta.get("doc") - self._fusil_ctor_arity = meta.get("ctor_arity") # class: [lo, hi] or None - self._fusil_is_exception = bool(meta.get("is_exception")) - self._fusil_class_name = meta.get("class_name", name) - - def _fusil_raw_methods(self): - """(name, method-proxy) candidates for ``_get_object_methods`` to filter (blacklist / - private / plugin / exception-``__init__`` filtering stays in the parent).""" - return [(m["name"], _MetaProxy(m["name"], m)) for m in self._meta.get("methods", [])] - - class PythonFuzzerError(Exception): """Custom exception raised when fuzzer encounters unrecoverable errors.""" @@ -327,7 +304,7 @@ def _get_object_methods( ``__init__``) is applied here, and the values are the objects/proxies the arity code reads. """ methods: dict[str, Callable[..., Any]] = {} - is_meta = getattr(obj_instance_or_class, "_fusil_is_meta", False) + is_meta = is_meta_proxy(obj_instance_or_class) if not is_meta and type(obj_instance_or_class) in TRIVIAL_TYPES: return methods @@ -2485,7 +2462,7 @@ def _fuzz_one_module_object(self, obj_idx: int, obj_name_str: str, obj_instance: # Metadata mode: the proxy carries the class name + method set; live mode reads them off # the instance. Pass the proxy itself as the "type" so _get_object_methods finds its # _fusil_raw_methods (type(proxy) would be _MetaProxy, not the target's type). - if getattr(obj_instance, "_fusil_is_meta", False): + if is_meta_proxy(obj_instance): class_name = obj_instance._fusil_class_name type_for_methods = obj_instance else: diff --git a/tests/python/test_meta_proxy.py b/tests/python/test_meta_proxy.py new file mode 100644 index 0000000..5167a70 --- /dev/null +++ b/tests/python/test_meta_proxy.py @@ -0,0 +1,88 @@ +"""A target object must not be able to impersonate fusil's metadata proxy. + +The proxy used to be recognised by ``getattr(obj, "_fusil_is_meta", False)``. Any object with a +catch-all ``__getattr__`` answers that truthily, and CPython 3.16 ships one -- +``traceback._ShutdownTheme``, the stand-in used when ``_colorize`` cannot be imported during +late shutdown: + + class _ShutdownTheme: + def __getattr__(self, _): return self + +Generation therefore took the metadata branch on it and called ``_fusil_raw_methods()``, which +returns the theme again and is not callable. The ``TypeError`` escaped into the MAS and killed +the whole fusil process rather than the session: **15 of 29 runs in one fleet**, about half the +wall clock, and it happened with ``--discover-in-target`` off, where no proxy can exist at all. +""" + +import unittest + +from fusil.python.arg_numbers import class_arg_number, get_arg_number +from fusil.python.meta_proxy import _MetaProxy, is_meta_proxy + + +class CatchAllGetattr: + """The shape that caused it -- traceback._ShutdownTheme, reduced.""" + + def __getattr__(self, _): + return self + + +class HostileMeta(type): + """A metaclass with the same catch-all, which is why the check is not on ``type(obj)``. + + fusil injects hostile metaclasses deliberately (the metaclass bombs), so a check that + merely moved the lookup from the instance to its type would still be forgeable. + """ + + def __getattr__(cls, _): + return cls + + +class HostileType(metaclass=HostileMeta): + pass + + +class MetaProxyIdentificationTests(unittest.TestCase): + def test_a_real_proxy_is_recognised(self): + self.assertTrue(is_meta_proxy(_MetaProxy("f", {"arity": [1, 2]}))) + + def test_catch_all_getattr_cannot_impersonate_a_proxy(self): + theme = CatchAllGetattr() + # The old check: truthy, which is the whole bug. + self.assertTrue(getattr(theme, "_fusil_is_meta", False)) + self.assertFalse(is_meta_proxy(theme)) + + def test_hostile_metaclass_cannot_impersonate_a_proxy(self): + self.assertTrue(getattr(HostileType, "_fusil_is_meta", False)) + self.assertFalse(is_meta_proxy(HostileType)) + + def test_ordinary_objects_are_not_proxies(self): + for value in (1, "s", [], object(), int, None): + with self.subTest(value=value): + self.assertFalse(is_meta_proxy(value)) + + +class ArityPathSurvivesACatchAllGetattrTests(unittest.TestCase): + """The arity helpers consult the same flag, so they had the same hole.""" + + def test_get_arg_number_does_not_read_forged_arity(self): + # Previously: _fusil_is_meta truthy -> _fusil_arity is the theme itself -> `ar[0]` + # raises. It must fall through to the ordinary path and return a usable range. + lo, hi = get_arg_number(CatchAllGetattr(), "some_func", 0) + self.assertIsInstance(lo, int) + self.assertIsInstance(hi, int) + self.assertLessEqual(lo, hi) + + def test_class_arg_number_does_not_read_forged_ctor_arity(self): + nb = class_arg_number("NotInTheTable", CatchAllGetattr()) + self.assertIsInstance(nb, int) + self.assertGreaterEqual(nb, 0) + + def test_a_real_proxy_still_drives_both_helpers(self): + self.assertEqual(get_arg_number(_MetaProxy("f", {"arity": [2, 2]}), "f", 0), (2, 2)) + proxy = _MetaProxy("C", {"ctor_arity": [1, 1]}) + self.assertEqual(class_arg_number("C_not_in_table", proxy), 1) + + +if __name__ == "__main__": + unittest.main()