diff --git a/volatility3/framework/deprecation.py b/volatility3/framework/deprecation.py index e2e91a0eb..859a32bad 100644 --- a/volatility3/framework/deprecation.py +++ b/volatility3/framework/deprecation.py @@ -88,3 +88,52 @@ def deprecated_method( return wrapper return decorator + + +def renamed_class(deprecated_class_name: str, message: str, removal_date: str): + """A decorator for marking classes as being renamed and removed in the future. + Callers to this function should explicitly update to use the other plugins instead. + + Args: + deprecated_class_name: The name of the class being deprecated + message: A message added to the standard deprecation warning. Should include the replacement API paths + removal_date: A YYYY-MM-DD formatted date of when the function will be removed from the framework + """ + + def decorator(replacement_func): + @functools.wraps(replacement_func) + def wrapper(*args, **kwargs): + warnings.warn( + f"This plugin ({deprecated_class_name}) has been renamed and will be removed in the first release after {removal_date}. {message}", + FutureWarning, + ) + return replacement_func(*args, **kwargs) + + return wrapper + + return decorator + + +class PluginRenameClass: + """Class to move all classmethod invocations (for when a plugin has been moved)""" + + def __init_subclass__(cls, replacement_class, removal_date, **kwargs): + deprecated_class_name = f"{cls.__module__}.{cls.__qualname__}" + super().__init_subclass__(**kwargs) + for attr, value in replacement_class.__dict__.items(): + if isinstance(value, classmethod) and attr != "get_requirements": + setattr( + cls, + attr, + classmethod( + renamed_class( + deprecated_class_name=deprecated_class_name, + removal_date=removal_date, + message=f"Please ensure all method calls to this plugin are replaced with calls to {replacement_class.__module__}.{replacement_class.__qualname__}", + )(value.__func__) + ), + ) + else: + if not attr.startswith("__"): + setattr(cls, attr, value) + return super(PluginRenameClass).__init_subclass__(**kwargs) diff --git a/volatility3/framework/plugins/windows/amcache.py b/volatility3/framework/plugins/windows/amcache.py index 4ac5554e0..be144be91 100644 --- a/volatility3/framework/plugins/windows/amcache.py +++ b/volatility3/framework/plugins/windows/amcache.py @@ -1,656 +1,20 @@ -# This file is Copyright 2024 Volatility Foundation and licensed under the Volatility Software License 1.0 +# 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 # - -import dataclasses -import datetime -import enum -import itertools import logging -from typing import Dict, Iterable, Iterator, List, Optional, Tuple, Union - -from volatility3.framework import interfaces, renderers -from volatility3.framework.configuration import requirements -from volatility3.framework.layers import registry -from volatility3.framework.renderers import conversion -from volatility3.framework.symbols.windows.extensions import registry as reg_extensions -from volatility3.plugins import timeliner -from volatility3.plugins.windows.registry import hivelist +from volatility3.framework import interfaces, deprecation +from volatility3.plugins.windows.registry import amcache vollog = logging.getLogger(__name__) -####################################################################### -# More information about the following enums can be found in the report -# 'Analysis of the AmCache` by Blanche Lagny, 2019 -####################################################################### - -class Win8FileValName(enum.Enum): - """ - An enumeration that creates a helpful mapping of opaque Windows 8 Amcache - 'File' subkey value names to their human-readable equivalent. - """ - - ProgramID = "100" - SHA1Hash = "101" - Product = "0" - Company = "1" - Size = "6" - SizeOfImage = "7" - PEHeaderChecksum = "9" - LastModTime = "11" # REG_QWORD FILETIME - CreateTime = "12" # REG_QWORD FILETIME - Path = "15" - LastModTime2 = "17" # REG_QWORD FILETIME - Version = "d" - CompileTime = "f" # REG_QWORD UNIX EPOCH - - -class Win8ProgramValName(enum.Enum): - """ - An enumeration that creates a helpful mapping of opaque Windows 8 Amcache - 'Program' subkey value names to their human-readable equivalent. - """ - - Product = "0" - Version = "1" - Publisher = "2" - InstallTime = "a" - MSIProductCode = "11" - MSIPackageCode = "12" - ProductCode = "f" - PackageCode = "10" - - -class Win10InvAppFileValName(enum.Enum): - """ - An enumeration containing the most useful Windows 10 Amcache - 'InventoryApplicationFile' subkey value names. - """ - - FileId = "FileId" - LinkDate = "LinkDate" - LowerCaseLongPath = "LowerCaseLongPath" - ProductName = "ProductName" - ProductVersion = "ProductVersion" - ProgramID = "ProgramId" - Publisher = "Publisher" - - -class Win10InvAppValName(enum.Enum): - """ - An enumeration containing the most useful Windows 10 Amcache - 'InventoryApplication' subkey value names. - """ - - InstallDate = "InstallDate" - Name = "Name" - Publisher = "Publisher" - RootDirPath = "RootDirPath" - Version = "Version" - - -class Win10DriverBinaryValName(enum.Enum): - """ - An enumeration containing the most useful Windows 10 Amcache - 'InventoryDriverBinary' subkey value names. - """ - - DriverId = "DriverId" - DriverName = "DriverName" - DriverCompany = "DriverCompany" - Product = "Product" - Service = "Service" - DriverTimeStamp = "DriverTimeStamp" - - -class AmcacheEntryType(enum.IntEnum): - Driver = 1 - Program = 2 - File = 3 - - -NullableString = Union[str, None, interfaces.renderers.BaseAbsentValue] -NullableDatetime = Union[datetime.datetime, None, interfaces.renderers.BaseAbsentValue] - - -@dataclasses.dataclass -class _AmcacheEntry: - """ - A class containing all information about an entry from the Amcache registry hive. - Because all values could potentially be paged out of memory or malformed, they are all - a union between their expected value and `interfaces.renderers.BaseAbsentValue`. - """ - - entry_type: str - path: NullableString = renderers.NotApplicableValue() - company: NullableString = renderers.NotApplicableValue() - last_modify_time: NullableDatetime = renderers.NotApplicableValue() - last_modify_time_2: NullableDatetime = renderers.NotApplicableValue() - install_time: NullableDatetime = renderers.NotApplicableValue() - compile_time: NullableDatetime = renderers.NotApplicableValue() - sha1_hash: NullableString = renderers.NotApplicableValue() - service: NullableString = renderers.NotApplicableValue() - product_name: NullableString = renderers.NotApplicableValue() - product_version: NullableString = renderers.NotApplicableValue() - - -def _entry_sort_key(entry_tuple: Tuple[NullableString, _AmcacheEntry]) -> str: - """Sorts entries by program ID. This is broken out as a function here - to ensure consistency in sorting between the `group_by` and `sorted` function - invocations. - """ - program_id, _ = entry_tuple - key = program_id if isinstance(program_id, str) else "" - return key - - -def _get_string_value( - values: Dict[str, reg_extensions.CM_KEY_VALUE], name: str -) -> NullableString: - try: - value = values[name] - except KeyError: - return renderers.NotAvailableValue() - - data = value.decode_data() - if not isinstance(data, bytes): - return renderers.UnparsableValue() - - return data.decode("utf-16le", errors="replace").rstrip("\u0000") - - -def _get_datetime_filetime_value( - values: Dict[str, reg_extensions.CM_KEY_VALUE], name: str -) -> NullableDatetime: - try: - value = values[name] - except KeyError: - return renderers.NotAvailableValue() - - data = value.decode_data() - if not isinstance(data, int): - return renderers.UnparsableValue() - - return conversion.wintime_to_datetime(data) - - -def _get_datetime_utc_epoch_value( - values: Dict[str, reg_extensions.CM_KEY_VALUE], name: str -) -> NullableDatetime: - try: - value = values[name] - except KeyError: - return renderers.NotAvailableValue() - - data = value.decode_data() - if not isinstance(data, (int, float)): - return renderers.UnparsableValue() - - try: - return datetime.datetime.fromtimestamp(float(data), datetime.timezone.utc) - except (ValueError, OverflowError, OSError): - return renderers.UnparsableValue() - - -def _get_datetime_str_value( - values: Dict[str, reg_extensions.CM_KEY_VALUE], name: str -) -> NullableDatetime: - try: - value = values[name] - except KeyError: - return renderers.NotAvailableValue() - - data = value.decode_data() - if not isinstance(data, int): - return renderers.UnparsableValue() - - if isinstance(data, str): - try: - return datetime.datetime.strptime(data, "%m/%d/%Y %H:%M:%S") - except ValueError: - return renderers.UnparsableValue() - else: - return renderers.UnparsableValue() - - -class Amcache(interfaces.plugins.PluginInterface, timeliner.TimeLinerInterface): - """Extract information on executed applications from the AmCache.""" +class Amcache( + interfaces.plugins.PluginInterface, + deprecation.PluginRenameClass, + replacement_class=amcache.Amcache, + removal_date="2025-09-25", +): + """Extract information on executed applications from the AmCache (deprecated).""" _required_framework_version = (2, 0, 0) - - # 2.0.0 - changed the signature of get_amcache_hive _version = (2, 0, 0) - - @classmethod - def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]: - # Since we're calling the plugin, make sure we have the plugin's requirements - return [ - requirements.ModuleRequirement( - name="kernel", - description="Windows kernel", - architectures=["Intel32", "Intel64"], - ), - requirements.VersionRequirement( - name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) - ), - requirements.VersionRequirement( - name="timeliner", - component=timeliner.TimeLinerInterface, - version=(1, 0, 0), - ), - ] - - def generate_timeline( - self, - ) -> Iterator[Tuple[str, timeliner.TimeLinerType, datetime.datetime]]: - for _, entry in self._generator(): - if isinstance(entry.last_modify_time, datetime.datetime): - yield f"Amcache: {entry.entry_type} {entry.path} registry key modified", timeliner.TimeLinerType.MODIFIED, entry.last_modify_time - if isinstance(entry.last_modify_time_2, datetime.datetime): - yield f"Amcache: {entry.entry_type} {entry.path} STANDARD_INFORMATION create time", timeliner.TimeLinerType.CREATED, entry.last_modify_time_2 - if isinstance(entry.install_time, datetime.datetime): - yield f"Amcache: {entry.entry_type} {entry.path} installed", timeliner.TimeLinerType.CREATED, entry.install_time - if isinstance(entry.compile_time, datetime.datetime): - yield f"Amcache: {entry.entry_type} {entry.path} compiled (PE metadata)", timeliner.TimeLinerType.MODIFIED, entry.compile_time - - @classmethod - def get_amcache_hive( - cls, - context: interfaces.context.ContextInterface, - config_path: str, - kernel_module_name: str, - ) -> Optional[registry.RegistryHive]: - """Retrieves the `Amcache.hve` registry hive from the kernel module, if it can be located.""" - return next( - hivelist.HiveList.list_hives( - context=context, - base_config_path=interfaces.configuration.path_join( - config_path, "hivelist" - ), - kernel_module_name=kernel_module_name, - filter_string="amcache", - ), - None, - ) - - @classmethod - def parse_file_key( - cls, file_key: reg_extensions.CM_KEY_NODE - ) -> Iterator[Tuple[NullableString, _AmcacheEntry]]: - """Parses File entries from the Windows 8 `Root\\File` key. - - :param programs_key: The `Root\\File` registry key. - - :return: An iterator of tuples, where the first member is the program ID string for - correlating `Root\\Program` entries, and the second member is the `AmcacheEntry`. - """ - - val_enum = Win8FileValName - - wanted_values = [key.value for key in val_enum] - - for file_entry_key in itertools.chain( - *(key.get_subkeys() for key in file_key.get_subkeys()) - ): - vollog.debug(f"Checking Win8 File key {file_entry_key.get_name()}") - values = { - str(value.get_name()): value - for value in file_entry_key.get_values() - if value.get_name() in wanted_values - } - - program_id = _get_string_value(values, val_enum.ProgramID.value) - path = _get_string_value(values, val_enum.Path.value) - company = _get_string_value(values, val_enum.Company.value) - last_mod_time = _get_datetime_filetime_value( - values, val_enum.LastModTime.value - ) - last_mod_time_2 = _get_datetime_filetime_value( - values, val_enum.LastModTime2.value - ) - install_time = _get_datetime_filetime_value( - values, val_enum.CreateTime.value - ) - compile_time = _get_datetime_utc_epoch_value( - values, val_enum.CompileTime.value - ) - sha1_hash = _get_string_value(values, val_enum.SHA1Hash.value) - vollog.debug(f"Found sha1hash {sha1_hash}") - product_name = _get_string_value(values, val_enum.Product.value) - - yield program_id, _AmcacheEntry( - AmcacheEntryType.File.name, - path=path, - company=company, - last_modify_time=last_mod_time, - last_modify_time_2=last_mod_time_2, - install_time=install_time, - compile_time=compile_time, - sha1_hash=( - sha1_hash.lstrip("0000") - if isinstance(sha1_hash, str) - else sha1_hash - ), - product_name=product_name, - ) - - @classmethod - def parse_programs_key( - cls, programs_key: reg_extensions.CM_KEY_NODE - ) -> Iterator[Tuple[str, _AmcacheEntry]]: - """Parses Program entries from the Windows 8 `Root\\Programs` key. - - :param programs_key: The `Root\\Programs` registry key. - - :return: An iterator of tuples, where the first member is the program ID string for - correlating `Root\\File` entries, and the second member is the `AmcacheEntry`. - """ - val_enum = Win8ProgramValName - - wanted_values = [key.value for key in val_enum] - for program_key in programs_key.get_subkeys(): - values = { - str(value.get_name()): value - for value in program_key.get_values() - if value.get_name() in wanted_values - } - vollog.debug(f"Parsing Win8 Program key {program_key.get_name()}") - program_id = program_key.get_name().strip().strip("\u0000") - - product = _get_string_value(values, val_enum.Product.value) - company = _get_string_value(values, val_enum.Publisher.value) - install_time = _get_datetime_utc_epoch_value( - values, val_enum.InstallTime.value - ) - version = _get_string_value(values, val_enum.Version.value) - - yield program_id, _AmcacheEntry( - AmcacheEntryType.Program.name, - company=company, - last_modify_time=conversion.wintime_to_datetime( - program_key.LastWriteTime.QuadPart - ), - install_time=install_time, - product_name=product, - product_version=version, - ) - - @classmethod - def parse_inventory_app_key( - cls, inv_app_key: reg_extensions.CM_KEY_NODE - ) -> Iterator[Tuple[str, _AmcacheEntry]]: - """Parses InventoryApplication entries from the Windows 10 `Root\\InventoryApplication` key. - - :param programs_key: The `Root\\InventoryApplication` registry key. - - :return: An iterator of tuples, where the first member is the program ID string for - correlating `Root\\InventoryApplicationFile` entries, and the second member is the `AmcacheEntry`. - """ - val_enum = Win10InvAppValName - - wanted_values = [key.value for key in val_enum] - - for program_key in inv_app_key.get_subkeys(): - program_id = program_key.get_name() - - values = { - str(value.get_name()): value - for value in program_key.get_values() - if value.get_name() in wanted_values - } - - name = _get_string_value(values, val_enum.Name.value) - version = _get_string_value(values, val_enum.Version.value) - publisher = _get_string_value(values, val_enum.Publisher.value) - path = _get_string_value(values, val_enum.RootDirPath.value) - install_date = _get_datetime_str_value(values, val_enum.InstallDate.value) - last_mod = conversion.wintime_to_datetime( - program_key.LastWriteTime.QuadPart - ) - - product: str = name if isinstance(name, str) else "UNKNOWN" # type: ignore - - yield program_id.strip().strip("\u0000"), _AmcacheEntry( - AmcacheEntryType.Program.name, - path=path, - last_modify_time=last_mod, - install_time=install_date, - product_name=product, - company=publisher, - product_version=version, - ) - - @classmethod - def parse_inventory_app_file_key( - cls, inv_app_file_key: reg_extensions.CM_KEY_NODE - ) -> Iterator[Tuple[NullableString, _AmcacheEntry]]: - """Parses executable file entries from the `Root\\InventoryApplicationFile` registry key. - - :param inv_app_file_key: The `Root\\InventoryApplicationFile` registry key. - :return: An iterator of tuples, where the first member is the program ID string for correlating - with it's parent `InventoryApplication` program entry, and the second member is the `Amcache` entry. - """ - - val_enum = Win10InvAppFileValName - - wanted_values = [key.value for key in val_enum] - - for file_key in inv_app_file_key.get_subkeys(): - - vollog.debug( - f"Parsing Win10 InventoryApplicationFile key {file_key.get_name()}" - ) - - values = { - str(value.get_name()): value - for value in file_key.get_values() - if value.get_name() in wanted_values - } - - last_mod = conversion.wintime_to_datetime(file_key.LastWriteTime.QuadPart) - path = _get_string_value(values, val_enum.LowerCaseLongPath.value) - linkdate = _get_datetime_str_value(values, val_enum.LinkDate.value) - sha1_hash = _get_string_value(values, val_enum.FileId.value) - publisher = _get_string_value(values, val_enum.Publisher.value) - prod_name = _get_string_value(values, val_enum.ProductName.value) - prod_ver = _get_string_value(values, val_enum.ProductVersion.value) - program_id = _get_string_value(values, val_enum.ProgramID.value) - - yield program_id, _AmcacheEntry( - AmcacheEntryType.File.name, - path=path, - company=publisher, - last_modify_time=last_mod, - compile_time=linkdate, - sha1_hash=( - sha1_hash.lstrip("0000") - if isinstance(sha1_hash, str) - else sha1_hash - ), - product_name=prod_name, - product_version=prod_ver, - ) - - @classmethod - def parse_driver_binary_key( - cls, driver_binary_key: reg_extensions.CM_KEY_NODE - ) -> Iterator[_AmcacheEntry]: - """Parses information about installed drivers from the `Root\\InventoryDriverBinary` registry key. - - :param driver_binary_key: The `Root\\InventoryDriverBinary` registry key - :return: An iterator of `AmcacheEntry`s - """ - val_enum = Win10DriverBinaryValName - - wanted_values = [key.value for key in val_enum] - - for binary_key in driver_binary_key.get_subkeys(): - - values = { - str(value.get_name()): value - for value in binary_key.get_values() - if value.get_name() in wanted_values - } - - # Depending on the Windows version, the key name will be either the name - # of the driver, or its SHA1 hash. - if "/" in str(binary_key.get_name()): - driver_name = str(binary_key.get_name()) - sha1_hash = _get_string_value(values, val_enum.DriverId.name) - else: - sha1_hash = str(binary_key.get_name()) - driver_name = _get_string_value(values, val_enum.DriverName.name) - - if isinstance(sha1_hash, str): - sha1_hash = sha1_hash[4:] if sha1_hash.startswith("0000") else sha1_hash - - company, product, service, last_write_time, driver_timestamp = ( - _get_string_value(values, val_enum.DriverCompany.name), - _get_string_value(values, val_enum.Product.name), - _get_string_value(values, val_enum.Service.name), - conversion.wintime_to_datetime(binary_key.LastWriteTime.QuadPart), - _get_datetime_utc_epoch_value(values, val_enum.DriverTimeStamp.name), - ) - - yield _AmcacheEntry( - entry_type=AmcacheEntryType.Driver.name, - path=driver_name, - company=company, - last_modify_time=last_write_time, - compile_time=driver_timestamp, - sha1_hash=( - sha1_hash.lstrip("0000") - if isinstance(sha1_hash, str) - else sha1_hash - ), - service=service, - product_name=product, - ) - - def _generator(self) -> Iterator[Tuple[int, _AmcacheEntry]]: - def indented( - entry_gen: Iterable[_AmcacheEntry], indent: int = 0 - ) -> Iterator[Tuple[int, _AmcacheEntry]]: - for item in entry_gen: - yield indent, item - - # Building the dictionary ahead of time is much better for performance - # vs looking up each service's DLL individually. - amcache = self.get_amcache_hive( - self.context, self.config_path, self.config["kernel"] - ) - if amcache is None: - return - - try: - yield from indented( - self.parse_driver_binary_key( - amcache.get_key("Root\\InventoryDriverBinary") # type: ignore - ) - ) - except (KeyError, registry.RegistryException): - # Registry key not found - pass - - try: - programs: Dict[str, _AmcacheEntry] = { - program_id: entry - for program_id, entry in self.parse_programs_key( - amcache.get_key("Root\\Programs") - ) # type: ignore - } - except (KeyError, registry.RegistryException): - programs = {} - - try: - files = sorted( - list( - self.parse_file_key(amcache.get_key("Root\\File")), # type: ignore - ), - key=_entry_sort_key, - ) - except (KeyError, registry.RegistryException): - files = [] - - for program_id, file_entries in itertools.groupby( - files, - key=_entry_sort_key, - ): - files_indent = 0 - if isinstance(program_id, str): - try: - program_entry = programs.pop(program_id.strip().strip("\u0000")) - yield (0, program_entry) - - files_indent = 1 - except KeyError: - # No parent program for this file entry - pass - for _, entry in file_entries: - yield files_indent, entry - - for empty_program in programs.values(): - yield 0, empty_program - - try: - programs: Dict[str, _AmcacheEntry] = dict( - self.parse_inventory_app_key( - amcache.get_key("Root\\InventoryApplication") # type: ignore - ) - ) - except (KeyError, registry.RegistryException): - programs = {} - - try: - files = sorted( - list( - self.parse_inventory_app_file_key(amcache.get_key("Root\\InventoryApplicationFile")), # type: ignore - ), - key=_entry_sort_key, - ) - except (KeyError, registry.RegistryException): - files = [] - - for program_id, file_entries in itertools.groupby( - files, - key=_entry_sort_key, - ): - files_indent = 0 - - if isinstance(program_id, str): - try: - program_entry = programs.pop(program_id.strip().strip("\u0000")) - yield (0, program_entry) - files_indent = 1 - except KeyError: - # No parent program for this file entry - pass - - for _, entry in file_entries: - yield files_indent, entry - - for empty_program in programs.values(): - yield 0, empty_program - - def run(self): - - return renderers.TreeGrid( - [ - ("EntryType", str), - ("Path", str), - ("Company", str), - ("LastModifyTime", datetime.datetime), - ("LastModifyTime2", datetime.datetime), - ("InstallTime", datetime.datetime), - ("CompileTime", datetime.datetime), - ("SHA1", str), - ("Service", str), - ("ProductName", str), - ("ProductVersion", str), - ], - ( - (indent, dataclasses.astuple(entry)) - for indent, entry in self._generator() - ), - ) diff --git a/volatility3/framework/plugins/windows/cachedump.py b/volatility3/framework/plugins/windows/cachedump.py index 520dc8054..35127c6f3 100644 --- a/volatility3/framework/plugins/windows/cachedump.py +++ b/volatility3/framework/plugins/windows/cachedump.py @@ -1,187 +1,20 @@ -# This file is Copyright 2020 Volatility Foundation and licensed under the Volatility Software License 1.0 +# 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 # import logging -from struct import unpack -from typing import Tuple - -from Crypto.Cipher import ARC4, AES -from Crypto.Hash import HMAC - -from volatility3.framework import interfaces, renderers, exceptions -from volatility3.framework.configuration import requirements -from volatility3.framework.layers import registry -from volatility3.framework.symbols.windows import versions -from volatility3.plugins.windows import hashdump, lsadump -from volatility3.plugins.windows.registry import hivelist +from volatility3.framework import interfaces, deprecation +from volatility3.plugins.windows.registry import cachedump vollog = logging.getLogger(__name__) -class Cachedump(interfaces.plugins.PluginInterface): - """Dumps lsa secrets from memory""" +class Cachedump( + interfaces.plugins.PluginInterface, + deprecation.PluginRenameClass, + replacement_class=cachedump.Cachedump, + removal_date="2025-09-25", +): + """Dumps lsa secrets from memory (deprecated)""" _required_framework_version = (2, 0, 0) _version = (1, 0, 2) - - @classmethod - def get_requirements(cls): - return [ - requirements.ModuleRequirement( - name="kernel", - description="Windows kernel", - architectures=["Intel32", "Intel64"], - ), - requirements.VersionRequirement( - name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) - ), - requirements.VersionRequirement( - name="lsadump", component=lsadump.Lsadump, version=(1, 0, 0) - ), - requirements.VersionRequirement( - name="hashdump", component=hashdump.Hashdump, version=(1, 1, 0) - ), - ] - - @classmethod - def get_nlkm( - cls, sechive: registry.RegistryHive, lsakey: bytes, is_vista_or_later: bool - ): - return lsadump.Lsadump.get_secret_by_name( - sechive, "NL$KM", lsakey, is_vista_or_later - ) - - @classmethod - def decrypt_hash(cls, edata: bytes, nlkm: bytes, ch, xp: bool): - if xp: - hmac_md5 = HMAC.new(nlkm, ch) - rc4key = hmac_md5.digest() - rc4 = ARC4.new(rc4key) - data = rc4.encrypt(edata) # lgtm [py/weak-cryptographic-algorithm] - else: - # Based on code from http://lab.mediaservice.net/code/cachedump.rb - aes = AES.new(nlkm[16:32], AES.MODE_CBC, ch) - data = b"" - for i in range(0, len(edata), 16): - buf = edata[i : i + 16] - if len(buf) < 16: - buf += (16 - len(buf)) * b"\00" - data += aes.decrypt(buf) - return data - - @classmethod - def parse_cache_entry(cls, cache_data: bytes) -> Tuple[int, int, int, bytes, bytes]: - (uname_len, domain_len) = unpack(" Tuple[str, str, str, bytes]: - """Get the data from the cache and separate it into the username, domain name, and hash data""" - uname_offset = 72 - pad = 2 * ((uname_len / 2) % 2) - domain_offset = int(uname_offset + uname_len + pad) - pad = 2 * ((domain_len / 2) % 2) - domain_name_offset = int(domain_offset + domain_len + pad) - hashh = dec_data[:0x10] - username = dec_data[uname_offset : uname_offset + uname_len].decode( - "utf-16-le", "replace" - ) - domain = dec_data[domain_offset : domain_offset + domain_len].decode( - "utf-16-le", "replace" - ) - domain_name = dec_data[ - domain_name_offset : domain_name_offset + domain_name_len - ].decode("utf-16-le", "replace") - - return (username, domain, domain_name, hashh) - - def _generator(self, syshive, sechive): - if not syshive or not sechive: - if syshive is None: - vollog.warning("Unable to locate SYSTEM hive") - if sechive is None: - vollog.warning("Unable to locate SECURITY hive") - return None - - bootkey = hashdump.Hashdump.get_bootkey(syshive) - if not bootkey: - vollog.warning("Unable to find bootkey") - return None - - kernel = self.context.modules[self.config["kernel"]] - - vista_or_later = versions.is_vista_or_later( - context=self.context, symbol_table=kernel.symbol_table_name - ) - - lsakey = lsadump.Lsadump.get_lsa_key(sechive, bootkey, vista_or_later) - if not lsakey: - vollog.warning("Unable to find lsa key") - return None - - nlkm = self.get_nlkm(sechive, lsakey, vista_or_later) - if not nlkm: - vollog.warning("Unable to find nlkma key") - return None - - cache = hashdump.Hashdump.get_hive_key(sechive, "Cache") - if not cache: - vollog.warning("Unable to find cache key") - return None - - for cache_item in cache.get_values(): - if cache_item.Name == "NL$Control": - continue - - try: - data = sechive.read(cache_item.Data + 4, cache_item.DataLength) - except exceptions.InvalidAddressException: - continue - - if not data: - continue - - ( - uname_len, - domain_len, - domain_name_len, - enc_data, - ch, - ) = self.parse_cache_entry(data) - # Skip if nothing in this cache entry - if uname_len == 0 or len(ch) == 0: - continue - dec_data = self.decrypt_hash(enc_data, nlkm, ch, not vista_or_later) - - (username, domain, domain_name, hashh) = self.parse_decrypted_cache( - dec_data, uname_len, domain_len, domain_name_len - ) - yield (0, (username, domain, domain_name, hashh)) - - def run(self): - offset = self.config.get("offset", None) - - syshive = sechive = None - - for hive in hivelist.HiveList.list_hives( - context=self.context, - base_config_path=self.config_path, - kernel_module_name=self.config["kernel"], - hive_offsets=None if offset is None else [offset], - ): - if hive.get_name().split("\\")[-1].upper() == "SYSTEM": - syshive = hive - if hive.get_name().split("\\")[-1].upper() == "SECURITY": - sechive = hive - - return renderers.TreeGrid( - [("Username", str), ("Domain", str), ("Domain name", str), ("Hash", bytes)], - self._generator(syshive, sechive), - ) diff --git a/volatility3/framework/plugins/windows/hashdump.py b/volatility3/framework/plugins/windows/hashdump.py index 630aa1cfd..e496e77a9 100644 --- a/volatility3/framework/plugins/windows/hashdump.py +++ b/volatility3/framework/plugins/windows/hashdump.py @@ -1,643 +1,20 @@ -# This file is Copyright 2020 Volatility Foundation and licensed under the Volatility Software License 1.0 +# 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 # -import binascii -import hashlib import logging -from struct import pack, unpack -from typing import List, Optional, Tuple - -from Crypto.Cipher import AES, ARC4, DES - -from volatility3.framework import interfaces, renderers, exceptions, constants -from volatility3.framework.configuration import requirements -from volatility3.framework.exceptions import InvalidAddressException -from volatility3.framework.layers import registry as registrylayer -from volatility3.framework.symbols.windows.extensions import registry -from volatility3.plugins.windows.registry import hivelist +from volatility3.framework import interfaces, deprecation +from volatility3.plugins.windows.registry import hashdump vollog = logging.getLogger(__name__) -class Hashdump(interfaces.plugins.PluginInterface): - """Dumps user hashes from memory""" +class Hashdump( + interfaces.plugins.PluginInterface, + deprecation.PluginRenameClass, + replacement_class=hashdump.Hashdump, + removal_date="2025-09-25", +): + """Dumps user hashes from memory (deprecated)""" _required_framework_version = (2, 0, 0) _version = (1, 1, 1) - - @classmethod - def get_requirements(cls): - return [ - requirements.ModuleRequirement( - name="kernel", - description="Windows kernel", - architectures=["Intel32", "Intel64"], - ), - requirements.VersionRequirement( - name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) - ), - ] - - odd_parity = [ - 1, - 1, - 2, - 2, - 4, - 4, - 7, - 7, - 8, - 8, - 11, - 11, - 13, - 13, - 14, - 14, - 16, - 16, - 19, - 19, - 21, - 21, - 22, - 22, - 25, - 25, - 26, - 26, - 28, - 28, - 31, - 31, - 32, - 32, - 35, - 35, - 37, - 37, - 38, - 38, - 41, - 41, - 42, - 42, - 44, - 44, - 47, - 47, - 49, - 49, - 50, - 50, - 52, - 52, - 55, - 55, - 56, - 56, - 59, - 59, - 61, - 61, - 62, - 62, - 64, - 64, - 67, - 67, - 69, - 69, - 70, - 70, - 73, - 73, - 74, - 74, - 76, - 76, - 79, - 79, - 81, - 81, - 82, - 82, - 84, - 84, - 87, - 87, - 88, - 88, - 91, - 91, - 93, - 93, - 94, - 94, - 97, - 97, - 98, - 98, - 100, - 100, - 103, - 103, - 104, - 104, - 107, - 107, - 109, - 109, - 110, - 110, - 112, - 112, - 115, - 115, - 117, - 117, - 118, - 118, - 121, - 121, - 122, - 122, - 124, - 124, - 127, - 127, - 128, - 128, - 131, - 131, - 133, - 133, - 134, - 134, - 137, - 137, - 138, - 138, - 140, - 140, - 143, - 143, - 145, - 145, - 146, - 146, - 148, - 148, - 151, - 151, - 152, - 152, - 155, - 155, - 157, - 157, - 158, - 158, - 161, - 161, - 162, - 162, - 164, - 164, - 167, - 167, - 168, - 168, - 171, - 171, - 173, - 173, - 174, - 174, - 176, - 176, - 179, - 179, - 181, - 181, - 182, - 182, - 185, - 185, - 186, - 186, - 188, - 188, - 191, - 191, - 193, - 193, - 194, - 194, - 196, - 196, - 199, - 199, - 200, - 200, - 203, - 203, - 205, - 205, - 206, - 206, - 208, - 208, - 211, - 211, - 213, - 213, - 214, - 214, - 217, - 217, - 218, - 218, - 220, - 220, - 223, - 223, - 224, - 224, - 227, - 227, - 229, - 229, - 230, - 230, - 233, - 233, - 234, - 234, - 236, - 236, - 239, - 239, - 241, - 241, - 242, - 242, - 244, - 244, - 247, - 247, - 248, - 248, - 251, - 251, - 253, - 253, - 254, - 254, - ] - - # Permutation matrix for boot key - bootkey_perm_table = [ - 0x8, - 0x5, - 0x4, - 0x2, - 0xB, - 0x9, - 0xD, - 0x3, - 0x0, - 0x6, - 0x1, - 0xC, - 0xE, - 0xA, - 0xF, - 0x7, - ] - - # Constants for SAM decrypt algorithm - aqwerty = b"!@#$%^&*()qwertyUIOPAzxcvbnmQQQQQQQQQQQQ)(*@&%\0" - anum = b"0123456789012345678901234567890123456789\0" - antpassword = b"NTPASSWORD\0" - almpassword = b"LMPASSWORD\0" - lmkey = b"KGS!@#$%" - - empty_lm = b"\xaa\xd3\xb4\x35\xb5\x14\x04\xee\xaa\xd3\xb4\x35\xb5\x14\x04\xee" - empty_nt = b"\x31\xd6\xcf\xe0\xd1\x6a\xe9\x31\xb7\x3c\x59\xd7\xe0\xc0\x89\xc0" - - @classmethod - def get_hive_key( - cls, hive: registry.RegistryHive, key: str - ) -> Optional["registry.CM_KEY_NODE"]: - result = None - try: - if hive: - result = hive.get_key(key) - except (KeyError, registrylayer.RegistryException): - vollog.info( - f"Unable to load the required registry key {hive.get_name()}\\{key} from this memory image" - ) - return result - - @classmethod - def get_user_keys( - cls, samhive: registry.RegistryHive - ) -> List[interfaces.objects.ObjectInterface]: - user_key_path = "SAM\\Domains\\Account\\Users" - - user_key = cls.get_hive_key(samhive, user_key_path) - - if not user_key: - return [] - return [k for k in user_key.get_subkeys() if k.Name != "Names"] - - @classmethod - def get_bootkey(cls, syshive: registry.RegistryHive) -> Optional[bytes]: - """ - Returns the scrambled bootkey necesary to decrypt hashes - """ - cs = 1 - lsa_base = f"ControlSet{cs:03}" + "\\Control\\Lsa" - lsa_keys = ["JD", "Skew1", "GBG", "Data"] - - lsa = cls.get_hive_key(syshive, lsa_base) - if not lsa: - return None - - bootkey = "" - - for lk in lsa_keys: - try: - key = cls.get_hive_key(syshive, lsa_base + "\\" + lk) - class_data = None - if key: - try: - class_data = syshive.read(key.Class + 4, key.ClassLength) - except exceptions.InvalidAddressException: - return None - - if class_data is None: - return None - bootkey += class_data.decode("utf-16-le") - except ( - InvalidAddressException, - registrylayer.RegistryException, - ) as excp: - vollog.log( - constants.LOGLEVEL_VVV, f"Unable to read Lsa key {lk}: {excp}" - ) - return None - - bootkey_str = binascii.unhexlify(bootkey) - bootkey_scrambled = bytes( - [bootkey_str[cls.bootkey_perm_table[i]] for i in range(len(bootkey_str))] - ) - return bootkey_scrambled - - @classmethod - def get_hbootkey( - cls, samhive: registry.RegistryHive, bootkey: bytes - ) -> Optional[bytes]: - sam_account_path = "SAM\\Domains\\Account" - - if not bootkey: - return None - - sam_account_key = cls.get_hive_key(samhive, sam_account_path) - if not sam_account_key: - return None - - sam_data = None - for v in sam_account_key.get_values(): - if v.get_name() == "F": - try: - sam_data = samhive.read(v.Data + 4, v.DataLength) - except exceptions.InvalidAddressException: - return None - - if not sam_data: - return None - - revision = sam_data[0x00] - if revision == 2: - md5 = hashlib.md5() - - md5.update(sam_data[0x70:0x80] + cls.aqwerty + bootkey + cls.anum) - rc4_key = md5.digest() - - rc4 = ARC4.new(rc4_key) - hbootkey = rc4.encrypt( - sam_data[0x80:0xA0] - ) # lgtm [py/weak-cryptographic-algorithm] - return hbootkey - elif revision == 3: - # AES encrypted - iv = sam_data[0x78:0x88] - encryptedHBootKey = sam_data[0x88:0xA8] - cipher = AES.new(bootkey, AES.MODE_CBC, iv) - hbootkey = cipher.decrypt(encryptedHBootKey) - return hbootkey[:16] - return None - - @classmethod - def decrypt_single_salted_hash( - cls, rid, hbootkey: bytes, enc_hash: bytes, _lmntstr, salt: bytes - ) -> Optional[bytes]: - (des_k1, des_k2) = cls.sid_to_key(rid) - des1 = DES.new(des_k1, DES.MODE_ECB) - des2 = DES.new(des_k2, DES.MODE_ECB) - cipher = AES.new(hbootkey[:16], AES.MODE_CBC, salt) - obfkey = cipher.decrypt(enc_hash) - return des1.decrypt(obfkey[:8]) + des2.decrypt( - obfkey[8:16] - ) # lgtm [py/weak-cryptographic-algorithm] - - @classmethod - def get_user_hashes( - cls, user: registry.CM_KEY_NODE, samhive: registry.RegistryHive, hbootkey: bytes - ) -> Optional[Tuple[bytes, bytes]]: - ## Will sometimes find extra user with rid = NAMES, returns empty strings right now - try: - rid = int(str(user.get_name()), 16) - except ValueError: - return None - sam_data = None - for v in user.get_values(): - if v.get_name() == "V": - try: - sam_data = samhive.read(v.Data + 4, v.DataLength) - except ( - exceptions.InvalidAddressException, - registrylayer.RegistryException, - ): - return None - - if not sam_data: - return None - - lm_offset = unpack(" Tuple[bytes, bytes]: - """Takes rid of a user and converts it to a key to be used by the DES cipher""" - bytestr1 = [ - sid & 0xFF, - (sid >> 8) & 0xFF, - (sid >> 16) & 0xFF, - (sid >> 24) & 0xFF, - ] - bytestr1 += bytestr1[0:3] - bytestr2 = [bytestr1[3]] + bytestr1[0:3] - bytestr2 += bytestr2[0:3] - return cls.sidbytes_to_key(bytes(bytestr1)), cls.sidbytes_to_key( - bytes(bytestr2) - ) - - @classmethod - def sidbytes_to_key(cls, s: bytes) -> bytes: - """Builds final DES key from the strings generated in sid_to_key""" - key = [ - s[0] >> 1, - ((s[0] & 0x01) << 6) | (s[1] >> 2), - ((s[1] & 0x03) << 5) | (s[2] >> 3), - ((s[2] & 0x07) << 4) | (s[3] >> 4), - ((s[3] & 0x0F) << 3) | (s[4] >> 5), - ((s[4] & 0x1F) << 2) | (s[5] >> 6), - ((s[5] & 0x3F) << 1) | (s[6] >> 7), - s[6] & 0x7F, - ] - for i in range(8): - key[i] = key[i] << 1 - key[i] = cls.odd_parity[key[i]] - return bytes(key) - - @classmethod - def decrypt_single_hash( - cls, rid: int, hbootkey: bytes, enc_hash: bytes, lmntstr: bytes - ): - (des_k1, des_k2) = cls.sid_to_key(rid) - des1 = DES.new(des_k1, DES.MODE_ECB) - des2 = DES.new(des_k2, DES.MODE_ECB) - md5 = hashlib.md5() - - md5.update(hbootkey[:0x10] + pack(" Optional[bytes]: - value = None - for v in user.get_values(): - if v.get_name() == "V": - try: - value = samhive.read(v.Data + 4, v.DataLength) - except exceptions.InvalidAddressException: - return None - - if not value: - return None - - name_offset = unpack(" len(value): - return None - - username = value[name_offset : name_offset + name_length] - return username - - # replaces the dump_hashes method in vol2 - def _generator( - self, syshive: registry.RegistryHive, samhive: registry.RegistryHive - ): - if syshive is None: - vollog.debug("SYSTEM address is None: No system hive found") - if samhive is None: - vollog.debug("SAM address is None: No SAM hive found") - bootkey = self.get_bootkey(syshive) - hbootkey = self.get_hbootkey(samhive, bootkey) - if hbootkey: - for user in self.get_user_keys(samhive): - ret = self.get_user_hashes(user, samhive, hbootkey) - if ret: - lmhash, nthash = ret - - ## temporary fix to prevent UnicodeDecodeError backtraces - ## however this can cause truncated user names as a result - name = self.get_user_name(user, samhive) - if name is None: - name = renderers.NotAvailableValue() - else: - name = str(name, "utf-16-le", errors="ignore") - - lmout = str(binascii.hexlify(lmhash or self.empty_lm), "latin-1") - ntout = str(binascii.hexlify(nthash or self.empty_nt), "latin-1") - rid = int(str(user.get_name()), 16) - yield (0, (name, rid, lmout, ntout)) - else: - vollog.warning("Hbootkey is not valid") - - def run(self): - offset = self.config.get("offset", None) - syshive = None - samhive = None - for hive in hivelist.HiveList.list_hives( - context=self.context, - base_config_path=self.config_path, - kernel_module_name=self.config["kernel"], - hive_offsets=None if offset is None else [offset], - ): - if hive.get_name().split("\\")[-1].upper() == "SYSTEM": - syshive = hive - if hive.get_name().split("\\")[-1].upper() == "SAM": - samhive = hive - - return renderers.TreeGrid( - [("User", str), ("rid", int), ("lmhash", str), ("nthash", str)], - self._generator(syshive, samhive), - ) diff --git a/volatility3/framework/plugins/windows/lsadump.py b/volatility3/framework/plugins/windows/lsadump.py index 72f2fa146..0b36ddef0 100644 --- a/volatility3/framework/plugins/windows/lsadump.py +++ b/volatility3/framework/plugins/windows/lsadump.py @@ -1,258 +1,20 @@ -# This file is Copyright 2020 Volatility Foundation and licensed under the Volatility Software License 1.0 +# 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 # import logging -from struct import unpack -from typing import Optional -import hashlib - -from Crypto.Cipher import ARC4, DES, AES - -from volatility3.framework import interfaces, renderers, exceptions -from volatility3.framework.configuration import requirements -from volatility3.framework.exceptions import InvalidAddressException - -from volatility3.framework.layers import registry -from volatility3.framework.symbols.windows import versions -from volatility3.plugins.windows import hashdump -from volatility3.plugins.windows.registry import hivelist -from volatility3.framework.renderers import format_hints +from volatility3.framework import interfaces, deprecation +from volatility3.plugins.windows.registry import lsadump vollog = logging.getLogger(__name__) -class Lsadump(interfaces.plugins.PluginInterface): - """Dumps lsa secrets from memory""" +class Lsadump( + interfaces.plugins.PluginInterface, + deprecation.PluginRenameClass, + replacement_class=lsadump.Lsadump, + removal_date="2025-09-25", +): + """Dumps lsa secrets from memory (deprecated)""" _required_framework_version = (2, 0, 0) _version = (1, 0, 1) - - @classmethod - def get_requirements(cls): - return [ - requirements.ModuleRequirement( - name="kernel", - description="Windows kernel", - architectures=["Intel32", "Intel64"], - ), - requirements.VersionRequirement( - name="hashdump", component=hashdump.Hashdump, version=(1, 1, 0) - ), - requirements.VersionRequirement( - name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) - ), - ] - - @classmethod - def decrypt_aes(cls, secret: bytes, key: bytes) -> bytes: - """ - Based on code from http://lab.mediaservice.net/code/cachedump.rb - """ - sha = hashlib.sha256() - sha.update(key) - for _i in range(1, 1000 + 1): - sha.update(secret[28:60]) - aeskey = sha.digest() - - data = b"" - for i in range(60, len(secret), 16): - aes = AES.new(aeskey, AES.MODE_CBC, b"\x00" * 16) - buf = secret[i : i + 16] - if len(buf) < 16: - buf += (16 - len(buf)) * "\00" - data += aes.decrypt(buf) - - return data - - @classmethod - def get_lsa_key( - cls, sechive: registry.RegistryHive, bootkey: bytes, vista_or_later: bool - ) -> Optional[bytes]: - if not bootkey: - return None - - if vista_or_later: - policy_key = "PolEKList" - else: - policy_key = "PolSecretEncryptionKey" - - enc_reg_key = hashdump.Hashdump.get_hive_key(sechive, "Policy\\" + policy_key) - if not enc_reg_key: - return None - enc_reg_value = next(enc_reg_key.get_values(), None) - if not enc_reg_value: - return None - - try: - obf_lsa_key = sechive.read(enc_reg_value.Data + 4, enc_reg_value.DataLength) - except exceptions.InvalidAddressException: - return None - - if not obf_lsa_key: - return None - if not vista_or_later: - md5 = hashlib.md5() - md5.update(bootkey) - for _i in range(1000): - md5.update(obf_lsa_key[60:76]) - rc4key = md5.digest() - - rc4 = ARC4.new(rc4key) - lsa_key = rc4.decrypt( - obf_lsa_key[12:60] - ) # lgtm [py/weak-cryptographic-algorithm] - lsa_key = lsa_key[0x10:0x20] - else: - lsa_key = cls.decrypt_aes(obf_lsa_key, bootkey) - lsa_key = lsa_key[68:100] - return lsa_key - - @classmethod - def get_secret_by_name( - cls, - sechive: registry.RegistryHive, - name: str, - lsakey: bytes, - is_vista_or_later: bool, - ) -> Optional[bytes]: - enc_secret_key = hashdump.Hashdump.get_hive_key( - sechive, "Policy\\Secrets\\" + name + "\\CurrVal" - ) - - secret = None - if enc_secret_key: - try: - enc_secret_value = next(enc_secret_key.get_values(), None) - except ( - InvalidAddressException, - registry.RegistryException, - ): - enc_secret_value = None - - if enc_secret_value: - try: - enc_secret = sechive.read( - enc_secret_value.Data + 4, enc_secret_value.DataLength - ) - except exceptions.InvalidAddressExceptions: - return None - - if enc_secret: - if not is_vista_or_later: - secret = cls.decrypt_secret(enc_secret[0xC:], lsakey) - else: - secret = cls.decrypt_aes(enc_secret, lsakey) - - return secret - - @classmethod - def decrypt_secret(cls, secret: bytes, key: bytes) -> bytes: - """Python implementation of SystemFunction005. - - Decrypts a block of data with DES using given key. - Note that key can be longer than 7 bytes.""" - decrypted_data = b"" - j = 0 # key index - - for i in range(0, len(secret), 8): - enc_block = secret[i : i + 8] - block_key = key[j : j + 7] - des_key = hashdump.Hashdump.sidbytes_to_key(block_key) - des = DES.new(des_key, DES.MODE_ECB) - enc_block = enc_block + b"\x00" * int(abs(8 - len(enc_block)) % 8) - decrypted_data += des.decrypt( - enc_block - ) # lgtm [py/weak-cryptographic-algorithm] - j += 7 - if len(key[j : j + 7]) < 7: - j = len(key[j : j + 7]) - - (dec_data_len,) = unpack(" str: + """Sorts entries by program ID. This is broken out as a function here + to ensure consistency in sorting between the `group_by` and `sorted` function + invocations. + """ + program_id, _ = entry_tuple + key = program_id if isinstance(program_id, str) else "" + return key + + +def _get_string_value( + values: Dict[str, reg_extensions.CM_KEY_VALUE], name: str +) -> NullableString: + try: + value = values[name] + except KeyError: + return renderers.NotAvailableValue() + + data = value.decode_data() + if not isinstance(data, bytes): + return renderers.UnparsableValue() + + return data.decode("utf-16le", errors="replace").rstrip("\u0000") + + +def _get_datetime_filetime_value( + values: Dict[str, reg_extensions.CM_KEY_VALUE], name: str +) -> NullableDatetime: + try: + value = values[name] + except KeyError: + return renderers.NotAvailableValue() + + data = value.decode_data() + if not isinstance(data, int): + return renderers.UnparsableValue() + + return conversion.wintime_to_datetime(data) + + +def _get_datetime_utc_epoch_value( + values: Dict[str, reg_extensions.CM_KEY_VALUE], name: str +) -> NullableDatetime: + try: + value = values[name] + except KeyError: + return renderers.NotAvailableValue() + + data = value.decode_data() + if not isinstance(data, (int, float)): + return renderers.UnparsableValue() + + try: + return datetime.datetime.fromtimestamp(float(data), datetime.timezone.utc) + except (ValueError, OverflowError, OSError): + return renderers.UnparsableValue() + + +def _get_datetime_str_value( + values: Dict[str, reg_extensions.CM_KEY_VALUE], name: str +) -> NullableDatetime: + try: + value = values[name] + except KeyError: + return renderers.NotAvailableValue() + + data = value.decode_data() + if not isinstance(data, int): + return renderers.UnparsableValue() + + if isinstance(data, str): + try: + return datetime.datetime.strptime(data, "%m/%d/%Y %H:%M:%S") + except ValueError: + return renderers.UnparsableValue() + else: + return renderers.UnparsableValue() + + +class Amcache(interfaces.plugins.PluginInterface, timeliner.TimeLinerInterface): + """Extract information on executed applications from the AmCache.""" + + _required_framework_version = (2, 0, 0) + + # 2.0.0 - changed the signature of get_amcache_hive + _version = (2, 0, 0) + + @classmethod + def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]: + # Since we're calling the plugin, make sure we have the plugin's requirements + return [ + requirements.ModuleRequirement( + name="kernel", + description="Windows kernel", + architectures=["Intel32", "Intel64"], + ), + requirements.VersionRequirement( + name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) + ), + requirements.VersionRequirement( + name="timeliner", + component=timeliner.TimeLinerInterface, + version=(1, 0, 0), + ), + ] + + def generate_timeline( + self, + ) -> Iterator[Tuple[str, timeliner.TimeLinerType, datetime.datetime]]: + for _, entry in self._generator(): + if isinstance(entry.last_modify_time, datetime.datetime): + yield f"Amcache: {entry.entry_type} {entry.path} registry key modified", timeliner.TimeLinerType.MODIFIED, entry.last_modify_time + if isinstance(entry.last_modify_time_2, datetime.datetime): + yield f"Amcache: {entry.entry_type} {entry.path} STANDARD_INFORMATION create time", timeliner.TimeLinerType.CREATED, entry.last_modify_time_2 + if isinstance(entry.install_time, datetime.datetime): + yield f"Amcache: {entry.entry_type} {entry.path} installed", timeliner.TimeLinerType.CREATED, entry.install_time + if isinstance(entry.compile_time, datetime.datetime): + yield f"Amcache: {entry.entry_type} {entry.path} compiled (PE metadata)", timeliner.TimeLinerType.MODIFIED, entry.compile_time + + @classmethod + def get_amcache_hive( + cls, + context: interfaces.context.ContextInterface, + config_path: str, + kernel_module_name: str, + ) -> Optional[registry.RegistryHive]: + """Retrieves the `Amcache.hve` registry hive from the kernel module, if it can be located.""" + return next( + hivelist.HiveList.list_hives( + context=context, + base_config_path=interfaces.configuration.path_join( + config_path, "hivelist" + ), + kernel_module_name=kernel_module_name, + filter_string="amcache", + ), + None, + ) + + @classmethod + def parse_file_key( + cls, file_key: reg_extensions.CM_KEY_NODE + ) -> Iterator[Tuple[NullableString, _AmcacheEntry]]: + """Parses File entries from the Windows 8 `Root\\File` key. + + :param programs_key: The `Root\\File` registry key. + + :return: An iterator of tuples, where the first member is the program ID string for + correlating `Root\\Program` entries, and the second member is the `AmcacheEntry`. + """ + + val_enum = Win8FileValName + + wanted_values = [key.value for key in val_enum] + + for file_entry_key in itertools.chain( + *(key.get_subkeys() for key in file_key.get_subkeys()) + ): + vollog.debug(f"Checking Win8 File key {file_entry_key.get_name()}") + values = { + str(value.get_name()): value + for value in file_entry_key.get_values() + if value.get_name() in wanted_values + } + + program_id = _get_string_value(values, val_enum.ProgramID.value) + path = _get_string_value(values, val_enum.Path.value) + company = _get_string_value(values, val_enum.Company.value) + last_mod_time = _get_datetime_filetime_value( + values, val_enum.LastModTime.value + ) + last_mod_time_2 = _get_datetime_filetime_value( + values, val_enum.LastModTime2.value + ) + install_time = _get_datetime_filetime_value( + values, val_enum.CreateTime.value + ) + compile_time = _get_datetime_utc_epoch_value( + values, val_enum.CompileTime.value + ) + sha1_hash = _get_string_value(values, val_enum.SHA1Hash.value) + vollog.debug(f"Found sha1hash {sha1_hash}") + product_name = _get_string_value(values, val_enum.Product.value) + + yield program_id, _AmcacheEntry( + AmcacheEntryType.File.name, + path=path, + company=company, + last_modify_time=last_mod_time, + last_modify_time_2=last_mod_time_2, + install_time=install_time, + compile_time=compile_time, + sha1_hash=( + sha1_hash.lstrip("0000") + if isinstance(sha1_hash, str) + else sha1_hash + ), + product_name=product_name, + ) + + @classmethod + def parse_programs_key( + cls, programs_key: reg_extensions.CM_KEY_NODE + ) -> Iterator[Tuple[str, _AmcacheEntry]]: + """Parses Program entries from the Windows 8 `Root\\Programs` key. + + :param programs_key: The `Root\\Programs` registry key. + + :return: An iterator of tuples, where the first member is the program ID string for + correlating `Root\\File` entries, and the second member is the `AmcacheEntry`. + """ + val_enum = Win8ProgramValName + + wanted_values = [key.value for key in val_enum] + for program_key in programs_key.get_subkeys(): + values = { + str(value.get_name()): value + for value in program_key.get_values() + if value.get_name() in wanted_values + } + vollog.debug(f"Parsing Win8 Program key {program_key.get_name()}") + program_id = program_key.get_name().strip().strip("\u0000") + + product = _get_string_value(values, val_enum.Product.value) + company = _get_string_value(values, val_enum.Publisher.value) + install_time = _get_datetime_utc_epoch_value( + values, val_enum.InstallTime.value + ) + version = _get_string_value(values, val_enum.Version.value) + + yield program_id, _AmcacheEntry( + AmcacheEntryType.Program.name, + company=company, + last_modify_time=conversion.wintime_to_datetime( + program_key.LastWriteTime.QuadPart + ), + install_time=install_time, + product_name=product, + product_version=version, + ) + + @classmethod + def parse_inventory_app_key( + cls, inv_app_key: reg_extensions.CM_KEY_NODE + ) -> Iterator[Tuple[str, _AmcacheEntry]]: + """Parses InventoryApplication entries from the Windows 10 `Root\\InventoryApplication` key. + + :param programs_key: The `Root\\InventoryApplication` registry key. + + :return: An iterator of tuples, where the first member is the program ID string for + correlating `Root\\InventoryApplicationFile` entries, and the second member is the `AmcacheEntry`. + """ + val_enum = Win10InvAppValName + + wanted_values = [key.value for key in val_enum] + + for program_key in inv_app_key.get_subkeys(): + program_id = program_key.get_name() + + values = { + str(value.get_name()): value + for value in program_key.get_values() + if value.get_name() in wanted_values + } + + name = _get_string_value(values, val_enum.Name.value) + version = _get_string_value(values, val_enum.Version.value) + publisher = _get_string_value(values, val_enum.Publisher.value) + path = _get_string_value(values, val_enum.RootDirPath.value) + install_date = _get_datetime_str_value(values, val_enum.InstallDate.value) + last_mod = conversion.wintime_to_datetime( + program_key.LastWriteTime.QuadPart + ) + + product: str = name if isinstance(name, str) else "UNKNOWN" # type: ignore + + yield program_id.strip().strip("\u0000"), _AmcacheEntry( + AmcacheEntryType.Program.name, + path=path, + last_modify_time=last_mod, + install_time=install_date, + product_name=product, + company=publisher, + product_version=version, + ) + + @classmethod + def parse_inventory_app_file_key( + cls, inv_app_file_key: reg_extensions.CM_KEY_NODE + ) -> Iterator[Tuple[NullableString, _AmcacheEntry]]: + """Parses executable file entries from the `Root\\InventoryApplicationFile` registry key. + + :param inv_app_file_key: The `Root\\InventoryApplicationFile` registry key. + :return: An iterator of tuples, where the first member is the program ID string for correlating + with it's parent `InventoryApplication` program entry, and the second member is the `Amcache` entry. + """ + + val_enum = Win10InvAppFileValName + + wanted_values = [key.value for key in val_enum] + + for file_key in inv_app_file_key.get_subkeys(): + vollog.debug( + f"Parsing Win10 InventoryApplicationFile key {file_key.get_name()}" + ) + + values = { + str(value.get_name()): value + for value in file_key.get_values() + if value.get_name() in wanted_values + } + + last_mod = conversion.wintime_to_datetime(file_key.LastWriteTime.QuadPart) + path = _get_string_value(values, val_enum.LowerCaseLongPath.value) + linkdate = _get_datetime_str_value(values, val_enum.LinkDate.value) + sha1_hash = _get_string_value(values, val_enum.FileId.value) + publisher = _get_string_value(values, val_enum.Publisher.value) + prod_name = _get_string_value(values, val_enum.ProductName.value) + prod_ver = _get_string_value(values, val_enum.ProductVersion.value) + program_id = _get_string_value(values, val_enum.ProgramID.value) + + yield program_id, _AmcacheEntry( + AmcacheEntryType.File.name, + path=path, + company=publisher, + last_modify_time=last_mod, + compile_time=linkdate, + sha1_hash=( + sha1_hash.lstrip("0000") + if isinstance(sha1_hash, str) + else sha1_hash + ), + product_name=prod_name, + product_version=prod_ver, + ) + + @classmethod + def parse_driver_binary_key( + cls, driver_binary_key: reg_extensions.CM_KEY_NODE + ) -> Iterator[_AmcacheEntry]: + """Parses information about installed drivers from the `Root\\InventoryDriverBinary` registry key. + + :param driver_binary_key: The `Root\\InventoryDriverBinary` registry key + :return: An iterator of `AmcacheEntry`s + """ + val_enum = Win10DriverBinaryValName + + wanted_values = [key.value for key in val_enum] + + for binary_key in driver_binary_key.get_subkeys(): + + values = { + str(value.get_name()): value + for value in binary_key.get_values() + if value.get_name() in wanted_values + } + + # Depending on the Windows version, the key name will be either the name + # of the driver, or its SHA1 hash. + if "/" in str(binary_key.get_name()): + driver_name = str(binary_key.get_name()) + sha1_hash = _get_string_value(values, val_enum.DriverId.name) + else: + sha1_hash = str(binary_key.get_name()) + driver_name = _get_string_value(values, val_enum.DriverName.name) + + if isinstance(sha1_hash, str): + sha1_hash = sha1_hash[4:] if sha1_hash.startswith("0000") else sha1_hash + + company, product, service, last_write_time, driver_timestamp = ( + _get_string_value(values, val_enum.DriverCompany.name), + _get_string_value(values, val_enum.Product.name), + _get_string_value(values, val_enum.Service.name), + conversion.wintime_to_datetime(binary_key.LastWriteTime.QuadPart), + _get_datetime_utc_epoch_value(values, val_enum.DriverTimeStamp.name), + ) + + yield _AmcacheEntry( + entry_type=AmcacheEntryType.Driver.name, + path=driver_name, + company=company, + last_modify_time=last_write_time, + compile_time=driver_timestamp, + sha1_hash=( + sha1_hash.lstrip("0000") + if isinstance(sha1_hash, str) + else sha1_hash + ), + service=service, + product_name=product, + ) + + def _generator(self) -> Iterator[Tuple[int, _AmcacheEntry]]: + def indented( + entry_gen: Iterable[_AmcacheEntry], indent: int = 0 + ) -> Iterator[Tuple[int, _AmcacheEntry]]: + for item in entry_gen: + yield indent, item + + # Building the dictionary ahead of time is much better for performance + # vs looking up each service's DLL individually. + amcache = self.get_amcache_hive( + self.context, self.config_path, self.config["kernel"] + ) + if amcache is None: + return + + try: + yield from indented( + self.parse_driver_binary_key( + amcache.get_key("Root\\InventoryDriverBinary") # type: ignore + ) + ) + except (KeyError, registry.RegistryException): + # Registry key not found + pass + + try: + programs: Dict[str, _AmcacheEntry] = { + program_id: entry + for program_id, entry in self.parse_programs_key( + amcache.get_key("Root\\Programs") + ) # type: ignore + } + except (KeyError, registry.RegistryException): + programs = {} + + try: + files = sorted( + list( + self.parse_file_key(amcache.get_key("Root\\File")), # type: ignore + ), + key=_entry_sort_key, + ) + except (KeyError, registry.RegistryException): + files = [] + + for program_id, file_entries in itertools.groupby( + files, + key=_entry_sort_key, + ): + files_indent = 0 + if isinstance(program_id, str): + try: + program_entry = programs.pop(program_id.strip().strip("\u0000")) + yield (0, program_entry) + + files_indent = 1 + except KeyError: + # No parent program for this file entry + pass + for _, entry in file_entries: + yield files_indent, entry + + for empty_program in programs.values(): + yield 0, empty_program + + try: + programs: Dict[str, _AmcacheEntry] = dict( + self.parse_inventory_app_key( + amcache.get_key("Root\\InventoryApplication") # type: ignore + ) + ) + except (KeyError, registry.RegistryException): + programs = {} + + try: + files = sorted( + list( + self.parse_inventory_app_file_key( + amcache.get_key("Root\\InventoryApplicationFile") + ), + # type: ignore + ), + key=_entry_sort_key, + ) + except (KeyError, registry.RegistryException): + files = [] + + for program_id, file_entries in itertools.groupby( + files, + key=_entry_sort_key, + ): + files_indent = 0 + + if isinstance(program_id, str): + try: + program_entry = programs.pop(program_id.strip().strip("\u0000")) + yield (0, program_entry) + files_indent = 1 + except KeyError: + # No parent program for this file entry + pass + + for _, entry in file_entries: + yield files_indent, entry + + for empty_program in programs.values(): + yield 0, empty_program + + def run(self): + + return renderers.TreeGrid( + [ + ("EntryType", str), + ("Path", str), + ("Company", str), + ("LastModifyTime", datetime.datetime), + ("LastModifyTime2", datetime.datetime), + ("InstallTime", datetime.datetime), + ("CompileTime", datetime.datetime), + ("SHA1", str), + ("Service", str), + ("ProductName", str), + ("ProductVersion", str), + ], + ( + (indent, dataclasses.astuple(entry)) + for indent, entry in self._generator() + ), + ) diff --git a/volatility3/framework/plugins/windows/registry/cachedump.py b/volatility3/framework/plugins/windows/registry/cachedump.py new file mode 100644 index 000000000..49c5495c2 --- /dev/null +++ b/volatility3/framework/plugins/windows/registry/cachedump.py @@ -0,0 +1,186 @@ +# This file is Copyright 2020 Volatility Foundation and licensed under the Volatility Software License 1.0 +# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 +# +import logging +from struct import unpack +from typing import Tuple + +from Crypto.Cipher import ARC4, AES +from Crypto.Hash import HMAC + +from volatility3.framework import interfaces, renderers, exceptions +from volatility3.framework.configuration import requirements +from volatility3.framework.layers import registry +from volatility3.framework.symbols.windows import versions +from volatility3.plugins.windows.registry import hashdump, hivelist, lsadump + +vollog = logging.getLogger(__name__) + + +class Cachedump(interfaces.plugins.PluginInterface): + """Dumps lsa secrets from memory""" + + _required_framework_version = (2, 0, 0) + _version = (1, 0, 2) + + @classmethod + def get_requirements(cls): + return [ + requirements.ModuleRequirement( + name="kernel", + description="Windows kernel", + architectures=["Intel32", "Intel64"], + ), + requirements.VersionRequirement( + name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) + ), + requirements.VersionRequirement( + name="lsadump", component=lsadump.Lsadump, version=(1, 0, 0) + ), + requirements.VersionRequirement( + name="hashdump", component=hashdump.Hashdump, version=(1, 1, 0) + ), + ] + + @classmethod + def get_nlkm( + cls, sechive: registry.RegistryHive, lsakey: bytes, is_vista_or_later: bool + ): + return lsadump.Lsadump.get_secret_by_name( + sechive, "NL$KM", lsakey, is_vista_or_later + ) + + @classmethod + def decrypt_hash(cls, edata: bytes, nlkm: bytes, ch, xp: bool): + if xp: + hmac_md5 = HMAC.new(nlkm, ch) + rc4key = hmac_md5.digest() + rc4 = ARC4.new(rc4key) + data = rc4.encrypt(edata) # lgtm [py/weak-cryptographic-algorithm] + else: + # Based on code from http://lab.mediaservice.net/code/cachedump.rb + aes = AES.new(nlkm[16:32], AES.MODE_CBC, ch) + data = b"" + for i in range(0, len(edata), 16): + buf = edata[i : i + 16] + if len(buf) < 16: + buf += (16 - len(buf)) * b"\00" + data += aes.decrypt(buf) + return data + + @classmethod + def parse_cache_entry(cls, cache_data: bytes) -> Tuple[int, int, int, bytes, bytes]: + (uname_len, domain_len) = unpack(" Tuple[str, str, str, bytes]: + """Get the data from the cache and separate it into the username, domain name, and hash data""" + uname_offset = 72 + pad = 2 * ((uname_len / 2) % 2) + domain_offset = int(uname_offset + uname_len + pad) + pad = 2 * ((domain_len / 2) % 2) + domain_name_offset = int(domain_offset + domain_len + pad) + hashh = dec_data[:0x10] + username = dec_data[uname_offset : uname_offset + uname_len].decode( + "utf-16-le", "replace" + ) + domain = dec_data[domain_offset : domain_offset + domain_len].decode( + "utf-16-le", "replace" + ) + domain_name = dec_data[ + domain_name_offset : domain_name_offset + domain_name_len + ].decode("utf-16-le", "replace") + + return (username, domain, domain_name, hashh) + + def _generator(self, syshive, sechive): + if not syshive or not sechive: + if syshive is None: + vollog.warning("Unable to locate SYSTEM hive") + if sechive is None: + vollog.warning("Unable to locate SECURITY hive") + return None + + bootkey = hashdump.Hashdump.get_bootkey(syshive) + if not bootkey: + vollog.warning("Unable to find bootkey") + return None + + kernel = self.context.modules[self.config["kernel"]] + + vista_or_later = versions.is_vista_or_later( + context=self.context, symbol_table=kernel.symbol_table_name + ) + + lsakey = lsadump.Lsadump.get_lsa_key(sechive, bootkey, vista_or_later) + if not lsakey: + vollog.warning("Unable to find lsa key") + return None + + nlkm = self.get_nlkm(sechive, lsakey, vista_or_later) + if not nlkm: + vollog.warning("Unable to find nlkma key") + return None + + cache = hashdump.Hashdump.get_hive_key(sechive, "Cache") + if not cache: + vollog.warning("Unable to find cache key") + return None + + for cache_item in cache.get_values(): + if cache_item.Name == "NL$Control": + continue + + try: + data = sechive.read(cache_item.Data + 4, cache_item.DataLength) + except exceptions.InvalidAddressException: + continue + + if not data: + continue + + ( + uname_len, + domain_len, + domain_name_len, + enc_data, + ch, + ) = self.parse_cache_entry(data) + # Skip if nothing in this cache entry + if uname_len == 0 or len(ch) == 0: + continue + dec_data = self.decrypt_hash(enc_data, nlkm, ch, not vista_or_later) + + (username, domain, domain_name, hashh) = self.parse_decrypted_cache( + dec_data, uname_len, domain_len, domain_name_len + ) + yield (0, (username, domain, domain_name, hashh)) + + def run(self): + offset = self.config.get("offset", None) + + syshive = sechive = None + + for hive in hivelist.HiveList.list_hives( + context=self.context, + base_config_path=self.config_path, + kernel_module_name=self.config["kernel"], + hive_offsets=None if offset is None else [offset], + ): + if hive.get_name().split("\\")[-1].upper() == "SYSTEM": + syshive = hive + if hive.get_name().split("\\")[-1].upper() == "SECURITY": + sechive = hive + + return renderers.TreeGrid( + [("Username", str), ("Domain", str), ("Domain name", str), ("Hash", bytes)], + self._generator(syshive, sechive), + ) diff --git a/volatility3/framework/plugins/windows/registry/hashdump.py b/volatility3/framework/plugins/windows/registry/hashdump.py new file mode 100644 index 000000000..630aa1cfd --- /dev/null +++ b/volatility3/framework/plugins/windows/registry/hashdump.py @@ -0,0 +1,643 @@ +# This file is Copyright 2020 Volatility Foundation and licensed under the Volatility Software License 1.0 +# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 +# +import binascii +import hashlib +import logging +from struct import pack, unpack +from typing import List, Optional, Tuple + +from Crypto.Cipher import AES, ARC4, DES + +from volatility3.framework import interfaces, renderers, exceptions, constants +from volatility3.framework.configuration import requirements +from volatility3.framework.exceptions import InvalidAddressException +from volatility3.framework.layers import registry as registrylayer +from volatility3.framework.symbols.windows.extensions import registry +from volatility3.plugins.windows.registry import hivelist + +vollog = logging.getLogger(__name__) + + +class Hashdump(interfaces.plugins.PluginInterface): + """Dumps user hashes from memory""" + + _required_framework_version = (2, 0, 0) + _version = (1, 1, 1) + + @classmethod + def get_requirements(cls): + return [ + requirements.ModuleRequirement( + name="kernel", + description="Windows kernel", + architectures=["Intel32", "Intel64"], + ), + requirements.VersionRequirement( + name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) + ), + ] + + odd_parity = [ + 1, + 1, + 2, + 2, + 4, + 4, + 7, + 7, + 8, + 8, + 11, + 11, + 13, + 13, + 14, + 14, + 16, + 16, + 19, + 19, + 21, + 21, + 22, + 22, + 25, + 25, + 26, + 26, + 28, + 28, + 31, + 31, + 32, + 32, + 35, + 35, + 37, + 37, + 38, + 38, + 41, + 41, + 42, + 42, + 44, + 44, + 47, + 47, + 49, + 49, + 50, + 50, + 52, + 52, + 55, + 55, + 56, + 56, + 59, + 59, + 61, + 61, + 62, + 62, + 64, + 64, + 67, + 67, + 69, + 69, + 70, + 70, + 73, + 73, + 74, + 74, + 76, + 76, + 79, + 79, + 81, + 81, + 82, + 82, + 84, + 84, + 87, + 87, + 88, + 88, + 91, + 91, + 93, + 93, + 94, + 94, + 97, + 97, + 98, + 98, + 100, + 100, + 103, + 103, + 104, + 104, + 107, + 107, + 109, + 109, + 110, + 110, + 112, + 112, + 115, + 115, + 117, + 117, + 118, + 118, + 121, + 121, + 122, + 122, + 124, + 124, + 127, + 127, + 128, + 128, + 131, + 131, + 133, + 133, + 134, + 134, + 137, + 137, + 138, + 138, + 140, + 140, + 143, + 143, + 145, + 145, + 146, + 146, + 148, + 148, + 151, + 151, + 152, + 152, + 155, + 155, + 157, + 157, + 158, + 158, + 161, + 161, + 162, + 162, + 164, + 164, + 167, + 167, + 168, + 168, + 171, + 171, + 173, + 173, + 174, + 174, + 176, + 176, + 179, + 179, + 181, + 181, + 182, + 182, + 185, + 185, + 186, + 186, + 188, + 188, + 191, + 191, + 193, + 193, + 194, + 194, + 196, + 196, + 199, + 199, + 200, + 200, + 203, + 203, + 205, + 205, + 206, + 206, + 208, + 208, + 211, + 211, + 213, + 213, + 214, + 214, + 217, + 217, + 218, + 218, + 220, + 220, + 223, + 223, + 224, + 224, + 227, + 227, + 229, + 229, + 230, + 230, + 233, + 233, + 234, + 234, + 236, + 236, + 239, + 239, + 241, + 241, + 242, + 242, + 244, + 244, + 247, + 247, + 248, + 248, + 251, + 251, + 253, + 253, + 254, + 254, + ] + + # Permutation matrix for boot key + bootkey_perm_table = [ + 0x8, + 0x5, + 0x4, + 0x2, + 0xB, + 0x9, + 0xD, + 0x3, + 0x0, + 0x6, + 0x1, + 0xC, + 0xE, + 0xA, + 0xF, + 0x7, + ] + + # Constants for SAM decrypt algorithm + aqwerty = b"!@#$%^&*()qwertyUIOPAzxcvbnmQQQQQQQQQQQQ)(*@&%\0" + anum = b"0123456789012345678901234567890123456789\0" + antpassword = b"NTPASSWORD\0" + almpassword = b"LMPASSWORD\0" + lmkey = b"KGS!@#$%" + + empty_lm = b"\xaa\xd3\xb4\x35\xb5\x14\x04\xee\xaa\xd3\xb4\x35\xb5\x14\x04\xee" + empty_nt = b"\x31\xd6\xcf\xe0\xd1\x6a\xe9\x31\xb7\x3c\x59\xd7\xe0\xc0\x89\xc0" + + @classmethod + def get_hive_key( + cls, hive: registry.RegistryHive, key: str + ) -> Optional["registry.CM_KEY_NODE"]: + result = None + try: + if hive: + result = hive.get_key(key) + except (KeyError, registrylayer.RegistryException): + vollog.info( + f"Unable to load the required registry key {hive.get_name()}\\{key} from this memory image" + ) + return result + + @classmethod + def get_user_keys( + cls, samhive: registry.RegistryHive + ) -> List[interfaces.objects.ObjectInterface]: + user_key_path = "SAM\\Domains\\Account\\Users" + + user_key = cls.get_hive_key(samhive, user_key_path) + + if not user_key: + return [] + return [k for k in user_key.get_subkeys() if k.Name != "Names"] + + @classmethod + def get_bootkey(cls, syshive: registry.RegistryHive) -> Optional[bytes]: + """ + Returns the scrambled bootkey necesary to decrypt hashes + """ + cs = 1 + lsa_base = f"ControlSet{cs:03}" + "\\Control\\Lsa" + lsa_keys = ["JD", "Skew1", "GBG", "Data"] + + lsa = cls.get_hive_key(syshive, lsa_base) + if not lsa: + return None + + bootkey = "" + + for lk in lsa_keys: + try: + key = cls.get_hive_key(syshive, lsa_base + "\\" + lk) + class_data = None + if key: + try: + class_data = syshive.read(key.Class + 4, key.ClassLength) + except exceptions.InvalidAddressException: + return None + + if class_data is None: + return None + bootkey += class_data.decode("utf-16-le") + except ( + InvalidAddressException, + registrylayer.RegistryException, + ) as excp: + vollog.log( + constants.LOGLEVEL_VVV, f"Unable to read Lsa key {lk}: {excp}" + ) + return None + + bootkey_str = binascii.unhexlify(bootkey) + bootkey_scrambled = bytes( + [bootkey_str[cls.bootkey_perm_table[i]] for i in range(len(bootkey_str))] + ) + return bootkey_scrambled + + @classmethod + def get_hbootkey( + cls, samhive: registry.RegistryHive, bootkey: bytes + ) -> Optional[bytes]: + sam_account_path = "SAM\\Domains\\Account" + + if not bootkey: + return None + + sam_account_key = cls.get_hive_key(samhive, sam_account_path) + if not sam_account_key: + return None + + sam_data = None + for v in sam_account_key.get_values(): + if v.get_name() == "F": + try: + sam_data = samhive.read(v.Data + 4, v.DataLength) + except exceptions.InvalidAddressException: + return None + + if not sam_data: + return None + + revision = sam_data[0x00] + if revision == 2: + md5 = hashlib.md5() + + md5.update(sam_data[0x70:0x80] + cls.aqwerty + bootkey + cls.anum) + rc4_key = md5.digest() + + rc4 = ARC4.new(rc4_key) + hbootkey = rc4.encrypt( + sam_data[0x80:0xA0] + ) # lgtm [py/weak-cryptographic-algorithm] + return hbootkey + elif revision == 3: + # AES encrypted + iv = sam_data[0x78:0x88] + encryptedHBootKey = sam_data[0x88:0xA8] + cipher = AES.new(bootkey, AES.MODE_CBC, iv) + hbootkey = cipher.decrypt(encryptedHBootKey) + return hbootkey[:16] + return None + + @classmethod + def decrypt_single_salted_hash( + cls, rid, hbootkey: bytes, enc_hash: bytes, _lmntstr, salt: bytes + ) -> Optional[bytes]: + (des_k1, des_k2) = cls.sid_to_key(rid) + des1 = DES.new(des_k1, DES.MODE_ECB) + des2 = DES.new(des_k2, DES.MODE_ECB) + cipher = AES.new(hbootkey[:16], AES.MODE_CBC, salt) + obfkey = cipher.decrypt(enc_hash) + return des1.decrypt(obfkey[:8]) + des2.decrypt( + obfkey[8:16] + ) # lgtm [py/weak-cryptographic-algorithm] + + @classmethod + def get_user_hashes( + cls, user: registry.CM_KEY_NODE, samhive: registry.RegistryHive, hbootkey: bytes + ) -> Optional[Tuple[bytes, bytes]]: + ## Will sometimes find extra user with rid = NAMES, returns empty strings right now + try: + rid = int(str(user.get_name()), 16) + except ValueError: + return None + sam_data = None + for v in user.get_values(): + if v.get_name() == "V": + try: + sam_data = samhive.read(v.Data + 4, v.DataLength) + except ( + exceptions.InvalidAddressException, + registrylayer.RegistryException, + ): + return None + + if not sam_data: + return None + + lm_offset = unpack(" Tuple[bytes, bytes]: + """Takes rid of a user and converts it to a key to be used by the DES cipher""" + bytestr1 = [ + sid & 0xFF, + (sid >> 8) & 0xFF, + (sid >> 16) & 0xFF, + (sid >> 24) & 0xFF, + ] + bytestr1 += bytestr1[0:3] + bytestr2 = [bytestr1[3]] + bytestr1[0:3] + bytestr2 += bytestr2[0:3] + return cls.sidbytes_to_key(bytes(bytestr1)), cls.sidbytes_to_key( + bytes(bytestr2) + ) + + @classmethod + def sidbytes_to_key(cls, s: bytes) -> bytes: + """Builds final DES key from the strings generated in sid_to_key""" + key = [ + s[0] >> 1, + ((s[0] & 0x01) << 6) | (s[1] >> 2), + ((s[1] & 0x03) << 5) | (s[2] >> 3), + ((s[2] & 0x07) << 4) | (s[3] >> 4), + ((s[3] & 0x0F) << 3) | (s[4] >> 5), + ((s[4] & 0x1F) << 2) | (s[5] >> 6), + ((s[5] & 0x3F) << 1) | (s[6] >> 7), + s[6] & 0x7F, + ] + for i in range(8): + key[i] = key[i] << 1 + key[i] = cls.odd_parity[key[i]] + return bytes(key) + + @classmethod + def decrypt_single_hash( + cls, rid: int, hbootkey: bytes, enc_hash: bytes, lmntstr: bytes + ): + (des_k1, des_k2) = cls.sid_to_key(rid) + des1 = DES.new(des_k1, DES.MODE_ECB) + des2 = DES.new(des_k2, DES.MODE_ECB) + md5 = hashlib.md5() + + md5.update(hbootkey[:0x10] + pack(" Optional[bytes]: + value = None + for v in user.get_values(): + if v.get_name() == "V": + try: + value = samhive.read(v.Data + 4, v.DataLength) + except exceptions.InvalidAddressException: + return None + + if not value: + return None + + name_offset = unpack(" len(value): + return None + + username = value[name_offset : name_offset + name_length] + return username + + # replaces the dump_hashes method in vol2 + def _generator( + self, syshive: registry.RegistryHive, samhive: registry.RegistryHive + ): + if syshive is None: + vollog.debug("SYSTEM address is None: No system hive found") + if samhive is None: + vollog.debug("SAM address is None: No SAM hive found") + bootkey = self.get_bootkey(syshive) + hbootkey = self.get_hbootkey(samhive, bootkey) + if hbootkey: + for user in self.get_user_keys(samhive): + ret = self.get_user_hashes(user, samhive, hbootkey) + if ret: + lmhash, nthash = ret + + ## temporary fix to prevent UnicodeDecodeError backtraces + ## however this can cause truncated user names as a result + name = self.get_user_name(user, samhive) + if name is None: + name = renderers.NotAvailableValue() + else: + name = str(name, "utf-16-le", errors="ignore") + + lmout = str(binascii.hexlify(lmhash or self.empty_lm), "latin-1") + ntout = str(binascii.hexlify(nthash or self.empty_nt), "latin-1") + rid = int(str(user.get_name()), 16) + yield (0, (name, rid, lmout, ntout)) + else: + vollog.warning("Hbootkey is not valid") + + def run(self): + offset = self.config.get("offset", None) + syshive = None + samhive = None + for hive in hivelist.HiveList.list_hives( + context=self.context, + base_config_path=self.config_path, + kernel_module_name=self.config["kernel"], + hive_offsets=None if offset is None else [offset], + ): + if hive.get_name().split("\\")[-1].upper() == "SYSTEM": + syshive = hive + if hive.get_name().split("\\")[-1].upper() == "SAM": + samhive = hive + + return renderers.TreeGrid( + [("User", str), ("rid", int), ("lmhash", str), ("nthash", str)], + self._generator(syshive, samhive), + ) diff --git a/volatility3/framework/plugins/windows/registry/lsadump.py b/volatility3/framework/plugins/windows/registry/lsadump.py new file mode 100644 index 000000000..2154923ec --- /dev/null +++ b/volatility3/framework/plugins/windows/registry/lsadump.py @@ -0,0 +1,257 @@ +# This file is Copyright 2020 Volatility Foundation and licensed under the Volatility Software License 1.0 +# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 +# +import logging +from struct import unpack +from typing import Optional +import hashlib + +from Crypto.Cipher import ARC4, DES, AES + +from volatility3.framework import interfaces, renderers, exceptions +from volatility3.framework.configuration import requirements +from volatility3.framework.exceptions import InvalidAddressException + +from volatility3.framework.layers import registry +from volatility3.framework.symbols.windows import versions +from volatility3.plugins.windows.registry import hashdump, hivelist +from volatility3.framework.renderers import format_hints + +vollog = logging.getLogger(__name__) + + +class Lsadump(interfaces.plugins.PluginInterface): + """Dumps lsa secrets from memory""" + + _required_framework_version = (2, 0, 0) + _version = (1, 0, 1) + + @classmethod + def get_requirements(cls): + return [ + requirements.ModuleRequirement( + name="kernel", + description="Windows kernel", + architectures=["Intel32", "Intel64"], + ), + requirements.VersionRequirement( + name="hashdump", component=hashdump.Hashdump, version=(1, 1, 0) + ), + requirements.VersionRequirement( + name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) + ), + ] + + @classmethod + def decrypt_aes(cls, secret: bytes, key: bytes) -> bytes: + """ + Based on code from http://lab.mediaservice.net/code/cachedump.rb + """ + sha = hashlib.sha256() + sha.update(key) + for _i in range(1, 1000 + 1): + sha.update(secret[28:60]) + aeskey = sha.digest() + + data = b"" + for i in range(60, len(secret), 16): + aes = AES.new(aeskey, AES.MODE_CBC, b"\x00" * 16) + buf = secret[i : i + 16] + if len(buf) < 16: + buf += (16 - len(buf)) * "\00" + data += aes.decrypt(buf) + + return data + + @classmethod + def get_lsa_key( + cls, sechive: registry.RegistryHive, bootkey: bytes, vista_or_later: bool + ) -> Optional[bytes]: + if not bootkey: + return None + + if vista_or_later: + policy_key = "PolEKList" + else: + policy_key = "PolSecretEncryptionKey" + + enc_reg_key = hashdump.Hashdump.get_hive_key(sechive, "Policy\\" + policy_key) + if not enc_reg_key: + return None + enc_reg_value = next(enc_reg_key.get_values(), None) + if not enc_reg_value: + return None + + try: + obf_lsa_key = sechive.read(enc_reg_value.Data + 4, enc_reg_value.DataLength) + except exceptions.InvalidAddressException: + return None + + if not obf_lsa_key: + return None + if not vista_or_later: + md5 = hashlib.md5() + md5.update(bootkey) + for _i in range(1000): + md5.update(obf_lsa_key[60:76]) + rc4key = md5.digest() + + rc4 = ARC4.new(rc4key) + lsa_key = rc4.decrypt( + obf_lsa_key[12:60] + ) # lgtm [py/weak-cryptographic-algorithm] + lsa_key = lsa_key[0x10:0x20] + else: + lsa_key = cls.decrypt_aes(obf_lsa_key, bootkey) + lsa_key = lsa_key[68:100] + return lsa_key + + @classmethod + def get_secret_by_name( + cls, + sechive: registry.RegistryHive, + name: str, + lsakey: bytes, + is_vista_or_later: bool, + ) -> Optional[bytes]: + enc_secret_key = hashdump.Hashdump.get_hive_key( + sechive, "Policy\\Secrets\\" + name + "\\CurrVal" + ) + + secret = None + if enc_secret_key: + try: + enc_secret_value = next(enc_secret_key.get_values(), None) + except ( + InvalidAddressException, + registry.RegistryException, + ): + enc_secret_value = None + + if enc_secret_value: + try: + enc_secret = sechive.read( + enc_secret_value.Data + 4, enc_secret_value.DataLength + ) + except exceptions.InvalidAddressExceptions: + return None + + if enc_secret: + if not is_vista_or_later: + secret = cls.decrypt_secret(enc_secret[0xC:], lsakey) + else: + secret = cls.decrypt_aes(enc_secret, lsakey) + + return secret + + @classmethod + def decrypt_secret(cls, secret: bytes, key: bytes) -> bytes: + """Python implementation of SystemFunction005. + + Decrypts a block of data with DES using given key. + Note that key can be longer than 7 bytes.""" + decrypted_data = b"" + j = 0 # key index + + for i in range(0, len(secret), 8): + enc_block = secret[i : i + 8] + block_key = key[j : j + 7] + des_key = hashdump.Hashdump.sidbytes_to_key(block_key) + des = DES.new(des_key, DES.MODE_ECB) + enc_block = enc_block + b"\x00" * int(abs(8 - len(enc_block)) % 8) + decrypted_data += des.decrypt( + enc_block + ) # lgtm [py/weak-cryptographic-algorithm] + j += 7 + if len(key[j : j + 7]) < 7: + j = len(key[j : j + 7]) + + (dec_data_len,) = unpack(" and repeat every days + Daily = "Daily" + + # run on days of week <(data2 as day_of_week bitmap)> every weeks starting at + Weekly = "Weekly" + + # run in months <(data3 as months bitmap> on days <(data2:data1 as day in month bitmap)> + # starting at + DaysInMonths = "Days In Months" + + # run in months <(data3 as months bitmap> in weeks <(data2 as week bitmap)> + # on days <(data1 as day_of_week bitmap)> starting at + DaysInWeeksInMonths = "Days In Weeks in Months" + + Unknown = "Unknown" + + +TIME_MODE_DESCRIPTION = { + TimeMode.Once: "Once", + TimeMode.Daily: "Daily", + TimeMode.Weekly: "Weekly", + TimeMode.DaysInMonths: "Days In Months", + TimeMode.DaysInWeeksInMonths: "Days In Weeks In Months", + TimeMode.Unknown: "Unknown", +} + + +class ActionType(enum.Enum): + """ + Enumeration that maps action types to their magic number encodings + """ + + Exe = 0x6666 + ComHandler = 0x7777 + Email = 0x8888 + MessageBox = 0x9999 + + +class TriggerType(enum.Enum): + """ + Enumeration that maps trigger types to their magic number encodings + """ + + WindowsNotificationFacility = 0x6666 + Session = 0x7777 + Registration = 0x8888 + Logon = 0xAAAA + Event = 0xCCCC + Time = 0xDDDD + Idle = 0xEEEE + Boot = 0xFFFF + + +class Weekday(enum.Enum): + """ + Enumeration that contains bitwise values for days of the week. + """ + + Sunday = 0x1 + Monday = 0x2 + Tuesday = 0x4 + Wednesday = 0x8 + Thursday = 0x10 + Friday = 0x20 + Saturday = 0x40 + + +class Months(enum.Enum): + """ + Enumeration that contains bitwise values for months of the year. + """ + + January = 0x1 + February = 0x2 + March = 0x4 + April = 0x8 + May = 0x10 + June = 0x20 + July = 0x40 + August = 0x80 + September = 0x100 + October = 0x200 + November = 0x400 + December = 0x800 + + +class SidType(enum.Enum): + """ + Enumeration that maps SID types to their encoded integer values + """ + + User = 1 + Group = 2 + Domain = 3 + Alias = 4 + WellKnownGroup = 5 + DeletedAccount = 6 + Invalid = 7 + Unknown = 8 + Computer = 9 + Label = 10 + LogonSession = 11 + + +@dataclasses.dataclass +class TaskSchedulerTimePeriod: + """ + Class containing information delimiting time periods within scheduled tasks. + """ + + years: int + months: int + weeks: int + days: int + hours: int + minutes: int + seconds: int + + +JOB_BUCKET_FLAGS = { + 0x2: "Run only if idle", + 0x4: "Restart on idle", + 0x8: "Stop on idle end", + 0x10: "Disallow start if on batteries", + 0x20: "Stop if going on batteries", + 0x40: "Start when available", + 0x80: "Run only if network available", + 0x100: "Allow start on demand", + 0x200: "Wake to run", + 0x400: "Execute parallel", + 0x800: "Execute stop existing", + 0x1000: "Execute queue", + 0x2000: "Execute ignore new", + 0x4000: "Logon type s4u", + 0x10000: "Logon type InteractiveToken", + 0x40000: "Logon type Password", + 0x80000: "Logon type InteractiveTokenOrPassword", + 0x400000: "Enabled", + 0x800000: "Hidden", + 0x1000000: "Runlevel highest available", + 0x2000000: "Task", + 0x4000000: "Version", + 0x8000000: "Token SID type none", + 0x10000000: "Token SID type unrestricted", + 0x20000000: "Interval", + 0x40000000: "Allow hard terminate", +} + +NULL = "\u0000" + + +class _ScheduledTasksReader(io.BytesIO): + + def read_task_scheduler_time(self) -> Optional[datetime.datetime]: + _ = bool(self.read_aligned_u1()) # is_localized + filetime = self.decode_filetime() + if filetime is None: + return None + + return filetime + + def read_bool(self, aligned=False) -> Optional[bool]: + try: + val = struct.unpack("?", self.read(1))[0] + if aligned: + self.seek(7) + return val + except struct.error: + return None + + def decode_filetime(self) -> Optional[datetime.datetime]: + filetime = self.read_u8() + if filetime is None: + return None + + if filetime == 0 or filetime == 0xFFFFFFFFFFFFFFFF: + return None + filetime = conversion.wintime_to_datetime(filetime) + if isinstance(filetime, datetime.datetime): + return filetime + else: + return None + + def _read_uint( + self, size: int, format: str, aligned: bool = False + ) -> Optional[int]: + try: + val = struct.unpack(format, self.read(size))[0] + if aligned: + self.seek(8 - size, io.SEEK_CUR) + return val + except struct.error: + return None + + def read_aligned_u1(self) -> Optional[int]: + return self._read_uint(1, "B", True) + + def read_u2(self) -> Optional[int]: + return self._read_uint(2, " Optional[int]: + return self._read_uint(2, " Optional[int]: + return self._read_uint(4, " Optional[int]: + return self._read_uint(8, " Optional[int]: + return self._read_uint(4, " Optional[bytes]: + count = self.read_u4() if not aligned else self.read_aligned_u4() + if count is None: + return None + data = self.read(count) + if aligned: + self.seek((8 - (count % 8)) % 8, io.SEEK_CUR) + return data + + def read_bstring(self, aligned=False) -> Optional[str]: + size = self.read_u4() if not aligned else self.read_aligned_u4() + if size is None: + return None + try: + raw = self.read(size) + val = raw.decode("utf-16le", errors="replace").rstrip(NULL) or None + except UnicodeDecodeError: + val = None + + if aligned: + self.seek((8 - (size % 8)) % 8, io.SEEK_CUR) + + return val + + def read_aligned_bstring_expand_sz(self) -> Optional[str]: + sz = self.read_aligned_u4() + if sz is None: + return None + byte_count = sz * 2 + 2 + + if sz == 0: + return None + + try: + content = self.read(byte_count).decode("utf-16le") + except UnicodeDecodeError: + content = None + + self.seek((8 - (byte_count % 8)) % 8, io.SEEK_CUR) + return content.rstrip("\x00") if content is not None else None + + def read_tstimeperiod(self) -> Optional[TaskSchedulerTimePeriod]: + values = ( + self.read_u2(), + self.read_u2(), + self.read_u2(), + self.read_u2(), + self.read_u2(), + self.read_u2(), + self.read_u2(), + ) + + if any(value is None for value in values): + return None + + return TaskSchedulerTimePeriod(*values) + + +def _build_guid_name_map(key: reg_extensions.CM_KEY_NODE) -> Dict[str, str]: + mapping = {} + task_id_value = None + for value in key.get_values(): + try: + if value.get_name() == "Id": + task_id_value = value + break + except ( + exceptions.InvalidAddressException, + registry.RegistryException, + ): + continue + + if ( + task_id_value is not None + and task_id_value.get_type() == reg_extensions.RegValueTypes.REG_SZ + ): + try: + id_str = task_id_value.decode_data() + except exceptions.InvalidAddressException: + id_str = None + + try: + if isinstance(id_str, bytes): + mapping[id_str.decode("utf-16le", errors="replace").rstrip(NULL)] = str( + key.get_name() + ) + except ( + exceptions.InvalidAddressException, + registry.RegistryException, + ) as excp: + vollog.debug(f"Exception occurred while decoding id_str: {excp}") + + for subkey in key.get_subkeys(): + mapping.update(_build_guid_name_map(subkey)) + return mapping + + +@dataclasses.dataclass +class TaskAction: + action_type: ActionType + action: str + action_args: Optional[str] + working_directory: Optional[str] + + @classmethod + def decode_messagebox_action( + cls, reader: _ScheduledTasksReader + ) -> Optional["TaskAction"]: + caption, content = reader.read_bstring(), reader.read_bstring() + return cls( + ActionType.MessageBox, + f'"{caption or ""}": {content or ""}', + None, + None, + ) + + @classmethod + def _decode_exe_action( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["TaskAction"]: + command = reader.read_bstring() + args = reader.read_bstring() + if command is None or args is None: + return None + + workdir = reader.read_bstring() + if version == 3: + _flags = reader.read_u2() + + return cls(ActionType.Exe, command, args, workdir) + + @classmethod + def _decode_email_action( + cls, reader: _ScheduledTasksReader + ) -> Optional["TaskAction"]: + props = { + "From": reader.read_bstring(), + "To": reader.read_bstring(), + "Cc": reader.read_bstring(), + "Bcc": reader.read_bstring(), + "Reply_to": reader.read_bstring(), + "Server": reader.read_bstring(), + "Subject": reader.read_bstring(), + "Body": reader.read_bstring(), + } + + num_attachment_filenames = reader.read_u4() + if num_attachment_filenames is not None: + + attachment_filenames = [ + reader.read_bstring() for _ in range(num_attachment_filenames) + ] + + props["Attachments"] = ( + "<" + + ", ".join( + filename + for filename in attachment_filenames + if filename is not None + ) + + ">" + ) + + num_headers = reader.read_u4() + if num_headers is not None: + headers = [ + (reader.read_bstring(), reader.read_bstring()) + for _ in range(num_headers) + ] + + props["Headers"] = ( + "<" + + ", ".join( + f"{field}: {value}" + for field, value in headers + if field is not None and value is not None + ) + + ">" + ) + + cls( + ActionType.Email, + ", ".join( + f"{key}: {value}" for key, value in props.items() if value is not None + ), + None, + None, + ) + + @classmethod + def _decode_comhandler_action( + cls, reader: _ScheduledTasksReader + ) -> Optional["TaskAction"]: + guid_raw = reader.read(16) + if not guid_raw and len(guid_raw) == 16: + return None + clsid = conversion.windows_bytes_to_guid(guid_raw) + args = reader.read_bstring() + + return cls(ActionType.ComHandler, clsid, args, None) + + +@dataclasses.dataclass +class _ScheduledTaskEntry: + name: Union[str, interfaces.renderers.BaseAbsentValue] + principal_id: Union[str, interfaces.renderers.BaseAbsentValue] + display_name: Union[str, interfaces.renderers.BaseAbsentValue] + enabled: Union[bool, interfaces.renderers.BaseAbsentValue] + creation_time: Union[datetime.datetime, interfaces.renderers.BaseAbsentValue] + last_run_time: Union[datetime.datetime, interfaces.renderers.BaseAbsentValue] + last_successful_run_time: Union[ + datetime.datetime, interfaces.renderers.BaseAbsentValue + ] + trigger_type: Union[str, interfaces.renderers.BaseAbsentValue] + trigger_description: Union[str, interfaces.renderers.BaseAbsentValue] + action_type: Union[str, interfaces.renderers.BaseAbsentValue] + action_description: Union[str, interfaces.renderers.BaseAbsentValue] + action_args: Union[str, interfaces.renderers.BaseAbsentValue] + action_context: Union[str, interfaces.renderers.BaseAbsentValue] + working_directory: Union[str, interfaces.renderers.BaseAbsentValue] + guid: str + + +@dataclasses.dataclass +class _JobSchedule: + start_boundary: Optional[datetime.datetime] + end_boundary: Optional[datetime.datetime] + repetition_interval_secs: Optional[int] + repetition_duration_secs: Optional[int] + execution_time_limit_secs: Optional[int] + mode: Optional[TimeMode] + data1: Optional[int] + data2: Optional[int] + data3: Optional[int] + stop_tasks_at_duration_end: Optional[int] + is_enabled: Optional[bool] + max_delay_seconds: Optional[int] + + def get_description(self) -> Optional[str]: + if self.mode == TimeMode.Once: + return "Run one time starting at {}".format( + self.start_boundary.isoformat() + if self.start_boundary is not None + else "" + ) + + elif self.mode == TimeMode.Daily: + if self.data1 is None: + return None + return "Run at {} and repeat every {} days".format( + ( + self.start_boundary.isoformat() + if self.start_boundary is not None + else "" + ), + self.data1, + ) + + elif self.mode == TimeMode.Weekly: + if self.data2 is None: + return None + + days = [k.name for k in Weekday if k.value & self.data2] + return "Run on {} every {} weeks starting at {}".format( + ", ".join(days), + self.data1, + ( + self.start_boundary.isoformat() + if self.start_boundary is not None + else "" + ), + ) + elif self.mode == TimeMode.DaysInMonths: + if self.data2 is None or self.data1 is None or self.data3 is None: + return None + months = [month.name for month in Months if month.value & self.data3] + days_bitmap = (self.data2 << 16) + self.data1 + days = [str(v + 1) for v in range(31) if (1 << v) & days_bitmap] + return "Run in months {} on days {} starting at {}".format( + ", ".join(months), + ", ".join(days), + ( + self.start_boundary.isoformat() + if self.start_boundary is not None + else "" + ), + ) + elif self.mode == TimeMode.DaysInWeeksInMonths: + if self.data1 is None or self.data2 is None or self.data3 is None: + return None + + months = [month.name for month in Months if month.value & self.data3] + weeks = [str(v + 1) for v in range(5) if (v << 1) & self.data2] + days = [day.name for day in Weekday if day.value & self.data1] + return "Run in months {} in weeks {} on days {} starting at {}".format( + ", ".join(months), + ", ".join(weeks), + ", ".join(days), + ( + self.start_boundary.isoformat() + if self.start_boundary is not None + else "" + ), + ) + else: + return None + + @classmethod + def decode(cls, reader: _ScheduledTasksReader) -> Optional["_JobSchedule"]: + start_boundary = reader.read_task_scheduler_time() + end_boundary = reader.read_task_scheduler_time() + + _ = reader.read_task_scheduler_time() + repetition_interval_secs = reader.read_u4() + repetition_duration_secs = reader.read_u4() + execution_time_limit_secs = reader.read_u4() + mode_index = reader.read_u4() + if mode_index is not None: + try: + mode = TimeMode(mode_index) + except ValueError: + mode = TimeMode.Unknown + else: + mode = None + + data1 = reader.read_u2() + data2 = reader.read_u2() + data3 = reader.read_u2() + + reader.seek(2, io.SEEK_CUR) # pad + stop_tasks_at_duration_end = reader.read_bool() + is_enabled = reader.read_bool() + reader.seek(6, io.SEEK_CUR) # pad (2) + unknown (4) + max_delay_seconds = reader.read_u4() + reader.seek(4, io.SEEK_CUR) # pad + + return cls( + start_boundary, + end_boundary, + repetition_interval_secs, + repetition_duration_secs, + execution_time_limit_secs, + mode, + data1, + data2, + data3, + stop_tasks_at_duration_end, + is_enabled, + max_delay_seconds, + ) + + +def decode_sid(data: bytes) -> Optional[str]: + """ + Decodes a windows SID from variable-length raw bytes + + Returns the string representation of the SID if decoding was successful, or None + if the data could not be parsed due to an insufficent number of bytes. + """ + try: + revision, subid_count, id_authority = struct.unpack( + ">BBQ", data[:2] + b"\x00\x00" + data[2:8] + ) + subauthorities = struct.unpack( + "<" + "I" * subid_count, data[8 : 8 + subid_count * 4] + ) + sid_string = "S-" + "-".join( + [str(item) for item in [revision, id_authority] + list(subauthorities)] + ) + except struct.error: + return None + + return sid_string + + +@dataclasses.dataclass +class UserInfo: + sid_type: Optional[SidType] + sid: Optional[str] + username: Optional[str] + + @classmethod + def _decode(cls, reader: _ScheduledTasksReader) -> Optional["UserInfo"]: + skip_user = reader.read_aligned_u1() != 0 + if not skip_user: + skip_sid = reader.read_aligned_u1() != 0 + else: + skip_sid = None + + sid_type = None + sid = None + if not skip_user and not skip_sid: + try: + sid_type = SidType(reader.read_aligned_u4()) + except ValueError: + sid_type = SidType.Unknown + + sid_raw = reader.read_buffer(aligned=True) + if sid_raw is None: + return None + sid = decode_sid(sid_raw) + + username = reader.read_bstring(aligned=True) if not skip_user else None + + return UserInfo(sid_type, sid, username) + + +@dataclasses.dataclass +class OptionalSettings: + IdleDurationSeconds: int + idleWaitTimeoutSeconds: int + ExecutionTimeLimitSeconds: int + DeleteExpiredTaskAfter: int + Priority: int + RestartOnFailureDelay: int + RestartOnFailureRetries: int + NetworkId: bytes + Privileges: Optional[List[str]] + Periodicity: Optional[TaskSchedulerTimePeriod] + Deadline: Optional[TaskSchedulerTimePeriod] + Exclusive: Optional[bool] + + @classmethod + def _decode(cls, reader: _ScheduledTasksReader) -> Optional["OptionalSettings"]: + LEN_WITH_PRIVILEGES = 0x38 + LEN_WITH_TIME_PERIODS = 0x58 + length = reader.read_aligned_u4() + if length == 0: + return None + + base_values = ( + reader.read_u4(), + reader.read_u4(), + reader.read_u4(), + reader.read_u4(), + reader.read_u4(), + reader.read_u4(), + reader.read_u4(), + binascii.hexlify(reader.read(16)), + ) + + if any(value is None for value in base_values): + return None + + reader.seek(4, io.SEEK_CUR) # padding + + privileges = None + periodicity = None + deadline = None + exclusive = None + if length == LEN_WITH_PRIVILEGES or length == LEN_WITH_TIME_PERIODS: + privileges_raw = reader.read_u8() + if privileges_raw is None: + return None + privileges = [ + priv.name for priv in Privileges if priv.value & privileges_raw + ] + if length == LEN_WITH_TIME_PERIODS: + periodicity = reader.read_tstimeperiod() + deadline = reader.read_tstimeperiod() + exclusive = reader.read_bool() + reader.seek(3, io.SEEK_CUR) # padding + + return OptionalSettings( + *base_values, privileges, periodicity, deadline, exclusive + ) + + +@dataclasses.dataclass +class JobBucket: + flags: List[str] + crc32: int + principal_id: Optional[str] + display_name: Optional[str] + user_info: Optional[UserInfo] + optional_settings: Optional[OptionalSettings] + + @classmethod + def _decode( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["JobBucket"]: + flags_raw = reader.read_aligned_u4() + if flags_raw is None: + return None + flags = [y for x, y in JOB_BUCKET_FLAGS.items() if x & flags_raw] + crc32 = reader.read_aligned_u4() + if crc32 is None: + return None + + principal_id = None + display_name = None + if version >= 0x16: + principal_id = reader.read_bstring(aligned=True) + if version >= 0x17: + display_name = reader.read_bstring(aligned=True) + + user_info = UserInfo._decode(reader) + optional_settings = OptionalSettings._decode(reader) + + return JobBucket( + flags, crc32, principal_id, display_name, user_info, optional_settings + ) + + +class Privileges(enum.Enum): + SeCreateTokenPrivilege = 0x4 + SeAssignPrimaryTokenPrivilege = 0x8 + SeLockMemoryPrivilege = 0x10 + SeIncreaseQuotaPrivilege = 0x20 + SeMachineAccountPrivilege = 0x40 + SeTcbPrivilege = 0x80 + SeSecurityPrivilege = 0x100 + SeTakeOwnershipPrivilege = 0x200 + SeLoadDriverPrivilege = 0x400 + SeSystemProfilePrivilege = 0x800 + SeSystemtimePrivilege = 0x1000 + SeProfileSingleProcessPrivilege = 0x2000 + SeIncreaseBasePriorityPrivilege = 0x4000 + SeCreatePagefilePrivilege = 0x8000 + SeCreatePermanentPrivilege = 0x10000 + SeBackupPrivilege = 0x20000 + SeRestorePrivilege = 0x40000 + SeShutdownPrivilege = 0x80000 + SeDebugPrivilege = 0x100000 + SeAuditPrivilege = 0x200000 + SeSystemEnvironmentPrivilege = 0x400000 + SeChangeNotifyPrivilege = 0x800000 + SeRemoteShutdownPrivilege = 0x1000000 + SeUndockPrivilege = 0x2000000 + SeSyncAgentPrivilege = 0x4000000 + SeEnableDelegationPrivilege = 0x8000000 + SeManageVolumePrivilege = 0x10000000 + SeImpersonatePrivilege = 0x20000000 + SeCreateGlobalPrivilege = 0x40000000 + SeTrustedCredManAccessPrivilege = 0x80000000 + SeRelabelPrivilege = 0x100000000 + SeIncreaseWorkingSetPrivilege = 0x200000000 + SeTimeZonePrivilege = 0x400000000 + SeCreateSymbolicLinkPrivilege = 0x800000000 + SeDelegateSessionUserImpersonatePrivilege = 0x1000000000 + + +class SessionState(enum.Enum): + ConsoleConnect = 1 + ConsoleDisconnect = 2 + RemoteConnect = 3 + RemoteDisconnect = 4 + SessionLock = 5 + SessionUnlock = 6 + Unknown = "Unknown" + + +@dataclasses.dataclass +class TaskTrigger: + start_boundary: Optional[datetime.datetime] + end_boundary: Optional[datetime.datetime] + repetition_interval_seconds: Optional[int] + enabled: Optional[bool] + trigger_type: TriggerType + description: Optional[str] + + @classmethod + def _decode_generic_trigger( + cls, reader: _ScheduledTasksReader, version: int, trigger_type: TriggerType + ) -> Optional["TaskTrigger"]: + start_boundary = reader.read_task_scheduler_time() + end_boundary = reader.read_task_scheduler_time() + + _ = reader.read_u4() # delay seconds + _ = reader.read_u4() # timeout seconds + + repetition_interval_secs = reader.read_u4() + _ = reader.read_u4() # reptition duration seconds + _ = reader.read_u4() # repetition duration seconds 2 + + _ = reader.read_bool() # stop at duration end + reader.seek(3, io.SEEK_CUR) + trigger_enabled = bool(reader.read_aligned_u1()) + reader.seek(8, io.SEEK_CUR) # unknown field + + if version >= 0x16: + cur = reader.tell() + _ = reader.read_bstring() # trigger id + reader.seek((8 - (reader.tell() - cur)) % 8, io.SEEK_CUR) # pad to block + + return cls( + start_boundary, + end_boundary, + repetition_interval_secs, + trigger_enabled, + trigger_type, + f"{trigger_type.name} trigger", + ) + + @classmethod + def _decode_logon_trigger( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["TaskTrigger"]: + base = cls._decode_generic_trigger(reader, version, TriggerType.Logon) + if base is None: + return None + + user = UserInfo._decode(reader) + if user is not None and user.username is not None: + base.description = f"{user.username}: {user.sid} ({user.sid_type})" + + return base + + @classmethod + def _decode_session_trigger( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["TaskTrigger"]: + base = cls._decode_generic_trigger(reader, version, TriggerType.Session) + if base is None: + return None + session_type_raw = reader.read_u4() + reader.seek(4, io.SEEK_CUR) + + try: + session_type = SessionState(session_type_raw) + except ValueError: + session_type = SessionState.Unknown + + user_info = UserInfo._decode(reader) + if user_info is not None and user_info.username is not None: + base.description = f"{session_type.name} for user {user_info.username}" + else: + base.description = session_type.name + + return base + + @classmethod + def _decode_time_trigger( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["TaskTrigger"]: + job_schedule = _JobSchedule.decode(reader) + if job_schedule is None: + return None + + if version >= 0x16: + cur = reader.tell() + _ = reader.read_bstring() # trigger id + reader.seek((8 - (reader.tell() - cur)) % 8, io.SEEK_CUR) # pad to block + + return cls( + job_schedule.start_boundary, + job_schedule.end_boundary, + job_schedule.repetition_interval_secs, + job_schedule.is_enabled, + TriggerType.Time, + job_schedule.get_description() or None, + ) + + @classmethod + def _decode_event_trigger( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["TaskTrigger"]: + base = cls._decode_generic_trigger(reader, version, TriggerType.Event) + if base is None: + return base + + subscription = reader.read_aligned_bstring_expand_sz() + reader.seek(8, io.SEEK_CUR) # 2 4-byte unknown fields + reader.read_aligned_bstring_expand_sz() # another unknown field + len_value_queries = reader.read_aligned_u4() + + if len_value_queries is None: + return base + + queries = [ + ( + reader.read_aligned_bstring_expand_sz(), + reader.read_aligned_bstring_expand_sz(), + ) + for _ in range(len_value_queries) + ] + valid = [(k, v) for (k, v) in queries if k is not None and v is not None] + if base.description is None: + base.description = "Event Trigger" + base.description += f": Subscription: {subscription}, Queries: {str(valid)}" + return base + + @classmethod + def _decode_boot_trigger( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["TaskTrigger"]: + return cls._decode_generic_trigger(reader, version, TriggerType.Boot) + + @classmethod + def _decode_wnf_trigger( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["TaskTrigger"]: + base = cls._decode_generic_trigger( + reader, version, TriggerType.WindowsNotificationFacility + ) + if base is None: + return None + + state_name = binascii.hexlify(reader.read(8)).decode("ascii") + datalen = reader.read_aligned_u4() + _ = base64.b64encode(reader.read(datalen)) # state binary data + base.description = f"WNF state {state_name}" + return base + + @classmethod + def _decode_idle_trigger( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["TaskTrigger"]: + return cls._decode_generic_trigger(reader, version, TriggerType.Logon) + + @classmethod + def _decode_registration_trigger( + cls, reader: _ScheduledTasksReader, version: int + ) -> Optional["TaskTrigger"]: + return cls._decode_generic_trigger(reader, version, TriggerType.Logon) + + +@dataclasses.dataclass +class TriggerSet: + job_bucket: JobBucket + triggers: List[TaskTrigger] + + @classmethod + def decode(cls, data) -> Optional["TriggerSet"]: + reader = _ScheduledTasksReader(data) + + version = reader.read_aligned_u1() + _ = reader.read_task_scheduler_time() # start boundary + _ = reader.read_task_scheduler_time() # end_boundary + + if version is None: + return None + + job_bucket = JobBucket._decode(reader, version) + if job_bucket is None: + return None + + triggers = [] + + while True: + magic = reader.read_aligned_u4() + if magic is None: + break + try: + trigger_type = TriggerType(magic) + except ValueError: + vollog.warning(f"Invalid trigger magic {hex(magic)}") + break + + if trigger_type == TriggerType.Logon: + trigger = TaskTrigger._decode_logon_trigger(reader, version) + elif trigger_type == TriggerType.Session: + trigger = TaskTrigger._decode_session_trigger(reader, version) + elif trigger_type == TriggerType.WindowsNotificationFacility: + trigger = TaskTrigger._decode_wnf_trigger(reader, version) + elif trigger_type == TriggerType.Boot: + trigger = TaskTrigger._decode_boot_trigger(reader, version) + elif trigger_type == TriggerType.Registration: + trigger = TaskTrigger._decode_registration_trigger(reader, version) + elif trigger_type == TriggerType.Event: + trigger = TaskTrigger._decode_event_trigger(reader, version) + elif trigger_type == TriggerType.Idle: + trigger = TaskTrigger._decode_idle_trigger(reader, version) + elif trigger_type == TriggerType.Time: + trigger = TaskTrigger._decode_time_trigger(reader, version) + else: + vollog.warning( + f"Invalid trigger magic {hex(magic)} encountered at offset {hex(reader.tell() - 8)}, stopping parsing" + ) + break + triggers.append(trigger) + + return cls(job_bucket, triggers) + + +@dataclasses.dataclass +class ActionSet: + actions: List[TaskAction] + context: Optional[str] + + @classmethod + def decode(cls, data: bytes) -> Optional["ActionSet"]: + reader = _ScheduledTasksReader(data) + actions = [] + + version = reader.read_u2() + if version is None: + return None + + if version in [2, 3]: + action_context = reader.read_bstring() + else: + action_context = None + + while True: + magic = reader.read_u2() + if magic is None: + break + + _ = ( + reader.read_bstring() + ) # action identifier, usually (but not always) empty + + if magic == ActionType.Email.value: + action = TaskAction._decode_email_action(reader) + elif magic == ActionType.Exe.value: + action = TaskAction._decode_exe_action(reader, version) + elif magic == ActionType.ComHandler.value: + action = TaskAction._decode_comhandler_action(reader) + elif magic == ActionType.MessageBox.value: + action = TaskAction.decode_messagebox_action(reader) + else: + break + actions.append(action) + + return cls(actions, action_context) + + +@dataclasses.dataclass +class DynamicInfo: + """ + Contains information about execution history for this task, + including timestamps and the last error code + """ + + creation_time: Optional[datetime.datetime] + last_run_time: Optional[datetime.datetime] + last_successful_run_time: Optional[datetime.datetime] + last_error_code: Optional[int] + + @classmethod + def decode(cls, data: bytes) -> Optional["DynamicInfo"]: + """ + Decodes a DynamicInfo structure from RegBin value data. + Raises a `ScheduledTaskDecodingError` if the magic bytes are invalid, but otherwise + attempts to decode as much as possible without returning an error. + """ + DYNAMICINFO_MAGIC = 3 + + reader = _ScheduledTasksReader(data) + magic = reader.read_u4() + if magic != DYNAMICINFO_MAGIC: + return None + + creation_time = reader.decode_filetime() + last_run_time = reader.decode_filetime() + + reader.seek(4, io.SEEK_CUR) # deprecated field 'TaskState' + + last_error_code = reader.read_u4() + last_success_time = reader.decode_filetime() + + vollog.debug((creation_time, last_run_time, last_success_time)) + + return cls( + last_run_time, + creation_time, + last_success_time, + last_error_code, + ) + + +class ScheduledTasks(interfaces.plugins.PluginInterface, timeliner.TimeLinerInterface): + """Decodes scheduled task information from the Windows registry, including + information about triggers, actions, run times, and creation times.""" + + _required_framework_version = (2, 11, 0) + _version = (2, 0, 0) + + @classmethod + def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]: + # Since we're calling the plugin, make sure we have the plugin's requirements + return [ + requirements.ModuleRequirement( + name="kernel", + description="Windows kernel", + architectures=["Intel33", "Intel64"], + ), + requirements.VersionRequirement( + name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) + ), + requirements.VersionRequirement( + name="timeliner", + component=timeliner.TimeLinerInterface, + version=(1, 0, 0), + ), + ] + + def generate_timeline( + self, + ) -> Iterator[Tuple[str, timeliner.TimeLinerType, datetime.datetime]]: + for _, task in self._generator(): + if isinstance(task.last_run_time, datetime.datetime): + yield f"ScheduledTasks: task action {task.action_description} with trigger {task.trigger_description} ran", timeliner.TimeLinerType.ACCESSED, task.last_run_time + if isinstance(task.last_successful_run_time, datetime.datetime): + yield f"ScheduledTasks: task action {task.action_description} with trigger {task.trigger_description} ran successfully", timeliner.TimeLinerType.ACCESSED, task.last_successful_run_time + if isinstance(task.creation_time, datetime.datetime): + yield f"ScheduledTasks: Creation Time for task {task.guid} with trigger {task.trigger_description or ''}", timeliner.TimeLinerType.CREATED, task.creation_time + + @classmethod + def get_software_hive( + cls, + context: interfaces.context.ContextInterface, + config_path: str, + kernel_module_name: str, + ) -> Optional[registry.RegistryHive]: + """Retrieves the `Amcache.hve` registry hive from the kernel module, if it can be located.""" + return next( + hivelist.HiveList.list_hives( + context=context, + base_config_path=interfaces.configuration.path_join( + config_path, "hivelist" + ), + kernel_module_name=kernel_module_name, + filter_string="SOFTWARE", + ), + None, + ) + + @classmethod + def parse_actions_value( + cls, actions_value: reg_extensions.CM_KEY_VALUE + ) -> Optional[ActionSet]: + """Parses File entries from the Windows 8 `Root\\File` key. + + :param programs_key: The `Root\\File` registry key. + + :return: An iterator of tuples, where the first member is the program ID string for + correlating `Root\\Program` entries, and the second member is the `AmcacheEntry`. + """ + try: + data = actions_value.decode_data() + except exceptions.InvalidAddressException: + data = None + + if not isinstance(data, bytes): + return None + + return ActionSet.decode(data) + + @classmethod + def parse_triggers_value( + cls, triggers_value: reg_extensions.CM_KEY_VALUE + ) -> Optional[TriggerSet]: + try: + data = triggers_value.decode_data() + except exceptions.InvalidAddressException: + data = None + + if not isinstance(data, bytes): + return None + + return TriggerSet.decode(data) + + @classmethod + def parse_dynamic_info_value( + cls, dyn_info_value: reg_extensions.CM_KEY_VALUE + ) -> Optional[DynamicInfo]: + + try: + data = dyn_info_value.decode_data() + except exceptions.InvalidAddressException: + data = None + + if not isinstance(data, bytes): + return None + + return DynamicInfo.decode(data) + + @classmethod + def _get_task_keys( + cls, software_hive: reg_extensions.RegistryHive + ) -> Tuple[ + Optional[reg_extensions.CM_KEY_NODE], Optional[reg_extensions.CM_KEY_NODE] + ]: + try: + task_key = software_hive.get_key( + "Microsoft\\Windows NT\\CurrentVersion\\Schedule\\TaskCache\\Tasks" + ) + except (KeyError, registry.RegistryException): + task_key = None + + try: + task_tree = software_hive.get_key( + "Microsoft\\Windows NT\\CurrentVersion\\Schedule\\TaskCache\\Tree" + ) + except (KeyError, registry.RegistryException): + task_tree = None + + return (task_key, task_tree) # type: ignore + + @classmethod + def _parse_task_key( + cls, key: reg_extensions.CM_KEY_NODE, guid_mapping: Dict[str, str] + ) -> Iterator[_ScheduledTaskEntry]: + values = {} + for value in key.get_values(): + try: + name = str(value.get_name()) + except ( + exceptions.InvalidAddressException, + registry.RegistryException, + ): + continue + + if name in ["Actions", "Triggers", "DynamicInfo"]: + values[name] = value + + try: + key_name = str(key.get_name()) + except ( + exceptions.InvalidAddressException, + registry.RegistryException, + ): + key_name = None + + try: + task_name = guid_mapping.get(key_name, renderers.NotAvailableValue()) + except ( + exceptions.InvalidAddressException, + registry.RegistryException, + ): + task_name = renderers.NotAvailableValue() + + try: + action_set = cls.parse_actions_value(values["Actions"]) + except KeyError: + vollog.debug("Failed to get Actions value") + action_set = None + + try: + triggers_value = values["Triggers"] + trigger_set = cls.parse_triggers_value(triggers_value) + except KeyError: + vollog.debug("Failed to get Triggers value") + trigger_set = None + + if trigger_set is not None: + vollog.debug("Parsed triggers successfully") + + principal_id = ( + trigger_set.job_bucket.principal_id or renderers.NotAvailableValue() + ) + display_name = ( + trigger_set.job_bucket.display_name or renderers.NotAvailableValue() + ) + else: + vollog.debug("Failed to parse triggers") + + principal_id = renderers.NotAvailableValue() + display_name = renderers.NotAvailableValue() + + try: + dynamic_info = cls.parse_dynamic_info_value(values["DynamicInfo"]) + except KeyError: + vollog.debug("DynamicInfo value not found") + dynamic_info = None + + vollog.debug(dynamic_info) + + creation_time = dynamic_info.creation_time if dynamic_info is not None else None + last_run_time = dynamic_info.last_run_time if dynamic_info is not None else None + last_successful_run_time = ( + dynamic_info.last_successful_run_time if dynamic_info is not None else None + ) + + all_triggers = ( + trigger_set.triggers or [None] if trigger_set is not None else [None] + ) + + all_actions = action_set.actions or [None] if action_set is not None else [None] + + for action, trigger in itertools.product(all_actions, all_triggers): + + if action is not None: + if action.action_type in ( + ActionType.Exe, + ActionType.ComHandler, + ): + if action.action_args is None: + args = renderers.NotAvailableValue() + else: + args = action.action_args + else: + args = renderers.NotApplicableValue() + + if action.action_type == ActionType.Exe: + working_directory = ( + action.working_directory or renderers.NotAvailableValue() + ) + else: + working_directory = renderers.NotApplicableValue() + + else: + args = renderers.NotAvailableValue() + working_directory = renderers.NotAvailableValue() + + if trigger is not None and trigger.enabled is not None: + enabled = trigger.enabled + else: + enabled = renderers.NotAvailableValue() + + yield _ScheduledTaskEntry( + task_name, + principal_id, + display_name, + enabled, + creation_time or renderers.NotAvailableValue(), + last_run_time or renderers.NotAvailableValue(), + last_successful_run_time or renderers.NotAvailableValue(), + ( + trigger.trigger_type.name + if trigger is not None + else renderers.NotAvailableValue() + ), + ( + trigger.description or renderers.NotAvailableValue() + if trigger is not None + else renderers.NotAvailableValue() + ), + ( + action.action_type.name + if action is not None + else renderers.NotAvailableValue() + ), + ( + action.action + if action is not None + else renderers.NotAvailableValue() + ), + args, + ( + action_set.context + if (action_set is not None and action_set.context is not None) + else renderers.NotAvailableValue() + ), + working_directory, + key_name or renderers.NotAvailableValue(), + ) + + def _generator(self) -> Iterator[Tuple[int, _ScheduledTaskEntry]]: + # Building the dictionary ahead of time is much better for performance + # vs looking up each service's DLL individually. + software_hive = self.get_software_hive( + self.context, self.config_path, self.config["kernel"] + ) + if software_hive is None: + vollog.warning("Failed to get SOFTWARE hive") + return + + task_key_root, task_tree = self._get_task_keys(software_hive) + if task_key_root is None: + vollog.warning("Failed to get 'Tasks' key") + return + + if task_tree is not None: + task_name_map = _build_guid_name_map(task_tree) + else: + vollog.info("'Tree' key not found, can't map GUIDs to task names") + task_name_map = {} + + for key in task_key_root.get_subkeys(): + for task in self._parse_task_key(key, task_name_map): + yield 0, task + + def run(self): + return renderers.TreeGrid( + [ + ("Task Name", str), + ("Principal ID", str), + ("Display Name", str), + ("Enabled", bool), + ("Creation Time", datetime.datetime), + ("Last Run Time", datetime.datetime), + ("Last Successful Run Time", datetime.datetime), + ("Trigger Type", str), + ("Trigger Description", str), + ("Action Type", str), + ("Action", str), + ("Action Arguments", str), + ("Action Context", str), + ("Working Directory", str), + ("Key Name", str), + ], + ( + (indent, dataclasses.astuple(entry)) + for indent, entry in self._generator() + ), + ) diff --git a/volatility3/framework/plugins/windows/scheduled_tasks.py b/volatility3/framework/plugins/windows/scheduled_tasks.py index 4247bda74..62d8e3b88 100644 --- a/volatility3/framework/plugins/windows/scheduled_tasks.py +++ b/volatility3/framework/plugins/windows/scheduled_tasks.py @@ -1,1436 +1,21 @@ -# This file is Copyright 2019 Volatility Foundation and licensed under the Volatility Software License 1.0 +# 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 # - -import base64 -import binascii -import dataclasses -import datetime -import enum -import io -import itertools import logging -import struct -from typing import Dict, Iterator, List, Optional, Tuple, Union - -from volatility3.framework import exceptions, interfaces, renderers -from volatility3.framework.configuration import requirements -from volatility3.framework.layers import registry -from volatility3.framework.renderers import conversion -from volatility3.framework.symbols.windows.extensions import registry as reg_extensions -from volatility3.plugins import timeliner -from volatility3.plugins.windows.registry import hivelist +from volatility3.framework import interfaces, deprecation +from volatility3.plugins.windows.registry import scheduled_tasks vollog = logging.getLogger(__name__) -# Reference: https://cyber.wtf/2022/06/01/windows-registry-analysis-todays-episode-tasks/ - -class TimeMode(enum.Enum): - """ - Enumeration containing the different time modes that a 'Time' trigger can be configured to run in. - """ - - Once = "Once" - - # run at and repeat every days - Daily = "Daily" - - # run on days of week <(data2 as day_of_week bitmap)> every weeks starting at - Weekly = "Weekly" - - # run in months <(data3 as months bitmap> on days <(data2:data1 as day in month bitmap)> - # starting at - DaysInMonths = "Days In Months" - - # run in months <(data3 as months bitmap> in weeks <(data2 as week bitmap)> - # on days <(data1 as day_of_week bitmap)> starting at - DaysInWeeksInMonths = "Days In Weeks in Months" - - Unknown = "Unknown" - - -TIME_MODE_DESCRIPTION = { - TimeMode.Once: "Once", - TimeMode.Daily: "Daily", - TimeMode.Weekly: "Weekly", - TimeMode.DaysInMonths: "Days In Months", - TimeMode.DaysInWeeksInMonths: "Days In Weeks In Months", - TimeMode.Unknown: "Unknown", -} - - -class ActionType(enum.Enum): - """ - Enumeration that maps action types to their magic number encodings - """ - - Exe = 0x6666 - ComHandler = 0x7777 - Email = 0x8888 - MessageBox = 0x9999 - - -class TriggerType(enum.Enum): - """ - Enumeration that maps trigger types to their magic number encodings - """ - - WindowsNotificationFacility = 0x6666 - Session = 0x7777 - Registration = 0x8888 - Logon = 0xAAAA - Event = 0xCCCC - Time = 0xDDDD - Idle = 0xEEEE - Boot = 0xFFFF - - -class Weekday(enum.Enum): - """ - Enumeration that contains bitwise values for days of the week. - """ - - Sunday = 0x1 - Monday = 0x2 - Tuesday = 0x4 - Wednesday = 0x8 - Thursday = 0x10 - Friday = 0x20 - Saturday = 0x40 - - -class Months(enum.Enum): - """ - Enumeration that contains bitwise values for months of the year. - """ - - January = 0x1 - February = 0x2 - March = 0x4 - April = 0x8 - May = 0x10 - June = 0x20 - July = 0x40 - August = 0x80 - September = 0x100 - October = 0x200 - November = 0x400 - December = 0x800 - - -class SidType(enum.Enum): - """ - Enumeration that maps SID types to their encoded integer values - """ - - User = 1 - Group = 2 - Domain = 3 - Alias = 4 - WellKnownGroup = 5 - DeletedAccount = 6 - Invalid = 7 - Unknown = 8 - Computer = 9 - Label = 10 - LogonSession = 11 - - -@dataclasses.dataclass -class TaskSchedulerTimePeriod: - """ - Class containing information delimiting time periods within scheduled tasks. - """ - - years: int - months: int - weeks: int - days: int - hours: int - minutes: int - seconds: int - - -JOB_BUCKET_FLAGS = { - 0x2: "Run only if idle", - 0x4: "Restart on idle", - 0x8: "Stop on idle end", - 0x10: "Disallow start if on batteries", - 0x20: "Stop if going on batteries", - 0x40: "Start when available", - 0x80: "Run only if network available", - 0x100: "Allow start on demand", - 0x200: "Wake to run", - 0x400: "Execute parallel", - 0x800: "Execute stop existing", - 0x1000: "Execute queue", - 0x2000: "Execute ignore new", - 0x4000: "Logon type s4u", - 0x10000: "Logon type InteractiveToken", - 0x40000: "Logon type Password", - 0x80000: "Logon type InteractiveTokenOrPassword", - 0x400000: "Enabled", - 0x800000: "Hidden", - 0x1000000: "Runlevel highest available", - 0x2000000: "Task", - 0x4000000: "Version", - 0x8000000: "Token SID type none", - 0x10000000: "Token SID type unrestricted", - 0x20000000: "Interval", - 0x40000000: "Allow hard terminate", -} - -NULL = "\u0000" - - -class _ScheduledTasksReader(io.BytesIO): - - def read_task_scheduler_time(self) -> Optional[datetime.datetime]: - _ = bool(self.read_aligned_u1()) # is_localized - filetime = self.decode_filetime() - if filetime is None: - return None - - return filetime - - def read_bool(self, aligned=False) -> Optional[bool]: - try: - val = struct.unpack("?", self.read(1))[0] - if aligned: - self.seek(7) - return val - except struct.error: - return None - - def decode_filetime(self) -> Optional[datetime.datetime]: - filetime = self.read_u8() - if filetime is None: - return None - - if filetime == 0 or filetime == 0xFFFFFFFFFFFFFFFF: - return None - filetime = conversion.wintime_to_datetime(filetime) - if isinstance(filetime, datetime.datetime): - return filetime - else: - return None - - def _read_uint( - self, size: int, format: str, aligned: bool = False - ) -> Optional[int]: - try: - val = struct.unpack(format, self.read(size))[0] - if aligned: - self.seek(8 - size, io.SEEK_CUR) - return val - except struct.error: - return None - - def read_aligned_u1(self) -> Optional[int]: - return self._read_uint(1, "B", True) - - def read_u2(self) -> Optional[int]: - return self._read_uint(2, " Optional[int]: - return self._read_uint(2, " Optional[int]: - return self._read_uint(4, " Optional[int]: - return self._read_uint(8, " Optional[int]: - return self._read_uint(4, " Optional[bytes]: - count = self.read_u4() if not aligned else self.read_aligned_u4() - if count is None: - return None - data = self.read(count) - if aligned: - self.seek((8 - (count % 8)) % 8, io.SEEK_CUR) - return data - - def read_bstring(self, aligned=False) -> Optional[str]: - size = self.read_u4() if not aligned else self.read_aligned_u4() - if size is None: - return None - try: - raw = self.read(size) - val = raw.decode("utf-16le", errors="replace").rstrip(NULL) or None - except UnicodeDecodeError: - val = None - - if aligned: - self.seek((8 - (size % 8)) % 8, io.SEEK_CUR) - - return val - - def read_aligned_bstring_expand_sz(self) -> Optional[str]: - sz = self.read_aligned_u4() - if sz is None: - return None - byte_count = sz * 2 + 2 - - if sz == 0: - return None - - try: - content = self.read(byte_count).decode("utf-16le") - except UnicodeDecodeError: - content = None - - self.seek((8 - (byte_count % 8)) % 8, io.SEEK_CUR) - return content.rstrip("\x00") if content is not None else None - - def read_tstimeperiod(self) -> Optional[TaskSchedulerTimePeriod]: - values = ( - self.read_u2(), - self.read_u2(), - self.read_u2(), - self.read_u2(), - self.read_u2(), - self.read_u2(), - self.read_u2(), - ) - - if any(value is None for value in values): - return None - - return TaskSchedulerTimePeriod(*values) - - -def _build_guid_name_map(key: reg_extensions.CM_KEY_NODE) -> Dict[str, str]: - mapping = {} - task_id_value = None - for value in key.get_values(): - try: - if value.get_name() == "Id": - task_id_value = value - break - except ( - exceptions.InvalidAddressException, - registry.RegistryException, - ): - continue - - if ( - task_id_value is not None - and task_id_value.get_type() == reg_extensions.RegValueTypes.REG_SZ - ): - try: - id_str = task_id_value.decode_data() - except exceptions.InvalidAddressException: - id_str = None - - try: - if isinstance(id_str, bytes): - mapping[id_str.decode("utf-16le", errors="replace").rstrip(NULL)] = str( - key.get_name() - ) - except ( - exceptions.InvalidAddressException, - registry.RegistryException, - ) as excp: - vollog.debug(f"Exception occurred while decoding id_str: {excp}") - - for subkey in key.get_subkeys(): - mapping.update(_build_guid_name_map(subkey)) - return mapping - - -@dataclasses.dataclass -class TaskAction: - action_type: ActionType - action: str - action_args: Optional[str] - working_directory: Optional[str] - - @classmethod - def decode_messagebox_action( - cls, reader: _ScheduledTasksReader - ) -> Optional["TaskAction"]: - caption, content = reader.read_bstring(), reader.read_bstring() - return cls( - ActionType.MessageBox, - f'"{caption or ""}": {content or ""}', - None, - None, - ) - - @classmethod - def _decode_exe_action( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["TaskAction"]: - command = reader.read_bstring() - args = reader.read_bstring() - if command is None or args is None: - return None - - workdir = reader.read_bstring() - if version == 3: - _flags = reader.read_u2() - - return cls(ActionType.Exe, command, args, workdir) - - @classmethod - def _decode_email_action( - cls, reader: _ScheduledTasksReader - ) -> Optional["TaskAction"]: - props = { - "From": reader.read_bstring(), - "To": reader.read_bstring(), - "Cc": reader.read_bstring(), - "Bcc": reader.read_bstring(), - "Reply_to": reader.read_bstring(), - "Server": reader.read_bstring(), - "Subject": reader.read_bstring(), - "Body": reader.read_bstring(), - } - - num_attachment_filenames = reader.read_u4() - if num_attachment_filenames is not None: - - attachment_filenames = [ - reader.read_bstring() for _ in range(num_attachment_filenames) - ] - - props["Attachments"] = ( - "<" - + ", ".join( - filename - for filename in attachment_filenames - if filename is not None - ) - + ">" - ) - - num_headers = reader.read_u4() - if num_headers is not None: - headers = [ - (reader.read_bstring(), reader.read_bstring()) - for _ in range(num_headers) - ] - - props["Headers"] = ( - "<" - + ", ".join( - f"{field}: {value}" - for field, value in headers - if field is not None and value is not None - ) - + ">" - ) - - cls( - ActionType.Email, - ", ".join( - f"{key}: {value}" for key, value in props.items() if value is not None - ), - None, - None, - ) - - @classmethod - def _decode_comhandler_action( - cls, reader: _ScheduledTasksReader - ) -> Optional["TaskAction"]: - guid_raw = reader.read(16) - if not guid_raw and len(guid_raw) == 16: - return None - clsid = conversion.windows_bytes_to_guid(guid_raw) - args = reader.read_bstring() - - return cls(ActionType.ComHandler, clsid, args, None) - - -@dataclasses.dataclass -class _ScheduledTaskEntry: - name: Union[str, interfaces.renderers.BaseAbsentValue] - principal_id: Union[str, interfaces.renderers.BaseAbsentValue] - display_name: Union[str, interfaces.renderers.BaseAbsentValue] - enabled: Union[bool, interfaces.renderers.BaseAbsentValue] - creation_time: Union[datetime.datetime, interfaces.renderers.BaseAbsentValue] - last_run_time: Union[datetime.datetime, interfaces.renderers.BaseAbsentValue] - last_successful_run_time: Union[ - datetime.datetime, interfaces.renderers.BaseAbsentValue - ] - trigger_type: Union[str, interfaces.renderers.BaseAbsentValue] - trigger_description: Union[str, interfaces.renderers.BaseAbsentValue] - action_type: Union[str, interfaces.renderers.BaseAbsentValue] - action_description: Union[str, interfaces.renderers.BaseAbsentValue] - action_args: Union[str, interfaces.renderers.BaseAbsentValue] - action_context: Union[str, interfaces.renderers.BaseAbsentValue] - working_directory: Union[str, interfaces.renderers.BaseAbsentValue] - guid: str - - -@dataclasses.dataclass -class _JobSchedule: - start_boundary: Optional[datetime.datetime] - end_boundary: Optional[datetime.datetime] - repetition_interval_secs: Optional[int] - repetition_duration_secs: Optional[int] - execution_time_limit_secs: Optional[int] - mode: Optional[TimeMode] - data1: Optional[int] - data2: Optional[int] - data3: Optional[int] - stop_tasks_at_duration_end: Optional[int] - is_enabled: Optional[bool] - max_delay_seconds: Optional[int] - - def get_description(self) -> Optional[str]: - if self.mode == TimeMode.Once: - return "Run one time starting at {}".format( - self.start_boundary.isoformat() - if self.start_boundary is not None - else "" - ) - - elif self.mode == TimeMode.Daily: - if self.data1 is None: - return None - return "Run at {} and repeat every {} days".format( - ( - self.start_boundary.isoformat() - if self.start_boundary is not None - else "" - ), - self.data1, - ) - - elif self.mode == TimeMode.Weekly: - if self.data2 is None: - return None - - days = [k.name for k in Weekday if k.value & self.data2] - return "Run on {} every {} weeks starting at {}".format( - ", ".join(days), - self.data1, - ( - self.start_boundary.isoformat() - if self.start_boundary is not None - else "" - ), - ) - elif self.mode == TimeMode.DaysInMonths: - if self.data2 is None or self.data1 is None or self.data3 is None: - return None - months = [month.name for month in Months if month.value & self.data3] - days_bitmap = (self.data2 << 16) + self.data1 - days = [str(v + 1) for v in range(31) if (1 << v) & days_bitmap] - return "Run in months {} on days {} starting at {}".format( - ", ".join(months), - ", ".join(days), - ( - self.start_boundary.isoformat() - if self.start_boundary is not None - else "" - ), - ) - elif self.mode == TimeMode.DaysInWeeksInMonths: - if self.data1 is None or self.data2 is None or self.data3 is None: - return None - - months = [month.name for month in Months if month.value & self.data3] - weeks = [str(v + 1) for v in range(5) if (v << 1) & self.data2] - days = [day.name for day in Weekday if day.value & self.data1] - return "Run in months {} in weeks {} on days {} starting at {}".format( - ", ".join(months), - ", ".join(weeks), - ", ".join(days), - ( - self.start_boundary.isoformat() - if self.start_boundary is not None - else "" - ), - ) - else: - return None - - @classmethod - def decode(cls, reader: _ScheduledTasksReader) -> Optional["_JobSchedule"]: - start_boundary = reader.read_task_scheduler_time() - end_boundary = reader.read_task_scheduler_time() - - _ = reader.read_task_scheduler_time() - repetition_interval_secs = reader.read_u4() - repetition_duration_secs = reader.read_u4() - execution_time_limit_secs = reader.read_u4() - mode_index = reader.read_u4() - if mode_index is not None: - try: - mode = TimeMode(mode_index) - except ValueError: - mode = TimeMode.Unknown - else: - mode = None - - data1 = reader.read_u2() - data2 = reader.read_u2() - data3 = reader.read_u2() - - reader.seek(2, io.SEEK_CUR) # pad - stop_tasks_at_duration_end = reader.read_bool() - is_enabled = reader.read_bool() - reader.seek(6, io.SEEK_CUR) # pad (2) + unknown (4) - max_delay_seconds = reader.read_u4() - reader.seek(4, io.SEEK_CUR) # pad - - return cls( - start_boundary, - end_boundary, - repetition_interval_secs, - repetition_duration_secs, - execution_time_limit_secs, - mode, - data1, - data2, - data3, - stop_tasks_at_duration_end, - is_enabled, - max_delay_seconds, - ) - - -def decode_sid(data: bytes) -> Optional[str]: - """ - Decodes a windows SID from variable-length raw bytes - - Returns the string representation of the SID if decoding was successful, or None - if the data could not be parsed due to an insufficent number of bytes. - """ - try: - revision, subid_count, id_authority = struct.unpack( - ">BBQ", data[:2] + b"\x00\x00" + data[2:8] - ) - subauthorities = struct.unpack( - "<" + "I" * subid_count, data[8 : 8 + subid_count * 4] - ) - sid_string = "S-" + "-".join( - [str(item) for item in [revision, id_authority] + list(subauthorities)] - ) - except struct.error: - return None - - return sid_string - - -@dataclasses.dataclass -class UserInfo: - sid_type: Optional[SidType] - sid: Optional[str] - username: Optional[str] - - @classmethod - def _decode(cls, reader: _ScheduledTasksReader) -> Optional["UserInfo"]: - skip_user = reader.read_aligned_u1() != 0 - if not skip_user: - skip_sid = reader.read_aligned_u1() != 0 - else: - skip_sid = None - - sid_type = None - sid = None - if not skip_user and not skip_sid: - try: - sid_type = SidType(reader.read_aligned_u4()) - except ValueError: - sid_type = SidType.Unknown - - sid_raw = reader.read_buffer(aligned=True) - if sid_raw is None: - return None - sid = decode_sid(sid_raw) - - username = reader.read_bstring(aligned=True) if not skip_user else None - - return UserInfo(sid_type, sid, username) - - -@dataclasses.dataclass -class OptionalSettings: - IdleDurationSeconds: int - idleWaitTimeoutSeconds: int - ExecutionTimeLimitSeconds: int - DeleteExpiredTaskAfter: int - Priority: int - RestartOnFailureDelay: int - RestartOnFailureRetries: int - NetworkId: bytes - Privileges: Optional[List[str]] - Periodicity: Optional[TaskSchedulerTimePeriod] - Deadline: Optional[TaskSchedulerTimePeriod] - Exclusive: Optional[bool] - - @classmethod - def _decode(cls, reader: _ScheduledTasksReader) -> Optional["OptionalSettings"]: - LEN_WITH_PRIVILEGES = 0x38 - LEN_WITH_TIME_PERIODS = 0x58 - length = reader.read_aligned_u4() - if length == 0: - return None - - base_values = ( - reader.read_u4(), - reader.read_u4(), - reader.read_u4(), - reader.read_u4(), - reader.read_u4(), - reader.read_u4(), - reader.read_u4(), - binascii.hexlify(reader.read(16)), - ) - - if any(value is None for value in base_values): - return None - - reader.seek(4, io.SEEK_CUR) # padding - - privileges = None - periodicity = None - deadline = None - exclusive = None - if length == LEN_WITH_PRIVILEGES or length == LEN_WITH_TIME_PERIODS: - privileges_raw = reader.read_u8() - if privileges_raw is None: - return None - privileges = [ - priv.name for priv in Privileges if priv.value & privileges_raw - ] - if length == LEN_WITH_TIME_PERIODS: - periodicity = reader.read_tstimeperiod() - deadline = reader.read_tstimeperiod() - exclusive = reader.read_bool() - reader.seek(3, io.SEEK_CUR) # padding - - return OptionalSettings( - *base_values, privileges, periodicity, deadline, exclusive - ) - - -@dataclasses.dataclass -class JobBucket: - flags: List[str] - crc32: int - principal_id: Optional[str] - display_name: Optional[str] - user_info: Optional[UserInfo] - optional_settings: Optional[OptionalSettings] - - @classmethod - def _decode( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["JobBucket"]: - flags_raw = reader.read_aligned_u4() - if flags_raw is None: - return None - flags = [y for x, y in JOB_BUCKET_FLAGS.items() if x & flags_raw] - crc32 = reader.read_aligned_u4() - if crc32 is None: - return None - - principal_id = None - display_name = None - if version >= 0x16: - principal_id = reader.read_bstring(aligned=True) - if version >= 0x17: - display_name = reader.read_bstring(aligned=True) - - user_info = UserInfo._decode(reader) - optional_settings = OptionalSettings._decode(reader) - - return JobBucket( - flags, crc32, principal_id, display_name, user_info, optional_settings - ) - - -class Privileges(enum.Enum): - SeCreateTokenPrivilege = 0x4 - SeAssignPrimaryTokenPrivilege = 0x8 - SeLockMemoryPrivilege = 0x10 - SeIncreaseQuotaPrivilege = 0x20 - SeMachineAccountPrivilege = 0x40 - SeTcbPrivilege = 0x80 - SeSecurityPrivilege = 0x100 - SeTakeOwnershipPrivilege = 0x200 - SeLoadDriverPrivilege = 0x400 - SeSystemProfilePrivilege = 0x800 - SeSystemtimePrivilege = 0x1000 - SeProfileSingleProcessPrivilege = 0x2000 - SeIncreaseBasePriorityPrivilege = 0x4000 - SeCreatePagefilePrivilege = 0x8000 - SeCreatePermanentPrivilege = 0x10000 - SeBackupPrivilege = 0x20000 - SeRestorePrivilege = 0x40000 - SeShutdownPrivilege = 0x80000 - SeDebugPrivilege = 0x100000 - SeAuditPrivilege = 0x200000 - SeSystemEnvironmentPrivilege = 0x400000 - SeChangeNotifyPrivilege = 0x800000 - SeRemoteShutdownPrivilege = 0x1000000 - SeUndockPrivilege = 0x2000000 - SeSyncAgentPrivilege = 0x4000000 - SeEnableDelegationPrivilege = 0x8000000 - SeManageVolumePrivilege = 0x10000000 - SeImpersonatePrivilege = 0x20000000 - SeCreateGlobalPrivilege = 0x40000000 - SeTrustedCredManAccessPrivilege = 0x80000000 - SeRelabelPrivilege = 0x100000000 - SeIncreaseWorkingSetPrivilege = 0x200000000 - SeTimeZonePrivilege = 0x400000000 - SeCreateSymbolicLinkPrivilege = 0x800000000 - SeDelegateSessionUserImpersonatePrivilege = 0x1000000000 - - -class SessionState(enum.Enum): - ConsoleConnect = 1 - ConsoleDisconnect = 2 - RemoteConnect = 3 - RemoteDisconnect = 4 - SessionLock = 5 - SessionUnlock = 6 - Unknown = "Unknown" - - -@dataclasses.dataclass -class TaskTrigger: - start_boundary: Optional[datetime.datetime] - end_boundary: Optional[datetime.datetime] - repetition_interval_seconds: Optional[int] - enabled: Optional[bool] - trigger_type: TriggerType - description: Optional[str] - - @classmethod - def _decode_generic_trigger( - cls, reader: _ScheduledTasksReader, version: int, trigger_type: TriggerType - ) -> Optional["TaskTrigger"]: - start_boundary = reader.read_task_scheduler_time() - end_boundary = reader.read_task_scheduler_time() - - _ = reader.read_u4() # delay seconds - _ = reader.read_u4() # timeout seconds - - repetition_interval_secs = reader.read_u4() - _ = reader.read_u4() # reptition duration seconds - _ = reader.read_u4() # repetition duration seconds 2 - - _ = reader.read_bool() # stop at duration end - reader.seek(3, io.SEEK_CUR) - trigger_enabled = bool(reader.read_aligned_u1()) - reader.seek(8, io.SEEK_CUR) # unknown field - - if version >= 0x16: - cur = reader.tell() - _ = reader.read_bstring() # trigger id - reader.seek((8 - (reader.tell() - cur)) % 8, io.SEEK_CUR) # pad to block - - return cls( - start_boundary, - end_boundary, - repetition_interval_secs, - trigger_enabled, - trigger_type, - f"{trigger_type.name} trigger", - ) - - @classmethod - def _decode_logon_trigger( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["TaskTrigger"]: - base = cls._decode_generic_trigger(reader, version, TriggerType.Logon) - if base is None: - return None - - user = UserInfo._decode(reader) - if user is not None and user.username is not None: - base.description = f"{user.username}: {user.sid} ({user.sid_type})" - - return base - - @classmethod - def _decode_session_trigger( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["TaskTrigger"]: - base = cls._decode_generic_trigger(reader, version, TriggerType.Session) - if base is None: - return None - session_type_raw = reader.read_u4() - reader.seek(4, io.SEEK_CUR) - - try: - session_type = SessionState(session_type_raw) - except ValueError: - session_type = SessionState.Unknown - - user_info = UserInfo._decode(reader) - if user_info is not None and user_info.username is not None: - base.description = f"{session_type.name} for user {user_info.username}" - else: - base.description = session_type.name - - return base - - @classmethod - def _decode_time_trigger( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["TaskTrigger"]: - job_schedule = _JobSchedule.decode(reader) - if job_schedule is None: - return None - - if version >= 0x16: - cur = reader.tell() - _ = reader.read_bstring() # trigger id - reader.seek((8 - (reader.tell() - cur)) % 8, io.SEEK_CUR) # pad to block - - return cls( - job_schedule.start_boundary, - job_schedule.end_boundary, - job_schedule.repetition_interval_secs, - job_schedule.is_enabled, - TriggerType.Time, - job_schedule.get_description() or None, - ) - - @classmethod - def _decode_event_trigger( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["TaskTrigger"]: - base = cls._decode_generic_trigger(reader, version, TriggerType.Event) - if base is None: - return base - - subscription = reader.read_aligned_bstring_expand_sz() - reader.seek(8, io.SEEK_CUR) # 2 4-byte unknown fields - reader.read_aligned_bstring_expand_sz() # another unknown field - len_value_queries = reader.read_aligned_u4() - - if len_value_queries is None: - return base - - queries = [ - ( - reader.read_aligned_bstring_expand_sz(), - reader.read_aligned_bstring_expand_sz(), - ) - for _ in range(len_value_queries) - ] - valid = [(k, v) for (k, v) in queries if k is not None and v is not None] - if base.description is None: - base.description = "Event Trigger" - base.description += f": Subscription: {subscription}, Queries: {str(valid)}" - return base - - @classmethod - def _decode_boot_trigger( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["TaskTrigger"]: - return cls._decode_generic_trigger(reader, version, TriggerType.Boot) - - @classmethod - def _decode_wnf_trigger( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["TaskTrigger"]: - base = cls._decode_generic_trigger( - reader, version, TriggerType.WindowsNotificationFacility - ) - if base is None: - return None - - state_name = binascii.hexlify(reader.read(8)).decode("ascii") - datalen = reader.read_aligned_u4() - _ = base64.b64encode(reader.read(datalen)) # state binary data - base.description = f"WNF state {state_name}" - return base - - @classmethod - def _decode_idle_trigger( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["TaskTrigger"]: - return cls._decode_generic_trigger(reader, version, TriggerType.Logon) - - @classmethod - def _decode_registration_trigger( - cls, reader: _ScheduledTasksReader, version: int - ) -> Optional["TaskTrigger"]: - return cls._decode_generic_trigger(reader, version, TriggerType.Logon) - - -@dataclasses.dataclass -class TriggerSet: - job_bucket: JobBucket - triggers: List[TaskTrigger] - - @classmethod - def decode(cls, data) -> Optional["TriggerSet"]: - reader = _ScheduledTasksReader(data) - - version = reader.read_aligned_u1() - _ = reader.read_task_scheduler_time() # start boundary - _ = reader.read_task_scheduler_time() # end_boundary - - if version is None: - return None - - job_bucket = JobBucket._decode(reader, version) - if job_bucket is None: - return None - - triggers = [] - - while True: - magic = reader.read_aligned_u4() - if magic is None: - break - try: - trigger_type = TriggerType(magic) - except ValueError: - vollog.warning(f"Invalid trigger magic {hex(magic)}") - break - - if trigger_type == TriggerType.Logon: - trigger = TaskTrigger._decode_logon_trigger(reader, version) - elif trigger_type == TriggerType.Session: - trigger = TaskTrigger._decode_session_trigger(reader, version) - elif trigger_type == TriggerType.WindowsNotificationFacility: - trigger = TaskTrigger._decode_wnf_trigger(reader, version) - elif trigger_type == TriggerType.Boot: - trigger = TaskTrigger._decode_boot_trigger(reader, version) - elif trigger_type == TriggerType.Registration: - trigger = TaskTrigger._decode_registration_trigger(reader, version) - elif trigger_type == TriggerType.Event: - trigger = TaskTrigger._decode_event_trigger(reader, version) - elif trigger_type == TriggerType.Idle: - trigger = TaskTrigger._decode_idle_trigger(reader, version) - elif trigger_type == TriggerType.Time: - trigger = TaskTrigger._decode_time_trigger(reader, version) - else: - vollog.warning( - f"Invalid trigger magic {hex(magic)} encountered at offset {hex(reader.tell() - 8)}, stopping parsing" - ) - break - triggers.append(trigger) - - return cls(job_bucket, triggers) - - -@dataclasses.dataclass -class ActionSet: - actions: List[TaskAction] - context: Optional[str] - - @classmethod - def decode(cls, data: bytes) -> Optional["ActionSet"]: - reader = _ScheduledTasksReader(data) - actions = [] - - version = reader.read_u2() - if version is None: - return None - - if version in [2, 3]: - action_context = reader.read_bstring() - else: - action_context = None - - while True: - magic = reader.read_u2() - if magic is None: - break - - _ = ( - reader.read_bstring() - ) # action identifier, usually (but not always) empty - - if magic == ActionType.Email.value: - action = TaskAction._decode_email_action(reader) - elif magic == ActionType.Exe.value: - action = TaskAction._decode_exe_action(reader, version) - elif magic == ActionType.ComHandler.value: - action = TaskAction._decode_comhandler_action(reader) - elif magic == ActionType.MessageBox.value: - action = TaskAction.decode_messagebox_action(reader) - else: - break - actions.append(action) - - return cls(actions, action_context) - - -@dataclasses.dataclass -class DynamicInfo: - """ - Contains information about execution history for this task, - including timestamps and the last error code - """ - - creation_time: Optional[datetime.datetime] - last_run_time: Optional[datetime.datetime] - last_successful_run_time: Optional[datetime.datetime] - last_error_code: Optional[int] - - @classmethod - def decode(cls, data: bytes) -> Optional["DynamicInfo"]: - """ - Decodes a DynamicInfo structure from RegBin value data. - Raises a `ScheduledTaskDecodingError` if the magic bytes are invalid, but otherwise - attempts to decode as much as possible without returning an error. - """ - DYNAMICINFO_MAGIC = 3 - - reader = _ScheduledTasksReader(data) - magic = reader.read_u4() - if magic != DYNAMICINFO_MAGIC: - return None - - creation_time = reader.decode_filetime() - last_run_time = reader.decode_filetime() - - reader.seek(4, io.SEEK_CUR) # deprecated field 'TaskState' - - last_error_code = reader.read_u4() - last_success_time = reader.decode_filetime() - - vollog.debug((creation_time, last_run_time, last_success_time)) - - return cls( - last_run_time, - creation_time, - last_success_time, - last_error_code, - ) - - -class ScheduledTasks(interfaces.plugins.PluginInterface, timeliner.TimeLinerInterface): - """Decodes scheduled task information from the Windows registry, including \ -information about triggers, actions, run times, and creation times.""" +class ScheduledTasks( + interfaces.plugins.PluginInterface, + deprecation.PluginRenameClass, + replacement_class=scheduled_tasks.ScheduledTasks, + removal_date="2025-09-25", +): + """Decodes scheduled task information from the Windows registry, including + information about triggers, actions, run times, and creation times (deprecated).""" _required_framework_version = (2, 11, 0) _version = (2, 0, 0) - - @classmethod - def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]: - # Since we're calling the plugin, make sure we have the plugin's requirements - return [ - requirements.ModuleRequirement( - name="kernel", - description="Windows kernel", - architectures=["Intel33", "Intel64"], - ), - requirements.VersionRequirement( - name="hivelist", component=hivelist.HiveList, version=(2, 0, 0) - ), - requirements.VersionRequirement( - name="timeliner", - component=timeliner.TimeLinerInterface, - version=(1, 0, 0), - ), - ] - - def generate_timeline( - self, - ) -> Iterator[Tuple[str, timeliner.TimeLinerType, datetime.datetime]]: - for _, task in self._generator(): - if isinstance(task.last_run_time, datetime.datetime): - yield f"ScheduledTasks: task action {task.action_description} with trigger {task.trigger_description} ran", timeliner.TimeLinerType.ACCESSED, task.last_run_time - if isinstance(task.last_successful_run_time, datetime.datetime): - yield f"ScheduledTasks: task action {task.action_description} with trigger {task.trigger_description} ran successfully", timeliner.TimeLinerType.ACCESSED, task.last_successful_run_time - if isinstance(task.creation_time, datetime.datetime): - yield f"ScheduledTasks: Creation Time for task {task.guid} with trigger {task.trigger_description or ''}", timeliner.TimeLinerType.CREATED, task.creation_time - - @classmethod - def get_software_hive( - cls, - context: interfaces.context.ContextInterface, - config_path: str, - kernel_module_name: str, - ) -> Optional[registry.RegistryHive]: - """Retrieves the `Amcache.hve` registry hive from the kernel module, if it can be located.""" - return next( - hivelist.HiveList.list_hives( - context=context, - base_config_path=interfaces.configuration.path_join( - config_path, "hivelist" - ), - kernel_module_name=kernel_module_name, - filter_string="SOFTWARE", - ), - None, - ) - - @classmethod - def parse_actions_value( - cls, actions_value: reg_extensions.CM_KEY_VALUE - ) -> Optional[ActionSet]: - """Parses File entries from the Windows 8 `Root\\File` key. - - :param programs_key: The `Root\\File` registry key. - - :return: An iterator of tuples, where the first member is the program ID string for - correlating `Root\\Program` entries, and the second member is the `AmcacheEntry`. - """ - try: - data = actions_value.decode_data() - except exceptions.InvalidAddressException: - data = None - - if not isinstance(data, bytes): - return None - - return ActionSet.decode(data) - - @classmethod - def parse_triggers_value( - cls, triggers_value: reg_extensions.CM_KEY_VALUE - ) -> Optional[TriggerSet]: - try: - data = triggers_value.decode_data() - except exceptions.InvalidAddressException: - data = None - - if not isinstance(data, bytes): - return None - - return TriggerSet.decode(data) - - @classmethod - def parse_dynamic_info_value( - cls, dyn_info_value: reg_extensions.CM_KEY_VALUE - ) -> Optional[DynamicInfo]: - - try: - data = dyn_info_value.decode_data() - except exceptions.InvalidAddressException: - data = None - - if not isinstance(data, bytes): - return None - - return DynamicInfo.decode(data) - - @classmethod - def _get_task_keys( - cls, software_hive: reg_extensions.RegistryHive - ) -> Tuple[ - Optional[reg_extensions.CM_KEY_NODE], Optional[reg_extensions.CM_KEY_NODE] - ]: - try: - task_key = software_hive.get_key( - "Microsoft\\Windows NT\\CurrentVersion\\Schedule\\TaskCache\\Tasks" - ) - except (KeyError, registry.RegistryException): - task_key = None - - try: - task_tree = software_hive.get_key( - "Microsoft\\Windows NT\\CurrentVersion\\Schedule\\TaskCache\\Tree" - ) - except (KeyError, registry.RegistryException): - task_tree = None - - return (task_key, task_tree) # type: ignore - - @classmethod - def _parse_task_key( - cls, key: reg_extensions.CM_KEY_NODE, guid_mapping: Dict[str, str] - ) -> Iterator[_ScheduledTaskEntry]: - values = {} - for value in key.get_values(): - try: - name = str(value.get_name()) - except ( - exceptions.InvalidAddressException, - registry.RegistryException, - ): - continue - - if name in ["Actions", "Triggers", "DynamicInfo"]: - values[name] = value - - try: - key_name = str(key.get_name()) - except ( - exceptions.InvalidAddressException, - registry.RegistryException, - ): - key_name = None - - try: - task_name = guid_mapping.get(key_name, renderers.NotAvailableValue()) - except ( - exceptions.InvalidAddressException, - registry.RegistryException, - ): - task_name = renderers.NotAvailableValue() - - try: - action_set = cls.parse_actions_value(values["Actions"]) - except KeyError: - vollog.debug("Failed to get Actions value") - action_set = None - - try: - triggers_value = values["Triggers"] - trigger_set = cls.parse_triggers_value(triggers_value) - except KeyError: - vollog.debug("Failed to get Triggers value") - trigger_set = None - - if trigger_set is not None: - vollog.debug("Parsed triggers successfully") - - principal_id = ( - trigger_set.job_bucket.principal_id or renderers.NotAvailableValue() - ) - display_name = ( - trigger_set.job_bucket.display_name or renderers.NotAvailableValue() - ) - else: - vollog.debug("Failed to parse triggers") - - principal_id = renderers.NotAvailableValue() - display_name = renderers.NotAvailableValue() - - try: - dynamic_info = cls.parse_dynamic_info_value(values["DynamicInfo"]) - except KeyError: - vollog.debug("DynamicInfo value not found") - dynamic_info = None - - vollog.debug(dynamic_info) - - creation_time = dynamic_info.creation_time if dynamic_info is not None else None - last_run_time = dynamic_info.last_run_time if dynamic_info is not None else None - last_successful_run_time = ( - dynamic_info.last_successful_run_time if dynamic_info is not None else None - ) - - all_triggers = ( - trigger_set.triggers or [None] if trigger_set is not None else [None] - ) - - all_actions = action_set.actions or [None] if action_set is not None else [None] - - for action, trigger in itertools.product(all_actions, all_triggers): - - if action is not None: - if action.action_type in ( - ActionType.Exe, - ActionType.ComHandler, - ): - if action.action_args is None: - args = renderers.NotAvailableValue() - else: - args = action.action_args - else: - args = renderers.NotApplicableValue() - - if action.action_type == ActionType.Exe: - working_directory = ( - action.working_directory or renderers.NotAvailableValue() - ) - else: - working_directory = renderers.NotApplicableValue() - - else: - args = renderers.NotAvailableValue() - working_directory = renderers.NotAvailableValue() - - if trigger is not None and trigger.enabled is not None: - enabled = trigger.enabled - else: - enabled = renderers.NotAvailableValue() - - yield _ScheduledTaskEntry( - task_name, - principal_id, - display_name, - enabled, - creation_time or renderers.NotAvailableValue(), - last_run_time or renderers.NotAvailableValue(), - last_successful_run_time or renderers.NotAvailableValue(), - ( - trigger.trigger_type.name - if trigger is not None - else renderers.NotAvailableValue() - ), - ( - trigger.description or renderers.NotAvailableValue() - if trigger is not None - else renderers.NotAvailableValue() - ), - ( - action.action_type.name - if action is not None - else renderers.NotAvailableValue() - ), - ( - action.action - if action is not None - else renderers.NotAvailableValue() - ), - args, - ( - action_set.context - if (action_set is not None and action_set.context is not None) - else renderers.NotAvailableValue() - ), - working_directory, - key_name or renderers.NotAvailableValue(), - ) - - def _generator(self) -> Iterator[Tuple[int, _ScheduledTaskEntry]]: - # Building the dictionary ahead of time is much better for performance - # vs looking up each service's DLL individually. - software_hive = self.get_software_hive( - self.context, self.config_path, self.config["kernel"] - ) - if software_hive is None: - vollog.warning("Failed to get SOFTWARE hive") - return - - task_key_root, task_tree = self._get_task_keys(software_hive) - if task_key_root is None: - vollog.warning("Failed to get 'Tasks' key") - return - - if task_tree is not None: - task_name_map = _build_guid_name_map(task_tree) - else: - vollog.info("'Tree' key not found, can't map GUIDs to task names") - task_name_map = {} - - for key in task_key_root.get_subkeys(): - for task in self._parse_task_key(key, task_name_map): - yield 0, task - - def run(self): - return renderers.TreeGrid( - [ - ("Task Name", str), - ("Principal ID", str), - ("Display Name", str), - ("Enabled", bool), - ("Creation Time", datetime.datetime), - ("Last Run Time", datetime.datetime), - ("Last Successful Run Time", datetime.datetime), - ("Trigger Type", str), - ("Trigger Description", str), - ("Action Type", str), - ("Action", str), - ("Action Arguments", str), - ("Action Context", str), - ("Working Directory", str), - ("Key Name", str), - ], - ( - (indent, dataclasses.astuple(entry)) - for indent, entry in self._generator() - ), - )