diff --git a/pyproject.toml b/pyproject.toml index 290a31abd4..699d5239fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,8 @@ full = [ "yara-python>=4.5.1,<5", "capstone>=5.0.3,<6", "pycryptodome>=3.21.0,<4", - "leechcorepyc>=2.19.2,<3; sys_platform != 'darwin'", + # 2.23.1 ships a malformed package initializer that raises SyntaxError + "leechcorepyc>=2.19.2,!=2.23.1,<3; sys_platform != 'darwin'", # https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst # 10.0.0 dropped support for Python3.7 # 11.0.0 dropped support for Python3.8, which is still supported by Volatility3 diff --git a/test/plugins/windows/test_clipboard.py b/test/plugins/windows/test_clipboard.py new file mode 100644 index 0000000000..b8bf91de8f --- /dev/null +++ b/test/plugins/windows/test_clipboard.py @@ -0,0 +1,136 @@ +# This file is Copyright 2026 Volatility Foundation and licensed under the Volatility Software License 1.0 +# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 + +from types import SimpleNamespace + +from volatility3.framework.symbols.windows.extensions import gui +from volatility3.plugins.windows import clipboard, windowstations + + +class _FakeFormat(int): + def __new__(cls, value, name=None): + result = int.__new__(cls, value) + result._name = name + return result + + def lookup(self): + if self._name is None: + raise ValueError + return self._name + + +class _FakePointer: + def __init__(self, value, target): + self._value = value + self._target = target + + def __int__(self): + return self._value + + def __bool__(self): + return bool(self._value) + + def dereference(self): + return self._target + + +def test_clipboard_format_names(): + get_format_name = gui.GUIExtensions.tagCLIP.get_format_name + + assert get_format_name(SimpleNamespace(fmt=_FakeFormat(4, "CF_SYLK"))) == "CF_SYLK" + assert ( + get_format_name(SimpleNamespace(fmt=_FakeFormat(0xC001))) + == "REGISTERED_FORMAT(0xc001)" + ) + assert ( + get_format_name(SimpleNamespace(fmt=_FakeFormat(0x123))) == "CF_UNKNOWN(0x123)" + ) + + +def test_list_clipboard_formats_uses_bounded_symbol_array(monkeypatch): + clips = [ + SimpleNamespace( + get_format_name=lambda index=index: f"FORMAT_{index}", + hData=0x1000 + index, + ) + for index in range(104) + ] + station = SimpleNamespace( + cNumClipFormats=512, + pClipBase=_FakePointer(0x2000, clips), + ) + monkeypatch.setattr( + windowstations.WindowStations, + "scan_window_stations", + lambda context, config_path, kernel_module_name: iter( + [(station, "WinSta0", 1)] + ), + ) + + results = list( + clipboard.Clipboard.list_clipboard_formats( + SimpleNamespace(), "plugins.Clipboard", "kernel" + ) + ) + + assert len(results) == 104 + assert results[0] == (1, "WinSta0", "FORMAT_0", 0x1000) + assert results[-1] == (1, "WinSta0", "FORMAT_103", 0x1067) + + +def test_list_clipboard_formats_skips_null_pointer(monkeypatch): + station = SimpleNamespace( + cNumClipFormats=1, + pClipBase=_FakePointer(0, None), + ) + monkeypatch.setattr( + windowstations.WindowStations, + "scan_window_stations", + lambda context, config_path, kernel_module_name: iter( + [(station, "WinSta0", 1)] + ), + ) + + results = list( + clipboard.Clipboard.list_clipboard_formats( + SimpleNamespace(), "plugins.Clipboard", "kernel" + ) + ) + + assert results == [] + + +def test_clipboard_requires_64_bit_kernel(): + kernel_requirement = clipboard.Clipboard.get_requirements()[0] + + assert kernel_requirement.requirements["layer_name"].architectures == ["Intel64"] + + +def test_clipboard_format_handles_non_int_fmt_with_lookup(): + """If int(self.fmt) raises, use a valid non-decimal lookup name.""" + + class _NonIntFmt: + def __int__(self): + raise ValueError("non-int") + + def lookup(self): + return "CF_LOOKUP_FORMAT" + + get_format_name = gui.GUIExtensions.tagCLIP.get_format_name + assert get_format_name(SimpleNamespace(fmt=_NonIntFmt())) == "CF_LOOKUP_FORMAT" + + +def test_clipboard_format_handles_missing_lookup(): + """If the fmt object lacks lookup(), fall back to numeric-based logic.""" + + class _NumericFmt: + def __int__(self): + return 0xC001 + + # no lookup() method -> AttributeError when accessed + + get_format_name = gui.GUIExtensions.tagCLIP.get_format_name + assert ( + get_format_name(SimpleNamespace(fmt=_NumericFmt())) + == "REGISTERED_FORMAT(0xc001)" + ) diff --git a/volatility3/framework/symbols/windows/extensions/gui.py b/volatility3/framework/symbols/windows/extensions/gui.py index 000c50319f..197be1df81 100644 --- a/volatility3/framework/symbols/windows/extensions/gui.py +++ b/volatility3/framework/symbols/windows/extensions/gui.py @@ -324,9 +324,43 @@ def get_string(self) -> interfaces.objects.ObjectInterface: encoding="utf16", ) + class tagCLIP(objects.StructType): + """A clipboard format entry.""" + + def get_format_name(self) -> str: + """Returns the symbol enum name, or a descriptive numeric fallback. + + Robust against partially-readable or malformed `fmt` objects. + """ + # Try to read numeric value; treat InvalidAddressException as unknown. + try: + fmt_val = int(self.fmt) + except exceptions.InvalidAddressException: + return "CF_UNKNOWN" + except (TypeError, ValueError, OverflowError): + # Non-numeric fmt value (e.g., int(...) raises ValueError) + fmt_val = None + + # Try to read the enum/string name via lookup() if present. + try: + fmt_name = self.fmt.lookup() + except (AttributeError, ValueError, exceptions.InvalidAddressException): + fmt_name = "" + + if fmt_name and not fmt_name.isdecimal(): + return fmt_name + + if fmt_val is not None: + if 0xC000 <= fmt_val <= 0xFFFF: + return f"REGISTERED_FORMAT({fmt_val:#x})" + return f"CF_UNKNOWN({fmt_val:#x})" + + return "CF_UNKNOWN" + class_types = { "tagWINDOWSTATION": tagWINDOWSTATION, "tagDESKTOP": tagDESKTOP, "tagWND": tagWND, "_LARGE_UNICODE_STRING": LARGE_UNICODE_STRING, + "tagCLIP": tagCLIP, } diff --git a/volatility3/plugins/windows/clipboard.py b/volatility3/plugins/windows/clipboard.py new file mode 100644 index 0000000000..cb21928342 --- /dev/null +++ b/volatility3/plugins/windows/clipboard.py @@ -0,0 +1,130 @@ +# This file is Copyright 2025 Volatility Foundation and licensed under the Volatility Software License 1.0 +# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 + + +"""Plugin to enumerate clipboard formats from Windows memory dumps.""" + +import logging +from typing import Iterable, List, Tuple + +from volatility3.framework import interfaces, renderers, exceptions +from volatility3.framework.configuration import requirements +from volatility3.framework.renderers import format_hints +from volatility3.plugins.windows import windowstations + +vollog = logging.getLogger(__name__) + + +class Clipboard(interfaces.plugins.PluginInterface): + """Enumerates clipboard formats and USER handles for each Window Station. + + Clipboard contents are not recovered because the GUI symbol tables do not + currently expose the session's ``gSharedInfo`` symbol needed to resolve + USER handles to ``tagCLIPDATA`` objects. + """ + + _required_framework_version = (2, 0, 0) + _version = (1, 0, 0) + + @classmethod + def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]: + return [ + requirements.ModuleRequirement( + name="kernel", + description="Windows kernel", + architectures=["Intel64"], + ), + requirements.VersionRequirement( + name="windowstations", + component=windowstations.WindowStations, + version=(1, 0, 0), + ), + ] + + @classmethod + def list_clipboard_formats( + cls, + context: interfaces.context.ContextInterface, + config_path: str, + kernel_module_name: str, + ) -> Iterable[Tuple[int, str, str, int]]: + """Yields session, Window Station, format name, and USER handle.""" + for ( + winsta, + station_name, + session_id, + ) in windowstations.WindowStations.scan_window_stations( + context, config_path, kernel_module_name + ): + try: + clip_count = int(winsta.cNumClipFormats) + clip_base = winsta.pClipBase + except exceptions.InvalidAddressException: + vollog.debug( + "Cannot read clipboard fields for station %s", station_name + ) + continue + + if clip_count <= 0 or not clip_base: + continue + + try: + clip_array = clip_base.dereference() + except exceptions.InvalidAddressException: + vollog.debug("Cannot read clipboard array for station %s", station_name) + continue + + vollog.debug( + "Station=%s Session=%s cNumClipFormats=%s pClipBase=%#x", + station_name, + session_id, + clip_count, + int(clip_base), + ) + + for index, clip in enumerate(clip_array): + if index >= clip_count: + break + try: + fmt_name = clip.get_format_name() + handle_val = int(clip.hData) + vollog.debug( + "clip[%s]: fmt=%s hData=%#x", + index, + fmt_name, + handle_val, + ) + yield session_id, station_name, fmt_name, handle_val + except exceptions.InvalidAddressException as e: + vollog.debug("clip[%s]: %s", index, e) + continue + + def _generator(self): + for ( + session_id, + station_name, + fmt_name, + handle_val, + ) in self.list_clipboard_formats( + self.context, self.config_path, self.config["kernel"] + ): + yield ( + 0, + ( + session_id, + station_name, + fmt_name, + format_hints.Hex(handle_val), + ), + ) + + def run(self): + return renderers.TreeGrid( + [ + ("Session", int), + ("WindowStation", str), + ("Format", str), + ("Handle", format_hints.Hex), + ], + self._generator(), + )