From 651e8c332849a113b7084f7cab76deb7596f6235 Mon Sep 17 00:00:00 2001 From: B0TAxy Date: Wed, 1 Oct 2025 01:41:58 +0300 Subject: [PATCH 01/17] Implemented ntds plugin --- .../plugins/os/windows/credential/ntds.py | 975 ++++++++++++++++++ 1 file changed, 975 insertions(+) create mode 100644 dissect/target/plugins/os/windows/credential/ntds.py diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/credential/ntds.py new file mode 100644 index 0000000000..d6bfec3870 --- /dev/null +++ b/dissect/target/plugins/os/windows/credential/ntds.py @@ -0,0 +1,975 @@ +from __future__ import annotations + +from binascii import hexlify, unhexlify +from hashlib import md5 +from struct import unpack +from typing import TYPE_CHECKING + +from Cryptodome.Cipher import AES, ARC4, DES +from dissect.cstruct import cstruct +from dissect.esedb import EseDB +from dissect.util import ts +from dissect.util.sid import read_sid + +from dissect.target.helpers.record import TargetRecordDescriptor +from dissect.target.plugin import Plugin, UnsupportedPluginError, export +from dissect.target.plugins.os.windows.credential.sam import rid_to_key + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.cstruct.types import structure + from flow.record import Record + + from dissect.target.target import Target + + +# User Account Control flags mapping +UAC_FLAGS = { + 0x0001: "script", + 0x0002: "account_disable", + 0x0008: "home_dir_required", + 0x0010: "lockout", + 0x0020: "passwd_not_reqd", + 0x0040: "passwd_cant_change", + 0x0080: "encrypted_text_pwd_allowed", + 0x0100: "temp_duplicate_account", + 0x0200: "normal_account", + 0x0800: "interdomain_trust_account", + 0x1000: "workstation_trust_account", + 0x2000: "server_trust_account", + 0x10000: "dont_expire_password", + 0x20000: "mns_logon_account", + 0x40000: "smartcard_required", + 0x80000: "trusted_for_delegation", + 0x100000: "not_delegated", + 0x200000: "use_des_key_only", + 0x400000: "dont_req_preauth", + 0x800000: "password_expired", + 0x1000000: "trusted_to_auth_for_delegation", + 0x04000000: "partial_secrets_account", +} + +# NTDS attribute name to internal field mapping +NAME_TO_INTERNAL = { + "usn_created": "ATTq131091", + "usn_changed": "ATTq131192", + "name": "ATTm3", + "object_guid": "ATTk589826", + "object_sid": "ATTr589970", + "user_account_control": "ATTj589832", + "primary_group_id": "ATTj589922", + "account_expires": "ATTq589983", + "logon_count": "ATTj589993", + "sam_account_name": "ATTm590045", + "sam_account_type": "ATTj590126", + "last_logon_timestamp": "ATTq589876", + "user_principal_name": "ATTm590480", + "unicode_pwd": "ATTk589914", + "dbcspwd": "ATTk589879", + "nt_pwd_history": "ATTk589918", + "lm_pwd_history": "ATTk589984", + "pek_list": "ATTk590689", + "supplemental_credentials": "ATTk589949", + "password_last_set": "ATTq589920", + "instance_type": "ATTj131073", +} + +# Kerberos encryption type mappings +KERBEROS_TYPE = { + # DES + 1: "des-cbc-crc", + 2: "des-cbc-md4", + 3: "des-cbc-md5", + # RC4 + 23: "rc4-hmac", + -133: "rc4-hmac-exp", + 0xFFFFFF74: "rc4_hmac_old", + # AES (RFC 3962) + 17: "aes128-cts-hmac-sha1-96", + 18: "aes256-cts-hmac-sha1-96", + # AES (newer RFC 8009) + 19: "aes128-cts-hmac-sha256-128", + 20: "aes256-cts-hmac-sha384-192", + # Other / legacy + 16: "des3-cbc-sha1", + 24: "rc4-hmac-exp-old", +} + +# Record descriptor for NTDS user secrets +NtdsUserSecretRecord = TargetRecordDescriptor( + "windows/credential/ntds", + [ + ("string", "upn"), + ("string", "sam_name"), + ("datetime", "password_last_set"), + ("string", "lm"), + ("string[]", "lm_history"), + ("string", "nt"), + ("string[]", "nt_history"), + *[("boolean", flag) for flag in UAC_FLAGS.values()], + ("string", "cleartext_password"), + ("string", "credential_type"), + ("string", "kerberos_type"), + ("string", "kerberos_key"), + ("string", "default_salt"), + ("uint32", "iteration_count"), + ], +) + + +class CryptoStructures: + """Container for C structure definitions used in NTDS crypto operations.""" + + # Dynamic structure templates + PEK_LIST_ENC_DEF = """ + typedef struct {{ + CHAR Header[8]; + CHAR KeyMaterial[16]; + BYTE EncryptedPek[{Length}]; + }} PEKLIST_ENC; + """ + + PEK_LIST_PLAIN_DEF = """ + typedef struct {{ + CHAR Header[32]; + BYTE DecryptedPek[{Length}]; + }} PEKLIST_PLAIN; + """ + + CRYPTED_HASH_W16_DEF = """ + typedef struct {{ + BYTE Header[8]; + BYTE KeyMaterial[16]; + DWORD Unknown; + BYTE EncryptedHash[{Length}]; + }} CRYPTED_HASHW16; + """ + + CRYPTED_HISTORY_DEF = """ + typedef struct {{ + BYTE Header[8]; + BYTE KeyMaterial[16]; + BYTE EncryptedHash[{Length}]; + }} CRYPTED_HISTORY; + """ + + CRYPTED_BLOB_DEF = """ + typedef struct {{ + BYTE Header[8]; + BYTE KeyMaterial[16]; + BYTE EncryptedHash[{Length}]; + }} CRYPTED_BLOB; + """ + + # Static structures + NTDS_CRYPTO_DEF = """ + typedef struct { + CHAR Header; + CHAR Padding[3]; + CHAR Key[16]; + } PEK_KEY; + + typedef struct { + BYTE Header[8]; + BYTE KeyMaterial[16]; + BYTE EncryptedHash[16]; + } CRYPTED_HASH; + """ + + SAMR_STRUCTS_DEF = """ + typedef struct { + uint16 NameLength; + uint16 ValueLength; + uint16 Reserved; + char PropertyName[NameLength]; + char PropertyValue[ValueLength]; + } USER_PROPERTY; + + typedef struct { + uint32 Reserved1; + uint32 Length; + uint16 Reserved2; + uint16 Reserved3; + BYTE Reserved4[96]; + uint16 PropertySignature; + uint16 PropertyCount; + USER_PROPERTY UserProperties[PropertyCount]; + } USER_PROPERTIES; + + typedef struct { + uint16 Reserved1; + uint16 Reserved2; + uint32 Reserved3; + uint32 IterationCount; + uint32 KeyType; + uint32 KeyLength; + uint32 KeyOffset; + } KERB_KEY_DATA_NEW; + + typedef struct { + uint16 Revision; + uint16 Flags; + uint16 CredentialCount; + uint16 ServiceCredentialCount; + uint16 OldCredentialCount; + uint16 OlderCredentialCount; + uint16 DefaultSaltLength; + uint16 DefaultSaltMaximumLength; + uint32 DefaultSaltOffset; + uint32 DefaultIterationCount; + KERB_KEY_DATA_NEW Credentials[CredentialCount]; + KERB_KEY_DATA_NEW ServiceCredentials[ServiceCredentialCount]; + KERB_KEY_DATA_NEW OldCredentials[OldCredentialCount]; + KERB_KEY_DATA_NEW OlderCredentials[OlderCredentialCount]; + } KERB_STORED_CREDENTIAL_NEW; + """ + + +# Initialize cstruct parsers +c_ntds_crypto = cstruct().load(CryptoStructures.NTDS_CRYPTO_DEF) +c_samr = cstruct().load(CryptoStructures.SAMR_STRUCTS_DEF) + + +class NtdsPlugin(Plugin): + """Plugin to parse NTDS.dit Active Directory database and extract user credentials. + + This plugin decrypts and extracts user password hashes, password history, + Kerberos keys, and other authentication data from the NTDS.dit database + found on Windows Domain Controllers. + """ + + __namespace__ = "ntds" + + # Struct constants + class StructConstant: + """Constants used for struct operations.""" + + PEK_LIST_ENC_LENGTH = len(cstruct().load(CryptoStructures.PEK_LIST_ENC_DEF.format(Length=0)).PEKLIST_ENC) + PEK_LIST_PLAIN_LENGTH = len(cstruct().load(CryptoStructures.PEK_LIST_PLAIN_DEF.format(Length=0)).PEKLIST_PLAIN) + CRYPTED_HASH_W16_LENGTH = len( + cstruct().load(CryptoStructures.CRYPTED_HASH_W16_DEF.format(Length=0)).CRYPTED_HASHW16 + ) + CRYPTED_HISTORY_LENGTH = len( + cstruct().load(CryptoStructures.CRYPTED_HISTORY_DEF.format(Length=0)).CRYPTED_HISTORY + ) + CRYPTED_BLOB_LENGTH = len(cstruct().load(CryptoStructures.CRYPTED_BLOB_DEF.format(Length=0)).CRYPTED_BLOB) + + # Encryption and crypto constants + class CryptoConstants: + """Constants used for cryptographic operations.""" + + PEK_LIST_ENTRY_SIZE = 20 + DES_BLOCK_SIZE = 8 + IV_SIZE = 16 + NTLM_HASH_SIZE = 16 + + # The header contains the PEK index encoded in the hex representation of the header bytes. + PEK_INDEX_HEX_START = 8 + PEK_INDEX_HEX_END = 10 + + # Number of MD5 iterations used when deriving the RC4 key for older PEK lists. + PEK_KEY_DERIVATION_ITERATIONS = 1000 + + # First 4 bytes are a version/marker, not ciphertext + AES_HASH_HEADER_SIZE = 4 + + # Version-specific headers + UP_TO_WINDOWS_2012_R2_PEK_HEADER = b"\x02\x00\x00\x00" + WINDOWS_2016_TP4_PEK_HEADER = b"\x03\x00\x00\x00" + WINDOWS_2016_TP4_HASH_HEADER = b"\x13\x00\x00\x00" + + # Default values + DEFAULT_LM_HASH = "aad3b435b51404eeaad3b435b51404ee" + DEFAULT_NT_HASH = "31d6cfe0d16ae931b73c59d7e0c089c0" + EMPTY_BYTE = b"\x00" + DEFAULT_AES_IV = b"\x00" * 16 + + # SAM account type constants + class AccountTypes: + """SAM account type constants.""" + + NORMAL_USER = 0x30000000 + MACHINE = 0x30000001 + TRUST = 0x30000002 + ALL_TYPES = (NORMAL_USER, MACHINE, TRUST) + + # Other constants + OBJECT_IS_WRITEABLE_ON_THIS_DIRECTORY = 4 + + def __init__(self, target: Target): + """Initialize the NTDS plugin. + + Args: + target: The target system to analyze. + """ + super().__init__(target) + self.ntds_path = self.target.fs.path("/sysvol/Windows/NTDS/ntds.dit") + self._pek_list: list[bytes] = [] + self.ntds_database = None + self._filter_fields = self._get_filter_fields() + + def _get_filter_fields(self) -> set[str]: + """Get the set of fields to filter for when scanning the database. + + Returns: + Set of internal field names to filter records by. + """ + return { + NAME_TO_INTERNAL["object_sid"], + NAME_TO_INTERNAL["dbcspwd"], + NAME_TO_INTERNAL["name"], + NAME_TO_INTERNAL["sam_account_type"], + NAME_TO_INTERNAL["unicode_pwd"], + NAME_TO_INTERNAL["sam_account_name"], + NAME_TO_INTERNAL["user_principal_name"], + NAME_TO_INTERNAL["nt_pwd_history"], + NAME_TO_INTERNAL["lm_pwd_history"], + NAME_TO_INTERNAL["password_last_set"], + NAME_TO_INTERNAL["user_account_control"], + NAME_TO_INTERNAL["supplemental_credentials"], + NAME_TO_INTERNAL["pek_list"], + NAME_TO_INTERNAL["instance_type"], + } + + def check_compatible(self) -> None: + """Check if the plugin can run on the target system. + + Raises: + UnsupportedPluginError: If NTDS.dit is not found or system hive is missing. + """ + if not self.ntds_path.exists(): + raise UnsupportedPluginError("NTDS.dit file not found") + + if not self.target.has_function("lsa") or not hasattr(self.target.lsa, "syskey"): + raise UnsupportedPluginError("System Hive is not present or LSA function not available") + + def _collect_pek_and_user_records(self) -> tuple[bytes, list[Record]]: + """Scan the ESE database and extract PEK list and user records. + + Returns: + Tuple containing: + - Raw PEK list blob (bytes) + - List of user records from the database + """ + db = EseDB(self.ntds_path.open(), False) + pek_blob = None + user_records: list[Record] = [] + + for table in db.tables(): + for record in table.records(): + try: + columns = record.as_dict().keys() + except TypeError: + continue + + if not self._filter_fields.intersection(columns): + continue + + # Extract PEK list if present + if record[NAME_TO_INTERNAL["pek_list"]]: + pek_blob = record[NAME_TO_INTERNAL["pek_list"]] + + # Collect user records that are writable and of valid account type + if self._is_valid_user_record(record): + user_records.append(record) + + self.ntds_database = db + return pek_blob, user_records + + def _is_valid_user_record(self, record: Record) -> bool: + """Check if a record represents a valid user account. + + Args: + record: Database record to check. + + Returns: + True if the record is a valid user account, False otherwise. + """ + return ( + record[NAME_TO_INTERNAL["sam_account_type"]] in self.AccountTypes.ALL_TYPES + and record[NAME_TO_INTERNAL["instance_type"]] & self.OBJECT_IS_WRITEABLE_ON_THIS_DIRECTORY + ) + + def _parse_and_decrypt_pek_list(self, pek_blob: bytes) -> None: + """Parse PEK list structure and decrypt PEK keys. + + Args: + pek_blob: Raw PEK list blob from the database. + """ + # Create structure with correct size + enc_struct = cstruct().load( + CryptoStructures.PEK_LIST_ENC_DEF.format(Length=len(pek_blob) - self.StructConstant.PEK_LIST_ENC_LENGTH) + ) + pek_list_enc = enc_struct.PEKLIST_ENC(pek_blob) + + header = bytearray(pek_list_enc.Header) + + if header.startswith(self.CryptoConstants.UP_TO_WINDOWS_2012_R2_PEK_HEADER): + self._decrypt_pek_legacy(pek_list_enc) + elif header.startswith(self.CryptoConstants.WINDOWS_2016_TP4_PEK_HEADER): + self._decrypt_pek_modern(pek_list_enc) + else: + self.target.log.error("Unknown PEK list header: %s", header) + + def _decrypt_pek_modern(self, pek_list_enc: structure) -> None: + """Decrypt PEK list for Windows Server 2016+ using AES encryption. + + Args: + pek_list_enc: Encrypted PEK list structure. + """ + # Decrypt using AES with syskey as key and KeyMaterial as IV + pek_plain_raw = self._aes_decrypt( + self.target.lsa.syskey, bytearray(pek_list_enc.EncryptedPek), pek_list_enc.KeyMaterial + ) + + # Parse decrypted structure + plain_struct = cstruct().load( + CryptoStructures.PEK_LIST_PLAIN_DEF.format( + Length=len(pek_plain_raw) - self.StructConstant.PEK_LIST_PLAIN_LENGTH + ) + ) + plain = plain_struct.PEKLIST_PLAIN(pek_plain_raw) + + # Extract PEK entries (4-byte index + 16-byte key) + self._extract_pek_entries(plain.DecryptedPek) + + def _decrypt_pek_legacy(self, pek_list_enc: structure) -> None: + """Decrypt PEK list for Windows Server 2012 R2 and earlier using RC4. + + Args: + pek_list_enc: Encrypted PEK list structure. + """ + # Derive RC4 key from syskey and KeyMaterial + rc4_key = self._derive_rc4_key( + self.target.lsa.syskey, pek_list_enc.KeyMaterial, self.CryptoConstants.PEK_KEY_DERIVATION_ITERATIONS + ) + + # Decrypt with RC4 + rc4 = ARC4.new(rc4_key) + pek_plain_raw = rc4.encrypt(bytearray(pek_list_enc.EncryptedPek)) + + # Parse decrypted structure + plain_struct = cstruct().load( + CryptoStructures.PEK_LIST_PLAIN_DEF.format( + Length=len(pek_plain_raw) - self.StructConstant.PEK_LIST_PLAIN_LENGTH + ) + ) + plain = plain_struct.PEKLIST_PLAIN(pek_plain_raw) + + # Extract PEK keys from legacy format + pek_key_len = len(c_ntds_crypto.PEK_KEY) + for i in range(0, len(plain.DecryptedPek), pek_key_len): + pek_key = c_ntds_crypto.PEK_KEY(plain.DecryptedPek[i : i + pek_key_len]).Key + self._pek_list.append(pek_key) + self.target.log.info("PEK #%d decrypted: %s", i // pek_key_len, hexlify(pek_key).decode()) + + def _extract_pek_entries(self, data: bytes) -> None: + """Extract PEK entries from decrypted data. + + PEK entries are stored as: [4-byte index][16-byte key] + The list is terminated by a non-sequential index. + + Args: + data: Decrypted PEK data containing entries. + """ + entry_size = self.CryptoConstants.PEK_LIST_ENTRY_SIZE + pos, expected_index = 0, 0 + + while pos + entry_size <= len(data): + pek_entry = data[pos : pos + entry_size] + index, pek = unpack(" bytes: + """Derive RC4 key using MD5 with multiple iterations. + + Args: + syskey: System key from LSA. + key_material: Random key material for this encryption. + iterations: Number of MD5 iterations to perform. + + Returns: + 16-byte RC4 key. + """ + hasher = md5() + hasher.update(syskey) + for _ in range(iterations): + hasher.update(key_material) + return hasher.digest() + + def _aes_decrypt(self, key: bytes, data: bytes, iv: bytes) -> bytes: + """Decrypt data using AES-CBC. + + Args: + key: AES encryption key. + data: Encrypted data. + iv: Initialization vector. + + Returns: + Decrypted data. + """ + aes = AES.new(key, AES.MODE_CBC, iv) + plain = b"" + + # Decrypt in IV-sized blocks + for idx in range(0, len(data), self.CryptoConstants.IV_SIZE): + block = data[idx : idx + self.CryptoConstants.IV_SIZE] + + # Pad incomplete blocks + if len(block) < self.CryptoConstants.IV_SIZE: + padding_size = self.CryptoConstants.IV_SIZE - len(block) + block = block + (self.CryptoConstants.EMPTY_BYTE * padding_size) + + plain += aes.decrypt(block) + + return plain + + def _get_pek_index_from_header(self, header: bytes) -> int: + """Extract PEK index from header bytes. + + The PEK index is encoded in the hex representation of the header + at positions 8:10. + + Args: + header: Header bytes containing encoded PEK index. + + Returns: + PEK index value. + """ + hex_header = hexlify(bytearray(header)) + start = self.CryptoConstants.PEK_INDEX_HEX_START + end = self.CryptoConstants.PEK_INDEX_HEX_END + return int(hex_header[start:end], 16) + + def _remove_rc4_layer(self, crypted: structure) -> bytes: + """Remove RC4 encryption layer using PEK key. + + Args: + crypted: Encrypted structure with Header, KeyMaterial, and EncryptedHash. + + Returns: + Data with RC4 layer removed. + """ + pek_index = self._get_pek_index_from_header(crypted.Header) + rc4_key = self._derive_rc4_key( + self._pek_list[pek_index], + bytearray(crypted.KeyMaterial), + iterations=1, # Single iteration for hash decryption + ) + + rc4 = ARC4.new(rc4_key) + return rc4.encrypt(bytearray(crypted.EncryptedHash)) + + def _remove_des_layer(self, crypted_hash: bytes, rid: int) -> bytes: + """Remove final DES encryption layer using RID-derived keys. + + The hash is split into two 8-byte blocks, each decrypted with + a different RID-derived key. + + Args: + crypted_hash: 16-byte DES-encrypted hash. + rid: Relative ID of the user account. + + Returns: + 16-byte decrypted hash. + + Raises: + ValueError: If crypted_hash is not 16 bytes. + """ + expected_size = 2 * self.CryptoConstants.DES_BLOCK_SIZE + if len(crypted_hash) != expected_size: + raise ValueError(f"crypted_hash must be {expected_size} bytes long") + + key1, key2 = rid_to_key(rid) + des1 = DES.new(key1, DES.MODE_ECB) + des2 = DES.new(key2, DES.MODE_ECB) + + block_size = self.CryptoConstants.DES_BLOCK_SIZE + block1 = des1.decrypt(crypted_hash[:block_size]) + block2 = des2.decrypt(crypted_hash[block_size : 2 * block_size]) + + return block1 + block2 + + def _decrypt_hash(self, blob: bytes, rid: int, is_lm: bool) -> str: + """Decrypt a single NT or LM password hash. + + Args: + blob: Encrypted hash blob from database. + rid: User's relative ID. + is_lm: True for LM hash, False for NT hash. + + Returns: + Hex string of the decrypted hash. + """ + if not blob: + return self.CryptoConstants.DEFAULT_LM_HASH if is_lm else self.CryptoConstants.DEFAULT_NT_HASH + + crypted = c_ntds_crypto.CRYPTED_HASH(blob) + header_bytes = bytearray(crypted.Header) + + if header_bytes.startswith(self.CryptoConstants.WINDOWS_2016_TP4_HASH_HEADER): + # Modern encryption (AES) + decrypted = self._decrypt_hash_modern(blob, rid) + else: + # Legacy encryption (RC4 + DES) + decrypted = self._decrypt_hash_legacy(crypted, rid) + + return hexlify(decrypted).decode() + + def _decrypt_hash_modern(self, blob: bytes, rid: int) -> bytes: + """Decrypt hash using modern AES encryption (Windows Server 2016+). + + Args: + blob: Encrypted hash blob. + rid: User's relative ID. + + Returns: + Decrypted hash bytes. + """ + # Parse structure with correct size + struct_w16 = cstruct().load( + CryptoStructures.CRYPTED_HASH_W16_DEF.format(Length=len(blob) - self.StructConstant.CRYPTED_HASH_W16_LENGTH) + ) + crypted = struct_w16.CRYPTED_HASHW16(blob) + + # Decrypt AES layer + pek_index = self._get_pek_index_from_header(crypted.Header) + decrypted = self._aes_decrypt( + self._pek_list[pek_index], + bytearray(crypted.EncryptedHash[: self.CryptoConstants.NTLM_HASH_SIZE]), + bytearray(crypted.KeyMaterial), + ) + + # Remove DES layer + return self._remove_des_layer(decrypted, rid) + + def _decrypt_hash_legacy(self, crypted: structure, rid: int) -> bytes: + """Decrypt hash using legacy RC4+DES encryption. + + Args: + crypted: Encrypted hash structure. + rid: User's relative ID. + + Returns: + Decrypted hash bytes. + """ + tmp = self._remove_rc4_layer(crypted) + return self._remove_des_layer(tmp, rid) + + def _decrypt_history(self, blob: bytes, rid: int) -> list[str]: + """Decrypt password history containing multiple hashes. + + Args: + blob: Encrypted history blob. + rid: User's relative ID. + + Returns: + List of hex-encoded password hashes. + """ + if not blob: + return [] + + # Parse structure + struct_hist = cstruct().load( + CryptoStructures.CRYPTED_HISTORY_DEF.format(Length=len(blob) - self.StructConstant.CRYPTED_HISTORY_LENGTH) + ) + crypted = struct_hist.CRYPTED_HISTORY(blob) + header_bytes = bytearray(crypted.Header) + + if header_bytes.startswith(self.CryptoConstants.WINDOWS_2016_TP4_HASH_HEADER): + # Modern AES encryption + decrypted = self._decrypt_history_modern(blob, rid) + else: + # Legacy RC4 encryption + decrypted = self._remove_rc4_layer(crypted) + + # Split into individual hashes and remove DES layer from each + hash_size = self.CryptoConstants.NTLM_HASH_SIZE + hashes = [] + for i in range(0, len(decrypted), hash_size): + block = decrypted[i : i + hash_size] + if len(block) == hash_size: + hash_bytes = self._remove_des_layer(block, rid) + hashes.append(hexlify(hash_bytes).decode()) + + return hashes + + def _decrypt_history_modern(self, blob: bytes, rid: int) -> bytes: + """Decrypt password history using modern AES encryption. + + Args: + blob: Encrypted history blob. + rid: User's relative ID. + + Returns: + Decrypted history data containing multiple hashes. + """ + struct_w16 = cstruct().load( + CryptoStructures.CRYPTED_HASH_W16_DEF.format(Length=len(blob) - self.StructConstant.CRYPTED_HASH_W16_LENGTH) + ) + crypted = struct_w16.CRYPTED_HASHW16(blob) + + pek_index = self._get_pek_index_from_header(crypted.Header) + return self._aes_decrypt( + self._pek_list[pek_index], + bytearray(crypted.EncryptedHash[: self.CryptoConstants.NTLM_HASH_SIZE]), + bytearray(crypted.KeyMaterial), + ) + + def _decode_user_account_control(self, uac: int) -> dict[str, bool]: + """Decode User Account Control flags. + + Args: + uac: User Account Control integer value. + + Returns: + Dictionary mapping flag names to boolean values. + """ + return {flag_name: bool(uac & flag_bit) for flag_bit, flag_name in UAC_FLAGS.items()} + + def _decrypt_supplemental_info(self, record: Record) -> Iterator[dict[str, str | None]]: + """Extract and decrypt supplemental credentials (Kerberos keys, cleartext passwords). + + Args: + record: User record from database. + + Yields: + Dictionary containing supplemental credential information. + """ + default_info = { + "cleartext_password": None, + "kerberos_type": None, + "kerberos_key": None, + "default_salt": None, + "iteration_count": None, + "credential_type": None, + } + + blob = record[NAME_TO_INTERNAL["supplemental_credentials"]] + if not blob or len(blob) < self.StructConstant.CRYPTED_BLOB_LENGTH: + yield default_info + return + + # Decrypt the supplemental blob + decrypted = self._decrypt_supplemental_blob(blob) + if not decrypted: + yield default_info + return + + # Parse USER_PROPERTIES structure + try: + user_properties = c_samr.USER_PROPERTIES(decrypted) + except Exception: + # Some old W2K3 systems have non-standard properties + self.target.log.warning("Failed to parse USER_PROPERTIES structure") + yield default_info + return + + # Process each property + for prop in user_properties.UserProperties: + property_name = prop.PropertyName.decode("utf-16le") + + if property_name == "Primary:CLEARTEXT": + info = default_info.copy() + info["cleartext_password"] = self._extract_cleartext_password(prop.PropertyValue) + yield info + + elif property_name == "Primary:Kerberos-Newer-Keys": + yield from self._extract_kerberos_keys(prop.PropertyValue, default_info) + + def _decrypt_supplemental_blob(self, blob: bytes) -> bytes | None: + """Decrypt the supplemental credentials blob. + + Args: + blob: Encrypted supplemental credentials blob. + + Returns: + Decrypted data or None if decryption fails. + """ + # Parse encrypted structure + struct_blob = cstruct().load( + CryptoStructures.CRYPTED_BLOB_DEF.format(Length=len(blob) - self.StructConstant.CRYPTED_BLOB_LENGTH) + ) + crypted = struct_blob.CRYPTED_BLOB(blob) + header_bytes = bytearray(crypted.Header) + + if header_bytes.startswith(self.CryptoConstants.WINDOWS_2016_TP4_HASH_HEADER): + # Modern AES encryption (skip first 4 bytes of EncryptedHash) + pek_index = self._get_pek_index_from_header(crypted.Header) + return self._aes_decrypt( + self._pek_list[pek_index], + bytearray(crypted.EncryptedHash[self.CryptoConstants.AES_HASH_HEADER_SIZE :]), + bytearray(crypted.KeyMaterial), + ) + # Legacy RC4 encryption + return self._remove_rc4_layer(crypted) + + def _extract_cleartext_password(self, property_value: bytes) -> str | None: + """Extract cleartext password from property value. + + Args: + property_value: Raw property value bytes. + + Returns: + Cleartext password string or None if extraction fails. + """ + try: + # Try to unhexlify and decode as UTF-16 + return unhexlify(property_value).decode("utf-16le") + except (UnicodeDecodeError, Exception): + try: + # Fallback to UTF-8 + return property_value.decode("utf-8") + except Exception: + return None + + def _extract_kerberos_keys(self, property_value: bytes, default_info: dict) -> Iterator[dict[str, str | None]]: + """Extract Kerberos keys from property value. + + Args: + property_value: Raw property value containing Kerberos keys. + default_info: Default info dictionary template. + + Yields: + Dictionary containing Kerberos key information. + """ + try: + property_buffer = unhexlify(property_value) + kerb = c_samr.KERB_STORED_CREDENTIAL_NEW(property_buffer) + except Exception: + self.target.log.warning("Failed to parse Kerberos credential structure") + yield default_info + return + + # Extract default salt if present + default_salt = None + if kerb.DefaultSaltLength and kerb.DefaultSaltOffset: + start = int(kerb.DefaultSaltOffset) + end = start + int(kerb.DefaultSaltLength) + if 0 <= start < len(property_buffer) and end <= len(property_buffer): + default_salt = hexlify(property_buffer[start:end]).decode() + + # Process all key entries + key_collections = { + "Credentials": kerb.Credentials, + "ServiceCredentials": kerb.ServiceCredentials, + "OldCredentials": kerb.OldCredentials, + "OlderCredentials": kerb.OlderCredentials, + } + + for credential_type, entries in key_collections.items(): + for entry in entries: + if entry.KeyLength <= 0 or entry.KeyOffset <= 0: + continue + + if entry.KeyOffset + entry.KeyLength > len(property_buffer): + self.target.log.error("Invalid Kerberos key offset/length") + continue + + info = default_info.copy() + info["credential_type"] = credential_type + info["kerberos_key"] = hexlify( + property_buffer[entry.KeyOffset : entry.KeyOffset + entry.KeyLength] + ).decode() + info["kerberos_type"] = KERBEROS_TYPE.get(entry.KeyType, str(entry.KeyType)) + info["iteration_count"] = entry.IterationCount + info["default_salt"] = default_salt + + yield info + + def _record_to_secret(self, record: Record) -> Iterator[NtdsUserSecretRecord]: + """Convert a database record to NTDS user secret records. + + Args: + record: User record from the database. + + Yields: + NtdsUserSecretRecord containing decrypted credentials. + """ + self.target.log.debug("Decrypting hash for user: %s", record[NAME_TO_INTERNAL["name"]]) + + # Extract RID from SID + sid = read_sid(record[NAME_TO_INTERNAL["object_sid"]], swap_last=True) + rid = int(sid.split("-").pop()) + + # Decrypt password hashes + lm_hash = self._decrypt_hash(record[NAME_TO_INTERNAL["dbcspwd"]], rid, is_lm=True) + nt_hash = self._decrypt_hash(record[NAME_TO_INTERNAL["unicode_pwd"]], rid, is_lm=False) + + # Decrypt password histories + lm_history = self._decrypt_history(record[NAME_TO_INTERNAL["lm_pwd_history"]], rid) + nt_history = self._decrypt_history(record[NAME_TO_INTERNAL["nt_pwd_history"]], rid) + + # Decode UAC flags + uac = record[NAME_TO_INTERNAL["user_account_control"]] + uac_flags = self._decode_user_account_control(uac) if uac else dict.fromkeys(UAC_FLAGS.values()) + + # Get password timestamp + password_ts = ( + ts.wintimestamp(record[NAME_TO_INTERNAL["password_last_set"]]) + if record[NAME_TO_INTERNAL["password_last_set"]] + else None + ) + + # Extract supplemental credentials and yield records + for supplemental_info in self._decrypt_supplemental_info(record): + yield NtdsUserSecretRecord( + upn=record[NAME_TO_INTERNAL["user_principal_name"]], + sam_name=record[NAME_TO_INTERNAL["sam_account_name"]], + password_last_set=password_ts, + lm=lm_hash, + lm_history=lm_history, + nt=nt_hash, + nt_history=nt_history, + **uac_flags, + **supplemental_info, + _target=self.target, + ) + + @export(record=NtdsUserSecretRecord, description="Extract credentials from NTDS.dit database") + def secrets(self) -> Iterator[NtdsUserSecretRecord]: + """Extract and decrypt all user credentials from the NTDS.dit database. + + This function orchestrates the entire extraction process: + 1. Opens and scans the NTDS.dit database + 2. Extracts and decrypts the PEK list using the system key + 3. Uses PEK keys to decrypt user password hashes + 4. Extracts additional credentials like Kerberos keys + + Yields: + NtdsUserSecretRecord for each user account found in the database. + + Raises: + ValueError: If PEK list cannot be found in the database. + """ + # Collect PEK blob and user records from database + pek_blob, user_records = self._collect_pek_and_user_records() + + if not pek_blob: + raise ValueError("Couldn't find pek_list in NTDS.dit") + + # Decrypt the PEK list + self._parse_and_decrypt_pek_list(pek_blob) + + if not self._pek_list: + self.target.log.error("No PEK keys obtained. Can't decrypt hashes.") + return + + # Process each user record + for record in user_records: + yield from self._record_to_secret(record) From 0be940c2dd08a3b90c11ba4252c2971fe8dc6f10 Mon Sep 17 00:00:00 2001 From: B0TAxy Date: Fri, 3 Oct 2025 16:53:59 +0300 Subject: [PATCH 02/17] Changed account type logic --- .../plugins/os/windows/credential/ntds.py | 94 ++++++++++++------- 1 file changed, 60 insertions(+), 34 deletions(-) diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/credential/ntds.py index d6bfec3870..56b7c04e73 100644 --- a/dissect/target/plugins/os/windows/credential/ntds.py +++ b/dissect/target/plugins/os/windows/credential/ntds.py @@ -26,28 +26,28 @@ # User Account Control flags mapping UAC_FLAGS = { - 0x0001: "script", - 0x0002: "account_disable", - 0x0008: "home_dir_required", - 0x0010: "lockout", - 0x0020: "passwd_not_reqd", - 0x0040: "passwd_cant_change", - 0x0080: "encrypted_text_pwd_allowed", - 0x0100: "temp_duplicate_account", - 0x0200: "normal_account", - 0x0800: "interdomain_trust_account", - 0x1000: "workstation_trust_account", - 0x2000: "server_trust_account", - 0x10000: "dont_expire_password", - 0x20000: "mns_logon_account", - 0x40000: "smartcard_required", - 0x80000: "trusted_for_delegation", - 0x100000: "not_delegated", - 0x200000: "use_des_key_only", - 0x400000: "dont_req_preauth", - 0x800000: "password_expired", - 0x1000000: "trusted_to_auth_for_delegation", - 0x04000000: "partial_secrets_account", + 0x0001: "SCRIPT", + 0x0002: "ACCOUNTDISABLE", + 0x0008: "HOMEDIR_REQUIRED", + 0x0010: "LOCKOUT", + 0x0020: "PASSWD_NOTREQD", + 0x0040: "PASSWD_CANT_CHANGE", + 0x0080: "ENCRYPTED_TEXT_PWD_ALLOWED", + 0x0100: "TEMP_DUPLICATE_ACCOUNT", + 0x0200: "NORMAL_ACCOUNT", + 0x0800: "INTERDOMAIN_TRUST_ACCOUNT", + 0x1000: "WORKSTATION_TRUST_ACCOUNT", + 0x2000: "SERVER_TRUST_ACCOUNT", + 0x10000: "DONT_EXPIRE_PASSWORD", + 0x20000: "MNS_LOGON_ACCOUNT", + 0x40000: "SMARTCARD_REQUIRED", + 0x80000: "TRUSTED_FOR_DELEGATION", + 0x100000: "NOT_DELEGATED", + 0x200000: "USE_DES_KEY_ONLY", + 0x400000: "DONT_REQ_PREAUTH", + 0x800000: "PASSWORD_EXPIRED", + 0x1000000: "TRUSTED_TO_AUTH_FOR_DELEGATION", + 0x04000000: "PARTIAL_SECRETS_ACCOUNT", } # NTDS attribute name to internal field mapping @@ -73,6 +73,16 @@ "supplemental_credentials": "ATTk589949", "password_last_set": "ATTq589920", "instance_type": "ATTj131073", + "governs_id": "ATTc131094", + "object_class": "ATTc0", + "link_id": "ATTj131122", + "is_deleted": "ATTi131120", + "attribute_id": "ATTc131102", + "attribute_name_ldap": "ATTm131532", + "Attribute_name_cn": "ATTm3", + "Attribute_name_dn": "ATTb49", + "msds-int_id": "ATTj591540", + "rdn": "ATTm589825", } # Kerberos encryption type mappings @@ -96,6 +106,22 @@ 24: "rc4-hmac-exp-old", } +# SAM account type constants +SAM_ACCOUNT_TYPE = { + "SAM_DOMAIN_OBJECT": 0x0, + "SAM_GROUP_OBJECT": 0x10000000, + "SAM_NON_SECURITY_GROUP_OBJECT": 0x10000001, + "SAM_ALIAS_OBJECT": 0x20000000, + "SAM_NON_SECURITY_ALIAS_OBJECT": 0x20000001, + "SAM_USER_OBJECT": 0x30000000, + "SAM_NORMAL_USER_ACCOUNT": 0x30000000, + "SAM_MACHINE_ACCOUNT": 0x30000001, + "SAM_TRUST_ACCOUNT": 0x30000002, + "SAM_APP_BASIC_GROUP": 0x40000000, + "SAM_APP_QUERY_GROUP": 0x40000001, + "SAM_ACCOUNT_TYPE_MAX": 0x7FFFFFFF, +} + # Record descriptor for NTDS user secrets NtdsUserSecretRecord = TargetRecordDescriptor( "windows/credential/ntds", @@ -107,7 +133,8 @@ ("string[]", "lm_history"), ("string", "nt"), ("string[]", "nt_history"), - *[("boolean", flag) for flag in UAC_FLAGS.values()], + ("boolean", "is_deleted"), + *[("boolean", flag.lower()) for flag in UAC_FLAGS.values()], ("string", "cleartext_password"), ("string", "credential_type"), ("string", "kerberos_type"), @@ -285,18 +312,16 @@ class CryptoConstants: EMPTY_BYTE = b"\x00" DEFAULT_AES_IV = b"\x00" * 16 - # SAM account type constants - class AccountTypes: - """SAM account type constants.""" - - NORMAL_USER = 0x30000000 - MACHINE = 0x30000001 - TRUST = 0x30000002 - ALL_TYPES = (NORMAL_USER, MACHINE, TRUST) - # Other constants OBJECT_IS_WRITEABLE_ON_THIS_DIRECTORY = 4 + # Currenlty supported types + SUPPORTED_ACOUNT_TYPES = tuple( + account_type + for key, account_type in SAM_ACCOUNT_TYPE.items() + if key in ("SAM_USER_OBJECT", "SAM_NORMAL_USER_ACCOUNT", "SAM_MACHINE_ACCOUNT", "SAM_TRUST_ACCOUNT") + ) + def __init__(self, target: Target): """Initialize the NTDS plugin. @@ -387,7 +412,7 @@ def _is_valid_user_record(self, record: Record) -> bool: True if the record is a valid user account, False otherwise. """ return ( - record[NAME_TO_INTERNAL["sam_account_type"]] in self.AccountTypes.ALL_TYPES + record[NAME_TO_INTERNAL["sam_account_type"]] in self.SUPPORTED_ACOUNT_TYPES and record[NAME_TO_INTERNAL["instance_type"]] & self.OBJECT_IS_WRITEABLE_ON_THIS_DIRECTORY ) @@ -740,7 +765,7 @@ def _decode_user_account_control(self, uac: int) -> dict[str, bool]: Returns: Dictionary mapping flag names to boolean values. """ - return {flag_name: bool(uac & flag_bit) for flag_bit, flag_name in UAC_FLAGS.items()} + return {flag_name.lower(): bool(uac & flag_bit) for flag_bit, flag_name in UAC_FLAGS.items()} def _decrypt_supplemental_info(self, record: Record) -> Iterator[dict[str, str | None]]: """Extract and decrypt supplemental credentials (Kerberos keys, cleartext passwords). @@ -936,6 +961,7 @@ def _record_to_secret(self, record: Record) -> Iterator[NtdsUserSecretRecord]: lm_history=lm_history, nt=nt_hash, nt_history=nt_history, + is_deleted=bool(record[NAME_TO_INTERNAL["is_deleted"]]), **uac_flags, **supplemental_info, _target=self.target, From 2c901ef229b585bf9764801278d0d26a7535f9af Mon Sep 17 00:00:00 2001 From: B0TAxy Date: Thu, 13 Nov 2025 22:53:04 +0200 Subject: [PATCH 03/17] Converted plugin to use NTDS database --- .../plugins/os/windows/credential/ntds.py | 323 +++++++----------- 1 file changed, 129 insertions(+), 194 deletions(-) diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/credential/ntds.py index 56b7c04e73..3de8c01df3 100644 --- a/dissect/target/plugins/os/windows/credential/ntds.py +++ b/dissect/target/plugins/os/windows/credential/ntds.py @@ -1,15 +1,15 @@ from __future__ import annotations from binascii import hexlify, unhexlify +from functools import cached_property from hashlib import md5 +from itertools import chain from struct import unpack from typing import TYPE_CHECKING from Cryptodome.Cipher import AES, ARC4, DES from dissect.cstruct import cstruct -from dissect.esedb import EseDB -from dissect.util import ts -from dissect.util.sid import read_sid +from dissect.database.ese.ntds import NTDS from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, UnsupportedPluginError, export @@ -19,7 +19,7 @@ from collections.abc import Iterator from dissect.cstruct.types import structure - from flow.record import Record + from dissect.database.ese.ntds.objects import Computer, User from dissect.target.target import Target @@ -50,41 +50,6 @@ 0x04000000: "PARTIAL_SECRETS_ACCOUNT", } -# NTDS attribute name to internal field mapping -NAME_TO_INTERNAL = { - "usn_created": "ATTq131091", - "usn_changed": "ATTq131192", - "name": "ATTm3", - "object_guid": "ATTk589826", - "object_sid": "ATTr589970", - "user_account_control": "ATTj589832", - "primary_group_id": "ATTj589922", - "account_expires": "ATTq589983", - "logon_count": "ATTj589993", - "sam_account_name": "ATTm590045", - "sam_account_type": "ATTj590126", - "last_logon_timestamp": "ATTq589876", - "user_principal_name": "ATTm590480", - "unicode_pwd": "ATTk589914", - "dbcspwd": "ATTk589879", - "nt_pwd_history": "ATTk589918", - "lm_pwd_history": "ATTk589984", - "pek_list": "ATTk590689", - "supplemental_credentials": "ATTk589949", - "password_last_set": "ATTq589920", - "instance_type": "ATTj131073", - "governs_id": "ATTc131094", - "object_class": "ATTc0", - "link_id": "ATTj131122", - "is_deleted": "ATTi131120", - "attribute_id": "ATTc131102", - "attribute_name_ldap": "ATTm131532", - "Attribute_name_cn": "ATTm3", - "Attribute_name_dn": "ATTb49", - "msds-int_id": "ATTj591540", - "rdn": "ATTm589825", -} - # Kerberos encryption type mappings KERBEROS_TYPE = { # DES @@ -107,28 +72,37 @@ } # SAM account type constants -SAM_ACCOUNT_TYPE = { - "SAM_DOMAIN_OBJECT": 0x0, - "SAM_GROUP_OBJECT": 0x10000000, - "SAM_NON_SECURITY_GROUP_OBJECT": 0x10000001, - "SAM_ALIAS_OBJECT": 0x20000000, - "SAM_NON_SECURITY_ALIAS_OBJECT": 0x20000001, - "SAM_USER_OBJECT": 0x30000000, - "SAM_NORMAL_USER_ACCOUNT": 0x30000000, - "SAM_MACHINE_ACCOUNT": 0x30000001, - "SAM_TRUST_ACCOUNT": 0x30000002, - "SAM_APP_BASIC_GROUP": 0x40000000, - "SAM_APP_QUERY_GROUP": 0x40000001, - "SAM_ACCOUNT_TYPE_MAX": 0x7FFFFFFF, +SAM_ACCOUNT_TYPE_INTERNAL_TO_NAME = { + 0x0: "SAM_DOMAIN_OBJECT", + 0x10000000: "SAM_GROUP_OBJECT", + 0x10000001: "SAM_NON_SECURITY_GROUP_OBJECT", + 0x20000000: "SAM_ALIAS_OBJECT", + 0x20000001: "SAM_NON_SECURITY_ALIAS_OBJECT", + 0x30000000: "SAM_USER_OBJECT", + 0x30000001: "SAM_MACHINE_ACCOUNT", + 0x30000002: "SAM_TRUST_ACCOUNT", + 0x40000000: "SAM_APP_BASIC_GROUP", + 0x40000001: "SAM_APP_QUERY_GROUP", + 0x7FFFFFFF: "SAM_ACCOUNT_TYPE_MAX", } # Record descriptor for NTDS user secrets -NtdsUserSecretRecord = TargetRecordDescriptor( +NtdsAccountSecretRecord = TargetRecordDescriptor( "windows/credential/ntds", [ + ("string[]", "object_classes"), ("string", "upn"), ("string", "sam_name"), + ("string", "sam_type"), + ("string", "description"), + ("string", "sid"), + ("varint", "rid"), ("datetime", "password_last_set"), + ("datetime", "logon_last_failed"), + ("datetime", "logon_last_success"), + ("datetime", "account_expires"), + ("datetime", "creation_time"), + ("datetime", "last_modified_time"), ("string", "lm"), ("string[]", "lm_history"), ("string", "nt"), @@ -310,17 +284,6 @@ class CryptoConstants: DEFAULT_LM_HASH = "aad3b435b51404eeaad3b435b51404ee" DEFAULT_NT_HASH = "31d6cfe0d16ae931b73c59d7e0c089c0" EMPTY_BYTE = b"\x00" - DEFAULT_AES_IV = b"\x00" * 16 - - # Other constants - OBJECT_IS_WRITEABLE_ON_THIS_DIRECTORY = 4 - - # Currenlty supported types - SUPPORTED_ACOUNT_TYPES = tuple( - account_type - for key, account_type in SAM_ACCOUNT_TYPE.items() - if key in ("SAM_USER_OBJECT", "SAM_NORMAL_USER_ACCOUNT", "SAM_MACHINE_ACCOUNT", "SAM_TRUST_ACCOUNT") - ) def __init__(self, target: Target): """Initialize the NTDS plugin. @@ -329,33 +292,14 @@ def __init__(self, target: Target): target: The target system to analyze. """ super().__init__(target) - self.ntds_path = self.target.fs.path("/sysvol/Windows/NTDS/ntds.dit") - self._pek_list: list[bytes] = [] - self.ntds_database = None - self._filter_fields = self._get_filter_fields() - def _get_filter_fields(self) -> set[str]: - """Get the set of fields to filter for when scanning the database. + if self.target.has_function("registry"): + ntds_path_key = self.target.registry.value( + key="HKLM\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters", value="DSA Database file" + ) + self.ntds_path = self.target.fs.path(ntds_path_key.value) - Returns: - Set of internal field names to filter records by. - """ - return { - NAME_TO_INTERNAL["object_sid"], - NAME_TO_INTERNAL["dbcspwd"], - NAME_TO_INTERNAL["name"], - NAME_TO_INTERNAL["sam_account_type"], - NAME_TO_INTERNAL["unicode_pwd"], - NAME_TO_INTERNAL["sam_account_name"], - NAME_TO_INTERNAL["user_principal_name"], - NAME_TO_INTERNAL["nt_pwd_history"], - NAME_TO_INTERNAL["lm_pwd_history"], - NAME_TO_INTERNAL["password_last_set"], - NAME_TO_INTERNAL["user_account_control"], - NAME_TO_INTERNAL["supplemental_credentials"], - NAME_TO_INTERNAL["pek_list"], - NAME_TO_INTERNAL["instance_type"], - } + self._pek_list: list[bytes] = [] def check_compatible(self) -> None: """Check if the plugin can run on the target system. @@ -363,65 +307,30 @@ def check_compatible(self) -> None: Raises: UnsupportedPluginError: If NTDS.dit is not found or system hive is missing. """ + if not self.target.has_function("registry"): + raise UnsupportedPluginError("Registry function not available") + if not self.ntds_path.exists(): raise UnsupportedPluginError("NTDS.dit file not found") if not self.target.has_function("lsa") or not hasattr(self.target.lsa, "syskey"): raise UnsupportedPluginError("System Hive is not present or LSA function not available") - def _collect_pek_and_user_records(self) -> tuple[bytes, list[Record]]: - """Scan the ESE database and extract PEK list and user records. + @cached_property + def ntds(self) -> NTDS: + return NTDS(self.ntds_path.open()) - Returns: - Tuple containing: - - Raw PEK list blob (bytes) - - List of user records from the database - """ - db = EseDB(self.ntds_path.open(), False) - pek_blob = None - user_records: list[Record] = [] - - for table in db.tables(): - for record in table.records(): - try: - columns = record.as_dict().keys() - except TypeError: - continue + def _extract_and_decrypt_pek_list(self) -> None: + """Extract PEK list structure and decrypt PEK keys. - if not self._filter_fields.intersection(columns): - continue - - # Extract PEK list if present - if record[NAME_TO_INTERNAL["pek_list"]]: - pek_blob = record[NAME_TO_INTERNAL["pek_list"]] - - # Collect user records that are writable and of valid account type - if self._is_valid_user_record(record): - user_records.append(record) - - self.ntds_database = db - return pek_blob, user_records - - def _is_valid_user_record(self, record: Record) -> bool: - """Check if a record represents a valid user account. - - Args: - record: Database record to check. - - Returns: - True if the record is a valid user account, False otherwise. + Raises: + ValueError: If PEK list cannot be found in the database. """ - return ( - record[NAME_TO_INTERNAL["sam_account_type"]] in self.SUPPORTED_ACOUNT_TYPES - and record[NAME_TO_INTERNAL["instance_type"]] & self.OBJECT_IS_WRITEABLE_ON_THIS_DIRECTORY - ) + pek_blob = next(self.ntds.lookup(objectCategory="domainDNS")).pekList - def _parse_and_decrypt_pek_list(self, pek_blob: bytes) -> None: - """Parse PEK list structure and decrypt PEK keys. + if not pek_blob: + raise ValueError("Couldn't find pek_list in NTDS.dit") - Args: - pek_blob: Raw PEK list blob from the database. - """ # Create structure with correct size enc_struct = cstruct().load( CryptoStructures.PEK_LIST_ENC_DEF.format(Length=len(pek_blob) - self.StructConstant.PEK_LIST_ENC_LENGTH) @@ -520,11 +429,11 @@ def _extract_pek_entries(self, data: bytes) -> None: if pos < len(data): self.target.log.warning("PEK list contained extra data after terminator") - def _derive_rc4_key(self, syskey: bytes, key_material: bytes, iterations: int) -> bytes: + def _derive_rc4_key(self, key: bytes, key_material: bytes, iterations: int) -> bytes: """Derive RC4 key using MD5 with multiple iterations. Args: - syskey: System key from LSA. + key: RC4 key. key_material: Random key material for this encryption. iterations: Number of MD5 iterations to perform. @@ -532,7 +441,7 @@ def _derive_rc4_key(self, syskey: bytes, key_material: bytes, iterations: int) - 16-byte RC4 key. """ hasher = md5() - hasher.update(syskey) + hasher.update(key) for _ in range(iterations): hasher.update(key_material) return hasher.digest() @@ -718,7 +627,7 @@ def _decrypt_history(self, blob: bytes, rid: int) -> list[str]: if header_bytes.startswith(self.CryptoConstants.WINDOWS_2016_TP4_HASH_HEADER): # Modern AES encryption - decrypted = self._decrypt_history_modern(blob, rid) + decrypted = self._decrypt_history_modern(blob) else: # Legacy RC4 encryption decrypted = self._remove_rc4_layer(crypted) @@ -734,12 +643,11 @@ def _decrypt_history(self, blob: bytes, rid: int) -> list[str]: return hashes - def _decrypt_history_modern(self, blob: bytes, rid: int) -> bytes: + def _decrypt_history_modern(self, blob: bytes) -> bytes: """Decrypt password history using modern AES encryption. Args: blob: Encrypted history blob. - rid: User's relative ID. Returns: Decrypted history data containing multiple hashes. @@ -767,11 +675,11 @@ def _decode_user_account_control(self, uac: int) -> dict[str, bool]: """ return {flag_name.lower(): bool(uac & flag_bit) for flag_bit, flag_name in UAC_FLAGS.items()} - def _decrypt_supplemental_info(self, record: Record) -> Iterator[dict[str, str | None]]: + def _decrypt_supplemental_info(self, account: User | Computer) -> Iterator[dict[str, str | None]]: """Extract and decrypt supplemental credentials (Kerberos keys, cleartext passwords). Args: - record: User record from database. + account: Account record from the database. Yields: Dictionary containing supplemental credential information. @@ -785,7 +693,12 @@ def _decrypt_supplemental_info(self, record: Record) -> Iterator[dict[str, str | "credential_type": None, } - blob = record[NAME_TO_INTERNAL["supplemental_credentials"]] + try: + blob = account.supplementalCredentials + except KeyError: + yield default_info + return + if not blob or len(blob) < self.StructConstant.CRYPTED_BLOB_LENGTH: yield default_info return @@ -917,85 +830,107 @@ def _extract_kerberos_keys(self, property_value: bytes, default_info: dict) -> I yield info - def _record_to_secret(self, record: Record) -> Iterator[NtdsUserSecretRecord]: - """Convert a database record to NTDS user secret records. + @staticmethod + def __extract_sid_and_rid(account: User | Computer) -> tuple[str, int]: + rid = int(account.objectSid.split("-").pop()) + + return account.objectSid, rid + + def _account_record_to_secret(self, account: User | Computer) -> Iterator[NtdsAccountSecretRecord]: + """Convert a database account record to NTDS account secret records. Args: - record: User record from the database. + account: Account object from the database. Yields: NtdsUserSecretRecord containing decrypted credentials. """ - self.target.log.debug("Decrypting hash for user: %s", record[NAME_TO_INTERNAL["name"]]) - - # Extract RID from SID - sid = read_sid(record[NAME_TO_INTERNAL["object_sid"]], swap_last=True) - rid = int(sid.split("-").pop()) + self.target.log.debug("Decrypting hash for user: %s", account.name) + sid, rid = self.__extract_sid_and_rid(account) # Decrypt password hashes - lm_hash = self._decrypt_hash(record[NAME_TO_INTERNAL["dbcspwd"]], rid, is_lm=True) - nt_hash = self._decrypt_hash(record[NAME_TO_INTERNAL["unicode_pwd"]], rid, is_lm=False) + try: + lm_hash = self._decrypt_hash(account.dBCSPwd, rid, True) + except KeyError: + lm_hash = self.CryptoConstants.DEFAULT_LM_HASH + + try: + nt_hash = self._decrypt_hash(account.unicodePwd, rid, False) + except KeyError: + nt_hash = self.CryptoConstants.DEFAULT_NT_HASH # Decrypt password histories - lm_history = self._decrypt_history(record[NAME_TO_INTERNAL["lm_pwd_history"]], rid) - nt_history = self._decrypt_history(record[NAME_TO_INTERNAL["nt_pwd_history"]], rid) + try: + lm_history = self._decrypt_history(account.lmPwdHistory, rid) + except KeyError: + lm_history = [] + + try: + nt_history = self._decrypt_history(account.ntPwdHistory, rid) + except KeyError: + nt_history = [] # Decode UAC flags - uac = record[NAME_TO_INTERNAL["user_account_control"]] - uac_flags = self._decode_user_account_control(uac) if uac else dict.fromkeys(UAC_FLAGS.values()) - - # Get password timestamp - password_ts = ( - ts.wintimestamp(record[NAME_TO_INTERNAL["password_last_set"]]) - if record[NAME_TO_INTERNAL["password_last_set"]] - else None + uac_flags = ( + self._decode_user_account_control(account.userAccountControl) + if account.userAccountControl + else dict.fromkeys(UAC_FLAGS.values()) ) + # Peripheral information + try: + upn = account.userPrincipalName + except KeyError: + upn = None + + try: + description = account.description + except KeyError: + description = None + + try: + is_deleted = account.isDeleted + except KeyError: + is_deleted = False + # Extract supplemental credentials and yield records - for supplemental_info in self._decrypt_supplemental_info(record): - yield NtdsUserSecretRecord( - upn=record[NAME_TO_INTERNAL["user_principal_name"]], - sam_name=record[NAME_TO_INTERNAL["sam_account_name"]], - password_last_set=password_ts, + for supplemental_info in self._decrypt_supplemental_info(account): + yield NtdsAccountSecretRecord( + object_classes=account.objectClass, + upn=upn, + sam_name=account.sAMAccountName, + sam_type=SAM_ACCOUNT_TYPE_INTERNAL_TO_NAME[account.sAMAccountType].lower(), + description=description, + sid=sid, + rid=rid, + password_last_set=account.pwdLastSet, + logon_last_failed=account.badPasswordTime, + logon_last_success=account.lastLogon, + account_expires=account.accountExpires if not isinstance(account.accountExpires, float) else None, + creation_time=account.whenCreated, + last_modified_time=account.whenChanged, lm=lm_hash, lm_history=lm_history, nt=nt_hash, nt_history=nt_history, - is_deleted=bool(record[NAME_TO_INTERNAL["is_deleted"]]), + is_deleted=is_deleted, **uac_flags, **supplemental_info, _target=self.target, ) - @export(record=NtdsUserSecretRecord, description="Extract credentials from NTDS.dit database") - def secrets(self) -> Iterator[NtdsUserSecretRecord]: + @export(record=NtdsAccountSecretRecord, description="Extract users & thier sercrets from NTDS.dit database") + def secrets(self) -> Iterator[NtdsAccountSecretRecord]: """Extract and decrypt all user credentials from the NTDS.dit database. - This function orchestrates the entire extraction process: - 1. Opens and scans the NTDS.dit database - 2. Extracts and decrypts the PEK list using the system key - 3. Uses PEK keys to decrypt user password hashes - 4. Extracts additional credentials like Kerberos keys - Yields: NtdsUserSecretRecord for each user account found in the database. - - Raises: - ValueError: If PEK list cannot be found in the database. """ - # Collect PEK blob and user records from database - pek_blob, user_records = self._collect_pek_and_user_records() - - if not pek_blob: - raise ValueError("Couldn't find pek_list in NTDS.dit") - - # Decrypt the PEK list - self._parse_and_decrypt_pek_list(pek_blob) + self._extract_and_decrypt_pek_list() if not self._pek_list: self.target.log.error("No PEK keys obtained. Can't decrypt hashes.") return - # Process each user record - for record in user_records: - yield from self._record_to_secret(record) + for user in chain(self.ntds.users(), self.ntds.computers()): + yield from self._account_record_to_secret(user) From aaa9cfc712ef8c685a21e5a453b9ccf019f87c82 Mon Sep 17 00:00:00 2001 From: B0TAxy Date: Thu, 13 Nov 2025 22:57:14 +0200 Subject: [PATCH 04/17] Added docs --- .../plugins/os/windows/credential/ntds.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/credential/ntds.py index 3de8c01df3..50a12324e8 100644 --- a/dissect/target/plugins/os/windows/credential/ntds.py +++ b/dissect/target/plugins/os/windows/credential/ntds.py @@ -832,8 +832,21 @@ def _extract_kerberos_keys(self, property_value: bytes, default_info: dict) -> I @staticmethod def __extract_sid_and_rid(account: User | Computer) -> tuple[str, int]: - rid = int(account.objectSid.split("-").pop()) + """Extract the Security Identifier (SID) and Relative Identifier (RID) from a user or computer account. + The SID is a unique identifier for the security principal in Active Directory. + The RID is the last component of the SID, which uniquely identifies the account within its domain. + + Args: + account (User | Computer): The Active Directory account object (User or Computer) + containing the `objectSid` attribute. + + Returns: + tuple[str, int]: A tuple containing: + - The full SID as a string (e.g., "S-1-5-21-1234567890-987654321-112233445-1001") + - The RID as an integer (e.g., 1001) + """ + rid = int(account.objectSid.split("-")[-1]) return account.objectSid, rid def _account_record_to_secret(self, account: User | Computer) -> Iterator[NtdsAccountSecretRecord]: @@ -919,7 +932,7 @@ def _account_record_to_secret(self, account: User | Computer) -> Iterator[NtdsAc _target=self.target, ) - @export(record=NtdsAccountSecretRecord, description="Extract users & thier sercrets from NTDS.dit database") + @export(record=NtdsAccountSecretRecord, description="Extract accounts & thier sercrets from NTDS.dit database") def secrets(self) -> Iterator[NtdsAccountSecretRecord]: """Extract and decrypt all user credentials from the NTDS.dit database. From 24df2a9255a5edfaa046847282a8eff946e68616 Mon Sep 17 00:00:00 2001 From: B0TAxy Date: Wed, 19 Nov 2025 00:22:45 +0200 Subject: [PATCH 05/17] Splitted records type & Added more bloodhound flieds --- .../plugins/os/windows/credential/ntds.py | 223 +++++++++++++----- 1 file changed, 169 insertions(+), 54 deletions(-) diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/credential/ntds.py index 50a12324e8..e229df94ca 100644 --- a/dissect/target/plugins/os/windows/credential/ntds.py +++ b/dissect/target/plugins/os/windows/credential/ntds.py @@ -1,15 +1,15 @@ from __future__ import annotations from binascii import hexlify, unhexlify -from functools import cached_property +from functools import cached_property, lru_cache from hashlib import md5 -from itertools import chain from struct import unpack -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from Cryptodome.Cipher import AES, ARC4, DES from dissect.cstruct import cstruct from dissect.database.ese.ntds import NTDS +from dissect.database.ese.ntds.utils import format_GUID from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, UnsupportedPluginError, export @@ -86,35 +86,61 @@ 0x7FFFFFFF: "SAM_ACCOUNT_TYPE_MAX", } + +GENERIC_FIELDS = [ + ("string", "common_name"), + ("string", "upn"), + ("string", "sam_name"), + ("string", "sam_type"), + ("string", "description"), + ("string", "sid"), + ("varint", "rid"), + ("datetime", "password_last_set"), + ("datetime", "logon_last_failed"), + ("datetime", "logon_last_success"), + ("datetime", "account_expires"), + ("datetime", "creation_time"), + ("datetime", "last_modified_time"), + ("boolean", "admin_count"), + ("boolean", "is_deleted"), + ("string", "lm"), + ("string[]", "lm_history"), + ("string", "nt"), + ("string[]", "nt_history"), + ("string", "cleartext_password"), + ("string", "credential_type"), + ("string", "kerberos_type"), + ("string", "kerberos_key"), + ("string", "default_salt"), + ("uint32", "iteration_count"), + ("uint32", "user_account_control"), + *[("boolean", flag.lower()) for flag in UAC_FLAGS.values()], + ("string[]", "object_classes"), + ("string", "distinguished_name"), + ("string", "object_guid"), + ("uint32", "primary_group_id"), + ("string[]", "member_of"), + ("string[]", "service_principal_name"), +] + # Record descriptor for NTDS user secrets -NtdsAccountSecretRecord = TargetRecordDescriptor( - "windows/credential/ntds", +NtdsUserAccountRecord = TargetRecordDescriptor( + "windows/credential/ntds/user", + [ + *GENERIC_FIELDS, + ("string", "info"), + ("string", "comment"), + ("string", "telephone_number"), + ("string", "home_directory"), + ], +) +NtdsComputerAccountRecord = TargetRecordDescriptor( + "windows/credential/ntds/computer", [ - ("string[]", "object_classes"), - ("string", "upn"), - ("string", "sam_name"), - ("string", "sam_type"), - ("string", "description"), - ("string", "sid"), - ("varint", "rid"), - ("datetime", "password_last_set"), - ("datetime", "logon_last_failed"), - ("datetime", "logon_last_success"), - ("datetime", "account_expires"), - ("datetime", "creation_time"), - ("datetime", "last_modified_time"), - ("string", "lm"), - ("string[]", "lm_history"), - ("string", "nt"), - ("string[]", "nt_history"), - ("boolean", "is_deleted"), - *[("boolean", flag.lower()) for flag in UAC_FLAGS.values()], - ("string", "cleartext_password"), - ("string", "credential_type"), - ("string", "kerberos_type"), - ("string", "kerberos_key"), - ("string", "default_salt"), - ("uint32", "iteration_count"), + *GENERIC_FIELDS, + ("string", "dns_hostname"), + ("string", "operating_system"), + ("string", "operating_system_version"), ], ) @@ -320,16 +346,18 @@ def check_compatible(self) -> None: def ntds(self) -> NTDS: return NTDS(self.ntds_path.open()) + @lru_cache(maxsize=1) # noqa: B019 def _extract_and_decrypt_pek_list(self) -> None: """Extract PEK list structure and decrypt PEK keys. Raises: - ValueError: If PEK list cannot be found in the database. + RuntimeError: If PEK list cannot be found in the database. + RuntimeError: If couldn't extract PEK keys. """ pek_blob = next(self.ntds.lookup(objectCategory="domainDNS")).pekList if not pek_blob: - raise ValueError("Couldn't find pek_list in NTDS.dit") + raise RuntimeError("Couldn't find pek_list in NTDS.dit") # Create structure with correct size enc_struct = cstruct().load( @@ -346,6 +374,9 @@ def _extract_and_decrypt_pek_list(self) -> None: else: self.target.log.error("Unknown PEK list header: %s", header) + if not self._pek_list: + raise RuntimeError("No PEK keys obtained. Can't decrypt hashes.") + def _decrypt_pek_modern(self, pek_list_enc: structure) -> None: """Decrypt PEK list for Windows Server 2016+ using AES encryption. @@ -849,7 +880,7 @@ def __extract_sid_and_rid(account: User | Computer) -> tuple[str, int]: rid = int(account.objectSid.split("-")[-1]) return account.objectSid, rid - def _account_record_to_secret(self, account: User | Computer) -> Iterator[NtdsAccountSecretRecord]: + def extract_generic_account_info(self, account: User | Computer) -> Iterator[dict[str, Any]]: """Convert a database account record to NTDS account secret records. Args: @@ -876,19 +907,15 @@ def _account_record_to_secret(self, account: User | Computer) -> Iterator[NtdsAc try: lm_history = self._decrypt_history(account.lmPwdHistory, rid) except KeyError: - lm_history = [] + lm_history = None try: nt_history = self._decrypt_history(account.ntPwdHistory, rid) except KeyError: - nt_history = [] + nt_history = None # Decode UAC flags - uac_flags = ( - self._decode_user_account_control(account.userAccountControl) - if account.userAccountControl - else dict.fromkeys(UAC_FLAGS.values()) - ) + uac_flags = self._decode_user_account_control(account.userAccountControl) # Peripheral information try: @@ -906,10 +933,29 @@ def _account_record_to_secret(self, account: User | Computer) -> Iterator[NtdsAc except KeyError: is_deleted = False + try: + admin_count = bool(account.adminCount) + except KeyError: + admin_count = False + + try: + member_of = [group.distinguishedName for group in account.groups()] + except KeyError: + member_of = None + + try: + service_principal_name = ( + [account.servicePrincipalName] + if isinstance(account.servicePrincipalName, str) + else account.servicePrincipalName + ) + except KeyError: + service_principal_name = None + # Extract supplemental credentials and yield records for supplemental_info in self._decrypt_supplemental_info(account): - yield NtdsAccountSecretRecord( - object_classes=account.objectClass, + yield dict( + common_name=account.cn, upn=upn, sam_name=account.sAMAccountName, sam_type=SAM_ACCOUNT_TYPE_INTERNAL_TO_NAME[account.sAMAccountType].lower(), @@ -922,28 +968,97 @@ def _account_record_to_secret(self, account: User | Computer) -> Iterator[NtdsAc account_expires=account.accountExpires if not isinstance(account.accountExpires, float) else None, creation_time=account.whenCreated, last_modified_time=account.whenChanged, + admin_count=admin_count, + is_deleted=is_deleted, lm=lm_hash, lm_history=lm_history, nt=nt_hash, nt_history=nt_history, - is_deleted=is_deleted, - **uac_flags, **supplemental_info, - _target=self.target, + user_account_control=account.userAccountControl, + **uac_flags, + object_classes=account.objectClass, + distinguished_name=account.distinguishedName, + object_guid=format_GUID(account.objectGUID), + primary_group_id=account.primaryGroupID, + member_of=member_of, + service_principal_name=service_principal_name, ) - @export(record=NtdsAccountSecretRecord, description="Extract accounts & thier sercrets from NTDS.dit database") - def secrets(self) -> Iterator[NtdsAccountSecretRecord]: - """Extract and decrypt all user credentials from the NTDS.dit database. + @export(record=NtdsUserAccountRecord, description="Extract user accounts & thier sercrets from NTDS.dit database") + def user_accounts(self) -> Iterator[NtdsUserAccountRecord]: + """Extract all user account from the NTDS.dit database. Yields: - NtdsUserSecretRecord for each user account found in the database. + ``NtdsUserAccountRecord``: for each user account found in the database. """ self._extract_and_decrypt_pek_list() - if not self._pek_list: - self.target.log.error("No PEK keys obtained. Can't decrypt hashes.") - return + for account in self.ntds.users(): + for generic_info in self.extract_generic_account_info(account): + # TODO: Fix the extraction here + try: + info = account.info + except KeyError: + info = None + + try: + comment = account.comment + except KeyError: + comment = None + + try: + telephone_number = account.telephoneNumber + except KeyError: + telephone_number = None + + try: + home_directory = account.homeDirectory + except KeyError: + home_directory = None + + yield NtdsUserAccountRecord( + **generic_info, + info=info, + comment=comment, + telephone_number=telephone_number, + home_directory=home_directory, + _target=self.target, + ) + + @export( + record=NtdsComputerAccountRecord, + description="Extract computer accounts & thier sercrets from NTDS.dit database", + ) + def computer_accounts(self) -> Iterator[NtdsComputerAccountRecord]: + """Extract all computer account from the NTDS.dit database. + + Yields: + ``NtdsComputerAccountRecord``: for each computer account found in the database. + """ + self._extract_and_decrypt_pek_list() - for user in chain(self.ntds.users(), self.ntds.computers()): - yield from self._account_record_to_secret(user) + for account in self.ntds.computers(): + for generic_info in self.extract_generic_account_info(account): + try: + dns_hostname = account.dNSHostName + except KeyError: + dns_hostname = None + + try: + operating_system = account.operatingSystem + except KeyError: + operating_system = None + + try: + operating_system_version = account.operatingSystemVersion + except KeyError: + operating_system_version = None + + yield NtdsComputerAccountRecord( + **generic_info, + dns_hostname=dns_hostname, + operating_system=operating_system, + operating_system_version=operating_system_version, + _target=self.target, + ) From 84a36c8b42dace55da4d12bfb4139a4b175d2079 Mon Sep 17 00:00:00 2001 From: B0TAxy Date: Wed, 19 Nov 2025 00:24:23 +0200 Subject: [PATCH 06/17] Fixed doc --- dissect/target/plugins/os/windows/credential/ntds.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/credential/ntds.py index e229df94ca..8414b78700 100644 --- a/dissect/target/plugins/os/windows/credential/ntds.py +++ b/dissect/target/plugins/os/windows/credential/ntds.py @@ -351,8 +351,7 @@ def _extract_and_decrypt_pek_list(self) -> None: """Extract PEK list structure and decrypt PEK keys. Raises: - RuntimeError: If PEK list cannot be found in the database. - RuntimeError: If couldn't extract PEK keys. + RuntimeError: If PEK list cannot be found in the database or couldn't extract PEK keys from the PEK list. """ pek_blob = next(self.ntds.lookup(objectCategory="domainDNS")).pekList From dd7ac94dacd6d7cb7c1d2811be0df316304543f9 Mon Sep 17 00:00:00 2001 From: B0TAxy Date: Wed, 19 Nov 2025 00:44:26 +0200 Subject: [PATCH 07/17] Changed lrucache to cached_property --- .../plugins/os/windows/credential/ntds.py | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/credential/ntds.py index 8414b78700..9df186a196 100644 --- a/dissect/target/plugins/os/windows/credential/ntds.py +++ b/dissect/target/plugins/os/windows/credential/ntds.py @@ -1,7 +1,7 @@ from __future__ import annotations from binascii import hexlify, unhexlify -from functools import cached_property, lru_cache +from functools import cached_property from hashlib import md5 from struct import unpack from typing import TYPE_CHECKING, Any @@ -325,8 +325,6 @@ def __init__(self, target: Target): ) self.ntds_path = self.target.fs.path(ntds_path_key.value) - self._pek_list: list[bytes] = [] - def check_compatible(self) -> None: """Check if the plugin can run on the target system. @@ -346,12 +344,15 @@ def check_compatible(self) -> None: def ntds(self) -> NTDS: return NTDS(self.ntds_path.open()) - @lru_cache(maxsize=1) # noqa: B019 - def _extract_and_decrypt_pek_list(self) -> None: + @cached_property + def pek_list(self) -> list[bytes]: """Extract PEK list structure and decrypt PEK keys. Raises: RuntimeError: If PEK list cannot be found in the database or couldn't extract PEK keys from the PEK list. + + Returns: + pek_list: list containing PEK keys """ pek_blob = next(self.ntds.lookup(objectCategory="domainDNS")).pekList @@ -367,20 +368,25 @@ def _extract_and_decrypt_pek_list(self) -> None: header = bytearray(pek_list_enc.Header) if header.startswith(self.CryptoConstants.UP_TO_WINDOWS_2012_R2_PEK_HEADER): - self._decrypt_pek_legacy(pek_list_enc) + extracted_keys = self._decrypt_pek_legacy(pek_list_enc) elif header.startswith(self.CryptoConstants.WINDOWS_2016_TP4_PEK_HEADER): - self._decrypt_pek_modern(pek_list_enc) + extracted_keys = self._decrypt_pek_modern(pek_list_enc) else: self.target.log.error("Unknown PEK list header: %s", header) - if not self._pek_list: + if not extracted_keys: raise RuntimeError("No PEK keys obtained. Can't decrypt hashes.") - def _decrypt_pek_modern(self, pek_list_enc: structure) -> None: + return extracted_keys + + def _decrypt_pek_modern(self, pek_list_enc: structure) -> list[bytes]: """Decrypt PEK list for Windows Server 2016+ using AES encryption. Args: pek_list_enc: Encrypted PEK list structure. + + Returns: + pek_list: list containing PEK keys """ # Decrypt using AES with syskey as key and KeyMaterial as IV pek_plain_raw = self._aes_decrypt( @@ -396,14 +402,19 @@ def _decrypt_pek_modern(self, pek_list_enc: structure) -> None: plain = plain_struct.PEKLIST_PLAIN(pek_plain_raw) # Extract PEK entries (4-byte index + 16-byte key) - self._extract_pek_entries(plain.DecryptedPek) + return self._extract_pek_entries(plain.DecryptedPek) - def _decrypt_pek_legacy(self, pek_list_enc: structure) -> None: + def _decrypt_pek_legacy(self, pek_list_enc: structure) -> list[bytes]: """Decrypt PEK list for Windows Server 2012 R2 and earlier using RC4. Args: pek_list_enc: Encrypted PEK list structure. + + Returns: + pek_list: list containing PEK keys """ + pek_list: list[bytes] = [] + # Derive RC4 key from syskey and KeyMaterial rc4_key = self._derive_rc4_key( self.target.lsa.syskey, pek_list_enc.KeyMaterial, self.CryptoConstants.PEK_KEY_DERIVATION_ITERATIONS @@ -425,10 +436,12 @@ def _decrypt_pek_legacy(self, pek_list_enc: structure) -> None: pek_key_len = len(c_ntds_crypto.PEK_KEY) for i in range(0, len(plain.DecryptedPek), pek_key_len): pek_key = c_ntds_crypto.PEK_KEY(plain.DecryptedPek[i : i + pek_key_len]).Key - self._pek_list.append(pek_key) + pek_list.append(pek_key) self.target.log.info("PEK #%d decrypted: %s", i // pek_key_len, hexlify(pek_key).decode()) - def _extract_pek_entries(self, data: bytes) -> None: + return pek_list + + def _extract_pek_entries(self, data: bytes) -> list[bytes]: """Extract PEK entries from decrypted data. PEK entries are stored as: [4-byte index][16-byte key] @@ -436,7 +449,12 @@ def _extract_pek_entries(self, data: bytes) -> None: Args: data: Decrypted PEK data containing entries. + + Returns: + pek_list: list containing PEK keys """ + pek_list: list[bytes] = [] + entry_size = self.CryptoConstants.PEK_LIST_ENTRY_SIZE pos, expected_index = 0, 0 @@ -450,7 +468,7 @@ def _extract_pek_entries(self, data: bytes) -> None: pos = len(data) continue - self._pek_list.append(pek) + pek_list.append(pek) self.target.log.info("PEK #%d found and decrypted: %s", index, hexlify(pek).decode()) expected_index += 1 @@ -459,6 +477,8 @@ def _extract_pek_entries(self, data: bytes) -> None: if pos < len(data): self.target.log.warning("PEK list contained extra data after terminator") + return pek_list + def _derive_rc4_key(self, key: bytes, key_material: bytes, iterations: int) -> bytes: """Derive RC4 key using MD5 with multiple iterations. @@ -531,7 +551,7 @@ def _remove_rc4_layer(self, crypted: structure) -> bytes: """ pek_index = self._get_pek_index_from_header(crypted.Header) rc4_key = self._derive_rc4_key( - self._pek_list[pek_index], + self.pek_list[pek_index], bytearray(crypted.KeyMaterial), iterations=1, # Single iteration for hash decryption ) @@ -614,7 +634,7 @@ def _decrypt_hash_modern(self, blob: bytes, rid: int) -> bytes: # Decrypt AES layer pek_index = self._get_pek_index_from_header(crypted.Header) decrypted = self._aes_decrypt( - self._pek_list[pek_index], + self.pek_list[pek_index], bytearray(crypted.EncryptedHash[: self.CryptoConstants.NTLM_HASH_SIZE]), bytearray(crypted.KeyMaterial), ) @@ -689,7 +709,7 @@ def _decrypt_history_modern(self, blob: bytes) -> bytes: pek_index = self._get_pek_index_from_header(crypted.Header) return self._aes_decrypt( - self._pek_list[pek_index], + self.pek_list[pek_index], bytearray(crypted.EncryptedHash[: self.CryptoConstants.NTLM_HASH_SIZE]), bytearray(crypted.KeyMaterial), ) @@ -780,7 +800,7 @@ def _decrypt_supplemental_blob(self, blob: bytes) -> bytes | None: # Modern AES encryption (skip first 4 bytes of EncryptedHash) pek_index = self._get_pek_index_from_header(crypted.Header) return self._aes_decrypt( - self._pek_list[pek_index], + self.pek_list[pek_index], bytearray(crypted.EncryptedHash[self.CryptoConstants.AES_HASH_HEADER_SIZE :]), bytearray(crypted.KeyMaterial), ) @@ -991,8 +1011,6 @@ def user_accounts(self) -> Iterator[NtdsUserAccountRecord]: Yields: ``NtdsUserAccountRecord``: for each user account found in the database. """ - self._extract_and_decrypt_pek_list() - for account in self.ntds.users(): for generic_info in self.extract_generic_account_info(account): # TODO: Fix the extraction here @@ -1035,8 +1053,6 @@ def computer_accounts(self) -> Iterator[NtdsComputerAccountRecord]: Yields: ``NtdsComputerAccountRecord``: for each computer account found in the database. """ - self._extract_and_decrypt_pek_list() - for account in self.ntds.computers(): for generic_info in self.extract_generic_account_info(account): try: From cebdfa10a67421523b9455d2f9ac13ee8c62d3a0 Mon Sep 17 00:00:00 2001 From: B0TAxy Date: Fri, 28 Nov 2025 16:12:54 +0200 Subject: [PATCH 08/17] Import fix --- dissect/target/plugins/os/windows/credential/ntds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/credential/ntds.py index 9df186a196..41d5bc0820 100644 --- a/dissect/target/plugins/os/windows/credential/ntds.py +++ b/dissect/target/plugins/os/windows/credential/ntds.py @@ -6,7 +6,7 @@ from struct import unpack from typing import TYPE_CHECKING, Any -from Cryptodome.Cipher import AES, ARC4, DES +from Crypto.Cipher import AES, ARC4, DES from dissect.cstruct import cstruct from dissect.database.ese.ntds import NTDS from dissect.database.ese.ntds.utils import format_GUID From 5738372caf023c08fe5e5a3e28c5f1cac18ae7ec Mon Sep 17 00:00:00 2001 From: B0TAxy <59702228+B0TAxy@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:33:33 +0200 Subject: [PATCH 09/17] Converted plugin to use ntds functions --- .../plugins/os/windows/credential/ntds.py | 575 ++++-------------- 1 file changed, 112 insertions(+), 463 deletions(-) diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/credential/ntds.py index 41d5bc0820..b1bd9e8960 100644 --- a/dissect/target/plugins/os/windows/credential/ntds.py +++ b/dissect/target/plugins/os/windows/credential/ntds.py @@ -1,15 +1,15 @@ from __future__ import annotations -from binascii import hexlify, unhexlify +from binascii import hexlify +from datetime import datetime from functools import cached_property from hashlib import md5 -from struct import unpack from typing import TYPE_CHECKING, Any from Crypto.Cipher import AES, ARC4, DES from dissect.cstruct import cstruct from dissect.database.ese.ntds import NTDS -from dissect.database.ese.ntds.utils import format_GUID +from dissect.database.ese.ntds.util import UserAccountControl from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, UnsupportedPluginError, export @@ -24,32 +24,6 @@ from dissect.target.target import Target -# User Account Control flags mapping -UAC_FLAGS = { - 0x0001: "SCRIPT", - 0x0002: "ACCOUNTDISABLE", - 0x0008: "HOMEDIR_REQUIRED", - 0x0010: "LOCKOUT", - 0x0020: "PASSWD_NOTREQD", - 0x0040: "PASSWD_CANT_CHANGE", - 0x0080: "ENCRYPTED_TEXT_PWD_ALLOWED", - 0x0100: "TEMP_DUPLICATE_ACCOUNT", - 0x0200: "NORMAL_ACCOUNT", - 0x0800: "INTERDOMAIN_TRUST_ACCOUNT", - 0x1000: "WORKSTATION_TRUST_ACCOUNT", - 0x2000: "SERVER_TRUST_ACCOUNT", - 0x10000: "DONT_EXPIRE_PASSWORD", - 0x20000: "MNS_LOGON_ACCOUNT", - 0x40000: "SMARTCARD_REQUIRED", - 0x80000: "TRUSTED_FOR_DELEGATION", - 0x100000: "NOT_DELEGATED", - 0x200000: "USE_DES_KEY_ONLY", - 0x400000: "DONT_REQ_PREAUTH", - 0x800000: "PASSWORD_EXPIRED", - 0x1000000: "TRUSTED_TO_AUTH_FOR_DELEGATION", - 0x04000000: "PARTIAL_SECRETS_ACCOUNT", -} - # Kerberos encryption type mappings KERBEROS_TYPE = { # DES @@ -113,8 +87,11 @@ ("string", "kerberos_key"), ("string", "default_salt"), ("uint32", "iteration_count"), + ("uint32", "default_iteration_count"), + ("string[]", "packages"), + ("string", "w_digest"), ("uint32", "user_account_control"), - *[("boolean", flag.lower()) for flag in UAC_FLAGS.values()], + *[("boolean", flag.name.lower()) for flag in UserAccountControl], ("string[]", "object_classes"), ("string", "distinguished_name"), ("string", "object_guid"), @@ -144,118 +121,42 @@ ], ) - -class CryptoStructures: - """Container for C structure definitions used in NTDS crypto operations.""" - - # Dynamic structure templates - PEK_LIST_ENC_DEF = """ - typedef struct {{ - CHAR Header[8]; - CHAR KeyMaterial[16]; - BYTE EncryptedPek[{Length}]; - }} PEKLIST_ENC; - """ - - PEK_LIST_PLAIN_DEF = """ - typedef struct {{ - CHAR Header[32]; - BYTE DecryptedPek[{Length}]; - }} PEKLIST_PLAIN; - """ - - CRYPTED_HASH_W16_DEF = """ - typedef struct {{ - BYTE Header[8]; - BYTE KeyMaterial[16]; - DWORD Unknown; - BYTE EncryptedHash[{Length}]; - }} CRYPTED_HASHW16; - """ - - CRYPTED_HISTORY_DEF = """ - typedef struct {{ - BYTE Header[8]; - BYTE KeyMaterial[16]; - BYTE EncryptedHash[{Length}]; - }} CRYPTED_HISTORY; - """ - - CRYPTED_BLOB_DEF = """ - typedef struct {{ - BYTE Header[8]; - BYTE KeyMaterial[16]; - BYTE EncryptedHash[{Length}]; - }} CRYPTED_BLOB; - """ - - # Static structures - NTDS_CRYPTO_DEF = """ - typedef struct { - CHAR Header; - CHAR Padding[3]; - CHAR Key[16]; - } PEK_KEY; - - typedef struct { - BYTE Header[8]; - BYTE KeyMaterial[16]; - BYTE EncryptedHash[16]; - } CRYPTED_HASH; - """ - - SAMR_STRUCTS_DEF = """ - typedef struct { - uint16 NameLength; - uint16 ValueLength; - uint16 Reserved; - char PropertyName[NameLength]; - char PropertyValue[ValueLength]; - } USER_PROPERTY; - - typedef struct { - uint32 Reserved1; - uint32 Length; - uint16 Reserved2; - uint16 Reserved3; - BYTE Reserved4[96]; - uint16 PropertySignature; - uint16 PropertyCount; - USER_PROPERTY UserProperties[PropertyCount]; - } USER_PROPERTIES; - - typedef struct { - uint16 Reserved1; - uint16 Reserved2; - uint32 Reserved3; - uint32 IterationCount; - uint32 KeyType; - uint32 KeyLength; - uint32 KeyOffset; - } KERB_KEY_DATA_NEW; - - typedef struct { - uint16 Revision; - uint16 Flags; - uint16 CredentialCount; - uint16 ServiceCredentialCount; - uint16 OldCredentialCount; - uint16 OlderCredentialCount; - uint16 DefaultSaltLength; - uint16 DefaultSaltMaximumLength; - uint32 DefaultSaltOffset; - uint32 DefaultIterationCount; - KERB_KEY_DATA_NEW Credentials[CredentialCount]; - KERB_KEY_DATA_NEW ServiceCredentials[ServiceCredentialCount]; - KERB_KEY_DATA_NEW OldCredentials[OldCredentialCount]; - KERB_KEY_DATA_NEW OlderCredentials[OlderCredentialCount]; - } KERB_STORED_CREDENTIAL_NEW; - """ +crypto_structures = """ +typedef struct { + BYTE Header[8]; + BYTE KeyMaterial[16]; + DWORD Unknown; + BYTE EncryptedHash[EOF]; +} CRYPTED_HASHW16; + +typedef struct { + BYTE Header[8]; + BYTE KeyMaterial[16]; + BYTE EncryptedHash[EOF]; +} CRYPTED_HISTORY; + +typedef struct { + BYTE Header[8]; + BYTE KeyMaterial[16]; + BYTE EncryptedHash[EOF]; +} CRYPTED_BLOB; + +typedef struct { + CHAR Header; + CHAR Padding[3]; + CHAR Key[16]; +} PEK_KEY; + +typedef struct { + BYTE Header[8]; + BYTE KeyMaterial[16]; + BYTE EncryptedHash[16]; +} CRYPTED_HASH; +""" # Initialize cstruct parsers -c_ntds_crypto = cstruct().load(CryptoStructures.NTDS_CRYPTO_DEF) -c_samr = cstruct().load(CryptoStructures.SAMR_STRUCTS_DEF) +c_ntds_crypto = cstruct().load(crypto_structures) class NtdsPlugin(Plugin): @@ -268,25 +169,10 @@ class NtdsPlugin(Plugin): __namespace__ = "ntds" - # Struct constants - class StructConstant: - """Constants used for struct operations.""" - - PEK_LIST_ENC_LENGTH = len(cstruct().load(CryptoStructures.PEK_LIST_ENC_DEF.format(Length=0)).PEKLIST_ENC) - PEK_LIST_PLAIN_LENGTH = len(cstruct().load(CryptoStructures.PEK_LIST_PLAIN_DEF.format(Length=0)).PEKLIST_PLAIN) - CRYPTED_HASH_W16_LENGTH = len( - cstruct().load(CryptoStructures.CRYPTED_HASH_W16_DEF.format(Length=0)).CRYPTED_HASHW16 - ) - CRYPTED_HISTORY_LENGTH = len( - cstruct().load(CryptoStructures.CRYPTED_HISTORY_DEF.format(Length=0)).CRYPTED_HISTORY - ) - CRYPTED_BLOB_LENGTH = len(cstruct().load(CryptoStructures.CRYPTED_BLOB_DEF.format(Length=0)).CRYPTED_BLOB) - # Encryption and crypto constants class CryptoConstants: """Constants used for cryptographic operations.""" - PEK_LIST_ENTRY_SIZE = 20 DES_BLOCK_SIZE = 8 IV_SIZE = 16 NTLM_HASH_SIZE = 16 @@ -295,15 +181,7 @@ class CryptoConstants: PEK_INDEX_HEX_START = 8 PEK_INDEX_HEX_END = 10 - # Number of MD5 iterations used when deriving the RC4 key for older PEK lists. - PEK_KEY_DERIVATION_ITERATIONS = 1000 - - # First 4 bytes are a version/marker, not ciphertext - AES_HASH_HEADER_SIZE = 4 - # Version-specific headers - UP_TO_WINDOWS_2012_R2_PEK_HEADER = b"\x02\x00\x00\x00" - WINDOWS_2016_TP4_PEK_HEADER = b"\x03\x00\x00\x00" WINDOWS_2016_TP4_HASH_HEADER = b"\x13\x00\x00\x00" # Default values @@ -354,130 +232,8 @@ def pek_list(self) -> list[bytes]: Returns: pek_list: list containing PEK keys """ - pek_blob = next(self.ntds.lookup(objectCategory="domainDNS")).pekList - - if not pek_blob: - raise RuntimeError("Couldn't find pek_list in NTDS.dit") - - # Create structure with correct size - enc_struct = cstruct().load( - CryptoStructures.PEK_LIST_ENC_DEF.format(Length=len(pek_blob) - self.StructConstant.PEK_LIST_ENC_LENGTH) - ) - pek_list_enc = enc_struct.PEKLIST_ENC(pek_blob) - - header = bytearray(pek_list_enc.Header) - - if header.startswith(self.CryptoConstants.UP_TO_WINDOWS_2012_R2_PEK_HEADER): - extracted_keys = self._decrypt_pek_legacy(pek_list_enc) - elif header.startswith(self.CryptoConstants.WINDOWS_2016_TP4_PEK_HEADER): - extracted_keys = self._decrypt_pek_modern(pek_list_enc) - else: - self.target.log.error("Unknown PEK list header: %s", header) - - if not extracted_keys: - raise RuntimeError("No PEK keys obtained. Can't decrypt hashes.") - - return extracted_keys - - def _decrypt_pek_modern(self, pek_list_enc: structure) -> list[bytes]: - """Decrypt PEK list for Windows Server 2016+ using AES encryption. - - Args: - pek_list_enc: Encrypted PEK list structure. - - Returns: - pek_list: list containing PEK keys - """ - # Decrypt using AES with syskey as key and KeyMaterial as IV - pek_plain_raw = self._aes_decrypt( - self.target.lsa.syskey, bytearray(pek_list_enc.EncryptedPek), pek_list_enc.KeyMaterial - ) - - # Parse decrypted structure - plain_struct = cstruct().load( - CryptoStructures.PEK_LIST_PLAIN_DEF.format( - Length=len(pek_plain_raw) - self.StructConstant.PEK_LIST_PLAIN_LENGTH - ) - ) - plain = plain_struct.PEKLIST_PLAIN(pek_plain_raw) - - # Extract PEK entries (4-byte index + 16-byte key) - return self._extract_pek_entries(plain.DecryptedPek) - - def _decrypt_pek_legacy(self, pek_list_enc: structure) -> list[bytes]: - """Decrypt PEK list for Windows Server 2012 R2 and earlier using RC4. - - Args: - pek_list_enc: Encrypted PEK list structure. - - Returns: - pek_list: list containing PEK keys - """ - pek_list: list[bytes] = [] - - # Derive RC4 key from syskey and KeyMaterial - rc4_key = self._derive_rc4_key( - self.target.lsa.syskey, pek_list_enc.KeyMaterial, self.CryptoConstants.PEK_KEY_DERIVATION_ITERATIONS - ) - - # Decrypt with RC4 - rc4 = ARC4.new(rc4_key) - pek_plain_raw = rc4.encrypt(bytearray(pek_list_enc.EncryptedPek)) - - # Parse decrypted structure - plain_struct = cstruct().load( - CryptoStructures.PEK_LIST_PLAIN_DEF.format( - Length=len(pek_plain_raw) - self.StructConstant.PEK_LIST_PLAIN_LENGTH - ) - ) - plain = plain_struct.PEKLIST_PLAIN(pek_plain_raw) - - # Extract PEK keys from legacy format - pek_key_len = len(c_ntds_crypto.PEK_KEY) - for i in range(0, len(plain.DecryptedPek), pek_key_len): - pek_key = c_ntds_crypto.PEK_KEY(plain.DecryptedPek[i : i + pek_key_len]).Key - pek_list.append(pek_key) - self.target.log.info("PEK #%d decrypted: %s", i // pek_key_len, hexlify(pek_key).decode()) - - return pek_list - - def _extract_pek_entries(self, data: bytes) -> list[bytes]: - """Extract PEK entries from decrypted data. - - PEK entries are stored as: [4-byte index][16-byte key] - The list is terminated by a non-sequential index. - - Args: - data: Decrypted PEK data containing entries. - - Returns: - pek_list: list containing PEK keys - """ - pek_list: list[bytes] = [] - - entry_size = self.CryptoConstants.PEK_LIST_ENTRY_SIZE - pos, expected_index = 0, 0 - - while pos + entry_size <= len(data): - pek_entry = data[pos : pos + entry_size] - index, pek = unpack(" bytes: """Derive RC4 key using MD5 with multiple iterations. @@ -589,7 +345,7 @@ def _remove_des_layer(self, crypted_hash: bytes, rid: int) -> bytes: return block1 + block2 - def _decrypt_hash(self, blob: bytes, rid: int, is_lm: bool) -> str: + def _decrypt_hash(self, blob: bytes | None, rid: int, is_lm: bool) -> str: """Decrypt a single NT or LM password hash. Args: @@ -626,10 +382,7 @@ def _decrypt_hash_modern(self, blob: bytes, rid: int) -> bytes: Decrypted hash bytes. """ # Parse structure with correct size - struct_w16 = cstruct().load( - CryptoStructures.CRYPTED_HASH_W16_DEF.format(Length=len(blob) - self.StructConstant.CRYPTED_HASH_W16_LENGTH) - ) - crypted = struct_w16.CRYPTED_HASHW16(blob) + crypted = c_ntds_crypto.CRYPTED_HASHW16(blob) # Decrypt AES layer pek_index = self._get_pek_index_from_header(crypted.Header) @@ -669,10 +422,7 @@ def _decrypt_history(self, blob: bytes, rid: int) -> list[str]: return [] # Parse structure - struct_hist = cstruct().load( - CryptoStructures.CRYPTED_HISTORY_DEF.format(Length=len(blob) - self.StructConstant.CRYPTED_HISTORY_LENGTH) - ) - crypted = struct_hist.CRYPTED_HISTORY(blob) + crypted = c_ntds_crypto.CRYPTED_HISTORY(blob) header_bytes = bytearray(crypted.Header) if header_bytes.startswith(self.CryptoConstants.WINDOWS_2016_TP4_HASH_HEADER): @@ -702,10 +452,7 @@ def _decrypt_history_modern(self, blob: bytes) -> bytes: Returns: Decrypted history data containing multiple hashes. """ - struct_w16 = cstruct().load( - CryptoStructures.CRYPTED_HASH_W16_DEF.format(Length=len(blob) - self.StructConstant.CRYPTED_HASH_W16_LENGTH) - ) - crypted = struct_w16.CRYPTED_HASHW16(blob) + crypted = c_ntds_crypto.CRYPTED_HASHW16(blob) pek_index = self._get_pek_index_from_header(crypted.Header) return self._aes_decrypt( @@ -723,9 +470,9 @@ def _decode_user_account_control(self, uac: int) -> dict[str, bool]: Returns: Dictionary mapping flag names to boolean values. """ - return {flag_name.lower(): bool(uac & flag_bit) for flag_bit, flag_name in UAC_FLAGS.items()} + return {flag.name.lower(): bool(uac & flag.value) for flag in UserAccountControl} - def _decrypt_supplemental_info(self, account: User | Computer) -> Iterator[dict[str, str | None]]: + def _extract_supplemental_info(self, account: User | Computer) -> Iterator[dict[str, str | None]]: """Extract and decrypt supplemental credentials (Kerberos keys, cleartext passwords). Args: @@ -734,170 +481,78 @@ def _decrypt_supplemental_info(self, account: User | Computer) -> Iterator[dict[ Yields: Dictionary containing supplemental credential information. """ - default_info = { - "cleartext_password": None, - "kerberos_type": None, - "kerberos_key": None, - "default_salt": None, - "iteration_count": None, - "credential_type": None, - } - try: - blob = account.supplementalCredentials + supplemental_credentials = account.supplementalCredentials except KeyError: - yield default_info + yield {} return - if not blob or len(blob) < self.StructConstant.CRYPTED_BLOB_LENGTH: - yield default_info + if supplemental_credentials is None: + yield {} return - # Decrypt the supplemental blob - decrypted = self._decrypt_supplemental_blob(blob) - if not decrypted: - yield default_info - return + for supplemental_credential in supplemental_credentials: + info = {} + if "Primary:CLEARTEXT" in supplemental_credential: + info["cleartext_password"] = supplemental_credential["Primary:CLEARTEXT"] - # Parse USER_PROPERTIES structure - try: - user_properties = c_samr.USER_PROPERTIES(decrypted) - except Exception: - # Some old W2K3 systems have non-standard properties - self.target.log.warning("Failed to parse USER_PROPERTIES structure") - yield default_info - return + if "Packages" in supplemental_credential: + info["packages"] = supplemental_credential["Packages"] - # Process each property - for prop in user_properties.UserProperties: - property_name = prop.PropertyName.decode("utf-16le") + if "Primary:WDigest" in supplemental_credential: + info["w_digest"] = "".join(supplemental_credential["Primary:WDigest"]) - if property_name == "Primary:CLEARTEXT": - info = default_info.copy() - info["cleartext_password"] = self._extract_cleartext_password(prop.PropertyValue) + if not {"Primary:Kerberos", "Primary:Kerberos-Newer-Keys"}.intersection(supplemental_credential): yield info + return - elif property_name == "Primary:Kerberos-Newer-Keys": - yield from self._extract_kerberos_keys(prop.PropertyValue, default_info) - - def _decrypt_supplemental_blob(self, blob: bytes) -> bytes | None: - """Decrypt the supplemental credentials blob. - - Args: - blob: Encrypted supplemental credentials blob. - - Returns: - Decrypted data or None if decryption fails. - """ - # Parse encrypted structure - struct_blob = cstruct().load( - CryptoStructures.CRYPTED_BLOB_DEF.format(Length=len(blob) - self.StructConstant.CRYPTED_BLOB_LENGTH) - ) - crypted = struct_blob.CRYPTED_BLOB(blob) - header_bytes = bytearray(crypted.Header) - - if header_bytes.startswith(self.CryptoConstants.WINDOWS_2016_TP4_HASH_HEADER): - # Modern AES encryption (skip first 4 bytes of EncryptedHash) - pek_index = self._get_pek_index_from_header(crypted.Header) - return self._aes_decrypt( - self.pek_list[pek_index], - bytearray(crypted.EncryptedHash[self.CryptoConstants.AES_HASH_HEADER_SIZE :]), - bytearray(crypted.KeyMaterial), - ) - # Legacy RC4 encryption - return self._remove_rc4_layer(crypted) - - def _extract_cleartext_password(self, property_value: bytes) -> str | None: - """Extract cleartext password from property value. + for key_information in self._extract_kerberos_keys(supplemental_credential["Primary:Kerberos-Newer-Keys"]): + key_information.update(info) + yield key_information - Args: - property_value: Raw property value bytes. - - Returns: - Cleartext password string or None if extraction fails. - """ - try: - # Try to unhexlify and decode as UTF-16 - return unhexlify(property_value).decode("utf-16le") - except (UnicodeDecodeError, Exception): - try: - # Fallback to UTF-8 - return property_value.decode("utf-8") - except Exception: - return None - - def _extract_kerberos_keys(self, property_value: bytes, default_info: dict) -> Iterator[dict[str, str | None]]: + def _extract_kerberos_keys(self, kerberos_keys: dict[str, Any]) -> Iterator[dict[str, str | None]]: """Extract Kerberos keys from property value. Args: - property_value: Raw property value containing Kerberos keys. - default_info: Default info dictionary template. + kerberos_keys: ``dict`` Kerberos keys. Yields: Dictionary containing Kerberos key information. """ - try: - property_buffer = unhexlify(property_value) - kerb = c_samr.KERB_STORED_CREDENTIAL_NEW(property_buffer) - except Exception: - self.target.log.warning("Failed to parse Kerberos credential structure") - yield default_info - return - # Extract default salt if present default_salt = None - if kerb.DefaultSaltLength and kerb.DefaultSaltOffset: - start = int(kerb.DefaultSaltOffset) - end = start + int(kerb.DefaultSaltLength) - if 0 <= start < len(property_buffer) and end <= len(property_buffer): - default_salt = hexlify(property_buffer[start:end]).decode() + if "DefaultSalt" in kerberos_keys: + default_salt = kerberos_keys["DefaultSalt"].hex() + + default_iteration_count = None + if "DefaultIterationCount" in kerberos_keys: + default_iteration_count = kerberos_keys["DefaultIterationCount"] # Process all key entries - key_collections = { - "Credentials": kerb.Credentials, - "ServiceCredentials": kerb.ServiceCredentials, - "OldCredentials": kerb.OldCredentials, - "OlderCredentials": kerb.OlderCredentials, + credential_types = { + "Credentials", + "ServiceCredentials", + "OldCredentials", + "OlderCredentials", } - for credential_type, entries in key_collections.items(): - for entry in entries: - if entry.KeyLength <= 0 or entry.KeyOffset <= 0: - continue - - if entry.KeyOffset + entry.KeyLength > len(property_buffer): - self.target.log.error("Invalid Kerberos key offset/length") - continue - - info = default_info.copy() - info["credential_type"] = credential_type - info["kerberos_key"] = hexlify( - property_buffer[entry.KeyOffset : entry.KeyOffset + entry.KeyLength] - ).decode() - info["kerberos_type"] = KERBEROS_TYPE.get(entry.KeyType, str(entry.KeyType)) - info["iteration_count"] = entry.IterationCount - info["default_salt"] = default_salt - - yield info - - @staticmethod - def __extract_sid_and_rid(account: User | Computer) -> tuple[str, int]: - """Extract the Security Identifier (SID) and Relative Identifier (RID) from a user or computer account. + for credential_type in credential_types: + if credential_type not in kerberos_keys: + continue - The SID is a unique identifier for the security principal in Active Directory. - The RID is the last component of the SID, which uniquely identifies the account within its domain. + for key in kerberos_keys[credential_type]: + key_information = { + "default_salt": default_salt, + "default_iteration_count": default_iteration_count, + } - Args: - account (User | Computer): The Active Directory account object (User or Computer) - containing the `objectSid` attribute. + key_information["credential_type"] = credential_type + key_information["kerberos_key"] = key["Key"].hex() + key_information["kerberos_type"] = KERBEROS_TYPE.get(key["KeyType"], str(key["KeyType"])) + key_information["iteration_count"] = key["IterationCount"] + key_information["default_salt"] = default_salt - Returns: - tuple[str, int]: A tuple containing: - - The full SID as a string (e.g., "S-1-5-21-1234567890-987654321-112233445-1001") - - The RID as an integer (e.g., 1001) - """ - rid = int(account.objectSid.split("-")[-1]) - return account.objectSid, rid + yield key_information def extract_generic_account_info(self, account: User | Computer) -> Iterator[dict[str, Any]]: """Convert a database account record to NTDS account secret records. @@ -909,32 +564,31 @@ def extract_generic_account_info(self, account: User | Computer) -> Iterator[dic NtdsUserSecretRecord containing decrypted credentials. """ self.target.log.debug("Decrypting hash for user: %s", account.name) - sid, rid = self.__extract_sid_and_rid(account) # Decrypt password hashes try: - lm_hash = self._decrypt_hash(account.dBCSPwd, rid, True) + lm_hash = self._decrypt_hash(account.dBCSPwd, account.rid, True) except KeyError: lm_hash = self.CryptoConstants.DEFAULT_LM_HASH try: - nt_hash = self._decrypt_hash(account.unicodePwd, rid, False) + nt_hash = self._decrypt_hash(account.unicodePwd, account.rid, False) except KeyError: nt_hash = self.CryptoConstants.DEFAULT_NT_HASH # Decrypt password histories try: - lm_history = self._decrypt_history(account.lmPwdHistory, rid) + lm_history = self._decrypt_history(account.lmPwdHistory, account.rid) except KeyError: lm_history = None try: - nt_history = self._decrypt_history(account.ntPwdHistory, rid) + nt_history = self._decrypt_history(account.ntPwdHistory, account.rid) except KeyError: nt_history = None # Decode UAC flags - uac_flags = self._decode_user_account_control(account.userAccountControl) + uac_flags = self._decode_user_account_control(account.user_account_control) # Peripheral information try: @@ -947,18 +601,13 @@ def extract_generic_account_info(self, account: User | Computer) -> Iterator[dic except KeyError: description = None - try: - is_deleted = account.isDeleted - except KeyError: - is_deleted = False - try: admin_count = bool(account.adminCount) except KeyError: admin_count = False try: - member_of = [group.distinguishedName for group in account.groups()] + member_of = [group.distinguished_name for group in account.groups()] except KeyError: member_of = None @@ -972,34 +621,34 @@ def extract_generic_account_info(self, account: User | Computer) -> Iterator[dic service_principal_name = None # Extract supplemental credentials and yield records - for supplemental_info in self._decrypt_supplemental_info(account): + for supplemental_info in self._extract_supplemental_info(account): yield dict( common_name=account.cn, upn=upn, - sam_name=account.sAMAccountName, + sam_name=account.sam_account_name, sam_type=SAM_ACCOUNT_TYPE_INTERNAL_TO_NAME[account.sAMAccountType].lower(), description=description, - sid=sid, - rid=rid, + sid=account.sid, + rid=account.rid, password_last_set=account.pwdLastSet, logon_last_failed=account.badPasswordTime, - logon_last_success=account.lastLogon, - account_expires=account.accountExpires if not isinstance(account.accountExpires, float) else None, - creation_time=account.whenCreated, - last_modified_time=account.whenChanged, + logon_last_success=account.instance_type, + account_expires=account.accountExpires if isinstance(account.accountExpires, datetime) else None, + creation_time=account.when_created, + last_modified_time=account.when_changed, admin_count=admin_count, - is_deleted=is_deleted, + is_deleted=account.is_deleted, lm=lm_hash, lm_history=lm_history, nt=nt_hash, nt_history=nt_history, **supplemental_info, - user_account_control=account.userAccountControl, + user_account_control=account.user_account_control, **uac_flags, - object_classes=account.objectClass, - distinguished_name=account.distinguishedName, - object_guid=format_GUID(account.objectGUID), - primary_group_id=account.primaryGroupID, + object_classes=account.object_class, + distinguished_name=account.distinguished_name, + object_guid=account.guid, + primary_group_id=account.primary_group_id, member_of=member_of, service_principal_name=service_principal_name, ) From 6fa3590f1dd936ff0c019d953427476d14aa5d6e Mon Sep 17 00:00:00 2001 From: B0TAxy <59702228+B0TAxy@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:10:48 +0200 Subject: [PATCH 10/17] Need to add test logic --- .../target/plugins/os/windows/ad/__init__.py | 0 .../os/windows/{credential => ad}/ntds.py | 356 ++---------------- .../plugins/os/windows/credential/sam.py | 35 +- .../os/windows/ad/ntds/goad/ntds.dit.gz | 3 + .../os/windows/ad/ntds/large/ntds.dit.gz | 3 + tests/plugins/os/windows/ad/__init__.py | 0 tests/plugins/os/windows/ad/test_ntds.py | 76 ++++ 7 files changed, 146 insertions(+), 327 deletions(-) create mode 100644 dissect/target/plugins/os/windows/ad/__init__.py rename dissect/target/plugins/os/windows/{credential => ad}/ntds.py (55%) create mode 100644 tests/_data/plugins/os/windows/ad/ntds/goad/ntds.dit.gz create mode 100644 tests/_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz create mode 100644 tests/plugins/os/windows/ad/__init__.py create mode 100644 tests/plugins/os/windows/ad/test_ntds.py diff --git a/dissect/target/plugins/os/windows/ad/__init__.py b/dissect/target/plugins/os/windows/ad/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dissect/target/plugins/os/windows/credential/ntds.py b/dissect/target/plugins/os/windows/ad/ntds.py similarity index 55% rename from dissect/target/plugins/os/windows/credential/ntds.py rename to dissect/target/plugins/os/windows/ad/ntds.py index b1bd9e8960..817ed8f6fa 100644 --- a/dissect/target/plugins/os/windows/credential/ntds.py +++ b/dissect/target/plugins/os/windows/ad/ntds.py @@ -1,24 +1,19 @@ from __future__ import annotations -from binascii import hexlify from datetime import datetime from functools import cached_property -from hashlib import md5 from typing import TYPE_CHECKING, Any -from Crypto.Cipher import AES, ARC4, DES -from dissect.cstruct import cstruct from dissect.database.ese.ntds import NTDS from dissect.database.ese.ntds.util import UserAccountControl from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, UnsupportedPluginError, export -from dissect.target.plugins.os.windows.credential.sam import rid_to_key +from dissect.target.plugins.os.windows.credential.sam import remove_des_layer if TYPE_CHECKING: from collections.abc import Iterator - from dissect.cstruct.types import structure from dissect.database.ese.ntds.objects import Computer, User from dissect.target.target import Target @@ -89,7 +84,7 @@ ("uint32", "iteration_count"), ("uint32", "default_iteration_count"), ("string[]", "packages"), - ("string", "w_digest"), + ("string[]", "w_digest"), ("uint32", "user_account_control"), *[("boolean", flag.name.lower()) for flag in UserAccountControl], ("string[]", "object_classes"), @@ -121,73 +116,23 @@ ], ) -crypto_structures = """ -typedef struct { - BYTE Header[8]; - BYTE KeyMaterial[16]; - DWORD Unknown; - BYTE EncryptedHash[EOF]; -} CRYPTED_HASHW16; - -typedef struct { - BYTE Header[8]; - BYTE KeyMaterial[16]; - BYTE EncryptedHash[EOF]; -} CRYPTED_HISTORY; - -typedef struct { - BYTE Header[8]; - BYTE KeyMaterial[16]; - BYTE EncryptedHash[EOF]; -} CRYPTED_BLOB; - -typedef struct { - CHAR Header; - CHAR Padding[3]; - CHAR Key[16]; -} PEK_KEY; - -typedef struct { - BYTE Header[8]; - BYTE KeyMaterial[16]; - BYTE EncryptedHash[16]; -} CRYPTED_HASH; -""" - - -# Initialize cstruct parsers -c_ntds_crypto = cstruct().load(crypto_structures) - class NtdsPlugin(Plugin): """Plugin to parse NTDS.dit Active Directory database and extract user credentials. - This plugin decrypts and extracts user password hashes, password history, - Kerberos keys, and other authentication data from the NTDS.dit database - found on Windows Domain Controllers. + This plugin extracts user password hashes, password history, Kerberos keys, + and other authentication data from the NTDS.dit database found on Windows Domain Controllers. """ __namespace__ = "ntds" - # Encryption and crypto constants - class CryptoConstants: - """Constants used for cryptographic operations.""" + # NTDS Registry consts + NTDS_PARAMETERS_REGISTRY_PATH = "HKLM\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters" + NTDS_PARAMETERS_DB_VALUE = "DSA Database file" - DES_BLOCK_SIZE = 8 - IV_SIZE = 16 - NTLM_HASH_SIZE = 16 - - # The header contains the PEK index encoded in the hex representation of the header bytes. - PEK_INDEX_HEX_START = 8 - PEK_INDEX_HEX_END = 10 - - # Version-specific headers - WINDOWS_2016_TP4_HASH_HEADER = b"\x13\x00\x00\x00" - - # Default values - DEFAULT_LM_HASH = "aad3b435b51404eeaad3b435b51404ee" - DEFAULT_NT_HASH = "31d6cfe0d16ae931b73c59d7e0c089c0" - EMPTY_BYTE = b"\x00" + # Default values + DEFAULT_LM_HASH = "aad3b435b51404eeaad3b435b51404ee" + DEFAULT_NT_HASH = "31d6cfe0d16ae931b73c59d7e0c089c0" def __init__(self, target: Target): """Initialize the NTDS plugin. @@ -199,7 +144,7 @@ def __init__(self, target: Target): if self.target.has_function("registry"): ntds_path_key = self.target.registry.value( - key="HKLM\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters", value="DSA Database file" + key=self.NTDS_PARAMETERS_REGISTRY_PATH, value=self.NTDS_PARAMETERS_DB_VALUE ) self.ntds_path = self.target.fs.path(ntds_path_key.value) @@ -209,257 +154,18 @@ def check_compatible(self) -> None: Raises: UnsupportedPluginError: If NTDS.dit is not found or system hive is missing. """ - if not self.target.has_function("registry"): - raise UnsupportedPluginError("Registry function not available") - - if not self.ntds_path.exists(): - raise UnsupportedPluginError("NTDS.dit file not found") - if not self.target.has_function("lsa") or not hasattr(self.target.lsa, "syskey"): raise UnsupportedPluginError("System Hive is not present or LSA function not available") - @cached_property - def ntds(self) -> NTDS: - return NTDS(self.ntds_path.open()) + if not self.ntds_path.exists(): + raise UnsupportedPluginError("NTDS.dit file does not exists") @cached_property - def pek_list(self) -> list[bytes]: - """Extract PEK list structure and decrypt PEK keys. - - Raises: - RuntimeError: If PEK list cannot be found in the database or couldn't extract PEK keys from the PEK list. - - Returns: - pek_list: list containing PEK keys - """ - self.ntds.pek.unlock(self.target.lsa.syskey) - return self.ntds.pek.keys - - def _derive_rc4_key(self, key: bytes, key_material: bytes, iterations: int) -> bytes: - """Derive RC4 key using MD5 with multiple iterations. - - Args: - key: RC4 key. - key_material: Random key material for this encryption. - iterations: Number of MD5 iterations to perform. - - Returns: - 16-byte RC4 key. - """ - hasher = md5() - hasher.update(key) - for _ in range(iterations): - hasher.update(key_material) - return hasher.digest() - - def _aes_decrypt(self, key: bytes, data: bytes, iv: bytes) -> bytes: - """Decrypt data using AES-CBC. - - Args: - key: AES encryption key. - data: Encrypted data. - iv: Initialization vector. - - Returns: - Decrypted data. - """ - aes = AES.new(key, AES.MODE_CBC, iv) - plain = b"" - - # Decrypt in IV-sized blocks - for idx in range(0, len(data), self.CryptoConstants.IV_SIZE): - block = data[idx : idx + self.CryptoConstants.IV_SIZE] - - # Pad incomplete blocks - if len(block) < self.CryptoConstants.IV_SIZE: - padding_size = self.CryptoConstants.IV_SIZE - len(block) - block = block + (self.CryptoConstants.EMPTY_BYTE * padding_size) - - plain += aes.decrypt(block) - - return plain - - def _get_pek_index_from_header(self, header: bytes) -> int: - """Extract PEK index from header bytes. - - The PEK index is encoded in the hex representation of the header - at positions 8:10. - - Args: - header: Header bytes containing encoded PEK index. - - Returns: - PEK index value. - """ - hex_header = hexlify(bytearray(header)) - start = self.CryptoConstants.PEK_INDEX_HEX_START - end = self.CryptoConstants.PEK_INDEX_HEX_END - return int(hex_header[start:end], 16) - - def _remove_rc4_layer(self, crypted: structure) -> bytes: - """Remove RC4 encryption layer using PEK key. - - Args: - crypted: Encrypted structure with Header, KeyMaterial, and EncryptedHash. - - Returns: - Data with RC4 layer removed. - """ - pek_index = self._get_pek_index_from_header(crypted.Header) - rc4_key = self._derive_rc4_key( - self.pek_list[pek_index], - bytearray(crypted.KeyMaterial), - iterations=1, # Single iteration for hash decryption - ) - - rc4 = ARC4.new(rc4_key) - return rc4.encrypt(bytearray(crypted.EncryptedHash)) - - def _remove_des_layer(self, crypted_hash: bytes, rid: int) -> bytes: - """Remove final DES encryption layer using RID-derived keys. - - The hash is split into two 8-byte blocks, each decrypted with - a different RID-derived key. - - Args: - crypted_hash: 16-byte DES-encrypted hash. - rid: Relative ID of the user account. - - Returns: - 16-byte decrypted hash. - - Raises: - ValueError: If crypted_hash is not 16 bytes. - """ - expected_size = 2 * self.CryptoConstants.DES_BLOCK_SIZE - if len(crypted_hash) != expected_size: - raise ValueError(f"crypted_hash must be {expected_size} bytes long") - - key1, key2 = rid_to_key(rid) - des1 = DES.new(key1, DES.MODE_ECB) - des2 = DES.new(key2, DES.MODE_ECB) - - block_size = self.CryptoConstants.DES_BLOCK_SIZE - block1 = des1.decrypt(crypted_hash[:block_size]) - block2 = des2.decrypt(crypted_hash[block_size : 2 * block_size]) - - return block1 + block2 - - def _decrypt_hash(self, blob: bytes | None, rid: int, is_lm: bool) -> str: - """Decrypt a single NT or LM password hash. - - Args: - blob: Encrypted hash blob from database. - rid: User's relative ID. - is_lm: True for LM hash, False for NT hash. - - Returns: - Hex string of the decrypted hash. - """ - if not blob: - return self.CryptoConstants.DEFAULT_LM_HASH if is_lm else self.CryptoConstants.DEFAULT_NT_HASH - - crypted = c_ntds_crypto.CRYPTED_HASH(blob) - header_bytes = bytearray(crypted.Header) - - if header_bytes.startswith(self.CryptoConstants.WINDOWS_2016_TP4_HASH_HEADER): - # Modern encryption (AES) - decrypted = self._decrypt_hash_modern(blob, rid) - else: - # Legacy encryption (RC4 + DES) - decrypted = self._decrypt_hash_legacy(crypted, rid) - - return hexlify(decrypted).decode() - - def _decrypt_hash_modern(self, blob: bytes, rid: int) -> bytes: - """Decrypt hash using modern AES encryption (Windows Server 2016+). - - Args: - blob: Encrypted hash blob. - rid: User's relative ID. - - Returns: - Decrypted hash bytes. - """ - # Parse structure with correct size - crypted = c_ntds_crypto.CRYPTED_HASHW16(blob) - - # Decrypt AES layer - pek_index = self._get_pek_index_from_header(crypted.Header) - decrypted = self._aes_decrypt( - self.pek_list[pek_index], - bytearray(crypted.EncryptedHash[: self.CryptoConstants.NTLM_HASH_SIZE]), - bytearray(crypted.KeyMaterial), - ) - - # Remove DES layer - return self._remove_des_layer(decrypted, rid) - - def _decrypt_hash_legacy(self, crypted: structure, rid: int) -> bytes: - """Decrypt hash using legacy RC4+DES encryption. - - Args: - crypted: Encrypted hash structure. - rid: User's relative ID. - - Returns: - Decrypted hash bytes. - """ - tmp = self._remove_rc4_layer(crypted) - return self._remove_des_layer(tmp, rid) - - def _decrypt_history(self, blob: bytes, rid: int) -> list[str]: - """Decrypt password history containing multiple hashes. - - Args: - blob: Encrypted history blob. - rid: User's relative ID. - - Returns: - List of hex-encoded password hashes. - """ - if not blob: - return [] - - # Parse structure - crypted = c_ntds_crypto.CRYPTED_HISTORY(blob) - header_bytes = bytearray(crypted.Header) - - if header_bytes.startswith(self.CryptoConstants.WINDOWS_2016_TP4_HASH_HEADER): - # Modern AES encryption - decrypted = self._decrypt_history_modern(blob) - else: - # Legacy RC4 encryption - decrypted = self._remove_rc4_layer(crypted) - - # Split into individual hashes and remove DES layer from each - hash_size = self.CryptoConstants.NTLM_HASH_SIZE - hashes = [] - for i in range(0, len(decrypted), hash_size): - block = decrypted[i : i + hash_size] - if len(block) == hash_size: - hash_bytes = self._remove_des_layer(block, rid) - hashes.append(hexlify(hash_bytes).decode()) - - return hashes - - def _decrypt_history_modern(self, blob: bytes) -> bytes: - """Decrypt password history using modern AES encryption. - - Args: - blob: Encrypted history blob. - - Returns: - Decrypted history data containing multiple hashes. - """ - crypted = c_ntds_crypto.CRYPTED_HASHW16(blob) + def ntds(self) -> NTDS: + ntds = NTDS(self.ntds_path.open()) + ntds.pek.unlock(self.target.lsa.syskey) - pek_index = self._get_pek_index_from_header(crypted.Header) - return self._aes_decrypt( - self.pek_list[pek_index], - bytearray(crypted.EncryptedHash[: self.CryptoConstants.NTLM_HASH_SIZE]), - bytearray(crypted.KeyMaterial), - ) + return ntds def _decode_user_account_control(self, uac: int) -> dict[str, bool]: """Decode User Account Control flags. @@ -484,8 +190,7 @@ def _extract_supplemental_info(self, account: User | Computer) -> Iterator[dict[ try: supplemental_credentials = account.supplementalCredentials except KeyError: - yield {} - return + supplemental_credentials = None if supplemental_credentials is None: yield {} @@ -500,7 +205,7 @@ def _extract_supplemental_info(self, account: User | Computer) -> Iterator[dict[ info["packages"] = supplemental_credential["Packages"] if "Primary:WDigest" in supplemental_credential: - info["w_digest"] = "".join(supplemental_credential["Primary:WDigest"]) + info["w_digest"] = [digest_hash.hex() for digest_hash in supplemental_credential["Primary:WDigest"]] if not {"Primary:Kerberos", "Primary:Kerberos-Newer-Keys"}.intersection(supplemental_credential): yield info @@ -565,27 +270,30 @@ def extract_generic_account_info(self, account: User | Computer) -> Iterator[dic """ self.target.log.debug("Decrypting hash for user: %s", account.name) - # Decrypt password hashes try: - lm_hash = self._decrypt_hash(account.dBCSPwd, account.rid, True) + lm_pwd_data = account.dBCSPwd except KeyError: - lm_hash = self.CryptoConstants.DEFAULT_LM_HASH + lm_pwd_data = None + lm_hash = remove_des_layer(lm_pwd_data, account.rid).hex() if lm_pwd_data else self.DEFAULT_LM_HASH try: - nt_hash = self._decrypt_hash(account.unicodePwd, account.rid, False) + nt_pwd_data = account.unicodePwd except KeyError: - nt_hash = self.CryptoConstants.DEFAULT_NT_HASH + nt_pwd_data = None + nt_hash = remove_des_layer(nt_pwd_data, account.rid).hex() if nt_pwd_data else self.DEFAULT_NT_HASH # Decrypt password histories try: - lm_history = self._decrypt_history(account.lmPwdHistory, account.rid) + lm_history_data = account.lmPwdHistory except KeyError: - lm_history = None + lm_history_data = None + lm_history = [remove_des_layer(lm, account.rid).hex() for lm in lm_history_data] if lm_history_data else None try: - nt_history = self._decrypt_history(account.ntPwdHistory, account.rid) + nt_history_data = account.ntPwdHistory except KeyError: - nt_history = None + nt_history_data = None + nt_history = [remove_des_layer(nt, account.rid).hex() for nt in nt_history_data] if nt_history_data else None # Decode UAC flags uac_flags = self._decode_user_account_control(account.user_account_control) @@ -608,7 +316,7 @@ def extract_generic_account_info(self, account: User | Computer) -> Iterator[dic try: member_of = [group.distinguished_name for group in account.groups()] - except KeyError: + except ValueError: member_of = None try: diff --git a/dissect/target/plugins/os/windows/credential/sam.py b/dissect/target/plugins/os/windows/credential/sam.py index 942d67b91b..c2f51be222 100644 --- a/dissect/target/plugins/os/windows/credential/sam.py +++ b/dissect/target/plugins/os/windows/credential/sam.py @@ -263,6 +263,37 @@ def rid_to_key(rid: int) -> tuple[bytes, bytes]: return k1, k2 +def remove_des_layer(crypted_hash: bytes, rid: int) -> bytes: + """Remove final DES encryption layer using RID-derived keys. + + The hash is split into two 8-byte blocks, each decrypted with + a different RID-derived key. + + Args: + crypted_hash: 16-byte DES-encrypted hash. + rid: Relative ID of the user account. + + Returns: + 16-byte decrypted hash. + + Raises: + ValueError: If crypted_hash is not 16 bytes. + """ + DES_BLOCK_SIZE = 8 + expected_size = 2 * DES_BLOCK_SIZE + if len(crypted_hash) != expected_size: + raise ValueError(f"crypted_hash must be {expected_size} bytes long") + + key1, key2 = rid_to_key(rid) + des1 = DES.new(key1, DES.MODE_ECB) + des2 = DES.new(key2, DES.MODE_ECB) + + block1 = des1.decrypt(crypted_hash[:DES_BLOCK_SIZE]) + block2 = des2.decrypt(crypted_hash[DES_BLOCK_SIZE:expected_size]) + + return block1 + block2 + + def decrypt_single_hash(rid: int, samkey: bytes, enc_hash: bytes, apwd: bytes) -> bytes: if not enc_hash: return b"" @@ -272,8 +303,6 @@ def decrypt_single_hash(rid: int, samkey: bytes, enc_hash: bytes, apwd: bytes) - if sh.revision not in [0x01, 0x02]: raise ValueError(f"Unsupported LM/NT hash revision encountered: {sh.revision}") - d1, d2 = (DES.new(k, DES.MODE_ECB) for k in rid_to_key(rid)) - if sh.revision == 0x01: # LM/NT revision 0x01 involving RC4 sh_hash = enc_hash[len(c_sam.SAM_HASH) :] if not sh_hash: # Empty hash @@ -290,7 +319,7 @@ def decrypt_single_hash(rid: int, samkey: bytes, enc_hash: bytes, apwd: bytes) - sh_hash = enc_hash[len(c_sam.SAM_HASH_AES) :] obfkey = AES.new(samkey, AES.MODE_CBC, sh.salt).decrypt(sh_hash)[:16] - return d1.decrypt(obfkey[:8]) + d2.decrypt(obfkey[8:]) + return remove_des_layer(obfkey, rid) class SamPlugin(Plugin): diff --git a/tests/_data/plugins/os/windows/ad/ntds/goad/ntds.dit.gz b/tests/_data/plugins/os/windows/ad/ntds/goad/ntds.dit.gz new file mode 100644 index 0000000000..442fdbb81f --- /dev/null +++ b/tests/_data/plugins/os/windows/ad/ntds/goad/ntds.dit.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f38cbda2b136e160f8c7e7ca2e7b4f1389975c4b40098dba1b6f944ba2c8950c +size 2159695 diff --git a/tests/_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz b/tests/_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz new file mode 100644 index 0000000000..a8bb820003 --- /dev/null +++ b/tests/_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33e8fe8cb3ce9630c7b745b0efe547faedb7248201d70911b6fce0b279c35563 +size 39132209 diff --git a/tests/plugins/os/windows/ad/__init__.py b/tests/plugins/os/windows/ad/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/plugins/os/windows/ad/test_ntds.py b/tests/plugins/os/windows/ad/test_ntds.py new file mode 100644 index 0000000000..e4947fb03e --- /dev/null +++ b/tests/plugins/os/windows/ad/test_ntds.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from dissect.target.helpers.regutil import VirtualHive, VirtualKey, VirtualValue +from dissect.target.plugins.os.windows.ad.ntds import NtdsPlugin +from tests._utils import absolute_path +from tests.plugins.os.windows.credential.test_lsa import map_lsa_system_keys + +if TYPE_CHECKING: + import pathlib + + from _pytest.fixtures import SubRequest + + from dissect.target.target import Target + + +GOAD_NTDS = absolute_path("_data/plugins/os/windows/ad/ntds/goad/ntds.dit.gz") +LARGE_NTDS = absolute_path("_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz") + +DEFAULT_NTDS_LOCATION = "c:/windows/ntds/ntds.dit" + + +def map_ntds_path(hive_hklm: VirtualHive, ntds_path: pathlib.Path) -> str: + _, registry_path = NtdsPlugin.NTDS_PARAMETERS_REGISTRY_PATH.replace("CurrentControlSet", "ControlSet001").split( + "\\", maxsplit=1 + ) + + hive_hklm.map_key(registry_path, VirtualKey(hive_hklm, registry_path)) + hive_hklm.map_value( + registry_path, + NtdsPlugin.NTDS_PARAMETERS_DB_VALUE, + VirtualValue(hive_hklm, NtdsPlugin.NTDS_PARAMETERS_DB_VALUE, str(ntds_path)), + ) + + return hive_hklm + + +@pytest.fixture( + params=[ + ( + LARGE_NTDS, + { + "JD": "3f52f315", + "Skew1": "57cf423d", + "GBG": "d972e780", + "Data": "45d316ac", + }, + ), + ( + GOAD_NTDS, + { + "JD": "ebaa656d", + "Skew1": "959f28b0", + "GBG": "0766a85b", + "Data": "1af1b31e", + }, + ), + ] +) +def target_win_ntds(target_win: Target, hive_hklm: VirtualHive, request: SubRequest) -> Target: + ntds_path, syskey = request.param + + map_ntds_path(hive_hklm, DEFAULT_NTDS_LOCATION) + map_lsa_system_keys(hive_hklm, syskey) + + target_win.fs.map_file(DEFAULT_NTDS_LOCATION, ntds_path, compression="gzip") + target_win.add_plugin(NtdsPlugin) + + return target_win + + +def test_abc(target_win_ntds: Target) -> None: + results = list(target_win_ntds.ntds.user_accounts()) From c24dd785c52a9d72626d29674c9939125369c635 Mon Sep 17 00:00:00 2001 From: B0TAxy <59702228+B0TAxy@users.noreply.github.com> Date: Sat, 17 Jan 2026 09:52:07 +0200 Subject: [PATCH 11/17] Added tests --- dissect/target/plugins/os/windows/ad/ntds.py | 2 +- .../plugins/os/windows/credential/sam.py | 4 ++- tests/plugins/os/windows/ad/test_ntds.py | 29 +++++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/dissect/target/plugins/os/windows/ad/ntds.py b/dissect/target/plugins/os/windows/ad/ntds.py index 817ed8f6fa..6185954e1d 100644 --- a/dissect/target/plugins/os/windows/ad/ntds.py +++ b/dissect/target/plugins/os/windows/ad/ntds.py @@ -316,7 +316,7 @@ def extract_generic_account_info(self, account: User | Computer) -> Iterator[dic try: member_of = [group.distinguished_name for group in account.groups()] - except ValueError: + except Exception: # TODO: Understand why multiple exception are thrown member_of = None try: diff --git a/dissect/target/plugins/os/windows/credential/sam.py b/dissect/target/plugins/os/windows/credential/sam.py index c2f51be222..29ec89746e 100644 --- a/dissect/target/plugins/os/windows/credential/sam.py +++ b/dissect/target/plugins/os/windows/credential/sam.py @@ -282,7 +282,9 @@ def remove_des_layer(crypted_hash: bytes, rid: int) -> bytes: DES_BLOCK_SIZE = 8 expected_size = 2 * DES_BLOCK_SIZE if len(crypted_hash) != expected_size: - raise ValueError(f"crypted_hash must be {expected_size} bytes long") + # TODO: Understand why hash bigger than 16 bytes are generated + # raise ValueError(f"crypted_hash must be {expected_size} bytes long") + return b"" key1, key2 = rid_to_key(rid) des1 = DES.new(key1, DES.MODE_ECB) diff --git a/tests/plugins/os/windows/ad/test_ntds.py b/tests/plugins/os/windows/ad/test_ntds.py index e4947fb03e..0255ec6810 100644 --- a/tests/plugins/os/windows/ad/test_ntds.py +++ b/tests/plugins/os/windows/ad/test_ntds.py @@ -48,6 +48,7 @@ def map_ntds_path(hive_hklm: VirtualHive, ntds_path: pathlib.Path) -> str: "GBG": "d972e780", "Data": "45d316ac", }, + "large", ), ( GOAD_NTDS, @@ -57,11 +58,12 @@ def map_ntds_path(hive_hklm: VirtualHive, ntds_path: pathlib.Path) -> str: "GBG": "0766a85b", "Data": "1af1b31e", }, + "goad", ), ] ) def target_win_ntds(target_win: Target, hive_hklm: VirtualHive, request: SubRequest) -> Target: - ntds_path, syskey = request.param + ntds_path, syskey, test_type = request.param map_ntds_path(hive_hklm, DEFAULT_NTDS_LOCATION) map_lsa_system_keys(hive_hklm, syskey) @@ -69,8 +71,31 @@ def target_win_ntds(target_win: Target, hive_hklm: VirtualHive, request: SubRequ target_win.fs.map_file(DEFAULT_NTDS_LOCATION, ntds_path, compression="gzip") target_win.add_plugin(NtdsPlugin) + target_win.test_type = test_type + return target_win -def test_abc(target_win_ntds: Target) -> None: +def test_user_accounts(target_win_ntds: Target) -> None: results = list(target_win_ntds.ntds.user_accounts()) + + sam_name_to_ntlm_hash_mapping = { + # Large test data + "henk.devries": "ac85b86a678c2b19e49cbcd236d037e9", # Password hash of Winter2025! + "beau.terham": "59ebeca2c2b7b942370f69e155df8ba2", # Password hash of Zomer2027!@ + # GOAD test data + } + + assert len(results) == 26955 if target_win_ntds.test_type == "large" else 78 + + for record in results: + if record.sam_name not in sam_name_to_ntlm_hash_mapping: + continue + + assert sam_name_to_ntlm_hash_mapping[record.sam_name] == record.nt + + +def test_computer_accounts(target_win_ntds: Target) -> None: + results = list(target_win_ntds.ntds.computer_accounts()) + + assert len(results) == 9045 if target_win_ntds.test_type == "large" else 8 From 31bdc9659b66a38cd20be927da2ad102688d6a7b Mon Sep 17 00:00:00 2001 From: Schamper <1254028+Schamper@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:31:24 +0100 Subject: [PATCH 12/17] Changes --- dissect/target/plugins/os/windows/ad/ntds.py | 434 ++++-------------- .../plugins/os/windows/credential/sam.py | 28 +- pyproject.toml | 2 +- .../os/windows/ad/ntds/large/ntds.dit.gz | 3 - tests/plugins/os/windows/ad/test_ntds.py | 99 ++-- 5 files changed, 134 insertions(+), 432 deletions(-) delete mode 100644 tests/_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz diff --git a/dissect/target/plugins/os/windows/ad/ntds.py b/dissect/target/plugins/os/windows/ad/ntds.py index 6185954e1d..24685ea407 100644 --- a/dissect/target/plugins/os/windows/ad/ntds.py +++ b/dissect/target/plugins/os/windows/ad/ntds.py @@ -5,11 +5,10 @@ from typing import TYPE_CHECKING, Any from dissect.database.ese.ntds import NTDS -from dissect.database.ese.ntds.util import UserAccountControl from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, UnsupportedPluginError, export -from dissect.target.plugins.os.windows.credential.sam import remove_des_layer +from dissect.target.plugins.os.windows.credential.sam import des_decrypt if TYPE_CHECKING: from collections.abc import Iterator @@ -19,45 +18,8 @@ from dissect.target.target import Target -# Kerberos encryption type mappings -KERBEROS_TYPE = { - # DES - 1: "des-cbc-crc", - 2: "des-cbc-md4", - 3: "des-cbc-md5", - # RC4 - 23: "rc4-hmac", - -133: "rc4-hmac-exp", - 0xFFFFFF74: "rc4_hmac_old", - # AES (RFC 3962) - 17: "aes128-cts-hmac-sha1-96", - 18: "aes256-cts-hmac-sha1-96", - # AES (newer RFC 8009) - 19: "aes128-cts-hmac-sha256-128", - 20: "aes256-cts-hmac-sha384-192", - # Other / legacy - 16: "des3-cbc-sha1", - 24: "rc4-hmac-exp-old", -} - -# SAM account type constants -SAM_ACCOUNT_TYPE_INTERNAL_TO_NAME = { - 0x0: "SAM_DOMAIN_OBJECT", - 0x10000000: "SAM_GROUP_OBJECT", - 0x10000001: "SAM_NON_SECURITY_GROUP_OBJECT", - 0x20000000: "SAM_ALIAS_OBJECT", - 0x20000001: "SAM_NON_SECURITY_ALIAS_OBJECT", - 0x30000000: "SAM_USER_OBJECT", - 0x30000001: "SAM_MACHINE_ACCOUNT", - 0x30000002: "SAM_TRUST_ACCOUNT", - 0x40000000: "SAM_APP_BASIC_GROUP", - 0x40000001: "SAM_APP_QUERY_GROUP", - 0x7FFFFFFF: "SAM_ACCOUNT_TYPE_MAX", -} - - GENERIC_FIELDS = [ - ("string", "common_name"), + ("string", "cn"), ("string", "upn"), ("string", "sam_name"), ("string", "sam_type"), @@ -76,17 +38,8 @@ ("string[]", "lm_history"), ("string", "nt"), ("string[]", "nt_history"), - ("string", "cleartext_password"), - ("string", "credential_type"), - ("string", "kerberos_type"), - ("string", "kerberos_key"), - ("string", "default_salt"), - ("uint32", "iteration_count"), - ("uint32", "default_iteration_count"), - ("string[]", "packages"), - ("string[]", "w_digest"), - ("uint32", "user_account_control"), - *[("boolean", flag.name.lower()) for flag in UserAccountControl], + ("string", "supplemental_credentials"), + ("string", "user_account_control"), ("string[]", "object_classes"), ("string", "distinguished_name"), ("string", "object_guid"), @@ -96,8 +49,8 @@ ] # Record descriptor for NTDS user secrets -NtdsUserAccountRecord = TargetRecordDescriptor( - "windows/credential/ntds/user", +NtdsUserRecord = TargetRecordDescriptor( + "windows/credential/ad/user", [ *GENERIC_FIELDS, ("string", "info"), @@ -106,8 +59,8 @@ ("string", "home_directory"), ], ) -NtdsComputerAccountRecord = TargetRecordDescriptor( - "windows/credential/ntds/computer", +NtdsComputerRecord = TargetRecordDescriptor( + "windows/credential/ad/computer", [ *GENERIC_FIELDS, ("string", "dns_hostname"), @@ -117,22 +70,23 @@ ) +# NTDS Registry consts +NTDS_PARAMETERS_REGISTRY_PATH = "HKLM\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters" +NTDS_PARAMETERS_DB_VALUE = "DSA Database file" + +# Default values +DEFAULT_LM_HASH = "aad3b435b51404eeaad3b435b51404ee" +DEFAULT_NT_HASH = "31d6cfe0d16ae931b73c59d7e0c089c0" + + class NtdsPlugin(Plugin): """Plugin to parse NTDS.dit Active Directory database and extract user credentials. - This plugin extracts user password hashes, password history, Kerberos keys, - and other authentication data from the NTDS.dit database found on Windows Domain Controllers. + This plugin extracts user password hashes, password history, Kerberos keys, and other authentication data + from the NTDS.dit database found on Windows Domain Controllers. """ - __namespace__ = "ntds" - - # NTDS Registry consts - NTDS_PARAMETERS_REGISTRY_PATH = "HKLM\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters" - NTDS_PARAMETERS_DB_VALUE = "DSA Database file" - - # Default values - DEFAULT_LM_HASH = "aad3b435b51404eeaad3b435b51404ee" - DEFAULT_NT_HASH = "31d6cfe0d16ae931b73c59d7e0c089c0" + __namespace__ = "ad" def __init__(self, target: Target): """Initialize the NTDS plugin. @@ -142,11 +96,11 @@ def __init__(self, target: Target): """ super().__init__(target) + self.path = None + if self.target.has_function("registry"): - ntds_path_key = self.target.registry.value( - key=self.NTDS_PARAMETERS_REGISTRY_PATH, value=self.NTDS_PARAMETERS_DB_VALUE - ) - self.ntds_path = self.target.fs.path(ntds_path_key.value) + key = self.target.registry.value(NTDS_PARAMETERS_REGISTRY_PATH, NTDS_PARAMETERS_DB_VALUE) + self.path = self.target.fs.path(key.value) def check_compatible(self) -> None: """Check if the plugin can run on the target system. @@ -154,283 +108,89 @@ def check_compatible(self) -> None: Raises: UnsupportedPluginError: If NTDS.dit is not found or system hive is missing. """ - if not self.target.has_function("lsa") or not hasattr(self.target.lsa, "syskey"): + if not self.target.has_function("lsa"): raise UnsupportedPluginError("System Hive is not present or LSA function not available") - if not self.ntds_path.exists(): - raise UnsupportedPluginError("NTDS.dit file does not exists") + if not self.path or not self.path.exists(): + raise UnsupportedPluginError("No NTDS.dit database found on target") @cached_property def ntds(self) -> NTDS: - ntds = NTDS(self.ntds_path.open()) + ntds = NTDS(self.path.open("rb")) ntds.pek.unlock(self.target.lsa.syskey) return ntds - def _decode_user_account_control(self, uac: int) -> dict[str, bool]: - """Decode User Account Control flags. - - Args: - uac: User Account Control integer value. - - Returns: - Dictionary mapping flag names to boolean values. - """ - return {flag.name.lower(): bool(uac & flag.value) for flag in UserAccountControl} - - def _extract_supplemental_info(self, account: User | Computer) -> Iterator[dict[str, str | None]]: - """Extract and decrypt supplemental credentials (Kerberos keys, cleartext passwords). - - Args: - account: Account record from the database. - - Yields: - Dictionary containing supplemental credential information. - """ - try: - supplemental_credentials = account.supplementalCredentials - except KeyError: - supplemental_credentials = None - - if supplemental_credentials is None: - yield {} - return - - for supplemental_credential in supplemental_credentials: - info = {} - if "Primary:CLEARTEXT" in supplemental_credential: - info["cleartext_password"] = supplemental_credential["Primary:CLEARTEXT"] - - if "Packages" in supplemental_credential: - info["packages"] = supplemental_credential["Packages"] - - if "Primary:WDigest" in supplemental_credential: - info["w_digest"] = [digest_hash.hex() for digest_hash in supplemental_credential["Primary:WDigest"]] - - if not {"Primary:Kerberos", "Primary:Kerberos-Newer-Keys"}.intersection(supplemental_credential): - yield info - return - - for key_information in self._extract_kerberos_keys(supplemental_credential["Primary:Kerberos-Newer-Keys"]): - key_information.update(info) - yield key_information - - def _extract_kerberos_keys(self, kerberos_keys: dict[str, Any]) -> Iterator[dict[str, str | None]]: - """Extract Kerberos keys from property value. - - Args: - kerberos_keys: ``dict`` Kerberos keys. - - Yields: - Dictionary containing Kerberos key information. - """ - # Extract default salt if present - default_salt = None - if "DefaultSalt" in kerberos_keys: - default_salt = kerberos_keys["DefaultSalt"].hex() - - default_iteration_count = None - if "DefaultIterationCount" in kerberos_keys: - default_iteration_count = kerberos_keys["DefaultIterationCount"] - - # Process all key entries - credential_types = { - "Credentials", - "ServiceCredentials", - "OldCredentials", - "OlderCredentials", - } - - for credential_type in credential_types: - if credential_type not in kerberos_keys: - continue - - for key in kerberos_keys[credential_type]: - key_information = { - "default_salt": default_salt, - "default_iteration_count": default_iteration_count, - } - - key_information["credential_type"] = credential_type - key_information["kerberos_key"] = key["Key"].hex() - key_information["kerberos_type"] = KERBEROS_TYPE.get(key["KeyType"], str(key["KeyType"])) - key_information["iteration_count"] = key["IterationCount"] - key_information["default_salt"] = default_salt - - yield key_information - - def extract_generic_account_info(self, account: User | Computer) -> Iterator[dict[str, Any]]: - """Convert a database account record to NTDS account secret records. - - Args: - account: Account object from the database. - - Yields: - NtdsUserSecretRecord containing decrypted credentials. - """ - self.target.log.debug("Decrypting hash for user: %s", account.name) - - try: - lm_pwd_data = account.dBCSPwd - except KeyError: - lm_pwd_data = None - lm_hash = remove_des_layer(lm_pwd_data, account.rid).hex() if lm_pwd_data else self.DEFAULT_LM_HASH - - try: - nt_pwd_data = account.unicodePwd - except KeyError: - nt_pwd_data = None - nt_hash = remove_des_layer(nt_pwd_data, account.rid).hex() if nt_pwd_data else self.DEFAULT_NT_HASH - - # Decrypt password histories - try: - lm_history_data = account.lmPwdHistory - except KeyError: - lm_history_data = None - lm_history = [remove_des_layer(lm, account.rid).hex() for lm in lm_history_data] if lm_history_data else None - - try: - nt_history_data = account.ntPwdHistory - except KeyError: - nt_history_data = None - nt_history = [remove_des_layer(nt, account.rid).hex() for nt in nt_history_data] if nt_history_data else None - - # Decode UAC flags - uac_flags = self._decode_user_account_control(account.user_account_control) - - # Peripheral information - try: - upn = account.userPrincipalName - except KeyError: - upn = None - - try: - description = account.description - except KeyError: - description = None - - try: - admin_count = bool(account.adminCount) - except KeyError: - admin_count = False - - try: - member_of = [group.distinguished_name for group in account.groups()] - except Exception: # TODO: Understand why multiple exception are thrown - member_of = None - - try: - service_principal_name = ( - [account.servicePrincipalName] - if isinstance(account.servicePrincipalName, str) - else account.servicePrincipalName + @export(record=NtdsUserRecord) + def users(self) -> Iterator[NtdsUserRecord]: + """Extract all user accounts from the NTDS.dit database.""" + for user in self.ntds.users(): + yield NtdsUserRecord( + **extract_user_info(self.ntds, user, self.target), + info=user.get("info"), + comment=user.get("comment"), + telephone_number=user.get("telephoneNumber"), + home_directory=user.get("homeDirectory"), + _target=self.target, ) - except KeyError: - service_principal_name = None - - # Extract supplemental credentials and yield records - for supplemental_info in self._extract_supplemental_info(account): - yield dict( - common_name=account.cn, - upn=upn, - sam_name=account.sam_account_name, - sam_type=SAM_ACCOUNT_TYPE_INTERNAL_TO_NAME[account.sAMAccountType].lower(), - description=description, - sid=account.sid, - rid=account.rid, - password_last_set=account.pwdLastSet, - logon_last_failed=account.badPasswordTime, - logon_last_success=account.instance_type, - account_expires=account.accountExpires if isinstance(account.accountExpires, datetime) else None, - creation_time=account.when_created, - last_modified_time=account.when_changed, - admin_count=admin_count, - is_deleted=account.is_deleted, - lm=lm_hash, - lm_history=lm_history, - nt=nt_hash, - nt_history=nt_history, - **supplemental_info, - user_account_control=account.user_account_control, - **uac_flags, - object_classes=account.object_class, - distinguished_name=account.distinguished_name, - object_guid=account.guid, - primary_group_id=account.primary_group_id, - member_of=member_of, - service_principal_name=service_principal_name, + + @export(record=NtdsComputerRecord) + def computers(self) -> Iterator[NtdsComputerRecord]: + """Extract all computer accounts from the NTDS.dit database.""" + for computer in self.ntds.computers(): + yield NtdsComputerRecord( + **extract_user_info(self.ntds, computer, self.target), + dns_hostname=computer.get("dNSHostName"), + operating_system=computer.get("operatingSystem"), + operating_system_version=computer.get("operatingSystemVersion"), + _target=self.target, ) - @export(record=NtdsUserAccountRecord, description="Extract user accounts & thier sercrets from NTDS.dit database") - def user_accounts(self) -> Iterator[NtdsUserAccountRecord]: - """Extract all user account from the NTDS.dit database. - Yields: - ``NtdsUserAccountRecord``: for each user account found in the database. - """ - for account in self.ntds.users(): - for generic_info in self.extract_generic_account_info(account): - # TODO: Fix the extraction here - try: - info = account.info - except KeyError: - info = None - - try: - comment = account.comment - except KeyError: - comment = None - - try: - telephone_number = account.telephoneNumber - except KeyError: - telephone_number = None - - try: - home_directory = account.homeDirectory - except KeyError: - home_directory = None - - yield NtdsUserAccountRecord( - **generic_info, - info=info, - comment=comment, - telephone_number=telephone_number, - home_directory=home_directory, - _target=self.target, - ) - - @export( - record=NtdsComputerAccountRecord, - description="Extract computer accounts & thier sercrets from NTDS.dit database", - ) - def computer_accounts(self) -> Iterator[NtdsComputerAccountRecord]: - """Extract all computer account from the NTDS.dit database. - - Yields: - ``NtdsComputerAccountRecord``: for each computer account found in the database. - """ - for account in self.ntds.computers(): - for generic_info in self.extract_generic_account_info(account): - try: - dns_hostname = account.dNSHostName - except KeyError: - dns_hostname = None - - try: - operating_system = account.operatingSystem - except KeyError: - operating_system = None - - try: - operating_system_version = account.operatingSystemVersion - except KeyError: - operating_system_version = None - - yield NtdsComputerAccountRecord( - **generic_info, - dns_hostname=dns_hostname, - operating_system=operating_system, - operating_system_version=operating_system_version, - _target=self.target, - ) +def extract_user_info(ntds: NTDS, user: User | Computer, target: Target) -> dict[str, Any]: + """Extract generic information from a User or Computer account.""" + + lm_hash = des_decrypt(lm_pwd, user.rid).hex() if (lm_pwd := user.get("dBCSPwd")) else DEFAULT_LM_HASH + nt_hash = des_decrypt(nt_pwd, user.rid).hex() if (nt_pwd := user.get("unicodePwd")) else DEFAULT_NT_HASH + + # Decrypt password history + lm_history = [des_decrypt(lm, user.rid).hex() for lm in user.get("lmPwdHistory") or []] + nt_history = [des_decrypt(nt, user.rid).hex() for nt in user.get("ntPwdHistory") or []] + + try: + member_of = [group.distinguished_name for group in user.groups()] + except Exception as e: + member_of = [] + target.log.warning("Failed to extract group membership for user %s: %s", user, e) + target.log.debug("", exc_info=e) + + # Extract supplemental credentials and yield records + return { + "cn": user.get("cn"), + "upn": user.get("userPrincipalName"), + "sam_name": user.sam_account_name, + "sam_type": user.sam_account_type.name, + "description": user.get("description"), + "sid": user.sid, + "rid": user.rid, + "password_last_set": user.get("pwdLastSet"), + "logon_last_failed": user.get("badPasswordTime"), + "logon_last_success": user.get("lastLogon"), + "account_expires": user.get("accountExpires") if isinstance(user.get("accountExpires"), datetime) else None, + "creation_time": user.when_created, + "last_modified_time": user.when_changed, + "admin_count": user.get("adminCount"), + "is_deleted": user.is_deleted, + "lm": lm_hash, + "lm_history": lm_history, + "nt": nt_hash, + "nt_history": nt_history, + "supplemental_credentials": user.get("supplementalCredentials"), + "user_account_control": user.user_account_control.name, + "object_classes": user.object_class, + "distinguished_name": user.distinguished_name, + "object_guid": user.guid, + "primary_group_id": user.primary_group_id, + "member_of": member_of, + "service_principal_name": user.get("servicePrincipalName"), + } diff --git a/dissect/target/plugins/os/windows/credential/sam.py b/dissect/target/plugins/os/windows/credential/sam.py index 29ec89746e..ca2c2291ad 100644 --- a/dissect/target/plugins/os/windows/credential/sam.py +++ b/dissect/target/plugins/os/windows/credential/sam.py @@ -263,35 +263,25 @@ def rid_to_key(rid: int) -> tuple[bytes, bytes]: return k1, k2 -def remove_des_layer(crypted_hash: bytes, rid: int) -> bytes: - """Remove final DES encryption layer using RID-derived keys. - - The hash is split into two 8-byte blocks, each decrypted with - a different RID-derived key. +def des_decrypt(data: bytes, rid: int) -> bytes: + """Decrypt a DES-encrypted hash using the RID-derived keys. Args: - crypted_hash: 16-byte DES-encrypted hash. + data: Encrypted data (16 bytes). rid: Relative ID of the user account. - Returns: - 16-byte decrypted hash. - Raises: - ValueError: If crypted_hash is not 16 bytes. + ValueError: If data is not 16 bytes. """ - DES_BLOCK_SIZE = 8 - expected_size = 2 * DES_BLOCK_SIZE - if len(crypted_hash) != expected_size: - # TODO: Understand why hash bigger than 16 bytes are generated - # raise ValueError(f"crypted_hash must be {expected_size} bytes long") - return b"" + if len(data) != 16: + raise ValueError("data must be 16 bytes long") key1, key2 = rid_to_key(rid) des1 = DES.new(key1, DES.MODE_ECB) des2 = DES.new(key2, DES.MODE_ECB) - block1 = des1.decrypt(crypted_hash[:DES_BLOCK_SIZE]) - block2 = des2.decrypt(crypted_hash[DES_BLOCK_SIZE:expected_size]) + block1 = des1.decrypt(data[:8]) + block2 = des2.decrypt(data[8:]) return block1 + block2 @@ -321,7 +311,7 @@ def decrypt_single_hash(rid: int, samkey: bytes, enc_hash: bytes, apwd: bytes) - sh_hash = enc_hash[len(c_sam.SAM_HASH_AES) :] obfkey = AES.new(samkey, AES.MODE_CBC, sh.salt).decrypt(sh_hash)[:16] - return remove_des_layer(obfkey, rid) + return des_decrypt(obfkey, rid) class SamPlugin(Plugin): diff --git a/pyproject.toml b/pyproject.toml index fe9f4f41cd..9ec54f5433 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dependencies = [ "defusedxml", "dissect.cstruct>=4,<5", - "dissect.database>=1.1.dev3,<2", # TODO: update on release! + "dissect.database>=1.1.dev4,<2", # TODO: update on release! "dissect.eventlog>=3,<4", "dissect.evidence>=3.13.dev2,<4", # TODO: update on release! "dissect.hypervisor>=3.20,<4", diff --git a/tests/_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz b/tests/_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz deleted file mode 100644 index a8bb820003..0000000000 --- a/tests/_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:33e8fe8cb3ce9630c7b745b0efe547faedb7248201d70911b6fce0b279c35563 -size 39132209 diff --git a/tests/plugins/os/windows/ad/test_ntds.py b/tests/plugins/os/windows/ad/test_ntds.py index 0255ec6810..7ba48e6a8e 100644 --- a/tests/plugins/os/windows/ad/test_ntds.py +++ b/tests/plugins/os/windows/ad/test_ntds.py @@ -10,92 +10,47 @@ from tests.plugins.os.windows.credential.test_lsa import map_lsa_system_keys if TYPE_CHECKING: - import pathlib - - from _pytest.fixtures import SubRequest - from dissect.target.target import Target -GOAD_NTDS = absolute_path("_data/plugins/os/windows/ad/ntds/goad/ntds.dit.gz") -LARGE_NTDS = absolute_path("_data/plugins/os/windows/ad/ntds/large/ntds.dit.gz") - -DEFAULT_NTDS_LOCATION = "c:/windows/ntds/ntds.dit" - - -def map_ntds_path(hive_hklm: VirtualHive, ntds_path: pathlib.Path) -> str: - _, registry_path = NtdsPlugin.NTDS_PARAMETERS_REGISTRY_PATH.replace("CurrentControlSet", "ControlSet001").split( - "\\", maxsplit=1 - ) - +@pytest.fixture +def target_win_ntds(target_win: Target, hive_hklm: VirtualHive) -> Target: + registry_path = "SYSTEM\\ControlSet001\\Services\\NTDS\\Parameters" hive_hklm.map_key(registry_path, VirtualKey(hive_hklm, registry_path)) hive_hklm.map_value( registry_path, - NtdsPlugin.NTDS_PARAMETERS_DB_VALUE, - VirtualValue(hive_hklm, NtdsPlugin.NTDS_PARAMETERS_DB_VALUE, str(ntds_path)), + "DSA Database file", + VirtualValue(hive_hklm, "DSA Database file", "c:/windows/ntds/ntds.dit"), ) - return hive_hklm - - -@pytest.fixture( - params=[ - ( - LARGE_NTDS, - { - "JD": "3f52f315", - "Skew1": "57cf423d", - "GBG": "d972e780", - "Data": "45d316ac", - }, - "large", - ), - ( - GOAD_NTDS, - { - "JD": "ebaa656d", - "Skew1": "959f28b0", - "GBG": "0766a85b", - "Data": "1af1b31e", - }, - "goad", - ), - ] -) -def target_win_ntds(target_win: Target, hive_hklm: VirtualHive, request: SubRequest) -> Target: - ntds_path, syskey, test_type = request.param - - map_ntds_path(hive_hklm, DEFAULT_NTDS_LOCATION) - map_lsa_system_keys(hive_hklm, syskey) - - target_win.fs.map_file(DEFAULT_NTDS_LOCATION, ntds_path, compression="gzip") - target_win.add_plugin(NtdsPlugin) - - target_win.test_type = test_type - - return target_win + map_lsa_system_keys( + hive_hklm, + { + "JD": "ebaa656d", + "Skew1": "959f28b0", + "GBG": "0766a85b", + "Data": "1af1b31e", + }, + ) + target_win.fs.map_file( + "c:/windows/ntds/ntds.dit", + absolute_path("_data/plugins/os/windows/ad/ntds/goad/ntds.dit.gz"), + compression="gzip", + ) -def test_user_accounts(target_win_ntds: Target) -> None: - results = list(target_win_ntds.ntds.user_accounts()) + return target_win - sam_name_to_ntlm_hash_mapping = { - # Large test data - "henk.devries": "ac85b86a678c2b19e49cbcd236d037e9", # Password hash of Winter2025! - "beau.terham": "59ebeca2c2b7b942370f69e155df8ba2", # Password hash of Zomer2027!@ - # GOAD test data - } - assert len(results) == 26955 if target_win_ntds.test_type == "large" else 78 +def test_users(target_win_ntds: Target) -> None: + assert NtdsPlugin(target_win_ntds).check_compatible() is None - for record in results: - if record.sam_name not in sam_name_to_ntlm_hash_mapping: - continue + results = list(target_win_ntds.ad.users()) - assert sam_name_to_ntlm_hash_mapping[record.sam_name] == record.nt + assert len(results) == 78 -def test_computer_accounts(target_win_ntds: Target) -> None: - results = list(target_win_ntds.ntds.computer_accounts()) +def test_computers(target_win_ntds: Target) -> None: + results = list(target_win_ntds.ad.computers()) - assert len(results) == 9045 if target_win_ntds.test_type == "large" else 8 + assert len(results) == 8 From a1e6f2d7dc8cb7ca0ff745dc31f64f1ed648bf25 Mon Sep 17 00:00:00 2001 From: B0TAxy <59702228+B0TAxy@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:02:28 +0200 Subject: [PATCH 13/17] Updated tests --- dissect/target/plugins/os/windows/ad/ntds.py | 6 +-- tests/plugins/os/windows/ad/test_ntds.py | 43 ++++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/dissect/target/plugins/os/windows/ad/ntds.py b/dissect/target/plugins/os/windows/ad/ntds.py index 24685ea407..78568e338c 100644 --- a/dissect/target/plugins/os/windows/ad/ntds.py +++ b/dissect/target/plugins/os/windows/ad/ntds.py @@ -126,7 +126,7 @@ def users(self) -> Iterator[NtdsUserRecord]: """Extract all user accounts from the NTDS.dit database.""" for user in self.ntds.users(): yield NtdsUserRecord( - **extract_user_info(self.ntds, user, self.target), + **extract_user_info(user, self.target), info=user.get("info"), comment=user.get("comment"), telephone_number=user.get("telephoneNumber"), @@ -139,7 +139,7 @@ def computers(self) -> Iterator[NtdsComputerRecord]: """Extract all computer accounts from the NTDS.dit database.""" for computer in self.ntds.computers(): yield NtdsComputerRecord( - **extract_user_info(self.ntds, computer, self.target), + **extract_user_info(computer, self.target), dns_hostname=computer.get("dNSHostName"), operating_system=computer.get("operatingSystem"), operating_system_version=computer.get("operatingSystemVersion"), @@ -147,7 +147,7 @@ def computers(self) -> Iterator[NtdsComputerRecord]: ) -def extract_user_info(ntds: NTDS, user: User | Computer, target: Target) -> dict[str, Any]: +def extract_user_info(user: User | Computer, target: Target) -> dict[str, Any]: """Extract generic information from a User or Computer account.""" lm_hash = des_decrypt(lm_pwd, user.rid).hex() if (lm_pwd := user.get("dBCSPwd")) else DEFAULT_LM_HASH diff --git a/tests/plugins/os/windows/ad/test_ntds.py b/tests/plugins/os/windows/ad/test_ntds.py index 7ba48e6a8e..2be411f234 100644 --- a/tests/plugins/os/windows/ad/test_ntds.py +++ b/tests/plugins/os/windows/ad/test_ntds.py @@ -5,8 +5,9 @@ import pytest from dissect.target.helpers.regutil import VirtualHive, VirtualKey, VirtualValue -from dissect.target.plugins.os.windows.ad.ntds import NtdsPlugin +from dissect.target.plugins.os.windows.ad.ntds import DEFAULT_NT_HASH from tests._utils import absolute_path +from tests.plugins.os.windows.credential.test_credhist import md4 from tests.plugins.os.windows.credential.test_lsa import map_lsa_system_keys if TYPE_CHECKING: @@ -43,14 +44,48 @@ def target_win_ntds(target_win: Target, hive_hklm: VirtualHive) -> Target: def test_users(target_win_ntds: Target) -> None: - assert NtdsPlugin(target_win_ntds).check_compatible() is None + """Tests if ``ad.users`` outputs the correct amount of records and their content""" + cn_to_ntlm_hash_mapping = { + "krbtgt": "988160b622eb37838dbff2523015e44c", # Unknown Password + "NORTH$": "8048b2621bb71945d6ca6e9a14084af1", # Unknown Password + "ESSOS$": "f1580437d0120689ad3909b9fe9b74fe", # Unknown Password + "Administrator": "c66d72021a2d4744409969a581a1705e", # Unknown Password + "renly.baratheon": "f667bd83b30c87801cef53856618d534", # Unknown Password + "vagrant": md4("vagrant"), + "lord.varys": md4("_W1sper_$"), + "jaime.lannister": md4("cersei"), + "tyron.lannister": md4("Alc00L&S3x"), + "cersei.lannister": md4("il0vejaime"), + "joffrey.baratheon": md4("1killerlion"), + "stannis.baratheon": md4("Drag0nst0ne"), + "petyer.baelish": md4("@littlefinger@"), + "tywin.lannister": md4("powerkingftw135"), + "maester.pycelle": md4("MaesterOfMaesters"), + } results = list(target_win_ntds.ad.users()) - assert len(results) == 78 + assert len(results) == 33 + + for result in results: + if result.cn not in cn_to_ntlm_hash_mapping or result.nt == DEFAULT_NT_HASH: + continue + + assert cn_to_ntlm_hash_mapping[result.cn] == result.nt def test_computers(target_win_ntds: Target) -> None: + """Tests if ``ad.computers`` outputs the correct amount of records and their content""" + cn_to_ntlm_hash_mapping = { + "KINGSLANDING": "00e3201a59af7ecc72e939a8c9794c64", # Unknown Password + } + results = list(target_win_ntds.ad.computers()) - assert len(results) == 8 + assert len(results) == 3 + + for result in results: + if result.cn not in cn_to_ntlm_hash_mapping or result.nt == DEFAULT_NT_HASH: + continue + + assert cn_to_ntlm_hash_mapping[result.cn] == result.nt From 7851cc553e3cf25e9c80388b5e805682e0e1b696 Mon Sep 17 00:00:00 2001 From: Schamper <1254028+Schamper@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:26:01 +0100 Subject: [PATCH 14/17] Changes --- dissect/target/plugins/os/windows/ad/ntds.py | 7 ++++--- tests/plugins/os/windows/ad/test_ntds.py | 20 +++++++++---------- .../os/windows/credential/test_credhist.py | 4 ++-- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/dissect/target/plugins/os/windows/ad/ntds.py b/dissect/target/plugins/os/windows/ad/ntds.py index 78568e338c..a8fc62e485 100644 --- a/dissect/target/plugins/os/windows/ad/ntds.py +++ b/dissect/target/plugins/os/windows/ad/ntds.py @@ -7,7 +7,7 @@ from dissect.database.ese.ntds import NTDS from dissect.target.helpers.record import TargetRecordDescriptor -from dissect.target.plugin import Plugin, UnsupportedPluginError, export +from dissect.target.plugin import Plugin, UnsupportedPluginError, export, internal from dissect.target.plugins.os.windows.credential.sam import des_decrypt if TYPE_CHECKING: @@ -115,6 +115,7 @@ def check_compatible(self) -> None: raise UnsupportedPluginError("No NTDS.dit database found on target") @cached_property + @internal def ntds(self) -> NTDS: ntds = NTDS(self.path.open("rb")) ntds.pek.unlock(self.target.lsa.syskey) @@ -154,8 +155,8 @@ def extract_user_info(user: User | Computer, target: Target) -> dict[str, Any]: nt_hash = des_decrypt(nt_pwd, user.rid).hex() if (nt_pwd := user.get("unicodePwd")) else DEFAULT_NT_HASH # Decrypt password history - lm_history = [des_decrypt(lm, user.rid).hex() for lm in user.get("lmPwdHistory") or []] - nt_history = [des_decrypt(nt, user.rid).hex() for nt in user.get("ntPwdHistory") or []] + lm_history = [des_decrypt(lm, user.rid).hex() for lm in user.get("lmPwdHistory")] + nt_history = [des_decrypt(nt, user.rid).hex() for nt in user.get("ntPwdHistory")] try: member_of = [group.distinguished_name for group in user.groups()] diff --git a/tests/plugins/os/windows/ad/test_ntds.py b/tests/plugins/os/windows/ad/test_ntds.py index 2be411f234..1dca870c1c 100644 --- a/tests/plugins/os/windows/ad/test_ntds.py +++ b/tests/plugins/os/windows/ad/test_ntds.py @@ -51,16 +51,16 @@ def test_users(target_win_ntds: Target) -> None: "ESSOS$": "f1580437d0120689ad3909b9fe9b74fe", # Unknown Password "Administrator": "c66d72021a2d4744409969a581a1705e", # Unknown Password "renly.baratheon": "f667bd83b30c87801cef53856618d534", # Unknown Password - "vagrant": md4("vagrant"), - "lord.varys": md4("_W1sper_$"), - "jaime.lannister": md4("cersei"), - "tyron.lannister": md4("Alc00L&S3x"), - "cersei.lannister": md4("il0vejaime"), - "joffrey.baratheon": md4("1killerlion"), - "stannis.baratheon": md4("Drag0nst0ne"), - "petyer.baelish": md4("@littlefinger@"), - "tywin.lannister": md4("powerkingftw135"), - "maester.pycelle": md4("MaesterOfMaesters"), + "vagrant": md4("vagrant").hex(), + "lord.varys": md4("_W1sper_$").hex(), + "jaime.lannister": md4("cersei").hex(), + "tyron.lannister": md4("Alc00L&S3x").hex(), + "cersei.lannister": md4("il0vejaime").hex(), + "joffrey.baratheon": md4("1killerlion").hex(), + "stannis.baratheon": md4("Drag0nst0ne").hex(), + "petyer.baelish": md4("@littlefinger@").hex(), + "tywin.lannister": md4("powerkingftw135").hex(), + "maester.pycelle": md4("MaesterOfMaesters").hex(), } results = list(target_win_ntds.ad.users()) diff --git a/tests/plugins/os/windows/credential/test_credhist.py b/tests/plugins/os/windows/credential/test_credhist.py index e2c8a57a78..445f2903d9 100644 --- a/tests/plugins/os/windows/credential/test_credhist.py +++ b/tests/plugins/os/windows/credential/test_credhist.py @@ -69,9 +69,9 @@ def test_credhist_partial(target_win_users: Target, fs_win: VirtualFilesystem) - assert [result.nt for result in results] == [md4("user").hex(), md4("password").hex(), None] -def md4(plaintext: str) -> str: +def md4(plaintext: str) -> bytes: return MD4.new(plaintext.encode("utf-16-le")).digest() -def sha1(plaintext: str) -> str: +def sha1(plaintext: str) -> bytes: return hashlib.sha1(plaintext.encode("utf-16-le")).digest() From d10e0ce2e3eeb92ac5f1a4a8c321a9bc5e97ada8 Mon Sep 17 00:00:00 2001 From: Schamper <1254028+Schamper@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:11:49 +0100 Subject: [PATCH 15/17] Update dissect.database dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f88001236d..dad6776b9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ dev = [ "dissect.clfs[dev]>=1.0.dev,<2.0.dev", "dissect.cramfs[dev]>=1.0.dev,<2.0.dev", "dissect.cstruct>=4.0.dev,<5.0.dev", - "dissect.database[dev]>=1.1.dev3,<2.0.dev", # TODO: update on release! + "dissect.database[dev]>=1.1.dev8,<2.0.dev", # TODO: update on release! "dissect.etl[dev]>=3.0.dev,<4.0.dev", "dissect.eventlog[dev]>=3.0.dev,<4.0.dev", "dissect.evidence[dev]>=3.13.dev2,<4.0.dev", From be071993d2c456fe862bb9e03f5a705a497bd3c6 Mon Sep 17 00:00:00 2001 From: Schamper <1254028+Schamper@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:23:54 +0100 Subject: [PATCH 16/17] Update record name --- dissect/target/plugins/os/windows/ad/ntds.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dissect/target/plugins/os/windows/ad/ntds.py b/dissect/target/plugins/os/windows/ad/ntds.py index a8fc62e485..3891eb7520 100644 --- a/dissect/target/plugins/os/windows/ad/ntds.py +++ b/dissect/target/plugins/os/windows/ad/ntds.py @@ -50,7 +50,7 @@ # Record descriptor for NTDS user secrets NtdsUserRecord = TargetRecordDescriptor( - "windows/credential/ad/user", + "windows/ad/user", [ *GENERIC_FIELDS, ("string", "info"), @@ -60,7 +60,7 @@ ], ) NtdsComputerRecord = TargetRecordDescriptor( - "windows/credential/ad/computer", + "windows/ad/computer", [ *GENERIC_FIELDS, ("string", "dns_hostname"), From 7e65e06934d6f1e75a4a87a38766a66e702e8a4c Mon Sep 17 00:00:00 2001 From: Schamper <1254028+Schamper@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:51:00 +0100 Subject: [PATCH 17/17] Small tweaks --- dissect/target/plugins/os/windows/ad/ntds.py | 17 ++++------------- .../target/plugins/os/windows/credential/lsa.py | 4 +++- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/dissect/target/plugins/os/windows/ad/ntds.py b/dissect/target/plugins/os/windows/ad/ntds.py index 3891eb7520..f0baf4e4b1 100644 --- a/dissect/target/plugins/os/windows/ad/ntds.py +++ b/dissect/target/plugins/os/windows/ad/ntds.py @@ -89,13 +89,7 @@ class NtdsPlugin(Plugin): __namespace__ = "ad" def __init__(self, target: Target): - """Initialize the NTDS plugin. - - Args: - target: The target system to analyze. - """ super().__init__(target) - self.path = None if self.target.has_function("registry"): @@ -103,22 +97,19 @@ def __init__(self, target: Target): self.path = self.target.fs.path(key.value) def check_compatible(self) -> None: - """Check if the plugin can run on the target system. - - Raises: - UnsupportedPluginError: If NTDS.dit is not found or system hive is missing. - """ if not self.target.has_function("lsa"): raise UnsupportedPluginError("System Hive is not present or LSA function not available") - if not self.path or not self.path.exists(): + if self.path is None or not self.path.is_file(): raise UnsupportedPluginError("No NTDS.dit database found on target") @cached_property @internal def ntds(self) -> NTDS: ntds = NTDS(self.path.open("rb")) - ntds.pek.unlock(self.target.lsa.syskey) + + if self.target.has_function("lsa"): + ntds.pek.unlock(self.target.lsa.syskey) return ntds diff --git a/dissect/target/plugins/os/windows/credential/lsa.py b/dissect/target/plugins/os/windows/credential/lsa.py index 51af560d26..d4984d80a1 100644 --- a/dissect/target/plugins/os/windows/credential/lsa.py +++ b/dissect/target/plugins/os/windows/credential/lsa.py @@ -6,7 +6,7 @@ from dissect.target.exceptions import RegistryKeyNotFoundError, UnsupportedPluginError from dissect.target.helpers.record import TargetRecordDescriptor -from dissect.target.plugin import Plugin, export +from dissect.target.plugin import Plugin, export, internal if TYPE_CHECKING: from collections.abc import Iterator @@ -52,6 +52,7 @@ def check_compatible(self) -> None: raise UnsupportedPluginError("Registry key not found: %s", self.SYSTEM_KEY) @cached_property + @internal def syskey(self) -> bytes: """Return byte value of Windows system SYSKEY, also called BootKey.""" lsa = self.target.registry.key(self.SYSTEM_KEY) @@ -63,6 +64,7 @@ def syskey(self) -> bytes: return bytes(r[i] for i in alterator) @cached_property + @internal def lsakey(self) -> bytes: """Decrypt and return the LSA key of the Windows system.""" security_pol = self.target.registry.key(self.SECURITY_POLICY_KEY)