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
6 changes: 4 additions & 2 deletions fusil/python/arg_numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions fusil/python/meta_proxy.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 3 additions & 26 deletions fusil/python/write_python_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
88 changes: 88 additions & 0 deletions tests/python/test_meta_proxy.py
Original file line number Diff line number Diff line change
@@ -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()
Loading