Skip to content
Open
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
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions test/plugins/windows/test_clipboard.py
Comment thread
AmirGhiassian marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
AmirGhiassian marked this conversation as resolved.
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)"
)
34 changes: 34 additions & 0 deletions volatility3/framework/symbols/windows/extensions/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
130 changes: 130 additions & 0 deletions volatility3/plugins/windows/clipboard.py
Original file line number Diff line number Diff line change
@@ -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(),
)
Loading