diff --git a/dissect/target/loaders/itunes.py b/dissect/target/loaders/itunes.py index 65891c23ca..7a96a46300 100644 --- a/dissect/target/loaders/itunes.py +++ b/dissect/target/loaders/itunes.py @@ -28,9 +28,9 @@ try: from Crypto.Cipher import AES - HAS_PYCRYPTODOME = True + HAS_CRYPTO = True except ImportError: - HAS_PYCRYPTODOME = False + HAS_CRYPTO = False DOMAIN_TRANSLATION = { @@ -383,7 +383,7 @@ def _create_cipher(key: bytes, iv: bytes = b"\x00" * 16, mode: str = "cbc") -> A raise ValueError(f"Invalid key size: {key_size}") return _pystandalone.cipher(f"aes-{key_size * 8}-{mode}", key, iv) - elif HAS_PYCRYPTODOME: + elif HAS_CRYPTO: mode_map = { "cbc": (AES.MODE_CBC, True), "ecb": (AES.MODE_ECB, False), diff --git a/dissect/target/plugins/apps/browser/brave.py b/dissect/target/plugins/apps/browser/brave.py index ef0d418383..f19ac8f5c7 100644 --- a/dissect/target/plugins/apps/browser/brave.py +++ b/dissect/target/plugins/apps/browser/brave.py @@ -8,6 +8,7 @@ GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_EXTENSION_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + GENERIC_PASSWORD_RECORD_FIELDS, BrowserPlugin, ) from dissect.target.plugins.apps.browser.chromium import ( @@ -47,6 +48,10 @@ class BravePlugin(ChromiumMixin, BrowserPlugin): "browser/brave/extension", GENERIC_EXTENSION_RECORD_FIELDS ) + BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])( + "browser/brave/password", GENERIC_PASSWORD_RECORD_FIELDS + ) + @export(record=BrowserHistoryRecord) def history(self) -> Iterator[BrowserHistoryRecord]: """Return browser history records for Brave.""" @@ -66,3 +71,8 @@ def downloads(self) -> Iterator[BrowserDownloadRecord]: def extensions(self) -> Iterator[BrowserExtensionRecord]: """Return browser extension records for Brave.""" yield from super().extensions("brave") + + @export(record=BrowserPasswordRecord) + def passwords(self) -> Iterator[BrowserPasswordRecord]: + """Return browser password records for Brave.""" + yield from super().passwords("brave") diff --git a/dissect/target/plugins/apps/browser/browser.py b/dissect/target/plugins/apps/browser/browser.py index 41786dab5c..f0378a4abc 100644 --- a/dissect/target/plugins/apps/browser/browser.py +++ b/dissect/target/plugins/apps/browser/browser.py @@ -1,6 +1,10 @@ +from functools import cache + +from dissect.target.helpers import keychain from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension from dissect.target.helpers.record import create_extended_descriptor from dissect.target.plugin import NamespacePlugin +from dissect.target.target import Target GENERIC_DOWNLOAD_RECORD_FIELDS = [ ("datetime", "ts_start"), @@ -63,6 +67,21 @@ ("uri", "from_url"), ("path", "source"), ] + +GENERIC_PASSWORD_RECORD_FIELDS = [ + ("datetime", "ts_created"), + ("datetime", "ts_last_used"), + ("datetime", "ts_last_changed"), + ("string", "browser"), + ("varint", "id"), + ("uri", "url"), + ("string", "encrypted_username"), + ("string", "encrypted_password"), + ("string", "decrypted_username"), + ("string", "decrypted_password"), + ("path", "source"), +] + BrowserDownloadRecord = create_extended_descriptor([UserRecordDescriptorExtension])( "browser/download", GENERIC_DOWNLOAD_RECORD_FIELDS ) @@ -75,11 +94,35 @@ BrowserCookieRecord = create_extended_descriptor([UserRecordDescriptorExtension])( "browser/cookie", GENERIC_COOKIE_FIELDS ) +BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])( + "browser/password", GENERIC_PASSWORD_RECORD_FIELDS +) class BrowserPlugin(NamespacePlugin): __namespace__ = "browser" + def __init__(self, target: Target): + super().__init__(target) + self.keychain = cache(self.keychain) + + def keychain(self) -> set: + """Retrieve a set of passphrases to use for decrypting saved browser credentials. + + Always adds an empty passphrase as some browsers encrypt values using empty passphrases. + + Returns: + Set of passphrase strings. + """ + passphrases = set() + for provider in [self.__namespace__, "browser", "user", None]: + for key in keychain.get_keys_for_provider(provider) if provider else keychain.get_keys_without_provider(): + if key.key_type == keychain.KeyType.PASSPHRASE: + passphrases.add(key.value) + + passphrases.add("") + return passphrases + def try_idna(url: str) -> bytes: """Attempts to convert a possible Unicode url to ASCII using the IDNA standard. diff --git a/dissect/target/plugins/apps/browser/chrome.py b/dissect/target/plugins/apps/browser/chrome.py index f8f6ed6ae0..3374df60b7 100644 --- a/dissect/target/plugins/apps/browser/chrome.py +++ b/dissect/target/plugins/apps/browser/chrome.py @@ -8,6 +8,7 @@ GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_EXTENSION_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + GENERIC_PASSWORD_RECORD_FIELDS, BrowserPlugin, ) from dissect.target.plugins.apps.browser.chromium import ( @@ -49,6 +50,10 @@ class ChromePlugin(ChromiumMixin, BrowserPlugin): "browser/chrome/extension", GENERIC_EXTENSION_RECORD_FIELDS ) + BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])( + "browser/chrome/password", GENERIC_PASSWORD_RECORD_FIELDS + ) + @export(record=BrowserHistoryRecord) def history(self) -> Iterator[BrowserHistoryRecord]: """Return browser history records for Google Chrome.""" @@ -68,3 +73,8 @@ def downloads(self) -> Iterator[BrowserDownloadRecord]: def extensions(self) -> Iterator[BrowserExtensionRecord]: """Return browser extension records for Google Chrome.""" yield from super().extensions("chrome") + + @export(record=BrowserPasswordRecord) + def passwords(self) -> Iterator[BrowserPasswordRecord]: + """Return browser password records for Google Chrome.""" + yield from super().passwords("chrome") diff --git a/dissect/target/plugins/apps/browser/chromium.py b/dissect/target/plugins/apps/browser/chromium.py index 46e11e91dc..c34a2d5387 100644 --- a/dissect/target/plugins/apps/browser/chromium.py +++ b/dissect/target/plugins/apps/browser/chromium.py @@ -1,3 +1,4 @@ +import base64 import itertools import json from collections import defaultdict @@ -12,17 +13,28 @@ from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension from dissect.target.helpers.fsutil import TargetPath, join from dissect.target.helpers.record import create_extended_descriptor -from dissect.target.plugin import export +from dissect.target.plugin import OperatingSystem, export from dissect.target.plugins.apps.browser.browser import ( GENERIC_COOKIE_FIELDS, GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_EXTENSION_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + GENERIC_PASSWORD_RECORD_FIELDS, BrowserPlugin, try_idna, ) from dissect.target.plugins.general.users import UserDetails +try: + from Crypto.Cipher import AES + from Crypto.Protocol.KDF import PBKDF2 + + HAS_CRYPTO = True + +except ImportError: + HAS_CRYPTO = False + + CHROMIUM_DOWNLOAD_RECORD_FIELDS = [ ("uri", "tab_url"), ("uri", "tab_referrer_url"), @@ -51,6 +63,10 @@ class ChromiumMixin: "browser/chromium/extension", GENERIC_EXTENSION_RECORD_FIELDS ) + BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])( + "browser/chromium/password", GENERIC_PASSWORD_RECORD_FIELDS + ) + def _build_userdirs(self, hist_paths: list[str]) -> list[tuple[UserDetails, TargetPath]]: """Join the selected browser dirs with the user home path. @@ -65,12 +81,14 @@ def _build_userdirs(self, hist_paths: list[str]) -> list[tuple[UserDetails, Targ for d in hist_paths: cur_dir: TargetPath = user_details.home_path.joinpath(d) cur_dir = cur_dir.resolve() - if not cur_dir.exists() or (user_details.user, cur_dir) in users_dirs: + if not cur_dir.exists() or (user_details, cur_dir) in users_dirs: continue - users_dirs.append((user_details.user, cur_dir)) + users_dirs.append((user_details, cur_dir)) return users_dirs - def _iter_db(self, filename: str, subdirs: Optional[list[str]] = None) -> Iterator[SQLite3]: + def _iter_db( + self, filename: str, subdirs: Optional[list[str]] = None + ) -> Iterator[tuple[UserDetails, TargetPath, SQLite3]]: """Generate a connection to a sqlite database file. Args: @@ -98,9 +116,9 @@ def _iter_db(self, filename: str, subdirs: Optional[list[str]] = None) -> Iterat except SQLError as e: self.target.log.warning("Could not open %s file: %s", filename, db_file, exc_info=e) - def _iter_json(self, filename: str) -> Iterator[tuple[str, TargetPath, dict]]: + def _iter_json(self, filename: str) -> Iterator[tuple[UserDetails, TargetPath, dict]]: """Iterate over all JSON files in the user directories, yielding a tuple - of user name, JSON file path, and the parsed JSON data. + of username, JSON file path, and the parsed JSON data. Args: filename (str): The name of the JSON file to search for in each @@ -120,7 +138,7 @@ def _iter_json(self, filename: str) -> Iterator[tuple[str, TargetPath, dict]]: self.target.log.warning("Could not find %s file: %s", filename, json_file) def check_compatible(self) -> None: - if not len(self._build_userdirs(self.DIRS)): + if not self._build_userdirs(self.DIRS): raise UnsupportedPluginError("No Chromium-based browser directories found") def history(self, browser_name: Optional[str] = None) -> Iterator[BrowserHistoryRecord]: @@ -179,7 +197,7 @@ def history(self, browser_name: Optional[str] = None) -> Iterator[BrowserHistory from_url=try_idna(from_url.url) if from_url else None, source=db_file, _target=self.target, - _user=user, + _user=user.user, ) except SQLError as e: self.target.log.warning("Error processing history file: %s", db_file, exc_info=e) @@ -205,14 +223,48 @@ def cookies(self, browser_name: Optional[str] = None) -> Iterator[BrowserCookieR same_site (bool): Cookie same site flag. """ for user, db_file, db in self._iter_db("Cookies", subdirs=["Network"]): + decrypted_key = None + + if self.target.os == OperatingSystem.WINDOWS.value: + try: + local_state_parent = db_file.parent.parent + if db_file.parent.name == "Network": + local_state_parent = local_state_parent.parent + local_state_path = local_state_parent.joinpath("Local State") + + decrypted_key = self._get_local_state_key(local_state_path, user.user.name) + except ValueError: + self.target.log.warning("Failed to decrypt local state key") + try: for cookie in db.table("cookies").rows(): + cookie_value = cookie.value + + if ( + not cookie_value + and decrypted_key + and (enc_value := cookie.get("encrypted_value")) + and enc_value.startswith(b"v10") + ): + try: + if self.target.os == OperatingSystem.LINUX.value: + cookie_value = decrypt_v10(enc_value) + elif self.target.os == OperatingSystem.WINDOWS.value: + cookie_value = decrypt_v10_2(enc_value, decrypted_key) + except (ValueError, UnicodeDecodeError): + pass + + if not cookie_value: + self.target.log.warning( + "Failed to decrypt cookie value for %s %s", cookie.host_key, cookie.name + ) + yield self.BrowserCookieRecord( ts_created=webkittimestamp(cookie.creation_utc), ts_last_accessed=webkittimestamp(cookie.last_access_utc), browser=browser_name, name=cookie.name, - value=cookie.value, + value=cookie_value, host=cookie.host_key, path=cookie.path, expiry=int(cookie.has_expires), @@ -220,7 +272,7 @@ def cookies(self, browser_name: Optional[str] = None) -> Iterator[BrowserCookieR is_http_only=bool(cookie.is_httponly), same_site=bool(cookie.samesite), source=db_file, - _user=user, + _user=user.user, ) except SQLError as e: self.target.log.warning("Error processing cookie file: %s", db_file, exc_info=e) @@ -280,7 +332,7 @@ def downloads(self, browser_name: Optional[str] = None) -> Iterator[BrowserDownl state=row.get("state"), source=db_file, _target=self.target, - _user=user, + _user=user.user, ) except SQLError as e: self.target.log.warning("Error processing history file: %s", db_file, exc_info=e) @@ -366,11 +418,135 @@ def extensions(self, browser_name: Optional[str] = None) -> Iterator[BrowserExte manifest_version=manifest_version, source=json_file, _target=self.target, - _user=user, + _user=user.user, ) except (AttributeError, KeyError) as e: self.target.log.info("No browser extensions found in: %s", json_file, exc_info=e) + def _get_local_state_key(self, local_state_path: TargetPath, username: str) -> Optional[bytes]: + """Get the Chromium ``os_crypt`` ``encrypted_key`` and decrypt it using DPAPI.""" + + if not local_state_path.exists(): + self.target.log.warning("File %s does not exist.", local_state_path) + return None + + try: + local_state_conf = json.loads(local_state_path.read_text()) + except json.JSONDecodeError: + self.target.log.warning("File %s does not contain valid JSON.", local_state_path) + return None + + if "os_crypt" not in local_state_conf: + self.target.log.warning( + "File %s does not contain os_crypt, Chrome is likely older than v80.", local_state_path + ) + return None + + encrypted_key = base64.b64decode(local_state_conf["os_crypt"]["encrypted_key"])[5:] + decrypted_key = self.target.dpapi.decrypt_user_blob(encrypted_key, username) + return decrypted_key + + def passwords(self, browser_name: str = None) -> Iterator[BrowserPasswordRecord]: + """Return browser password records from Chromium browsers. + + Chromium on Linux has ``basic``, ``gnome`` and ``kwallet`` methods for password storage: + - ``basic`` ciphertext prefixed with ``v10`` and encrypted with hard coded parameters. + - ``gnome`` and ``kwallet`` ciphertext prefixed with ``v11`` which is not implemented (yet). + + Chromium on Windows uses DPAPI user encryption. + + The SHA1 hash of the user's password or the plaintext password is required to decrypt passwords + when dealing with encrypted passwords created with Chromium v80 (February 2020) and newer. + + You can supply a SHA1 hash or plaintext password using the keychain. + + Resources: + - https://chromium.googlesource.com/chromium/src/+/master/docs/linux/password_storage.md + - https://chromium.googlesource.com/chromium/src/+/master/components/os_crypt/sync/os_crypt_linux.cc#40 + """ + + for user, db_file, db in self._iter_db("Login Data"): + decrypted_key = None + + if self.target.os == OperatingSystem.WINDOWS.value: + try: + local_state_path = db_file.parent.parent.joinpath("Local State") + decrypted_key = self._get_local_state_key(local_state_path, user.user.name) + except ValueError: + self.target.log.warning("Failed to decrypt local state key") + + for row in db.table("logins").rows(): + encrypted_password: bytes = row.password_value + decrypted_password = None + + # 1. Windows DPAPI encrypted password. Chrome > 80 + # For passwords saved after Chromium v80, we have to use DPAPI to decrypt the AES key + # stored by Chromium to encrypt and decrypt passwords. + if self.target.os == OperatingSystem.WINDOWS.value and encrypted_password.startswith(b"v10"): + if not decrypted_key: + self.target.log.warning("Cannot decrypt password, no decrypted_key could be calculated") + + else: + try: + decrypted_password = decrypt_v10_2(encrypted_password, decrypted_key) + except Exception as e: + self.target.log.warning("Failed to decrypt AES Chromium password") + self.target.log.debug("", exc_info=e) + + # 2. Windows DPAPI encrypted password. Chrome < 80 + # For passwords saved before Chromium v80, we use DPAPI directly for each entry. + elif self.target.os == OperatingSystem.WINDOWS.value and encrypted_password.startswith( + b"\x01\x00\x00\x00" + ): + try: + decrypted_password = self.target.dpapi.decrypt_blob(encrypted_password) + except ValueError as e: + self.target.log.warning("Failed to decrypt DPAPI Chromium password") + self.target.log.debug("", exc_info=e) + except UnsupportedPluginError as e: + self.target.log.warning("Target is missing required registry keys for DPAPI") + self.target.log.debug("", exc_info=e) + + # 3. Linux 'basic' v10 encrypted password. + elif self.target.os != OperatingSystem.WINDOWS.value and encrypted_password.startswith(b"v10"): + try: + decrypted_password = decrypt_v10(encrypted_password) + except Exception as e: + self.target.log.warning("Failed to decrypt AES Chromium password") + self.target.log.debug("", exc_info=e) + + # 4. Linux 'gnome' or 'kwallet' encrypted password. + elif self.target.os != OperatingSystem.WINDOWS.value and encrypted_password.startswith(b"v11"): + self.target.log.warning( + "Unable to decrypt %s password in '%s': unsupported format", browser_name, db_file + ) + + # 5. Unsupported. + else: + prefix = encrypted_password[:10] + self.target.log.warning( + "Unsupported %s encrypted password found in '%s' with prefix '%s'", + browser_name, + db_file, + prefix, + ) + + yield self.BrowserPasswordRecord( + ts_created=webkittimestamp(row.date_created), + ts_last_used=webkittimestamp(row.date_last_used), + ts_last_changed=webkittimestamp(row.date_password_modified or 0), + browser=browser_name, + id=row.id, + url=row.origin_url, + encrypted_username=None, + encrypted_password=base64.b64encode(row.password_value), + decrypted_username=row.username_value, + decrypted_password=decrypted_password, + source=db_file, + _target=self.target, + _user=user.user, + ) + class ChromiumPlugin(ChromiumMixin, BrowserPlugin): """Chromium browser plugin.""" @@ -405,3 +581,49 @@ def downloads(self) -> Iterator[ChromiumMixin.BrowserDownloadRecord]: def extensions(self) -> Iterator[ChromiumMixin.BrowserExtensionRecord]: """Return browser extension records for Chromium browser.""" yield from super().extensions("chromium") + + @export(record=ChromiumMixin.BrowserPasswordRecord) + def passwords(self) -> Iterator[ChromiumMixin.BrowserPasswordRecord]: + """Return browser password records for Chromium browser.""" + yield from super().passwords("chromium") + + +def remove_padding(decrypted: bytes) -> bytes: + number_of_padding_bytes = decrypted[-1] + return decrypted[:-number_of_padding_bytes] + + +def decrypt_v10(encrypted_password: bytes) -> str: + if not HAS_CRYPTO: + raise ValueError("Missing pycryptodome dependency for AES operation") + + encrypted_password = encrypted_password[3:] + + salt = b"saltysalt" + iv = b" " * 16 + pbkdf_password = "peanuts" + + key = PBKDF2(pbkdf_password, salt, 16, 1) + cipher = AES.new(key, AES.MODE_CBC, IV=iv) + + decrypted = cipher.decrypt(encrypted_password) + return remove_padding(decrypted).decode() + + +def decrypt_v10_2(encrypted_password: bytes, key: bytes) -> str: + """ + struct chrome_pass { + byte signature[3] = 'v10'; + byte iv[12]; + byte ciphertext[EOF]; + } + """ + + if not HAS_CRYPTO: + raise ValueError("Missing pycryptodome dependency for AES operation") + + iv = encrypted_password[3:15] + ciphertext = encrypted_password[15:] + cipher = AES.new(key, AES.MODE_GCM, iv) + plaintext = cipher.decrypt(ciphertext) + return plaintext[:-16].decode(errors="backslashreplace") diff --git a/dissect/target/plugins/apps/browser/edge.py b/dissect/target/plugins/apps/browser/edge.py index 8367507a6f..3ed7d35439 100644 --- a/dissect/target/plugins/apps/browser/edge.py +++ b/dissect/target/plugins/apps/browser/edge.py @@ -8,6 +8,7 @@ GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_EXTENSION_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + GENERIC_PASSWORD_RECORD_FIELDS, BrowserPlugin, ) from dissect.target.plugins.apps.browser.chromium import ( @@ -47,6 +48,10 @@ class EdgePlugin(ChromiumMixin, BrowserPlugin): "browser/edge/extension", GENERIC_EXTENSION_RECORD_FIELDS ) + BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])( + "browser/edge/password", GENERIC_PASSWORD_RECORD_FIELDS + ) + @export(record=BrowserHistoryRecord) def history(self) -> Iterator[BrowserHistoryRecord]: """Return browser history records for Microsoft Edge.""" @@ -66,3 +71,8 @@ def downloads(self) -> Iterator[BrowserDownloadRecord]: def extensions(self) -> Iterator[BrowserExtensionRecord]: """Return browser extension records for Microsoft Edge.""" yield from super().extensions("edge") + + @export(record=BrowserPasswordRecord) + def passwords(self) -> Iterator[BrowserPasswordRecord]: + """Return browser password records for Microsoft Edge.""" + yield from super().passwords("edge") diff --git a/dissect/target/plugins/apps/browser/firefox.py b/dissect/target/plugins/apps/browser/firefox.py index 251c9fd204..28c9ecdea6 100644 --- a/dissect/target/plugins/apps/browser/firefox.py +++ b/dissect/target/plugins/apps/browser/firefox.py @@ -1,5 +1,9 @@ +import hmac import json -from typing import Iterator +import logging +from base64 import b64decode +from hashlib import pbkdf2_hmac, sha1 +from typing import Iterator, Optional from dissect.sql import sqlite3 from dissect.sql.exceptions import Error as SQLError @@ -8,15 +12,39 @@ from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension +from dissect.target.helpers.fsutil import TargetPath from dissect.target.helpers.record import create_extended_descriptor from dissect.target.plugin import export from dissect.target.plugins.apps.browser.browser import ( GENERIC_COOKIE_FIELDS, GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + GENERIC_PASSWORD_RECORD_FIELDS, BrowserPlugin, try_idna, ) +from dissect.target.plugins.general.users import UserDetails + +try: + from asn1crypto import algos, core + + HAS_ASN1 = True + +except ImportError: + HAS_ASN1 = False + + +try: + from Crypto.Cipher import AES, DES3 + from Crypto.Util.Padding import unpad + + HAS_CRYPTO = True + +except ImportError: + HAS_CRYPTO = False + + +log = logging.getLogger(__name__) class FirefoxPlugin(BrowserPlugin): @@ -48,21 +76,33 @@ class FirefoxPlugin(BrowserPlugin): "browser/firefox/download", GENERIC_DOWNLOAD_RECORD_FIELDS ) + BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])( + "browser/firefox/password", GENERIC_PASSWORD_RECORD_FIELDS + ) + def __init__(self, target): super().__init__(target) - self.users_dirs = [] + self.users_dirs: list[tuple[UserDetails, TargetPath]] = [] for user_details in self.target.user_details.all_with_home(): for directory in self.DIRS: cur_dir = user_details.home_path.joinpath(directory) if not cur_dir.exists(): continue - self.users_dirs.append((user_details.user, cur_dir)) + self.users_dirs.append((user_details, cur_dir)) def check_compatible(self) -> None: if not len(self.users_dirs): raise UnsupportedPluginError("No Firefox directories found") - def _iter_db(self, filename: str) -> Iterator[SQLite3]: + def _iter_profiles(self) -> Iterator[tuple[UserDetails, TargetPath, TargetPath]]: + """Yield user directories.""" + for user, cur_dir in self.users_dirs: + for profile_dir in cur_dir.iterdir(): + if not profile_dir.is_dir(): + continue + yield user, cur_dir, profile_dir + + def _iter_db(self, filename: str) -> Iterator[tuple[UserDetails, SQLite3]]: """Yield opened history database files of all users. Args: @@ -71,16 +111,15 @@ def _iter_db(self, filename: str) -> Iterator[SQLite3]: Yields: Opened SQLite3 databases. """ - for user, cur_dir in self.users_dirs: - for profile_dir in cur_dir.iterdir(): - if profile_dir.is_dir(): - db_file = profile_dir.joinpath(filename) - try: - yield user, db_file, sqlite3.SQLite3(db_file.open()) - except FileNotFoundError: - self.target.log.warning("Could not find %s file: %s", filename, db_file) - except SQLError as e: - self.target.log.warning("Could not open %s file: %s", filename, db_file, exc_info=e) + for user, cur_dir, profile_dir in self._iter_profiles(): + db_file = profile_dir.joinpath(filename) + try: + yield user, db_file, sqlite3.SQLite3(db_file.open()) + except FileNotFoundError: + self.target.log.warning("Could not find %s file: %s", filename, db_file) + except SQLError as e: + self.target.log.warning("Could not open %s file: %s", filename, db_file) + self.target.log.debug("", exc_info=e) @export(record=BrowserHistoryRecord) def history(self) -> Iterator[BrowserHistoryRecord]: @@ -135,7 +174,7 @@ def history(self) -> Iterator[BrowserHistoryRecord]: from_url=try_idna(from_place.url) if from_place else None, source=db_file, _target=self.target, - _user=user, + _user=user.user, ) except SQLError as e: self.target.log.warning("Error processing history file: %s", db_file, exc_info=e) @@ -167,7 +206,7 @@ def cookies(self) -> Iterator[BrowserCookieRecord]: yield self.BrowserCookieRecord( ts_created=from_unix_us(cookie.creationTime), ts_last_accessed=from_unix_us(cookie.lastAccessed), - browser="Firefox", + browser="firefox", name=cookie.name, value=cookie.value, host=cookie.host, @@ -177,7 +216,7 @@ def cookies(self) -> Iterator[BrowserCookieRecord]: is_http_only=bool(cookie.isHttpOnly), same_site=bool(cookie.sameSite), source=db_file, - _user=user, + _user=user.user, ) except SQLError as e: self.target.log.warning("Error processing cookie file: %s", db_file, exc_info=e) @@ -261,8 +300,390 @@ def downloads(self) -> Iterator[BrowserDownloadRecord]: state=state, source=db_file, _target=self.target, - _user=user, + _user=user.user, ) except SQLError as e: self.target.log.warning("Error processing history file: %s", db_file, exc_info=e) - self.target.log.warning("Error processing history file: %s", db_file, exc_info=e) + + @export(record=BrowserPasswordRecord) + def passwords(self) -> Iterator[BrowserPasswordRecord]: + """Return Firefox browser password records. + + Automatically decrypts passwords from Firefox 58 onwards (2018) if no primary password is set. + Alternatively, you can supply a primary password through the keychain to access the Firefox password store. + + ``PASSPHRASE`` passwords in the keychain with providers ``browser``, ``firefox``, ``user`` and no provider + can be used to decrypt secrets for this plugin. + + Resources: + - https://github.com/lclevy/firepwd + """ + for user, _, profile_dir in self._iter_profiles(): + login_file = profile_dir.joinpath("logins.json") + key3_file = profile_dir.joinpath("key3.db") + key4_file = profile_dir.joinpath("key4.db") + + if not login_file.exists(): + self.target.log.warning( + "No 'logins.json' password file found for user %s in directory %s", user, profile_dir + ) + continue + + if key3_file.exists() and not key4_file.exists(): + self.target.log.warning("Unsupported file 'key3.db' found in %s", profile_dir) + continue + + if not key4_file.exists(): + self.target.log.warning("No 'key4.db' found in %s", profile_dir) + continue + + try: + logins = json.load(login_file.open()) + + for login in logins.get("logins", []): + decrypted_username = None + decrypted_password = None + + for password in self.keychain(): + try: + decrypted_username, decrypted_password = decrypt( + login.get("encryptedUsername"), + login.get("encryptedPassword"), + key4_file, + password, + ) + except ValueError as e: + self.target.log.warning("Exception while trying to decrypt") + self.target.log.debug("", exc_info=e) + + if decrypted_password and decrypted_username: + break + + yield self.BrowserPasswordRecord( + ts_created=login.get("timeCreated", 0) // 1000, + ts_last_used=login.get("timeLastUsed", 0) // 1000, + ts_last_changed=login.get("timePasswordChanged", 0) // 1000, + browser="firefox", + id=login.get("id"), + url=login.get("hostname"), + encrypted_username=login.get("encryptedUsername"), + encrypted_password=login.get("encryptedPassword"), + decrypted_username=decrypted_username, + decrypted_password=decrypted_password, + source=login_file, + _target=self.target, + _user=user.user, + ) + + except FileNotFoundError: + self.target.log.info("No password file found for user %s in directory %s", user, profile_dir) + except json.JSONDecodeError: + self.target.log.warning( + "logins.json file in directory %s is malformed, consider inspecting the file manually", profile_dir + ) + + +# Define separately because it is not defined in asn1crypto +pbeWithSha1AndTripleDES_CBC = "1.2.840.113549.1.12.5.1.3" +CKA_ID = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" + + +def decrypt_moz_3des(global_salt: bytes, primary_password: bytes, entry_salt: str, encrypted: bytes) -> bytes: + if not HAS_CRYPTO: + raise ValueError("Missing pycryptodome dependency") + + hp = sha1(global_salt + primary_password).digest() + pes = entry_salt + b"\x00" * (20 - len(entry_salt)) + chp = sha1(hp + entry_salt).digest() + k1 = hmac.new(chp, pes + entry_salt, sha1).digest() + tk = hmac.new(chp, pes, sha1).digest() + k2 = hmac.new(chp, tk + entry_salt, sha1).digest() + k = k1 + k2 + iv = k[-8:] + key = k[:24] + return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encrypted) + + +def decode_login_data(data: str) -> tuple[bytes, bytes, bytes]: + """Decode Firefox login data. + + Args: + data: Base64 encoded data in string format. + + Raises: + ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies. + + Returns: + Tuple of bytes with ``key_id``, ``iv`` and ``ciphertext`` + """ + + # SEQUENCE { + # KEY_ID + # SEQUENCE { + # OBJECT_IDENTIFIER + # IV + # } + # CIPHERTEXT + # } + + if not HAS_CRYPTO: + raise ValueError("Missing pycryptodome dependency") + + if not HAS_ASN1: + raise ValueError("Missing asn1crypto dependency") + + decoded = core.load(b64decode(data)) + key_id = decoded[0].native + iv = decoded[1][1].native + ciphertext = decoded[2].native + return key_id, iv, ciphertext + + +def decrypt_pbes2(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> bytes: + """Decrypt an item with the given primary password and salt. + + Args: + decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below. + primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with. + global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key. + + Raises: + ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies. + + Returns: + Bytes of decrypted AES ciphertext. + """ + + # SEQUENCE { + # SEQUENCE { + # OBJECTIDENTIFIER 1.2.840.113549.1.5.13 => pkcs5 pbes2 + # SEQUENCE { + # SEQUENCE { + # OBJECTIDENTIFIER 1.2.840.113549.1.5.12 => pbkdf2 + # SEQUENCE { + # OCTETSTRING 32 bytes, entrySalt + # INTEGER 01 + # INTEGER 20 + # SEQUENCE { + # OBJECTIDENTIFIER 1.2.840.113549.2.9 => hmacWithSHA256 + # } + # } + # } + # SEQUENCE { + # OBJECTIDENTIFIER 2.16.840.1.101.3.4.1.42 => aes256-CBC + # OCTETSTRING 14 bytes, iv + # } + # } + # } + # OCTETSTRING encrypted + # } + + if not HAS_CRYPTO: + raise ValueError("Missing pycryptodome dependency") + + if not HAS_ASN1: + raise ValueError("Missing asn1crypto dependency") + + pkcs5_oid = decoded_item[0][1][0][0].dotted + if algos.KdfAlgorithmId.map(pkcs5_oid) != "pbkdf2": + raise ValueError(f"Expected pbkdf2 object identifier, got: {pkcs5_oid}") + + sha256_oid = decoded_item[0][1][0][1][3][0].dotted + if algos.HmacAlgorithmId.map(sha256_oid) != "sha256": + raise ValueError(f"Expected SHA256 object identifier, got: {pkcs5_oid}") + + aes256_cbc_oid = decoded_item[0][1][1][0].dotted + if algos.EncryptionAlgorithmId.map(aes256_cbc_oid) != "aes256_cbc": + raise ValueError(f"Expected AES256-CBC object identifier, got: {pkcs5_oid}") + + entry_salt = decoded_item[0][1][0][1][0].native + iteration_count = decoded_item[0][1][0][1][1].native + key_length = decoded_item[0][1][0][1][2].native + + if key_length != 32: + raise ValueError(f"Expected key_length to be 32, got: {key_length}") + + k = sha1(global_salt + primary_password).digest() + key = pbkdf2_hmac("sha256", k, entry_salt, iteration_count, dklen=key_length) + + iv = b"\x04\x0e" + decoded_item[0][1][1][1].native + cipher_text = decoded_item[1].native + return AES.new(key, AES.MODE_CBC, iv).decrypt(cipher_text) + + +def decrypt_sha1_triple_des_cbc(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> bytes: + """Decrypt an item with the given Firefox primary password and salt. + + Args: + decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below. + primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with. + global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key. + + Raises: + ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies. + + Returns: + Bytes of decrypted 3DES ciphertext. + """ + + # SEQUENCE { + # SEQUENCE { + # OBJECTIDENTIFIER 1.2.840.113549.1.12.5.1.3 + # SEQUENCE { + # OCTETSTRING entry_salt + # INTEGER 01 + # } + # } + # OCTETSTRING encrypted + # } + + entry_salt = decoded_item[0][1][0].native + cipher_text = decoded_item[1].native + key = decrypt_moz_3des(global_salt, primary_password, entry_salt, cipher_text) + return key[:24] + + +def decrypt_master_key(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> tuple[bytes, str]: + """Decrypt the provided ``core.Sequence`` with the provided Firefox primary password and salt. + + At this stage we are not yet sure of the structure of ``decoded_item``. The structure will depend on the + ``core.Sequence`` object identifier at ``decoded_item[0][0]``, hence we extract it. This function will + then call the apropriate ``decrypt_pbes2``or ``decrypt_sha1_triple_des_cbc`` functions to decrypt the item. + + Args: + decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below. + primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with. + global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key. + + Raises: + ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies. + + Returns: + Tuple of decrypted bytes and a string representation of the identified encryption algorithm. + """ + + # SEQUENCE { + # SEQUENCE { + # OBJECTIDENTIFIER ??? + # ... + # } + # ... + # } + + if not HAS_CRYPTO: + raise ValueError("Missing pycryptodome dependency") + + if not HAS_ASN1: + raise ValueError("Missing asn1crypto depdendency") + + object_identifier = decoded_item[0][0] + algorithm = object_identifier.dotted + + if algos.EncryptionAlgorithmId.map(algorithm) == "pbes2": + return decrypt_pbes2(decoded_item, primary_password, global_salt), algorithm + elif algorithm == pbeWithSha1AndTripleDES_CBC: + return decrypt_sha1_triple_des_cbc(decoded_item, primary_password, global_salt), algorithm + else: + # Firefox supports other algorithms (i.e. Firefox before 2018), but decrypting these is not (yet) supported. + return b"", algorithm + + +def query_global_salt(key4_file: TargetPath) -> tuple[str, str]: + with key4_file.open("rb") as fh: + db = sqlite3.SQLite3(fh) + for row in db.table("metadata").rows(): + if row.get("id") == "password": + return row.get("item1", ""), row.get("item2", "") + + +def query_master_key(key4_file: TargetPath) -> tuple[str, str]: + with key4_file.open("rb") as fh: + db = sqlite3.SQLite3(fh) + for row in db.table("nssPrivate").rows(): + return row.get("a11", ""), row.get("a102", "") + + +def retrieve_master_key(primary_password: bytes, key4_file: TargetPath) -> tuple[bytes, str]: + if not HAS_CRYPTO: + raise ValueError("Missing pycryptodome dependency") + + if not HAS_ASN1: + raise ValueError("Missing asn1crypto dependency") + + global_salt, password_check = query_global_salt(key4_file) + decoded_password_check = core.load(password_check) + + try: + decrypted_password_check, algorithm = decrypt_master_key(decoded_password_check, primary_password, global_salt) + except EOFError: + raise ValueError("No primary password provided") + + if not decrypted_password_check: + raise ValueError(f"Encountered unknown algorithm {algorithm} while decrypting master key") + + expected_password_check = b"password-check\x02\x02" + if decrypted_password_check != b"password-check\x02\x02": + log.debug("Expected %s but got %s", expected_password_check, decrypted_password_check) + raise ValueError("Master key decryption failed. Provided password could be missing or incorrect") + + master_key, master_key_cka = query_master_key(key4_file) + if master_key == b"": + raise ValueError("Password master key is not defined") + + if master_key_cka != CKA_ID: + raise ValueError(f"Password master key CKA_ID '{master_key_cka}' is not equal to expected value '{CKA_ID}'") + + decoded_master_key = core.load(master_key) + decrypted, algorithm = decrypt_master_key(decoded_master_key, primary_password, global_salt) + return decrypted[:24], algorithm + + +def decrypt_field(key: bytes, field: tuple[bytes, bytes, bytes]) -> bytes: + if not HAS_CRYPTO: + raise ValueError("Missing pycryptodome dependency") + + cka, iv, ciphertext = field + + if cka != CKA_ID: + raise ValueError(f"Expected cka to equal '{CKA_ID}' but got '{cka}'") + + return unpad(DES3.new(key, DES3.MODE_CBC, iv).decrypt(ciphertext), 8) + + +def decrypt( + username: str, password: str, key4_file: TargetPath, primary_password: str = "" +) -> tuple[Optional[str], Optional[str]]: + """Decrypt a stored username and password using provided credentials and key4 file. + + Args: + username: Encoded and encrypted password. + password Encoded and encrypted password. + key4_file: Path to key4.db file. + primary_password: Password to use for decryption routine. + + Returns: + A tuple of decoded username and password strings. + + Resources: + - https://github.com/lclevy/firepwd + """ + if not HAS_CRYPTO: + raise ValueError("Missing pycryptodome dependency") + + if not HAS_ASN1: + raise ValueError("Missing asn1crypto dependency") + + try: + username = decode_login_data(username) + password = decode_login_data(password) + + primary_password_bytes = primary_password.encode() + key, algorithm = retrieve_master_key(primary_password_bytes, key4_file) + + if algorithm == pbeWithSha1AndTripleDES_CBC or algos.EncryptionAlgorithmId.map(algorithm) == "pbes2": + username = decrypt_field(key, username) + password = decrypt_field(key, password) + return username.decode(), password.decode() + + except ValueError as e: + raise ValueError(f"Failed to decrypt password using keyfile: {key4_file}, password: {primary_password}") from e diff --git a/dissect/target/plugins/apps/browser/iexplore.py b/dissect/target/plugins/apps/browser/iexplore.py index 0e057a68ae..8956d854b9 100644 --- a/dissect/target/plugins/apps/browser/iexplore.py +++ b/dissect/target/plugins/apps/browser/iexplore.py @@ -26,7 +26,7 @@ def __init__(self, target: Target, fh: BinaryIO): self.target = target self.db = esedb.EseDB(fh) - def find_containers(self, name: str) -> table.Table: + def find_containers(self, name: str) -> Iterator[table.Table]: """Look up all ``ContainerId`` values for a given container name. Args: diff --git a/dissect/target/plugins/apps/ssh/putty.py b/dissect/target/plugins/apps/ssh/putty.py index 3c7674f3a8..b3ac1d6811 100644 --- a/dissect/target/plugins/apps/ssh/putty.py +++ b/dissect/target/plugins/apps/ssh/putty.py @@ -4,7 +4,13 @@ from pathlib import Path from typing import Iterator, Optional, Union -from Crypto.PublicKey import ECC, RSA +try: + from Crypto.PublicKey import ECC, RSA + + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + from flow.record.fieldtypes import posix_path, windows_path from dissect.target.exceptions import RegistryKeyNotFoundError, UnsupportedPluginError @@ -225,6 +231,9 @@ def construct_public_key(key_type: str, iv: str) -> tuple[str, tuple[str, str, s - https://pycryptodome.readthedocs.io/en/latest/src/public_key/ecc.html - https://github.com/mkorthof/reg2kh """ + if not HAS_CRYPTO: + log.warning("Could not reconstruct public key: missing pycryptodome dependency") + return iv if not isinstance(key_type, str) or not isinstance(iv, str): raise ValueError("Invalid key_type or iv") diff --git a/dissect/target/plugins/os/unix/linux/fortios/_os.py b/dissect/target/plugins/os/unix/linux/fortios/_os.py index 026774b0f5..ccbee1f690 100644 --- a/dissect/target/plugins/os/unix/linux/fortios/_os.py +++ b/dissect/target/plugins/os/unix/linux/fortios/_os.py @@ -23,9 +23,9 @@ try: from Crypto.Cipher import AES, ChaCha20 - HAS_PYCRYPTODOME = True + HAS_CRYPTO = True except ImportError: - HAS_PYCRYPTODOME = False + HAS_CRYPTO = False FortiOSUserRecord = TargetRecordDescriptor( "fortios/user", @@ -442,8 +442,8 @@ def decrypt_password(input: str) -> str: - https://www.fortiguard.com/psirt/FG-IR-19-007 """ - if not HAS_PYCRYPTODOME: - raise RuntimeError("PyCryptodome module not available") + if not HAS_CRYPTO: + raise RuntimeError("Missing pycryptodome dependency") if input[:3] in ["SH2", "AK1"]: raise ValueError("Password is a hash (SHA-256 or SHA-1) and cannot be decrypted.") @@ -511,8 +511,8 @@ def decrypt_rootfs(fh: BinaryIO, key: bytes, iv: bytes) -> BinaryIO: RuntimeError: When PyCryptodome is not available. """ - if not HAS_PYCRYPTODOME: - raise RuntimeError("PyCryptodome module not available") + if not HAS_CRYPTO: + raise RuntimeError("Missing pycryptodome dependency") # First 8 bytes = counter, last 8 bytes = nonce # PyCryptodome interally divides this seek by 64 to get a (position, offset) tuple diff --git a/dissect/target/plugins/os/windows/catroot.py b/dissect/target/plugins/os/windows/catroot.py index e775e59525..aece5545d0 100644 --- a/dissect/target/plugins/os/windows/catroot.py +++ b/dissect/target/plugins/os/windows/catroot.py @@ -1,7 +1,5 @@ from typing import Iterator, Optional -from asn1crypto.cms import ContentInfo -from asn1crypto.core import Sequence from dissect.esedb import EseDB from flow.record.fieldtypes import digest @@ -9,6 +7,14 @@ from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, export +try: + from asn1crypto.cms import ContentInfo + from asn1crypto.core import Sequence + + HAS_ASN1 = True +except ImportError: + HAS_ASN1 = False + HINT_NEEDLE = b"\x1e\x08\x00H\x00i\x00n\x00t" PACKAGE_NAME_NEEDLE = b"\x06\n+\x06\x01\x04\x01\x827\x0c\x02\x01" DIGEST_NEEDLES = { @@ -77,6 +83,9 @@ def __init__(self, target): self.catroot2_dir = self.target.fs.path("sysvol/windows/system32/catroot2") def check_compatible(self) -> None: + if not HAS_ASN1: + raise UnsupportedPluginError("Missing asn1crypto dependency") + if next(self.catroot2_dir.rglob("catdb"), None) is None and next(self.catroot_dir.rglob("*.cat"), None) is None: raise UnsupportedPluginError("No catroot files or catroot ESE databases found") diff --git a/dissect/target/plugins/os/windows/dpapi/crypto.py b/dissect/target/plugins/os/windows/dpapi/crypto.py index d31721ba6e..01374be586 100644 --- a/dissect/target/plugins/os/windows/dpapi/crypto.py +++ b/dissect/target/plugins/os/windows/dpapi/crypto.py @@ -4,7 +4,12 @@ import hmac from typing import Optional, Union -from Crypto.Cipher import AES, ARC4 +try: + from Crypto.Cipher import AES, ARC4 + + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False CIPHER_ALGORITHMS: dict[Union[int, str], CipherAlgorithm] = {} HASH_ALGORITHMS: dict[Union[int, str], HashAlgorithm] = {} @@ -62,6 +67,9 @@ class _AES(CipherAlgorithm): block_length = 128 // 8 def decrypt(self, data: bytes, key: bytes, iv: Optional[bytes] = None) -> bytes: + if not HAS_CRYPTO: + raise RuntimeError("Missing pycryptodome dependency") + cipher = AES.new( key[: self.key_length], mode=AES.MODE_CBC, IV=iv[: self.iv_length] if iv else b"\x00" * self.iv_length ) @@ -93,6 +101,9 @@ class _RC4(CipherAlgorithm): block_length = 1 // 8 def decrypt(self, data: bytes, key: bytes, iv: Optional[bytes] = None) -> bytes: + if not HAS_CRYPTO: + raise RuntimeError("Missing pycryptodome dependency") + cipher = ARC4.new(key[: self.key_length]) return cipher.decrypt(data) diff --git a/dissect/target/plugins/os/windows/dpapi/dpapi.py b/dissect/target/plugins/os/windows/dpapi/dpapi.py index befaca5ade..381963418a 100644 --- a/dissect/target/plugins/os/windows/dpapi/dpapi.py +++ b/dissect/target/plugins/os/windows/dpapi/dpapi.py @@ -1,21 +1,27 @@ import hashlib import re -from functools import cached_property +from functools import cache, cached_property from pathlib import Path -from Crypto.Cipher import AES +try: + from Crypto.Cipher import AES + + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + -from dissect.target import Target from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers import keychain from dissect.target.plugin import InternalPlugin from dissect.target.plugins.os.windows.dpapi.blob import Blob as DPAPIBlob from dissect.target.plugins.os.windows.dpapi.master_key import CredSystem, MasterKeyFile +from dissect.target.target import Target class DPAPIPlugin(InternalPlugin): __namespace__ = "dpapi" - # This matches master key file names MASTER_KEY_REGEX = re.compile("^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$") SECURITY_POLICY_KEY = "HKEY_LOCAL_MACHINE\\SECURITY\\Policy" @@ -25,11 +31,26 @@ class DPAPIPlugin(InternalPlugin): def __init__(self, target: Target): super().__init__(target) + self.keychain = cache(self.keychain) def check_compatible(self) -> None: + if not HAS_CRYPTO: + raise UnsupportedPluginError("Missing pycryptodome dependency") + if not list(self.target.registry.keys(self.SYSTEM_KEY)): raise UnsupportedPluginError(f"Registry key not found: {self.SYSTEM_KEY}") + def keychain(self) -> set: + passwords = set() + + for key in keychain.get_keys_for_provider("user") + keychain.get_keys_without_provider(): + if key.key_type == keychain.KeyType.PASSPHRASE: + passwords.add(key.value) + + # It is possible to encrypt using an empty passphrase. + passwords.add("") + return passwords + @cached_property def syskey(self) -> bytes: lsa = self.target.registry.key(self.SYSTEM_KEY) @@ -84,6 +105,10 @@ def master_keys(self) -> dict[str, dict[str, MasterKeyFile]]: return result + @cached_property + def _users(self) -> dict[str, dict[str, str]]: + return {u.name: {"sid": u.sid} for u in self.target.users()} + def _load_master_keys_from_path(self, username: str, path: Path) -> dict[str, MasterKeyFile]: if not path.exists(): return {} @@ -104,21 +129,51 @@ def _load_master_keys_from_path(self, username: str, path: Path) -> dict[str, Ma if not mkf.decrypted: raise Exception("Failed to decrypt System master key") + if user := self._users.get(username): + for mk_pass in self.keychain(): + if mkf.decrypt_with_password(user["sid"], mk_pass): + break + + try: + if mkf.decrypt_with_hash(user["sid"], bytes.fromhex(mk_pass)) is True: + break + except ValueError: + pass + + if not mkf.decrypted: + self.target.log.warning("Could not decrypt DPAPI master key for username '%s'", username) + result[file.name] = mkf return result def decrypt_system_blob(self, data: bytes) -> bytes: + """Decrypt the given bytes using the System master key.""" + return self.decrypt_user_blob(data, self.SYSTEM_USERNAME) + + def decrypt_user_blob(self, data: bytes, username: str) -> bytes: + """Decrypt the given bytes using the master key of the given user.""" blob = DPAPIBlob(data) - if not (mk := self.master_keys.get(self.SYSTEM_USERNAME, {}).get(blob.guid)): - raise ValueError("Blob UUID is unknown to system master keys") + if not (mk := self.master_keys.get(username, {}).get(blob.guid)): + raise ValueError(f"Blob UUID is unknown to {username} master keys") if not blob.decrypt(mk.key): - raise ValueError("Failed to decrypt system blob") + raise ValueError(f"Failed to decrypt blob for user {username}") return blob.clear_text + def decrypt_blob(self, data: bytes) -> bytes: + """Attempt to decrypt the given bytes using any of the available master keys.""" + blob = DPAPIBlob(data) + + for user in self.master_keys: + for mk in self.master_keys[user].values(): + if blob.decrypt(mk.key): + return blob.clear_text + + raise ValueError("Failed to decrypt blob") + def _decrypt_aes(data: bytes, key: bytes) -> bytes: ctx = hashlib.sha256() diff --git a/dissect/target/plugins/os/windows/dpapi/master_key.py b/dissect/target/plugins/os/windows/dpapi/master_key.py index f909cedf39..061f517bc1 100644 --- a/dissect/target/plugins/os/windows/dpapi/master_key.py +++ b/dissect/target/plugins/os/windows/dpapi/master_key.py @@ -1,4 +1,5 @@ import hashlib +import logging from io import BytesIO from typing import BinaryIO @@ -11,6 +12,16 @@ dpapi_hmac, ) +try: + from Crypto.Hash import MD4 + + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + +log = logging.getLogger(__name__) + + master_key_def = """ struct DomainKey { DWORD dwVersion; @@ -85,9 +96,18 @@ def decrypt_with_hash_10(self, user_sid: str, password_hash: bytes) -> bool: def decrypt_with_password(self, user_sid: str, pwd: str) -> bool: """Decrypts the master key with the given user's password and SID.""" + pwd = pwd.encode("utf-16-le") + for algo in ["sha1", "md4"]: - pwd_hash = hashlib.new(algo, pwd.encode("utf-16-le")).digest() - self.decrypt_with_key(derive_password_hash(pwd_hash, user_sid)) + if algo in hashlib.algorithms_available: + pwd_hash = hashlib.new(algo, pwd) + elif HAS_CRYPTO and algo == "md4": + pwd_hash = MD4.new(pwd) + else: + log.warning("No cryptography capabilities for algorithm %s", algo) + continue + + self.decrypt_with_key(derive_password_hash(pwd_hash.digest(), user_sid)) if self.decrypted: break diff --git a/dissect/target/plugins/os/windows/sam.py b/dissect/target/plugins/os/windows/sam.py index 79c8d34777..28fa5c5ac0 100644 --- a/dissect/target/plugins/os/windows/sam.py +++ b/dissect/target/plugins/os/windows/sam.py @@ -2,7 +2,13 @@ from struct import pack from typing import Iterator -from Crypto.Cipher import AES, ARC4, DES +try: + from Crypto.Cipher import AES, ARC4, DES + + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + from dissect import cstruct from dissect.util import ts @@ -295,6 +301,9 @@ class SamPlugin(Plugin): SAM_KEY = "HKEY_LOCAL_MACHINE\\SAM\\SAM\\Domains\\Account" def check_compatible(self) -> None: + if not HAS_CRYPTO: + raise UnsupportedPluginError("Missing pycryptodome dependency") + if not len(list(self.target.registry.keys(self.SAM_KEY))) > 0: raise UnsupportedPluginError(f"Registry key not found: {self.SAM_KEY}") diff --git a/tests/_data/plugins/apps/browser/chrome/History.sqlite b/tests/_data/plugins/apps/browser/chrome/History similarity index 100% rename from tests/_data/plugins/apps/browser/chrome/History.sqlite rename to tests/_data/plugins/apps/browser/chrome/History diff --git a/tests/_data/plugins/apps/browser/chrome/Login Data b/tests/_data/plugins/apps/browser/chrome/Login Data new file mode 100755 index 0000000000..a94dd75733 --- /dev/null +++ b/tests/_data/plugins/apps/browser/chrome/Login Data @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0ab7c48e1cb4c2e2ab4b49b1644a1e5c845f6c768b320606f724a0c861fd6cf +size 51200 diff --git a/tests/_data/plugins/apps/browser/chrome/windows/Preferences b/tests/_data/plugins/apps/browser/chrome/Preferences similarity index 100% rename from tests/_data/plugins/apps/browser/chrome/windows/Preferences rename to tests/_data/plugins/apps/browser/chrome/Preferences diff --git a/tests/_data/plugins/apps/browser/chrome/windows/Secure Preferences b/tests/_data/plugins/apps/browser/chrome/Secure Preferences similarity index 100% rename from tests/_data/plugins/apps/browser/chrome/windows/Secure Preferences rename to tests/_data/plugins/apps/browser/chrome/Secure Preferences diff --git a/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/History b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/History new file mode 100644 index 0000000000..4fd1e85587 --- /dev/null +++ b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/History @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ac03ca112427630e63261ff0fda1a474141adc744e654cda847651b9afe47ee +size 163840 diff --git a/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Login Data b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Login Data new file mode 100644 index 0000000000..31aefdd749 --- /dev/null +++ b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Login Data @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ce1b8afe9cd52c66438976cf0d677a6f57dcde9fe9241c98779835bf4d017a8 +size 40960 diff --git a/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Network/Cookies b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Network/Cookies new file mode 100644 index 0000000000..a6e7a484ed --- /dev/null +++ b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Network/Cookies @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21a5ffdbda293cd2c3bd695467c5007f072e58849243335c8ff5cd1700b18a76 +size 20480 diff --git a/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Preferences b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Preferences new file mode 100644 index 0000000000..c9981742b3 --- /dev/null +++ b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Preferences @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f420b3d574d6b7c1d7fd57e6b96c7e8b9aafaca08dcd5b5a1b57c874966f625 +size 9781 diff --git a/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Secure Preferences b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Secure Preferences new file mode 100644 index 0000000000..f13e5084ca --- /dev/null +++ b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Default/Secure Preferences @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3264439e1875f37c329276f492fc7f452dc7bb99cfe5ce3a51ce7cbf2083531 +size 10232 diff --git a/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Local State b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Local State new file mode 100644 index 0000000000..d04f8358eb --- /dev/null +++ b/tests/_data/plugins/apps/browser/chrome/dpapi/User_Data/Local State @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4cb63217b7424a0e92bc26db665bd2980cd9a878e1deb029bb82f1dcefc76446 +size 3323 diff --git a/tests/_data/plugins/apps/browser/chromium/Cookies.sqlite b/tests/_data/plugins/apps/browser/chromium/Cookies similarity index 100% rename from tests/_data/plugins/apps/browser/chromium/Cookies.sqlite rename to tests/_data/plugins/apps/browser/chromium/Cookies diff --git a/tests/_data/plugins/apps/browser/chromium/History.sqlite b/tests/_data/plugins/apps/browser/chromium/History similarity index 100% rename from tests/_data/plugins/apps/browser/chromium/History.sqlite rename to tests/_data/plugins/apps/browser/chromium/History diff --git a/tests/_data/plugins/apps/browser/chromium/Login Data b/tests/_data/plugins/apps/browser/chromium/Login Data new file mode 100755 index 0000000000..a94dd75733 --- /dev/null +++ b/tests/_data/plugins/apps/browser/chromium/Login Data @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0ab7c48e1cb4c2e2ab4b49b1644a1e5c845f6c768b320606f724a0c861fd6cf +size 51200 diff --git a/tests/_data/plugins/apps/browser/chromium/windows/Preferences b/tests/_data/plugins/apps/browser/chromium/Preferences similarity index 100% rename from tests/_data/plugins/apps/browser/chromium/windows/Preferences rename to tests/_data/plugins/apps/browser/chromium/Preferences diff --git a/tests/_data/plugins/apps/browser/chromium/windows/Secure Preferences b/tests/_data/plugins/apps/browser/chromium/Secure Preferences similarity index 100% rename from tests/_data/plugins/apps/browser/chromium/windows/Secure Preferences rename to tests/_data/plugins/apps/browser/chromium/Secure Preferences diff --git a/tests/_data/plugins/apps/browser/chromium/unix/basic/Login Data b/tests/_data/plugins/apps/browser/chromium/unix/basic/Login Data new file mode 100755 index 0000000000..bf0736220a --- /dev/null +++ b/tests/_data/plugins/apps/browser/chromium/unix/basic/Login Data @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef1dac54707ef7654b53344454021974154f4b1797900b7e49b752a884c53c47 +size 51200 diff --git a/tests/_data/plugins/apps/browser/chromium/unix/gnome/Login Data b/tests/_data/plugins/apps/browser/chromium/unix/gnome/Login Data new file mode 100755 index 0000000000..f42b347f07 --- /dev/null +++ b/tests/_data/plugins/apps/browser/chromium/unix/gnome/Login Data @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9629cac01c67d87b8faa680e682b38173770f1c88e92ea391a55c021a830c52c +size 51200 diff --git a/tests/_data/plugins/apps/browser/edge/History.sqlite b/tests/_data/plugins/apps/browser/edge/History similarity index 100% rename from tests/_data/plugins/apps/browser/edge/History.sqlite rename to tests/_data/plugins/apps/browser/edge/History diff --git a/tests/_data/plugins/apps/browser/edge/Login Data b/tests/_data/plugins/apps/browser/edge/Login Data new file mode 100755 index 0000000000..a94dd75733 --- /dev/null +++ b/tests/_data/plugins/apps/browser/edge/Login Data @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0ab7c48e1cb4c2e2ab4b49b1644a1e5c845f6c768b320606f724a0c861fd6cf +size 51200 diff --git a/tests/_data/plugins/apps/browser/edge/windows/Preferences b/tests/_data/plugins/apps/browser/edge/Preferences similarity index 100% rename from tests/_data/plugins/apps/browser/edge/windows/Preferences rename to tests/_data/plugins/apps/browser/edge/Preferences diff --git a/tests/_data/plugins/apps/browser/edge/windows/Secure Preferences b/tests/_data/plugins/apps/browser/edge/Secure Preferences similarity index 100% rename from tests/_data/plugins/apps/browser/edge/windows/Secure Preferences rename to tests/_data/plugins/apps/browser/edge/Secure Preferences diff --git a/tests/_data/plugins/apps/browser/firefox/key4.db b/tests/_data/plugins/apps/browser/firefox/key4.db new file mode 100755 index 0000000000..d584c14a08 --- /dev/null +++ b/tests/_data/plugins/apps/browser/firefox/key4.db @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3aae13f5dbe060088653ace2ad4c4822ef964413633270553a7aaba3a6aad73e +size 294912 diff --git a/tests/_data/plugins/apps/browser/firefox/logins.json b/tests/_data/plugins/apps/browser/firefox/logins.json new file mode 100644 index 0000000000..7e86bcc0b9 --- /dev/null +++ b/tests/_data/plugins/apps/browser/firefox/logins.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8923ee736da29962461bc3f1b2ac39e077dc119da455bb781935fe6525efc363 +size 1294 diff --git a/tests/_data/plugins/apps/browser/firefox/passwords/primary/key4.db b/tests/_data/plugins/apps/browser/firefox/passwords/primary/key4.db new file mode 100755 index 0000000000..06f1813b4b --- /dev/null +++ b/tests/_data/plugins/apps/browser/firefox/passwords/primary/key4.db @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa48509d65a56c492199f629a56fb91632394b98cfa432739defe5b3b57b477c +size 294912 diff --git a/tests/_data/plugins/apps/browser/firefox/passwords/primary/logins.json b/tests/_data/plugins/apps/browser/firefox/passwords/primary/logins.json new file mode 100644 index 0000000000..dc5783aae3 --- /dev/null +++ b/tests/_data/plugins/apps/browser/firefox/passwords/primary/logins.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd2768d0795bee9072227f2cbd34de6801522a75caaef882640221561fa3a341 +size 606 diff --git a/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/Preferred b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/Preferred new file mode 100644 index 0000000000..f64418e1c3 --- /dev/null +++ b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/Preferred @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67c41509c750e207761a1d254dcf4aeecae3853b775f478a4132d1c217a55a1d +size 24 diff --git a/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/User/21dcc7af-f47e-419c-9c6c-5eb60045abee b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/User/21dcc7af-f47e-419c-9c6c-5eb60045abee new file mode 100644 index 0000000000..ba4d88dbb0 --- /dev/null +++ b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/User/21dcc7af-f47e-419c-9c6c-5eb60045abee @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5531273bfc08fa67445584b18a99d39b390b3a2e60b3ef56dd68032ac1b1e25 +size 468 diff --git a/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/User/3552359b-7b5b-4483-98dd-51c647e1211e b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/User/3552359b-7b5b-4483-98dd-51c647e1211e new file mode 100644 index 0000000000..5a3b0daf06 --- /dev/null +++ b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/User/3552359b-7b5b-4483-98dd-51c647e1211e @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07bcfd95357a472518b93a6dd1e7f675fd8f1d58982ab6f236822c1f17ff045c +size 468 diff --git a/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/User/Preferred b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/User/Preferred new file mode 100644 index 0000000000..91f1115f28 --- /dev/null +++ b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/User/Preferred @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f2eeda89abfd99ba85fb149cfb8f2abaa7f6dd61fd24a6aef1cdddafe090eee +size 24 diff --git a/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/d218f2bb-0499-44d0-9927-625c3d41f35c b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/d218f2bb-0499-44d0-9927-625c3d41f35c new file mode 100644 index 0000000000..72dcfc7f39 --- /dev/null +++ b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/d218f2bb-0499-44d0-9927-625c3d41f35c @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43ade20fa621cbe0dd66746f5345ec9c6b93971757e6ea1dff702d0d0b9db886 +size 468 diff --git a/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/f293c1d0-ae10-4dc4-90de-b14bcae4456d b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/f293c1d0-ae10-4dc4-90de-b14bcae4456d new file mode 100644 index 0000000000..597213bbca --- /dev/null +++ b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_System32/S-1-5-18/f293c1d0-ae10-4dc4-90de-b14bcae4456d @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66f8140d48a8ebe43e270b7dc70fe589e6a2105bb656a209f561f6ad4333def3 +size 468 diff --git a/tests/_data/plugins/os/windows/dpapi/fixture/Protect_User/CREDHIST b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_User/CREDHIST new file mode 100644 index 0000000000..2fc29856b1 --- /dev/null +++ b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_User/CREDHIST @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df369185e6e10ab830621a4afc97988e69b3ea5ca04cc53e287bfba8d29265b6 +size 24 diff --git a/tests/_data/plugins/os/windows/dpapi/fixture/Protect_User/S-1-5-21-1342509979-482553916-3960431919-1000/2ff65009-be78-4375-af02-65ff7c28c123 b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_User/S-1-5-21-1342509979-482553916-3960431919-1000/2ff65009-be78-4375-af02-65ff7c28c123 new file mode 100644 index 0000000000..0895e987bb --- /dev/null +++ b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_User/S-1-5-21-1342509979-482553916-3960431919-1000/2ff65009-be78-4375-af02-65ff7c28c123 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfdc96b4750c45537e601d7384970311a86808d0e57ad0bf2dba24321d39e107 +size 468 diff --git a/tests/_data/plugins/os/windows/dpapi/fixture/Protect_User/S-1-5-21-1342509979-482553916-3960431919-1000/Preferred b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_User/S-1-5-21-1342509979-482553916-3960431919-1000/Preferred new file mode 100644 index 0000000000..518d401709 --- /dev/null +++ b/tests/_data/plugins/os/windows/dpapi/fixture/Protect_User/S-1-5-21-1342509979-482553916-3960431919-1000/Preferred @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8402ba287151f9aec18553d4bd76e3954427de1b343d013777b4d924d84a0b5b +size 24 diff --git a/tests/conftest.py b/tests/conftest.py index 411f4ae2af..b65bca80bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ from dissect.target.plugins.os.unix.linux.suse._os import SuSEPlugin from dissect.target.plugins.os.windows import registry from dissect.target.plugins.os.windows._os import WindowsPlugin +from dissect.target.plugins.os.windows.dpapi.dpapi import DPAPIPlugin from dissect.target.target import Target from tests._utils import absolute_path @@ -351,6 +352,70 @@ def target_win_users(hive_hklm: VirtualHive, hive_hku: VirtualHive, target_win: yield target_win +SYSTEM_KEY_PATH = "SYSTEM\\ControlSet001\\Control\\LSA" +POLICY_KEY_PATH = "SECURITY\\Policy\\PolEKList" +DPAPI_KEY_PATH = "SECURITY\\Policy\\Secrets\\DPAPI_SYSTEM\\CurrVal" + + +@pytest.fixture +def target_win_users_dpapi( + hive_hklm: VirtualHive, hive_hku: VirtualHive, fs_win: VirtualFilesystem, target_win: Target +) -> Iterator[Target]: + # Add User + profile_list_key_name = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProfileList" + profile_list_key = VirtualKey(hive_hklm, profile_list_key_name) + + sid_local_system = "S-1-5-18" + profile1_key = VirtualKey(hive_hklm, f"{profile_list_key_name}\\{sid_local_system}") + profile1_key.add_value( + "ProfileImagePath", VirtualValue(hive_hklm, "ProfileImagePath", "%systemroot%\\system32\\config\\systemprofile") + ) + + sid_user = "S-1-5-21-1342509979-482553916-3960431919-1000" + profile2_key = VirtualKey(hive_hklm, f"{profile_list_key_name}\\{sid_user}") + profile2_key.add_value("ProfileImagePath", VirtualValue(hive_hklm, "ProfileImagePath", "C:\\Users\\user")) + + profile_list_key.add_subkey(sid_local_system, profile1_key) + profile_list_key.add_subkey(sid_user, profile2_key) + + hive_hklm.map_key(profile_list_key_name, profile_list_key) + + target_win.registry.add_hive("HKEY_USERS", f"HKEY_USERS\\{sid_user}", hive_hku, TargetPath(target_win.fs, "")) + + # Add system dpapi files + fs_win.map_dir( + "Windows/System32/Microsoft/Protect", absolute_path("_data/plugins/os/windows/dpapi/fixture/Protect_System32") + ) + + # Add user dpapi files + fs_win.map_dir( + "Users/User/AppData/Roaming/Microsoft/Protect", + absolute_path("_data/plugins/os/windows/dpapi/fixture/Protect_User"), + ) + + # Add registry dpapi keys + system_key = VirtualKey(hive_hklm, SYSTEM_KEY_PATH) + system_key.add_subkey("Data", VirtualKey(hive_hklm, "Data", class_name="8fa8e1fb")) + system_key.add_subkey("GBG", VirtualKey(hive_hklm, "GBG", class_name="a6e23eb8")) + system_key.add_subkey("JD", VirtualKey(hive_hklm, "JD", class_name="fe5ffdaf")) + system_key.add_subkey("Skew1", VirtualKey(hive_hklm, "Skew1", class_name="6e289261")) + hive_hklm.map_key(SYSTEM_KEY_PATH, system_key) + + policy_key = VirtualKey(hive_hklm, POLICY_KEY_PATH) + policy_key_value = b"\x00\x00\x00\x01\xec\xff\xe1{*\x99t@\xaa\x93\x9a\xdb\xff&\xf1\xfc\x03\x00\x00\x00\x00\x00\x00\x00goX67\xc3\xe0\xe7\xb9\xed\xf4;;)\xb1\xd0\xd2L\xb6\xbf\xc6\x0e\x0f\xc4\xdcDn}$M053\xb9\n+\xd72\xfc\xf9\x85t\x8a\x89\x17\xae\xa7>\x9d\x0b)\x0e\xe4\xba/S\xe6\xa9\xa0\xac\x9b<\x9b&\xe7!\xb0\x1bzl\x1f\x92\xb5\x17\xe2\xa3?_m\xe7\xf76qg\x93\xb1\x98r\x05\x95\x95\xe6\xb4\xdc\x88\x8d\x19\xd1\xd6\x15\xd6\x02\xbe\xd5SG\x8cA\x1d/\xed\x04V\x02\xdd\xbbZ\xdc1\xc9\x90\x10!\xad3\x9b\xca6\x8b\xdbUO\xfe\x07JptR\x8d^\x9d\xcb\xb4g" # noqa + policy_key.add_value("(Default)", policy_key_value) + hive_hklm.map_key(POLICY_KEY_PATH, policy_key) + + secrets_key = VirtualKey(hive_hklm, DPAPI_KEY_PATH) + secrets_key_value = b"\x00\x00\x00\x01|>q\xec\xa8\xfbN\xed\x03\xeaCa\xfb\xc7\x83\x87\x03\x00\x00\x00\x00\x00\x00\x00\xafd\xca2\xa1PY\xf8\xe3\x8f2\x8a_\x16\xd0c\x93\x9b\xdb\xb92\x1b\xa1Y\xdc\xaf\xd9\xcd\xf3\x16\xd8/\x89\xa8)\xd7X\x02K'm\t\x9e\xf2)\x0c\xa4o\xc7\xb2cUhP\x0b\xf2\xd3\x1e\xd8\xce\x1e\x0304\\\xca^\xf3\xe8\xd1\x83\x99\xa2*\xe8\x8d\xb1(r\xee[\xb0\xc1\xf0\xdd;\x83\x06bi\xd0\xd9a\x8b\x19\xbb" # noqa + secrets_key.add_value("(Default)", secrets_key_value) + hive_hklm.map_key(DPAPI_KEY_PATH, secrets_key) + + target_win.add_plugin(DPAPIPlugin) + + yield target_win + + @pytest.fixture def target_win_tzinfo(hive_hklm: VirtualHive, target_win: Target) -> Iterator[Target]: tz_info_path = "SYSTEM\\ControlSet001\\Control\\TimeZoneInformation" diff --git a/tests/plugins/apps/browser/test_brave.py b/tests/plugins/apps/browser/test_brave.py index 3567d72e6a..8a6794ff7a 100644 --- a/tests/plugins/apps/browser/test_brave.py +++ b/tests/plugins/apps/browser/test_brave.py @@ -12,10 +12,11 @@ def target_brave(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: base_path = "Users\\John\\AppData\\Local\\BraveSoftware\\Brave-Browser\\User Data\\Default" files = [ - ("History", "_data/plugins/apps/browser/chrome/History.sqlite"), - ("Preferences", "_data/plugins/apps/browser/chrome/windows/Preferences"), - ("Secure Preferences", "_data/plugins/apps/browser/chrome/windows/Secure Preferences"), - ("Network\\Cookies", "_data/plugins/apps/browser/chromium/Cookies.sqlite"), + ("History", "_data/plugins/apps/browser/chrome/History"), + ("Preferences", "_data/plugins/apps/browser/chrome/Preferences"), + ("Secure Preferences", "_data/plugins/apps/browser/chrome/Secure Preferences"), + ("Network\\Cookies", "_data/plugins/apps/browser/chromium/Cookies"), + ("Login Data", "_data/plugins/apps/browser/chromium/Login Data"), ] for filename, test_path in files: @@ -45,3 +46,17 @@ def test_brave_cookies(target_brave: Target) -> None: records = list(target_brave.brave.cookies()) assert len(records) == 5 assert all(record.host == ".tweakers.net" for record in records) + + +def test_windows_edge_passwords_plugin(target_brave: Target) -> None: + records = list(target_brave.brave.passwords()) + + assert len(records) == 2 + + for record in records: + assert record.browser == "brave" + assert record.decrypted_username == "username" + assert record.decrypted_password is None + + assert records[0].url == "https://example.com/" + assert records[1].url == "https://example.org/" diff --git a/tests/plugins/apps/browser/test_chrome.py b/tests/plugins/apps/browser/test_chrome.py index 7e40f152db..8679575ffc 100644 --- a/tests/plugins/apps/browser/test_chrome.py +++ b/tests/plugins/apps/browser/test_chrome.py @@ -1,40 +1,206 @@ -from typing import Iterator +from typing import Iterator, Optional import pytest +from dissect.util.ts import webkittimestamp +from flow.record.fieldtypes import datetime as dt from dissect.target import Target from dissect.target.filesystem import VirtualFilesystem -from dissect.target.plugins.apps.browser import chrome +from dissect.target.helpers import keychain +from dissect.target.plugins.apps.browser.chrome import ChromePlugin from tests._utils import absolute_path @pytest.fixture -def target_chrome(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: - base_path = "Users\\John\\AppData\\Local\\Google\\Chrome\\User Data\\Default" - files = [ - ("History", "_data/plugins/apps/browser/chrome/History.sqlite"), - ("Preferences", "_data/plugins/apps/browser/chrome/windows/Preferences"), - ("Secure Preferences", "_data/plugins/apps/browser/chrome/windows/Secure Preferences"), - ] +def target_chrome_win(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: + fs_win.map_dir( + "Users\\John\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\", + absolute_path("_data/plugins/apps/browser/chrome/"), + ) - for filename, test_path in files: - fs_win.map_file("\\".join([base_path, filename]), absolute_path(test_path)) - - target_win_users.add_plugin(chrome.ChromePlugin) + target_win_users.add_plugin(ChromePlugin) yield target_win_users -def test_chrome_history(target_chrome: Target) -> None: - records = list(target_chrome.chrome.history()) +@pytest.fixture +def target_chrome_unix(target_unix_users: Target, fs_unix: VirtualFilesystem) -> Iterator[Target]: + fs_unix.map_dir("/root/.config/google-chrome/Default/", absolute_path("_data/plugins/apps/browser/chrome/")) + + target_unix_users.add_plugin(ChromePlugin) + + yield target_unix_users + + +@pytest.mark.parametrize( + "target_platform", + ["target_chrome_win", "target_chrome_unix"], +) +def test_chrome_history(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.chrome.history()) + assert len(records) == 5 + assert set(["chrome"]) == set(record.browser for record in records) + + assert ( + records[0].url == "https://www.google.com/search?q=github+fox-it+dissect&oq=github+fox-it+dissect" + "&aqs=chrome..69i57.12832j0j4&sourceid=chrome&ie=UTF-8" + ) + assert records[0].id == "1" + assert records[0].visit_count == 2 + assert records[0].ts == dt("2023-02-24T11:54:07.157810+00:00") + +@pytest.mark.parametrize( + "target_platform", + ["target_chrome_win", "target_chrome_unix"], +) +def test_chrome_downloads(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.chrome.downloads()) -def test_chrome_downloads(target_chrome: Target) -> None: - records = list(target_chrome.chrome.downloads()) assert len(records) == 1 + assert set(["chrome"]) == set(record.browser for record in records) + + assert records[0].id == 6 + assert records[0].ts_start == dt("2023-02-24T11:54:19.726147+00:00") + assert records[0].ts_end == dt("2023-02-24T11:54:21.030043+00:00") + assert records[0].url == "https://codeload.github.com/fox-it/dissect/zip/refs/heads/main" + +@pytest.mark.parametrize( + "target_platform", + ["target_chrome_win", "target_chrome_unix"], +) +def test_chrome_extensions(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.chrome.extensions()) -def test_chrome_extensions(target_chrome: Target) -> None: - records = list(target_chrome.chrome.extensions()) assert len(records) == 8 + assert set(["chrome"]) == set(record.browser for record in records) + + assert records[0].ts_install == dt("2022-11-24T15:20:43.682152+00:00") + assert records[0].ts_update == dt("2022-11-24T15:20:43.682152+00:00") + assert records[0].name == "Web Store" + assert records[0].version == "0.2" + assert records[0].id == "ahfgeienlihckogmohjhadlkjgocpleb" + + +def test_windows_chrome_passwords_plugin(target_chrome_win: Target) -> None: + records = list(target_chrome_win.chrome.passwords()) + + assert len(records) == 2 + + for record in records: + assert record.browser == "chrome" + assert record.decrypted_username == "username" + assert record.decrypted_password is None + + assert records[0].url == "https://example.com/" + assert records[1].url == "https://example.org/" + + +def test_unix_chrome_passwords_basic_plugin(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.map_dir( + "/root/.config/google-chrome/Default/", absolute_path("_data/plugins/apps/browser/chromium/unix/basic/") + ) + target_unix_users.add_plugin(ChromePlugin) + + records = list(target_unix_users.chrome.passwords()) + + assert len(records) == 2 + + for record in records: + assert record.browser == "chrome" + assert record.decrypted_username == "username" + assert record.decrypted_password == "password" + + assert records[0].url == "https://example.com/" + assert records[1].url == "https://example.org/" + + +def test_unix_chrome_passwords_gnome_plugin(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.map_dir( + "/root/.config/google-chrome/Default/", absolute_path("_data/plugins/apps/browser/chromium/unix/gnome/") + ) + target_unix_users.add_plugin(ChromePlugin) + + records = list(target_unix_users.chrome.passwords()) + + assert len(records) == 1 + + assert records[0].decrypted_username == "username" + assert records[0].decrypted_password is None + assert records[0].url == "https://test.com/" + + +@pytest.mark.parametrize( + "keychain_value, expected_password", + [ + ("user", "StrongPassword"), + ("invalid", None), + ], +) +def test_windows_chrome_passwords_dpapi( + target_win_users_dpapi: Target, fs_win: VirtualFilesystem, keychain_value: str, expected_password: Optional[str] +) -> None: + fs_win.map_dir( + "Users/user/AppData/Local/Google/Chrome/User Data", + absolute_path("_data/plugins/apps/browser/chrome/dpapi/User_Data"), + ) + + target_win_users_dpapi.add_plugin(ChromePlugin) + + keychain.KEYCHAIN.clear() + keychain.register_key( + key_type=keychain.KeyType.PASSPHRASE, + value=keychain_value, + identifier=None, + provider="user", + ) + + records = list(target_win_users_dpapi.chrome.passwords()) + + assert len(keychain.get_all_keys()) == 1 + assert len(records) == 2 + + assert records[0].url == "https://example.com/" + assert records[0].encrypted_password == "djEwT8fVcC9jiZPrMl8QdcFGSlfNArTPJG7Q/Wz4svHp9cRVG1NqC1/Jc8QR" + assert records[0].decrypted_password == expected_password + + +def test_windows_chrome_cookies_dpapi(target_win_users_dpapi: Target, fs_win: VirtualFilesystem) -> None: + fs_win.map_dir( + "Users/user/AppData/Local/Google/Chrome/User Data", + absolute_path("_data/plugins/apps/browser/chrome/dpapi/User_Data"), + ) + + target_win_users_dpapi.add_plugin(ChromePlugin) + + keychain.KEYCHAIN.clear() + keychain.register_key( + key_type=keychain.KeyType.PASSPHRASE, + value="user", + identifier=None, + provider="user", + ) + + records = list(target_win_users_dpapi.chrome.cookies()) + + assert len(records) == 4 + + assert records[0].ts_created == webkittimestamp(13370000000000000) + assert records[0].ts_last_accessed == webkittimestamp(13370000000000000) + assert records[0].browser == "chrome" + assert records[0].name == "tbb" + assert records[0].value == "false" + assert records[0].host == ".tweakers.net" + assert records[0].is_secure + + assert {c.name: c.value for c in records} == { + "tbb": "false", + "twk-theme": "twk-light", + "GPS": "1", + "PREF": "tz=Europe.Berlin", + } diff --git a/tests/plugins/apps/browser/test_chromium.py b/tests/plugins/apps/browser/test_chromium.py index 024398219b..e9161a16da 100644 --- a/tests/plugins/apps/browser/test_chromium.py +++ b/tests/plugins/apps/browser/test_chromium.py @@ -1,43 +1,83 @@ from typing import Iterator import pytest +from flow.record.fieldtypes import datetime as dt from dissect.target import Target from dissect.target.filesystem import VirtualFilesystem -from dissect.target.plugins.apps.browser import chromium +from dissect.target.plugins.apps.browser.chromium import ChromiumPlugin, decrypt_v10 from tests._utils import absolute_path @pytest.fixture -def target_chromium(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: - base_path = "Users\\John\\AppData\\Local\\Chromium\\User Data\\Default" - files = [ - ("History", "_data/plugins/apps/browser/chromium/History.sqlite"), - ("Cookies", "_data/plugins/apps/browser/chromium/Cookies.sqlite"), - ("Preferences", "_data/plugins/apps/browser/chromium/windows/Preferences"), - ("Secure Preferences", "_data/plugins/apps/browser/chromium/windows/Secure Preferences"), - ] - - for filename, test_path in files: - fs_win.map_file("\\".join([base_path, filename]), absolute_path(test_path)) +def target_chromium_win(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: + fs_win.map_dir( + "Users\\John\\AppData\\Local\\Chromium\\User Data\\Default\\", + absolute_path("_data/plugins/apps/browser/chromium/"), + ) - target_win_users.add_plugin(chromium.ChromiumPlugin) + target_win_users.add_plugin(ChromiumPlugin) yield target_win_users -def test_chromium_history(target_chromium: Target) -> None: - records = list(target_chromium.chromium.history()) +@pytest.fixture +def target_chromium_unix(target_unix_users: Target, fs_unix: VirtualFilesystem) -> Iterator[Target]: + fs_unix.map_dir("/root/.config/chromium/Default/", absolute_path("_data/plugins/apps/browser/chromium/")) + target_unix_users.add_plugin(ChromiumPlugin) + + yield target_unix_users + + +@pytest.mark.parametrize( + "target_platform", + ["target_chromium_win", "target_chromium_unix"], +) +def test_chromium_history(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.chromium.history()) + assert len(records) == 5 + assert set(["chromium"]) == set(record.browser for record in records) + + assert ( + records[0].url + == "https://www.google.com/search?q=fox-it+github+dissect&oq=fox-it+github+dissect&gs_lcrp=EgZjaHJvbWUyBggA" + "EEUYOTIHCAEQIRigAdIBCDU2OTNqMGo3qAIAsAIA&sourceid=chrome&ie=UTF-8" + ) + assert records[0].id == "1" + assert records[0].visit_count == 2 + assert records[0].ts == dt("2022-12-22T12:14:26.396332+00:00") + +@pytest.mark.parametrize( + "target_platform", + ["target_chromium_win", "target_chromium_unix"], +) +def test_chromium_downloads(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.chromium.downloads()) -def test_chromium_downloads(target_chromium: Target) -> None: - records = list(target_chromium.chromium.downloads()) assert len(records) == 1 + assert set(["chromium"]) == set(record.browser for record in records) + assert records[0].id == 1 + assert records[0].ts_start == dt("2022-12-22T12:14:38.440832+00:00") + assert records[0].ts_end == dt("2022-12-22T12:14:38.964170+00:00") + assert records[0].url == "https://codeload.github.com/fox-it/dissect/zip/refs/heads/main" + + +@pytest.mark.parametrize( + "target_platform", + ["target_chromium_win", "target_chromium_unix"], +) +def test_chromium_cookies(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.chromium.cookies()) + + assert len(records) == 5 + assert set(["chromium"]) == set(record.browser for record in records) -def test_chromium_cookies(target_chromium: Target) -> None: - records = list(target_chromium.chromium.cookies()) assert sorted([*map(lambda c: c.name, records)]) == [ "pl", "ssa-did", @@ -47,6 +87,69 @@ def test_chromium_cookies(target_chromium: Target) -> None: ] -def test_chromium_extensions(target_chromium: Target) -> None: - records = list(target_chromium.chromium.extensions()) +@pytest.mark.parametrize( + "target_platform", + ["target_chromium_win", "target_chromium_unix"], +) +def test_chromium_extensions(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.chromium.extensions()) + assert len(records) == 4 + assert set(["chromium"]) == set(record.browser for record in records) + + assert records[0].ts_install == dt("2023-04-18T08:43:37.773874+00:00") + assert records[0].ts_update == dt("2023-04-18T08:43:37.773874+00:00") + assert records[0].name == "Web Store" + assert records[0].version == "0.2" + assert records[0].id == "ahfgeienlihckogmohjhadlkjgocpleb" + + +def test_windows_chromium_passwords(target_chromium_win: Target) -> None: + records = list(target_chromium_win.chromium.passwords()) + + assert len(records) == 2 + + for record in records: + assert record.browser == "chromium" + assert record.decrypted_username == "username" + assert record.decrypted_password is None + + assert records[0].url == "https://example.com/" + assert records[1].url == "https://example.org/" + + +def test_unix_chromium_passwords_basic(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.map_dir("/root/.config/chromium/Default/", absolute_path("_data/plugins/apps/browser/chromium/unix/basic/")) + target_unix_users.add_plugin(ChromiumPlugin) + + records = list(target_unix_users.chromium.passwords()) + + assert len(records) == 2 + + for record in records: + assert record.browser == "chromium" + assert record.decrypted_username == "username" + assert record.decrypted_password == "password" + + assert records[0].url == "https://example.com/" + assert records[1].url == "https://example.org/" + + +def test_unix_chromium_passwords_gnome(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.map_dir("/root/.config/chromium/Default/", absolute_path("_data/plugins/apps/browser/chromium/unix/gnome/")) + target_unix_users.add_plugin(ChromiumPlugin) + + records = list(target_unix_users.chromium.passwords()) + + assert len(records) == 1 + + assert records[0].decrypted_username == "username" + assert records[0].decrypted_password is None + assert records[0].url == "https://test.com/" + + +def test_decrypt_v10(): + encrypted = b"v10\xd0&E\xbb\x85\xe7_\xfd\xf8\x93\x90/\x08{'\xa9" + decrypted = decrypt_v10(encrypted) + assert decrypted == "password" diff --git a/tests/plugins/apps/browser/test_edge.py b/tests/plugins/apps/browser/test_edge.py index 66af9f525c..5043d31a1c 100644 --- a/tests/plugins/apps/browser/test_edge.py +++ b/tests/plugins/apps/browser/test_edge.py @@ -1,40 +1,131 @@ from typing import Iterator import pytest +from flow.record.fieldtypes import datetime as dt from dissect.target import Target from dissect.target.filesystem import VirtualFilesystem -from dissect.target.plugins.apps.browser import edge +from dissect.target.plugins.apps.browser.edge import EdgePlugin from tests._utils import absolute_path +# NOTE: Missing cookie tests for Edge. -@pytest.fixture -def target_edge(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: - base_path = "Users\\John\\AppData\\Local\\Microsoft\\Edge\\User Data\\Default" - files = [ - ("History", "_data/plugins/apps/browser/edge/History.sqlite"), - ("Preferences", "_data/plugins/apps/browser/edge/windows/Preferences"), - ("Secure Preferences", "_data/plugins/apps/browser/edge/windows/Secure Preferences"), - ] - for filename, test_path in files: - fs_win.map_file("\\".join([base_path, filename]), absolute_path(test_path)) +@pytest.fixture +def target_edge_win(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: + fs_win.map_dir( + "Users\\John\\AppData\\Local\\Microsoft\\Edge\\User Data\\Default\\", + absolute_path("_data/plugins/apps/browser/edge/"), + ) - target_win_users.add_plugin(edge.EdgePlugin) + target_win_users.add_plugin(EdgePlugin) yield target_win_users -def test_edge_history(target_edge: Target) -> None: - records = list(target_edge.edge.history()) +@pytest.fixture +def target_edge_unix(target_unix_users, fs_unix): + fs_unix.map_dir("/root/.config/microsoft-edge/Default/", absolute_path("_data/plugins/apps/browser/edge/")) + target_unix_users.add_plugin(EdgePlugin) + + yield target_unix_users + + +@pytest.mark.parametrize( + "target_platform", + ["target_edge_win", "target_edge_unix"], +) +def test_edge_history(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.edge.history()) + assert len(records) == 45 + assert set(["edge"]) == set(record.browser for record in records) + + assert records[-1].url == "https://github.com/fox-it/dissect" + assert records[-1].id == "45" + assert records[-1].visit_count == 2 + assert records[-1].ts == dt("2023-02-24T11:54:44.875477+00:00") -def test_edge_downloads(target_edge: Target) -> None: - records = list(target_edge.edge.downloads()) +@pytest.mark.parametrize( + "target_platform", + ["target_edge_win", "target_edge_unix"], +) +def test_edge_downloads(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.edge.downloads()) + assert len(records) == 2 + assert set(["edge"]) == set(record.browser for record in records) + + assert records[0].id == 1 + assert records[0].ts_start == dt("2023-02-24T11:52:36.631304+00:00") + assert records[0].ts_end == dt("2023-02-24T11:52:37.068768+00:00") + assert records[0].url == "https://codeload.github.com/fox-it/dissect/zip/refs/heads/main" -def test_edge_extensions(target_edge: Target) -> None: - records = list(target_edge.edge.extensions()) +@pytest.mark.parametrize( + "target_platform", + ["target_edge_win", "target_edge_unix"], +) +def test_edge_extensions(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.edge.extensions()) + assert len(records) == 39 + assert set(["edge"]) == set(record.browser for record in records) + + assert records[0].ts_install == dt("2023-04-18T08:39:57.968208+00:00") + assert records[0].ts_update == dt("2023-04-18T08:39:57.968208+00:00") + assert records[0].name == "Web Store" + assert records[0].version == "0.2" + assert records[0].id == "ahfgeienlihckogmohjhadlkjgocpleb" + + +def test_windows_edge_passwords_plugin(target_edge_win: Target) -> None: + records = list(target_edge_win.edge.passwords()) + + assert len(records) == 2 + + for record in records: + assert record.browser == "edge" + assert record.decrypted_username == "username" + assert record.decrypted_password is None + + assert records[0].url == "https://example.com/" + assert records[1].url == "https://example.org/" + + +def test_unix_edge_passwords_basic_plugin(target_edge_unix: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.map_file( + "/root/.config/microsoft-edge/Default/Login Data", + absolute_path("_data/plugins/apps/browser/chromium/unix/basic/Login Data"), + ) + + records = list(target_edge_unix.edge.passwords()) + + assert len(records) == 2 + + for record in records: + assert record.browser == "edge" + assert record.decrypted_username == "username" + assert record.decrypted_password == "password" + + assert records[0].url == "https://example.com/" + assert records[1].url == "https://example.org/" + + +def test_unix_edge_passwords_gnome_plugin(target_edge_unix: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.map_file( + "/root/.config/microsoft-edge/Default/Login Data", + absolute_path("_data/plugins/apps/browser/chromium/unix/gnome/Login Data"), + ) + + records = list(target_edge_unix.edge.passwords()) + + assert len(records) == 1 + + assert records[0].decrypted_username == "username" + assert records[0].decrypted_password is None + assert records[0].url == "https://test.com/" diff --git a/tests/plugins/apps/browser/test_firefox.py b/tests/plugins/apps/browser/test_firefox.py index e3b2f23a63..85bb0c61d0 100644 --- a/tests/plugins/apps/browser/test_firefox.py +++ b/tests/plugins/apps/browser/test_firefox.py @@ -1,44 +1,263 @@ from typing import Iterator +from unittest.mock import patch import pytest +from _pytest.fixtures import fixture +from asn1crypto.algos import EncryptionAlgorithmId +from flow.record.fieldtypes import datetime as dt from dissect.target import Target from dissect.target.filesystem import VirtualFilesystem -from dissect.target.plugins.apps.browser import firefox +from dissect.target.helpers import keychain +from dissect.target.helpers.fsutil import TargetPath +from dissect.target.plugins.apps.browser.firefox import ( + CKA_ID, + FirefoxPlugin, + decrypt, + query_master_key, + retrieve_master_key, +) from tests._utils import absolute_path +# NOTE: Missing extensions tests for Firefox. -@pytest.fixture -def target_firefox(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: - base_path = "Users\\John\\AppData\\Local\\Mozilla\\Firefox\\Profiles\\g1rbw8y7.default-release" - files = [ - ("places.sqlite", "_data/plugins/apps/browser/firefox/places.sqlite"), - ("cookies.sqlite", "_data/plugins/apps/browser/firefox/cookies.sqlite"), - ] - for filename, test_path in files: - fs_win.map_file("\\".join([base_path, filename]), absolute_path(test_path)) +@pytest.fixture +def target_firefox_win(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: + fs_win.map_dir( + "Users\\John\\AppData\\Local\\Mozilla\\Firefox\\Profiles\\g1rbw8y7.default-release\\", + absolute_path("_data/plugins/apps/browser/firefox/"), + ) - target_win_users.add_plugin(firefox.FirefoxPlugin) + target_win_users.add_plugin(FirefoxPlugin) yield target_win_users -def test_firefox_history(target_firefox: Target) -> None: - records = list(target_firefox.firefox.history()) +@pytest.fixture +def target_firefox_unix(target_unix_users: Target, fs_unix: VirtualFilesystem) -> Iterator[Target]: + fs_unix.map_dir( + "/root/.mozilla/firefox/g1rbw8y7.default-release/", absolute_path("_data/plugins/apps/browser/firefox/") + ) + fs_unix.map_dir( + "/root/.mozilla/firefox/g1rbw8y7.default-release/", + absolute_path("_data/plugins/apps/browser/firefox/passwords/default/"), + ) + + target_unix_users.add_plugin(FirefoxPlugin) + + yield target_unix_users + + +@pytest.mark.parametrize( + "target_platform", + ["target_firefox_win", "target_firefox_unix"], +) +def test_firefox_history(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.firefox.history()) + assert len(records) == 24 + assert set(["firefox"]) == set(record.browser for record in records) + + assert records[0].url == "https://www.mozilla.org/privacy/firefox/" + assert records[0].id == "1" + assert records[0].description == "47356411089529" + assert records[0].visit_count == 1 + assert records[0].ts == dt("2021-12-01T10:42:05.742000+00:00") + +@pytest.mark.parametrize( + "target_platform", + ["target_firefox_win", "target_firefox_unix"], +) +def test_firefox_downloads(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.firefox.downloads()) -def test_firefox_downloads(target_firefox: Target) -> None: - records = list(target_firefox.firefox.downloads()) assert len(records) == 3 + assert set(["firefox"]) == set(record.browser for record in records) + + assert records[0].id == 1 + assert records[0].ts_start == dt("2021-12-01T10:57:01.175000+00:00") + assert records[0].ts_end == dt("2021-12-01T10:57:01.321000+00:00") + assert ( + records[0].url + == "https://dl.google.com/tag/s/appguid%3D%7B8A69D345-D564-463C-AFF1-A69D9E530F96%7D%26iid%3D%7B2098EF96-29DB" + "-B268-0B90-01AD59CD5C17%7D%26lang%3Dnl%26browser%3D3%26usagestats%3D1%26appname%3DGoogle%2520Chrome%26needs" + "admin%3Dprefers%26ap%3Dx64-stable-statsdef_1%26installdataindex%3Dempty/update2/installers/ChromeSetup.exe" + ) + + +@pytest.mark.parametrize( + "target_platform", + ["target_firefox_win", "target_firefox_unix"], +) +def test_firefox_cookies(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + records = list(target_platform.firefox.cookies()) + + assert len(records) == 4 + assert set(["firefox"]) == set(record.browser for record in records) -def test_firefox_cookies(target_firefox: Target) -> None: - records = list(target_firefox.firefox.cookies()) assert sorted([*map(lambda c: c.name, records)]) == [ "_lr_env_src_ats", "_lr_retry_request", "_uc_referrer", "_uc_referrer", ] + + +@pytest.mark.parametrize( + "target_platform", + ["target_firefox_win", "target_firefox_unix"], +) +def test_firefox_password_plugin(target_platform: Target, request: pytest.FixtureRequest) -> None: + target_platform = request.getfixturevalue(target_platform) + + records = list(target_platform.firefox.passwords()) + assert len(records) == 2 + + for record in records: + assert record.browser == "firefox" + assert record.decrypted_username == "username" + assert record.decrypted_password == "password" + + +def test_unix_firefox_password_plugin_with_primary_password( + target_unix_users: Target, fs_unix: VirtualFilesystem +) -> None: + fs_unix.map_dir( + "/root/.mozilla/firefox/g1rbw8y7.default-release/", + absolute_path("_data/plugins/apps/browser/firefox/passwords/primary/"), + ) + target_unix_users.add_plugin(FirefoxPlugin) + + keychain.register_key( + keychain.KeyType.PASSPHRASE, + "PrimaryPassword", + identifier=None, + provider="browser", + ) + + records = list(target_unix_users.firefox.passwords()) + + assert len(records) == 1 + + for record in records: + assert record.browser == "firefox" + assert record.username == "root" + assert record.user_home == "/root" + + assert record.decrypted_username == "username" + assert record.decrypted_password == "password" + + +@fixture +def path_key4(fs_unix): + fs_unix.map_file( + "/key4.db", + absolute_path("_data/plugins/apps/browser/firefox/key4.db"), + ) + return TargetPath(fs_unix, "/key4.db") + + +PRIMARY_PASSWORD = "PrimaryPassword" + + +@fixture +def path_key4_primary_password(fs_unix): + fs_unix.map_file( + "/key4.db", + absolute_path("_data/plugins/apps/browser/firefox/passwords/primary/key4.db"), + ) + return TargetPath(fs_unix, "/key4.db") + + +@fixture +def logins(): + return { + "username": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECIFVMX6MyYxpBBC2j8K+bCEaE9/FmqE1wo2A", + "password": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECB2ZPCZBORUJBBDZ8dBZUVgECoiFD5vPvTbP", + } + + +@fixture +def logins_with_primary_password(): + return { + "username": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECPEZvPh6dhBkBBB3/O2puJy1NiBUo5gS8hZh", + "password": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECM00msnMxFyVBBByTarrnS+FSR5OHQhZfs8t", + } + + +@fixture +def decrypted(): + return { + "username": "username", + "password": "password", + } + + +def test_decrypt_is_succesful(path_key4, logins, decrypted): + dec_username, dec_password = decrypt(logins.get("username"), logins.get("password"), path_key4) + assert dec_username == decrypted.get("username") + assert dec_password == decrypted.get("password") + + +@patch("dissect.target.plugins.apps.browser.firefox.retrieve_master_key", side_effect=ValueError("")) +def test_decrypt_bad_master_key(mock_target, path_key4, logins): + with pytest.raises(ValueError): + decrypt(logins.get("username"), logins.get("password"), path_key4) + + +def test_decrypt_with_primary_password_is_succesful( + path_key4_primary_password, logins_with_primary_password, decrypted +): + dec_username, dec_password = decrypt( + logins_with_primary_password.get("username"), + logins_with_primary_password.get("password"), + path_key4_primary_password, + PRIMARY_PASSWORD, + ) + assert dec_username == decrypted.get("username") + assert dec_password == decrypted.get("password") + + +def test_decrypt_with_bad_primary_password_is_unsuccesful( + path_key4_primary_password, + logins_with_primary_password, +): + with pytest.raises(ValueError) as e: + decrypt( + logins_with_primary_password.get("username"), + logins_with_primary_password.get("password"), + path_key4_primary_password, + "BAD_PRIMARY_PASSWORD", + ) + assert e.msg == "Failed to decrypt password using keyfile /key4.db and password 'BAD_PRIMARY_PASSWORD'" + + +def test_retrieve_master_key_is_succesful(path_key4): + key, algorithm = retrieve_master_key(b"", path_key4) + + assert EncryptionAlgorithmId.map(algorithm) == "pbes2" + assert key.hex() == "452c2f920285f794614af1c2d99ed331940d73298a526140" + + +@patch("dissect.target.plugins.apps.browser.firefox.query_master_key", return_value=(b"aaaa", "BAD_CKA_VALUE")) +def test_retrieve_master_key_bad_cka_value(mock_target, path_key4): + with pytest.raises(ValueError) as e: + retrieve_master_key(b"", path_key4) + assert "Password master key CKA_ID 'BAD_CKA_VALUE' is not equal to expected value" in e.msg + + +def test_query_master_key(path_key4): + master_key, master_key_cka = query_master_key(path_key4) + + assert isinstance(master_key, bytes) + assert len(master_key) == 148 + + assert isinstance(master_key_cka, bytes) + assert len(master_key_cka) == 16 + assert master_key_cka == CKA_ID diff --git a/tests/plugins/apps/browser/test_iexplore.py b/tests/plugins/apps/browser/test_iexplore.py index 0a5bbd91a1..fcbdebbf40 100644 --- a/tests/plugins/apps/browser/test_iexplore.py +++ b/tests/plugins/apps/browser/test_iexplore.py @@ -8,6 +8,8 @@ from dissect.target.plugins.apps.browser import iexplore from tests._utils import absolute_path +# NOTE: Missing cookies, extensions and passwords tests. + @pytest.fixture def target_iexplore(target_win_users: Target, fs_win: VirtualFilesystem) -> Iterator[Target]: @@ -28,6 +30,7 @@ def test_iexplore_history(target_iexplore: Target) -> None: records = list(target_iexplore.iexplore.history()) assert len(records) == 41 + # Test namespaced plugin records = list(target_iexplore.browser.history()) assert len(records) == 41 diff --git a/tests/tools/test_utils.py b/tests/tools/test_utils.py index 68cc06c61a..d7066dbc06 100644 --- a/tests/tools/test_utils.py +++ b/tests/tools/test_utils.py @@ -4,8 +4,13 @@ import pytest -from dissect.target.plugin import arg -from dissect.target.tools.utils import args_to_uri, persist_execution_report +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.plugin import arg, find_plugin_functions +from dissect.target.tools.utils import ( + args_to_uri, + get_target_attribute, + persist_execution_report, +) def test_persist_execution_report(): @@ -51,3 +56,24 @@ class FakeLoader: with patch("dissect.target.tools.utils.LOADERS_BY_SCHEME", {"loader": FakeLoader}): assert args_to_uri(targets, loader_name, rest) == uris + + +@pytest.mark.parametrize( + "pattern, expected_function", + [ + ("passwords", "dissect.target.plugins.os.unix.shadow.ShadowPlugin"), + ("firefox.passwords", "Unsupported function `firefox` for target"), + ], +) +def test_plugin_name_confusion_regression(target_unix_users, pattern, expected_function): + plugins, _ = find_plugin_functions(target_unix_users, pattern) + assert len(plugins) == 1 + + # We don't expect these functions to work since our target_unix_users fixture + # does not include the neccesary artifacts for them to work. However we are + # only interested in the plugin or namespace that was called so we check + # the exception stack trace. + with pytest.raises(UnsupportedPluginError) as exc_info: + get_target_attribute(target_unix_users, plugins[0]) + + assert expected_function in str(exc_info.value)