Skip to content

Commit

Permalink
Add Windows credhist plugin (#566)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Erik Schamper <[email protected]>
  • Loading branch information
JSCU-CNI and Schamper authored Apr 19, 2024
1 parent c22059e commit 34c4aeb
Show file tree
Hide file tree
Showing 3 changed files with 283 additions and 0 deletions.
210 changes: 210 additions & 0 deletions dissect/target/plugins/os/windows/credhist.py
Original file line number Diff line number Diff line change
@@ -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"<CredHistFile fh='{self.fh}' entries={len(self.entries)}>"

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
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/windows/credhist/CREDHIST
Git LFS file not shown
70 changes: 70 additions & 0 deletions tests/plugins/os/windows/test_credhist.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 34c4aeb

Please sign in to comment.