diff --git a/dissect/target/plugins/os/windows/credhist.py b/dissect/target/plugins/os/windows/credhist.py new file mode 100644 index 000000000..875033bb8 --- /dev/null +++ b/dissect/target/plugins/os/windows/credhist.py @@ -0,0 +1,210 @@ +import hashlib +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import BinaryIO, Iterator, Optional +from uuid import UUID + +from dissect.cstruct import cstruct +from dissect.util.sid import read_sid + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers import keychain +from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension +from dissect.target.helpers.record import create_extended_descriptor +from dissect.target.plugin import Plugin, export +from dissect.target.plugins.general.users import UserDetails +from dissect.target.plugins.os.windows.dpapi.crypto import ( + CipherAlgorithm, + HashAlgorithm, + derive_password_hash, +) +from dissect.target.target import Target + +log = logging.getLogger(__name__) + + +CredHistRecord = create_extended_descriptor([UserRecordDescriptorExtension])( + "windows/credential/history", + [ + ("string", "guid"), + ("boolean", "decrypted"), + ("string", "sha1"), + ("string", "nt"), + ], +) + + +credhist_def = """ +struct entry { + DWORD dwVersion; + CHAR guidLink[16]; + DWORD dwNextLinkSize; + DWORD dwCredLinkType; + DWORD algHash; // ALG_ID + DWORD dwPbkdf2IterationCount; + DWORD dwSidSize; + DWORD algCrypt; // ALG_ID + DWORD dwShaHashSize; + DWORD dwNtHashSize; + CHAR pSalt[16]; + CHAR pSid[dwSidSize]; + CHAR encrypted[0]; +}; +""" + +c_credhist = cstruct() +c_credhist.load(credhist_def) + + +@dataclass +class CredHistEntry: + version: int + guid: str + user_sid: str + sha1: Optional[bytes] + nt: Optional[bytes] + hash_alg: HashAlgorithm = field(repr=False) + cipher_alg: CipherAlgorithm = field(repr=False) + raw: c_credhist.entry = field(repr=False) + decrypted: bool = False + + def decrypt(self, password_hash: bytes) -> None: + """Decrypt this CREDHIST entry using the provided password hash. Modifies ``CredHistEntry.sha1`` + and ``CredHistEntry.nt`` values. + + If the decrypted ``nt`` value is 16 bytes we assume the decryption was successful. + + Args: + password_hash: Bytes of SHA1 password hash digest. + + Raises: + ValueError: If the decryption seems to have failed. + """ + data = self.cipher_alg.decrypt_with_hmac( + data=self.raw.encrypted, + key=derive_password_hash(password_hash, self.user_sid), + iv=self.raw.pSalt, + hash_algorithm=self.hash_alg, + rounds=self.raw.dwPbkdf2IterationCount, + ) + + sha_size = self.raw.dwShaHashSize + nt_size = self.raw.dwNtHashSize + + sha1 = data[:sha_size] + nt = data[sha_size : sha_size + nt_size].rstrip(b"\x00") + + if len(nt) != 16: + raise ValueError("Decrypting failed, invalid password hash?") + + self.decrypted = True + self.sha1 = sha1 + self.nt = nt + + +class CredHistFile: + def __init__(self, fh: BinaryIO) -> None: + self.fh = fh + self.entries = list(self._parse()) + + def __repr__(self) -> str: + return f"" + + def _parse(self) -> Iterator[CredHistEntry]: + self.fh.seek(0) + try: + while True: + entry = c_credhist.entry(self.fh) + + # determine size of encrypted data and add to entry + cipher_alg = CipherAlgorithm.from_id(entry.algCrypt) + enc_size = entry.dwShaHashSize + entry.dwNtHashSize + enc_size += enc_size % cipher_alg.block_length + entry.encrypted = self.fh.read(enc_size) + + yield CredHistEntry( + version=entry.dwVersion, + guid=UUID(bytes_le=entry.guidLink), + user_sid=read_sid(entry.pSid), + hash_alg=HashAlgorithm.from_id(entry.algHash), + cipher_alg=cipher_alg, + sha1=None, + nt=None, + raw=entry, + ) + except EOFError: + pass + + def decrypt(self, password_hash: bytes) -> None: + """Decrypt a CREDHIST chain using the provided password SHA1 hash.""" + + for entry in reversed(self.entries): + try: + entry.decrypt(password_hash) + except ValueError as e: + log.warning("Could not decrypt entry %s with password %s", entry.guid, password_hash.hex()) + log.debug("", exc_info=e) + continue + password_hash = entry.sha1 + + +class CredHistPlugin(Plugin): + """Windows CREDHIST file parser. + + Windows XP: ``C:\\Documents and Settings\\username\\Application Data\\Microsoft\\Protect\\CREDHIST`` + Windows 7 and up: ``C:\\Users\\username\\AppData\\Roaming\\Microsoft\\Protect\\CREDHIST`` + + Resources: + - https://www.passcape.com/index.php?section=docsys&cmd=details&id=28#41 + """ + + def __init__(self, target: Target): + super().__init__(target) + self.files = list(self._find_files()) + + def _find_files(self) -> Iterator[tuple[UserDetails, Path]]: + hashes = set() + for user_details in self.target.user_details.all_with_home(): + for path in ["AppData/Roaming/Microsoft/Protect/CREDHIST", "Application Data/Microsoft/Protect/CREDHIST"]: + credhist_path = user_details.home_path.joinpath(path) + if credhist_path.exists() and (hash := credhist_path.get().hash()) not in hashes: + hashes.add(hash) + yield user_details.user, credhist_path + + def check_compatible(self) -> None: + if not self.files: + raise UnsupportedPluginError("No CREDHIST files found on target.") + + @export(record=CredHistRecord) + def credhist(self) -> Iterator[CredHistRecord]: + """Yield and decrypt all Windows CREDHIST entries on the target.""" + passwords = keychain_passwords() + + if not passwords: + self.target.log.warning("No passwords provided in keychain, cannot decrypt CREDHIST hashes") + + for user, path in self.files: + credhist = CredHistFile(path.open("rb")) + + for password in passwords: + credhist.decrypt(hashlib.sha1(password.encode("utf-16-le")).digest()) + + for entry in credhist.entries: + yield CredHistRecord( + guid=entry.guid, + decrypted=entry.decrypted, + sha1=entry.sha1.hex() if entry.sha1 else None, + nt=entry.nt.hex() if entry.nt else None, + _user=user, + _target=self.target, + ) + + +def keychain_passwords() -> set: + passphrases = set() + for key in keychain.get_keys_for_provider("user") + keychain.get_keys_without_provider(): + if key.key_type == keychain.KeyType.PASSPHRASE: + passphrases.add(key.value) + passphrases.add("") + return passphrases diff --git a/tests/_data/plugins/os/windows/credhist/CREDHIST b/tests/_data/plugins/os/windows/credhist/CREDHIST new file mode 100644 index 000000000..db0f7ce42 --- /dev/null +++ b/tests/_data/plugins/os/windows/credhist/CREDHIST @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:192be75a92909b26bcc1070aad32e85f5b6799a8eec9edb46b61564e10a783d4 +size 456 diff --git a/tests/plugins/os/windows/test_credhist.py b/tests/plugins/os/windows/test_credhist.py new file mode 100644 index 000000000..30d8c063a --- /dev/null +++ b/tests/plugins/os/windows/test_credhist.py @@ -0,0 +1,70 @@ +import hashlib + +from Crypto.Hash import MD4 + +from dissect.target import Target +from dissect.target.filesystem import VirtualFilesystem +from dissect.target.helpers import keychain +from dissect.target.plugins.os.windows.credhist import CredHistFile, CredHistPlugin +from tests._utils import absolute_path + + +def test_credhist() -> None: + """The provided CREDHIST file has the following password history: ``user -> password -> password3``. + The current password of the user is ``password4``. + """ + with open(absolute_path("_data/plugins/os/windows/credhist/CREDHIST"), "rb") as fh: + ch = CredHistFile(fh) + + assert len(ch.entries) == 3 + + for entry in ch.entries: + assert ch.entries[0].version == 1 + assert entry.user_sid.upper() == "S-1-5-21-1342509979-482553916-3960431919-1000" + + ch.decrypt(password_hash=sha1("password4")) + + assert str(ch.entries[0].guid) == "99ec7176-d16c-41bd-9c94-d3a4c5b94232" + assert ch.entries[0].sha1 == sha1("user") + assert ch.entries[0].nt == md4("user") + + assert str(ch.entries[1].guid) == "120a3a30-309c-4fda-bfb8-06f44ea93cb2" + assert ch.entries[1].sha1 == sha1("password") + assert ch.entries[1].nt == md4("password") + + assert str(ch.entries[2].guid) == "5657891f-28dd-4f69-baba-95e44bcd178a" + assert ch.entries[2].sha1 == sha1("password3") + assert ch.entries[2].nt == md4("password3") + + +def test_credhist_partial(target_win_users: Target, fs_win: VirtualFilesystem) -> None: + """Test if we can get a partially decrypted CREDHIST chain if we know an intermediate password. + + The latest entry is encrypted with 'password4' but we provide 'password3'. The plugin + should decrypt every entry except the latest entry. + """ + fs_win.map_file( + "Users/John/AppData/Roaming/Microsoft/Protect/CREDHIST", + absolute_path("_data/plugins/os/windows/credhist/CREDHIST"), + ) + target_win_users.add_plugin(CredHistPlugin) + + keychain.KEYCHAIN.clear() + keychain.register_key( + key_type=keychain.KeyType.PASSPHRASE, + value="password3", + identifier=None, + provider="user", + ) + + results = list(target_win_users.credhist()) + assert len(results) == 3 + assert [result.nt for result in results] == [md4("user").hex(), md4("password").hex(), None] + + +def md4(plaintext: str) -> str: + return MD4.new(plaintext.encode("utf-16-le")).digest() + + +def sha1(plaintext: str) -> str: + return hashlib.sha1(plaintext.encode("utf-16-le")).digest()