Skip to content

Commit

Permalink
Add msoffice productivity plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
twiggler committed Dec 5, 2024
1 parent 9875243 commit 805ab29
Show file tree
Hide file tree
Showing 12 changed files with 514 additions and 11 deletions.
14 changes: 13 additions & 1 deletion dissect/target/helpers/regutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
KeyType = Union[regf.IndexLeaf, regf.FastLeaf, regf.HashLeaf, regf.IndexRoot, regf.NamedKey]
"""The possible key types that can be returned from the registry."""

ValueType = Union[int, str, bytes, list[str]]
ValueType = Union[int, str, bytes, list[str], None]
"""The possible value types that can be returned from the registry."""


Expand Down Expand Up @@ -172,6 +172,18 @@ def value(self, value: str) -> RegistryValue:
"""
raise NotImplementedError()

def value_or_default(self, value: str, default: ValueType = None) -> RegistryValue:
"""Returns a specific value from this key, or a default value if it does not exist.
Args:
value: The name of the value to retrieve.
default: The default value to return if the value does not exist.
"""
try:
return self.value(value)
except RegistryValueNotFoundError:
return VirtualValue(VirtualHive(), value, default)

def values(self) -> list[RegistryValue]:
"""Returns a list of all the values from this key."""
raise NotImplementedError()
Expand Down
9 changes: 6 additions & 3 deletions dissect/target/helpers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,20 @@ def findall(buf: bytes, needle: bytes) -> Iterator[int]:
T = TypeVar("T")


def to_list(value: T | list[T]) -> list[T]:
"""Convert a single value or a list of values to a list.
def to_list(value: T | list[T] | None) -> list[T]:
"""Convert a single value or a list of values to a list. A value of `None` is converted to an empty list.
Args:
value: The value to convert.
Returns:
A list of values.
"""
if not isinstance(value, list):
if value is None:
return []
elif not isinstance(value, list):
return [value]

return value


Expand Down
320 changes: 320 additions & 0 deletions dissect/target/plugins/apps/productivity/msoffice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
from __future__ import annotations

import itertools
from pathlib import Path
from typing import Iterable, Iterator, Set
from xml.etree.ElementTree import Element

from defusedxml import ElementTree
from flow.record.fieldtypes import windows_path

from dissect.target.exceptions import RegistryError, UnsupportedPluginError
from dissect.target.helpers import fsutil
from dissect.target.helpers.record import TargetRecordDescriptor
from dissect.target.helpers.regutil import KeyCollection
from dissect.target.helpers.utils import to_list
from dissect.target.plugin import Plugin, export
from dissect.target.target import Target

OfficeStartupItem = TargetRecordDescriptor(
"productivity/msoffice/startup_item", [("path", "path"), ("datetime", "creation_time")]
)

# Web add-in
OfficeWebAddinRecord = TargetRecordDescriptor(
"productivity/msoffice/web_addin",
[
("string[]", "source_locations"),
("string", "name"),
("path", "manifest"),
("string", "version"),
("string", "provider_name"),
],
)

# COM and VSTO add-ins
OfficeNativeAddinRecord = TargetRecordDescriptor(
"productivity/msoffice/native_addin",
[
("string", "name"),
("string", "type"),
("path[]", "codebases"),
("string", "load_behavior"),
("path", "manifest"),
],
)


class ClickOnceDeploymentManifestParser:
"""Parser for information about vsto plugins"""

XML_NAMESPACE = {"": "urn:schemas-microsoft-com:asm.v2"}

def __init__(self, target: Target, user_sid: str) -> None:
self._target = target
self._user_sid = user_sid
self._visited_manifests: Set[Path] = set()
self._codebases: Set[Path] = set()

def find_codebases(self, manifest_path: str) -> Iterable[str]:
"""Dig for executables given a manifest"""

self._visited_manifests.clear()
self._codebases.clear()
return self._parse_manifest(manifest_path)

def _parse_manifest(self, manifest_path_str: str) -> Set[Path]:
# See https://learn.microsoft.com/en-us/visualstudio/deployment/clickonce-deployment-manifest?view=vs-2022

manifest_path: Path = self._target.resolve(manifest_path_str, self._user_sid)
if manifest_path in self._visited_manifests:
return self._codebases # Prevent cycles

Check warning on line 71 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L71

Added line #L71 was not covered by tests

self._visited_manifests.add(manifest_path)
try:
manifest_tree: Element = ElementTree.fromstring(manifest_path.read_text("utf-8-sig"))
except Exception as e:
self._target.log.warning("Error parsing manifest %s", manifest_path)
self._target.log.debug("", exc_info=e)
return set()

Check warning on line 79 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L76-L79

Added lines #L76 - L79 were not covered by tests

dependent_assemblies = manifest_tree.findall(".//dependentAssembly", self.XML_NAMESPACE)
for dependent_assembly in dependent_assemblies:
self._parse_dependent_assembly(dependent_assembly, manifest_path.parent)

return self._codebases

def _parse_dependent_assembly(self, dependent_assembly: Element, cwd: Path) -> None:
# See https://learn.microsoft.com/en-us/visualstudio/deployment/dependency-element-clickonce-deployment?view=vs-2022#dependentassembly # noqa: E501

if dependent_assembly.get("dependencyType") != "install":
return # Ignore prerequisites dependencies

if not (codebase_str_path := dependent_assembly.get("codebase")):
return

Check warning on line 94 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L94

Added line #L94 was not covered by tests

codebase_str_path = fsutil.abspath(codebase_str_path, str(cwd), alt_separator=self._target.fs.alt_separator)
codebase_path: Path = self._target.resolve(codebase_str_path, self._user_sid)
if not codebase_path.exists():
return # Ignore files which are not actually installed, for example due to language settings

if codebase_path.name.endswith(".manifest"):
self._parse_manifest(str(codebase_path)) # Yes, a codebase can point to another manifest
return

self._codebases.add(codebase_path)


class MSOffice(Plugin):
"""Microsoft Office productivity suite plugin."""

__namespace__ = "msoffice"

HIVES = ["HKLM", "HKCU"]
OFFICE_KEY = "Software\\Microsoft\\Office"
OFFICE_COMPONENTS = ["Access", "Excel", "Outlook", "PowerPoint", "Word", "OneNote"]
ADD_IN_KEY = "Addins"
WEB_ADDIN_MANIFEST_GLOB = "AppData/Local/Microsoft/Office/16.0/Wef/**/Manifests/**/*"
OFFICE_DEFAULT_USER_STARTUP = [
"%APPDATA%/Microsoft/Templates",
"%APPDATA%/Microsoft/Word/Startup",
"%APPDATA%/Microsoft/Excel/XLSTART",
]

OFFICE_DEFAULT_ROOT = "C:/Program Files/Microsoft Office/root/Office16/"

# Office is fixed at version 16.0 since Microsoft Office 2016 (released in 2015)
OFFICE_STARTUP_OPTIONS = [
("Software\\Microsoft\\Office\\16.0\\Word\\Options", "STARTUP-PATH"),
("Software\\Microsoft\\Office\\16.0\\Word\\Options", "UserTemplates"),
("Software\\Microsoft\\Office\\16.0\\Excel\\Options", "AltStartup"),
]

CLASSES_ROOTS = [
"HKCR",
# Click To Run Application Virtualization:
"HKLM\\SOFTWARE\\Microsoft\\Office\\ClickToRun\\REGISTRY\\MACHINE\\Software\\Classes",
# For 32-bit software running under 64-bit Windows:
"HKLM\\SOFTWARE\\Wow6432Node\\Classes",
]

def check_compatible(self) -> None:
if not self.target.has_function("registry") or not list(self.target.registry.keys(f"HKLM\\{self.OFFICE_KEY}")):
raise UnsupportedPluginError("Registry key not found: %s", self.OFFICE_KEY)

Check warning on line 143 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L142-L143

Added lines #L142 - L143 were not covered by tests

@export(record=[OfficeWebAddinRecord, OfficeNativeAddinRecord, OfficeStartupItem])
def all(self) -> Iterator[OfficeWebAddinRecord | OfficeNativeAddinRecord | OfficeStartupItem]:
"""Aggregate to list all add-in types and startup items"""

yield from self.web()
yield from self.native()
yield from self.startup()

Check warning on line 151 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L149-L151

Added lines #L149 - L151 were not covered by tests

@export(record=OfficeWebAddinRecord)
def web(self) -> Iterator[OfficeWebAddinRecord]:
"""List all web add-ins by parsing the manifests in the web extension framework cache"""

for manifest_file in self._wef_cache_folders():
try:
yield self._parse_web_addin_manifest(manifest_file)
except Exception as e:
self.target.log.warning("Error parsing web-addin manifest %s", manifest_file)
self.target.log.debug("", exc_info=e)

Check warning on line 162 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L160-L162

Added lines #L160 - L162 were not covered by tests

@export(record=OfficeNativeAddinRecord)
def native(self) -> Iterator[OfficeNativeAddinRecord]:
"""List all native (COM / vsto) add-ins by parsing the registry and manifest files."""

addin_path_tuples = itertools.product(self.HIVES, [self.OFFICE_KEY], self.OFFICE_COMPONENTS, [self.ADD_IN_KEY])
for addin_path_tuple in addin_path_tuples:
addin_path = "\\".join(addin_path_tuple)
addin_key: KeyCollection = self.target.registry.key_or_empty(addin_path)

for addin in itertools.chain.from_iterable(addin_key.subkeys()):
key_owner = self.target.registry.get_user(addin)
sid = key_owner.sid if key_owner else None

if manifest_path_str := addin.value_or_default("Manifest").value:
addin_type = "vsto"
executables = self._parse_vsto_manifest(manifest_path_str, sid)
else:
addin_type = "com"
dll_str = self._lookup_com_executable(addin.name)
executables = to_list(self.target.resolve(dll_str, sid))

yield OfficeNativeAddinRecord(
name=addin.value_or_default("FriendlyName").value,
load_behavior=self._parse_load_behavior(addin),
type=addin_type,
manifest=windows_path(manifest_path_str) if manifest_path_str else None,
codebases=executables,
)

@export(record=OfficeStartupItem)
def startup(self) -> Iterable[OfficeStartupItem]:
"""List items in startup paths.
Note that on Office 365, legacy addins such as .wll are no longer automatically loaded.
"""

# Get items from default machine-scoped startup folder
for machine_startup in self._machine_startup_folders():
yield from self._walk_startup_folder(machine_startup)

# Get items from default user-scoped startup folder
for user in self.target.user_details.all_with_home():
for user_startup_folder in self.OFFICE_DEFAULT_USER_STARTUP:
yield from self._walk_startup_folder(user_startup_folder, user.user.sid)

# Get items from alternate machine or user scoped startup folder
for hive in self.HIVES:
for options_key, startup_value in self.OFFICE_STARTUP_OPTIONS:
for alt_startup_folder in self.target.registry.value_or_empty(f"{hive}\\{options_key}", startup_value):
user = self.target.registry.get_user(alt_startup_folder)
user_sid = user.sid if user else None
yield from self._walk_startup_folder(alt_startup_folder.value, user_sid)

def _wef_cache_folders(self) -> Iterable[Path]:
"""List cache folders which contain office web-addin data."""

for user_details in self.target.user_details.all_with_home():
home_dir: Path = user_details.home_path
for manifest_path in home_dir.glob(self.WEB_ADDIN_MANIFEST_GLOB):
if manifest_path.is_file():
yield manifest_path

def _walk_startup_folder(self, startup_folder: str, user_sid: str | None = None) -> Iterable[OfficeStartupItem]:
"""Resolve the given path and return all statup items"""

resolved_startup_folder_str = self.target.resolve(startup_folder, user_sid)
resolved_startup_folder: Path = self.target.fs.path(resolved_startup_folder_str)
if not resolved_startup_folder.exists() or not resolved_startup_folder.is_dir():
return

for current_path, _, plugin_files in resolved_startup_folder.walk():
for plugin_file in plugin_files:
item_startup = current_path / plugin_file
yield OfficeStartupItem(path=item_startup, creation_time=item_startup.stat().st_birthtime)

def _lookup_com_executable(self, prog_id: str) -> str | None:
"""Lookup the com executable given a prog id using the registry."""

for classes_root in self.CLASSES_ROOTS:
try:
cls_id = self.target.registry.value(f"{classes_root}\\{prog_id}\\CLSID", "(Default)").value
inproc_key = f"{classes_root}\\CLSID\\{cls_id}\\InprocServer32"
return self.target.registry.value(inproc_key, "(Default)").value
except RegistryError:
pass

Check warning on line 248 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L247-L248

Added lines #L247 - L248 were not covered by tests

def _parse_vsto_manifest(self, manifest_path: str, user_sid: str) -> Iterable[str]:
"""Parse a vsto manifest.
Non-local manifests, i.e. not ending with suffix "vstolocal" are listed but skipped.
"""

if not manifest_path.endswith("vstolocal"):
self.target.log.warning("Parsing of remote vsto manifest %s is not supported")
return [manifest_path]

Check warning on line 258 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L257-L258

Added lines #L257 - L258 were not covered by tests

manifest_parser = ClickOnceDeploymentManifestParser(self.target, user_sid)
return manifest_parser.find_codebases(manifest_path.removesuffix("|vstolocal"))

def _parse_web_addin_manifest(self, manifest_path: Path) -> OfficeWebAddinRecord:
"""Parses a web addin manifest.
See https://learn.microsoft.com/en-us/office/dev/add-ins/develop/xml-manifest-overview?tabs=tabid-1
"""

ns = {"": "http://schemas.microsoft.com/office/appforoffice/1.1"}

manifest_tree: Element = ElementTree.fromstring(manifest_path.read_text("utf-8-sig"))

source_location_elements = manifest_tree.findall(".//SourceLocation", ns)
source_locations = [source_location.get("DefaultValue") for source_location in source_location_elements]

display_name_element = manifest_tree.find(".//DisplayName", ns)
display_name = display_name_element.get("DefaultValue") if display_name_element is not None else None

return OfficeWebAddinRecord(
name=display_name,
manifest=manifest_path,
version=manifest_tree.findtext(".//Version", namespaces=ns),
provider_name=manifest_tree.findtext(".//ProviderName", namespaces=ns),
source_locations=filter(None, source_locations),
)

def _office_install_root(self, component: str) -> str:
"""Return the installation root for a office component"""

# Typically, all components share the same root.
# Curiously enough, the "Common" component has no InstallRoot defined.
key = f"HKLM\\{self.OFFICE_KEY}\\16.0\\{component}\\InstallRoot"
if office_key := self.target.registry.value_or_empty(key, "Path"):
return office_key.value

return self.OFFICE_DEFAULT_ROOT

def _machine_startup_folders(self) -> Iterable[str]:
"""Return machine-scoped office startup folders"""

yield fsutil.join(self._office_install_root("Word"), "STARTUP", alt_separator="\\")
yield fsutil.join(self._office_install_root("Excel"), "XLSTART", alt_separator="\\")
yield fsutil.join(self._office_install_root("Word"), "Templates", alt_separator="\\")
yield fsutil.join(self._office_install_root("Word"), "Document Themes", alt_separator="\\")

def _parse_load_behavior(self, addin: KeyCollection) -> str | None:
"""Parse the registry value which controls if the add-in autostarts.
See https://learn.microsoft.com/en-us/visualstudio/vsto/registry-entries-for-vsto-add-ins?view=vs-2022#LoadBehavior # noqa: E501
"""

load_behavior = addin.value_or_default("LoadBehavior").value
if load_behavior is None:
return None

Check warning on line 314 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L314

Added line #L314 was not covered by tests
elif load_behavior == 3 or load_behavior == 16:
return "Autostart"
elif load_behavior == 9:
return "OnDemand"

Check warning on line 318 in dissect/target/plugins/apps/productivity/msoffice.py

View check run for this annotation

Codecov / codecov/patch

dissect/target/plugins/apps/productivity/msoffice.py#L318

Added line #L318 was not covered by tests

return "Manual"
6 changes: 3 additions & 3 deletions dissect/target/plugins/filesystem/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def resolve_windows(self, path: str, user_sid: Optional[str] = None) -> str:
for entry, environment in REPLACEMENTS:
path = re.sub(entry, re.escape(environment), path, flags=re.IGNORECASE)

path = self.target.expand_env(path)
path = self.target.expand_env(path, user_sid)
# Normalize again because environment variable expansion may have introduced backslashes again
path = fsutil.normalize(path, alt_separator=self.target.fs.alt_separator)

Expand Down Expand Up @@ -96,12 +96,12 @@ def resolve_windows(self, path: str, user_sid: Optional[str] = None) -> str:
lookup = " ".join([lookup, part]) if lookup else part
for ext in pathext:
lookup_ext = lookup + ext
if self.target.fs.exists(lookup_ext):
if self.target.fs.is_file(lookup_ext):
return lookup_ext

for search_path in search_paths:
lookup_path = fsutil.join(search_path, lookup_ext, alt_separator=self.target.fs.alt_separator)
if self.target.fs.exists(lookup_path):
if self.target.fs.is_file(lookup_path):
return lookup_path

return path
Expand Down
Loading

0 comments on commit 805ab29

Please sign in to comment.