diff --git a/examples/regsecrets.py b/examples/regsecrets.py index 5bab7b2f7..c254a87c3 100755 --- a/examples/regsecrets.py +++ b/examples/regsecrets.py @@ -123,7 +123,7 @@ def dump(self): if not self.__nosam: try: - self.__SAMHashes = SAMHashes(bootKey, remoteOps=self.__remoteOps, throttle=self.__throttle) + self.__SAMHashes = SAMHashes(bootKey, remoteOps=self.__remoteOps, history=self.__history, throttle=self.__throttle) self.__SAMHashes.dump() if self.__outputFileName is not None: self.__SAMHashes.export(self.__outputFileName) diff --git a/examples/secretsdump.py b/examples/secretsdump.py index 168384eb1..00698d969 100755 --- a/examples/secretsdump.py +++ b/examples/secretsdump.py @@ -278,7 +278,7 @@ def dump(self): SAMFileName = self.__remoteOps.saveSAM() else: SAMFileName = self.__samHive - self.__SAMHashes = SAMHashes(SAMFileName, bootKey, isRemote=self.__isRemote, printUserStatus=self.__printUserStatus) + self.__SAMHashes = SAMHashes(SAMFileName, bootKey, isRemote=self.__isRemote,history=self.__history, printUserStatus=self.__printUserStatus) self.__SAMHashes.dump() if self.__outputFileName is not None: self.__SAMHashes.export(self.__outputFileName) diff --git a/impacket/examples/regsecrets.py b/impacket/examples/regsecrets.py index 5866a3a88..17dfdbed6 100644 --- a/impacket/examples/regsecrets.py +++ b/impacket/examples/regsecrets.py @@ -19,7 +19,7 @@ from impacket.krb5 import constants from impacket.krb5.crypto import string_to_key -from impacket.examples.secretsdump import CryptoCommon, _print_helper, DOMAIN_ACCOUNT_F, SAM_KEY_DATA, SAM_KEY_DATA_AES, ARC4, DES, USER_ACCOUNT_V, SAM_HASH, SAM_HASH_AES, LSA_SECRET_XP, HMAC, MD4, MD5, LSA_SECRET, LSA_SECRET_BLOB, NL_RECORD, DPAPI_SYSTEM, NTDSHashes +from impacket.examples.secretsdump import CryptoCommon, _print_helper, DOMAIN_ACCOUNT_F, SAM_KEY_DATA, SAM_KEY_DATA_AES, ARC4, DES, AES, USER_ACCOUNT_V, SAM_HASH, SAM_HASH_AES, LSA_SECRET_XP, HMAC, MD4, MD5, LSA_SECRET, LSA_SECRET_BLOB, NL_RECORD, DPAPI_SYSTEM, NTDSHashes from binascii import unhexlify, hexlify # Helper to create files for exporting @@ -345,13 +345,15 @@ def __parse_lp_data(self, valueType, valueData): pass class SAMHashes(): - def __init__(self, bootKey, perSecretCallback = lambda secret: _print_helper(secret), remoteOps:RemoteOperations=None, throttle=0): + def __init__(self, bootKey, perSecretCallback = lambda secret: _print_helper(secret), remoteOps:RemoteOperations=None, history=False, throttle=0): self.__remoteOps = remoteOps self.__hashedBootKey = b'' self.__bootKey = bootKey self.__cryptoCommon = CryptoCommon() self.__itemsFound = {} self.__perSecretCallback = perSecretCallback + self.__history = history + self.__historyItems = [] self.__throttle = throttle def MD5(self, data): @@ -407,12 +409,120 @@ def __decryptHash(self, rid, cryptedHash, constant, newStyle = False): return decryptedHash + def __unwrap_history_block(self, rid_int, block16): + key1, key2 = self.__cryptoCommon.deriveKey(rid_int) + crypt1 = DES.new(key1, DES.MODE_ECB) + crypt2 = DES.new(key2, DES.MODE_ECB) + return crypt1.decrypt(block16[:8]) + crypt2.decrypt(block16[8:16]) + + def __decrypt_history_entries_aes(self, rid_int, entries): + out = [] + key = self.__hashedBootKey[:0x10] + for salt, enc in entries: + if not enc or (len(enc) % 16) != 0: + continue + cipher = AES.new(key, AES.MODE_CBC, iv=salt) + plain = cipher.decrypt(enc) + for off in range(0, len(plain), 16): + block = plain[off:off + 16] + if len(block) < 16: + break + out.append(self.__unwrap_history_block(rid_int, block)) + return out + + def __scan_v_for_aes_entries(self, vdata): + entries = [] + length = len(vdata) + offset = 0x100 if length > 0x200 else 0 + while offset + 20 <= length: + salt = vdata[offset:offset + 16] + data_len = int.from_bytes(vdata[offset + 16:offset + 20], 'little', signed=False) + if data_len == 0 or data_len > 0x2000: + offset += 4 + continue + data_off = offset + 20 + if data_off + data_len > length or (data_len % 16) != 0: + offset += 4 + continue + enc = vdata[data_off:data_off + data_len] + entries.append((salt, enc)) + offset = data_off + data_len + if offset % 4: + offset += (4 - (offset % 4)) + return entries + + def __decode_aes_history_block(self, rid_int, data, offset, length): + if offset <= 0 or length <= 0: + return [] + end = offset + length + if end > len(data): + return [] + + blob = data[offset:end] + if len(blob) < 24: + return [] + + try: + record = SAM_HASH_AES(blob) + except Exception: + LOG.debug('Failed to parse SAM_HASH_AES history block at 0x%x (len=%d)', offset, length, exc_info=True) + return [] + + enc = record['Hash'] + if not enc: + return [] + + enc_len = len(enc) - (len(enc) % 16) + if enc_len <= 0: + return [] + + enc = enc[:enc_len] + return self.__decrypt_history_entries_aes(rid_int, [(record['Salt'], enc)]) + + def __extract_local_history(self, rid_int, new_style, user_account): + lm_const = b"LMPASSWORDHISTORY\0" + nt_const = b"NTPASSWORDHISTORY\0" + + result = {'lm': [], 'nt': []} + meta = user_account['Unknown15'] + data = user_account['Data'] + + lm_offset = int.from_bytes(meta[0:4], 'little', signed=False) + lm_length = int.from_bytes(meta[4:8], 'little', signed=False) + nt_offset = int.from_bytes(meta[12:16], 'little', signed=False) + nt_length = int.from_bytes(meta[16:20], 'little', signed=False) + + if not new_style: + LOG.debug('Skipping old-style history for RID %d; RC4/DES path disabled', rid_int) + return result + + lm_entries = self.__decode_aes_history_block(rid_int, data, lm_offset, lm_length) + nt_entries = self.__decode_aes_history_block(rid_int, data, nt_offset, nt_length) + + if not lm_entries and lm_length: + blob = data[lm_offset:lm_offset + lm_length] + lm_entries = self.__decrypt_history_entries_aes(rid_int, self.__scan_v_for_aes_entries(blob)) + if not nt_entries and nt_length: + blob = data[nt_offset:nt_offset + nt_length] + nt_entries = self.__decrypt_history_entries_aes(rid_int, self.__scan_v_for_aes_entries(blob)) + + result['lm'] = nt_entries + result['nt'] = lm_entries + + if not result['lm'] and not result['nt'] and lm_length == 0 and nt_length == 0: + fallback_entries = self.__scan_v_for_aes_entries(data) + if fallback_entries: + result['nt'] = self.__decrypt_history_entries_aes(rid_int, fallback_entries) + + return result + def dump(self): NTPASSWORD = b"NTPASSWORD\0" LMPASSWORD = b"LMPASSWORD\0" LOG.info('Dumping local SAM hashes (uid:rid:lmhash:nthash)') self.getHBootKey() + self.__historyItems = [] usersKey = r'SAM\SAM\Domains\Account\Users' @@ -424,6 +534,9 @@ def dump(self): except: pass + empty_lm_hex = hexlify(ntlm.LMOWFv1('', '')).decode('utf-8') + empty_nt_hex = hexlify(ntlm.NTOWFv1('', '')).decode('utf-8') + for rid in rids: userAccount = USER_ACCOUNT_V(self.__remoteOps.retrieveSubKey(ntpath.join(usersKey, rid), 'V', throttle=self.__throttle)[1]) rid = int(rid, 16) @@ -471,6 +584,30 @@ def dump(self): self.__itemsFound[rid] = answer self.__perSecretCallback(answer) + if self.__history: + try: + history = self.__extract_local_history(rid, newStyle, userAccount) + lm_hist = history.get('lm', []) + nt_hist = history.get('nt', []) + while lm_hist and nt_hist and lm_hist[-1] == nt_hist[-1]: + lm_hist.pop() + nt_hist.pop() + LOG.debug('History lengths for %s (RID %d): lm=%d nt=%d', userName, rid, + len(lm_hist), len(nt_hist)) + count = max(len(lm_hist), len(nt_hist)) + for idx in range(count): + lm_val = lm_hist[idx] if idx < len(lm_hist) else b'' + nt_val = nt_hist[idx] if idx < len(nt_hist) else b'' + lm_hex = hexlify(lm_val).decode('utf-8') if lm_val else empty_lm_hex + nt_hex = hexlify(nt_val).decode('utf-8') if nt_val else empty_nt_hex + if lm_hex == empty_lm_hex and nt_hex == empty_nt_hex: + continue + history_line = f"{userName}_history{idx}:{rid}:{lm_hex}:{nt_hex}:::" + self.__historyItems.append(history_line) + self.__perSecretCallback(history_line) + except Exception as exc: + LOG.debug('SAM history parsing failed for RID %d: %s', rid, exc, exc_info=True) + def export(self, baseFileName, openFileFunc = None): if len(self.__itemsFound) > 0: items = sorted(self.__itemsFound) @@ -478,6 +615,8 @@ def export(self, baseFileName, openFileFunc = None): fd = openFile(fileName, openFileFunc=openFileFunc) for item in items: fd.write(self.__itemsFound[item] + '\n') + for line in self.__historyItems: + fd.write(line + '\n') fd.close() return fileName diff --git a/impacket/examples/secretsdump.py b/impacket/examples/secretsdump.py index 2939dd640..1433d0c27 100644 --- a/impacket/examples/secretsdump.py +++ b/impacket/examples/secretsdump.py @@ -1420,7 +1420,7 @@ def finish(self): self.__registryHive.close() class SAMHashes(OfflineRegistry): - def __init__(self, samFile, bootKey, isRemote = False, printUserStatus=False, perSecretCallback = lambda secret: _print_helper(secret)): + def __init__(self, samFile, bootKey, isRemote=False, history=False, printUserStatus=False, perSecretCallback=lambda secret: _print_helper(secret)): OfflineRegistry.__init__(self, samFile, isRemote) self.__samFile = samFile self.__hashedBootKey = b'' @@ -1429,6 +1429,8 @@ def __init__(self, samFile, bootKey, isRemote = False, printUserStatus=False, pe self.__cryptoCommon = CryptoCommon() self.__itemsFound = {} self.__perSecretCallback = perSecretCallback + self.__history = history + self.__historyItems = [] def binary_to_sid(self, binary_data, without_prefix=False): if len(binary_data) < 12: @@ -1532,6 +1534,115 @@ def __encryptHash(self, rid, plaintextHash, salt, constant, newStyle = False): encryptedHash = self.__cryptoCommon.encryptAES(self.__hashedBootKey[:0x10], key, salt) return encryptedHash + + def __unwrap_history_block(self, rid_int, block16): + key1, key2 = self.__cryptoCommon.deriveKey(rid_int) + crypt1 = DES.new(key1, DES.MODE_ECB) + crypt2 = DES.new(key2, DES.MODE_ECB) + return crypt1.decrypt(block16[:8]) + crypt2.decrypt(block16[8:16]) + + def __decrypt_history_entries_aes(self, rid_int, entries): + out = [] + key = self.__hashedBootKey[:0x10] + for salt, enc in entries: + if not enc or (len(enc) % 16) != 0: + continue + cipher = AES.new(key, AES.MODE_CBC, iv=salt) + plain = cipher.decrypt(enc) + for off in range(0, len(plain), 16): + block = plain[off:off + 16] + if len(block) < 16: + break + out.append(self.__unwrap_history_block(rid_int, block)) + return out + + def __scan_v_for_aes_entries(self, vdata): + entries = [] + length = len(vdata) + offset = 0x100 if length > 0x200 else 0 + while offset + 20 <= length: + salt = vdata[offset:offset + 16] + data_len = int.from_bytes(vdata[offset + 16:offset + 20], 'little', signed=False) + if data_len == 0 or data_len > 0x2000: + offset += 4 + continue + data_off = offset + 20 + if data_off + data_len > length or (data_len % 16) != 0: + offset += 4 + continue + enc = vdata[data_off:data_off + data_len] + entries.append((salt, enc)) + offset = data_off + data_len + if offset % 4: + offset += (4 - (offset % 4)) + return entries + + def __decode_aes_history_block(self, rid_int, data, offset, length): + if offset <= 0 or length <= 0: + return [] + end = offset + length + if end > len(data): + return [] + + blob = data[offset:end] + if len(blob) < 24: + return [] + + try: + record = SAM_HASH_AES(blob) + except Exception: + LOG.debug('Failed to parse SAM_HASH_AES history block at 0x%x (len=%d)', offset, length, exc_info=True) + return [] + + enc = record['Hash'] + if not enc: + return [] + + enc_len = len(enc) - (len(enc) % 16) + if enc_len <= 0: + return [] + + enc = enc[:enc_len] + return self.__decrypt_history_entries_aes(rid_int, [(record['Salt'], enc)]) + + def __extract_local_history(self, rid_int, new_style, user_account): + lm_const = b"LMPASSWORDHISTORY\0" + nt_const = b"NTPASSWORDHISTORY\0" + + result = {'lm': [], 'nt': []} + meta = user_account['Unknown15'] + data = user_account['Data'] + + lm_offset = int.from_bytes(meta[0:4], 'little', signed=False) + lm_length = int.from_bytes(meta[4:8], 'little', signed=False) + nt_offset = int.from_bytes(meta[12:16], 'little', signed=False) + nt_length = int.from_bytes(meta[16:20], 'little', signed=False) + + if not new_style: + LOG.debug('Skipping old-style history for RID %d; RC4/DES path disabled', rid_int) + return result + + lm_entries = self.__decode_aes_history_block(rid_int, data, lm_offset, lm_length) + nt_entries = self.__decode_aes_history_block(rid_int, data, nt_offset, nt_length) + + if not lm_entries and lm_length: + blob = data[lm_offset:lm_offset + lm_length] + lm_entries = self.__decrypt_history_entries_aes(rid_int, self.__scan_v_for_aes_entries(blob)) + if not nt_entries and nt_length: + blob = data[nt_offset:nt_offset + nt_length] + nt_entries = self.__decrypt_history_entries_aes(rid_int, self.__scan_v_for_aes_entries(blob)) + + # Windows stores NT history in the first slot and LM history in the second when + # AES ("new style") protection is used, so swap before returning. + result['lm'] = nt_entries + result['nt'] = lm_entries + + if not result['lm'] and not result['nt'] and lm_length == 0 and nt_length == 0: + fallback_entries = self.__scan_v_for_aes_entries(data) + if fallback_entries: + result['nt'] = self.__decrypt_history_entries_aes(rid_int, fallback_entries) + + return result def __replaceValue(self, obj, offset, value): obj = bytearray(obj) @@ -1549,6 +1660,7 @@ def dump(self): LOG.info('Dumping local SAM hashes (uid:rid:lmhash:nthash)') self.getHBootKey() + self.__historyItems = [] usersKey = 'SAM\\Domains\\Account\\Users' @@ -1615,6 +1727,9 @@ def dump(self): if member.strip() ] + empty_lm_hex = hexlify(ntlm.LMOWFv1('', '')).decode('utf-8') + empty_nt_hex = hexlify(ntlm.NTOWFv1('', '')).decode('utf-8') + for rid in rids: disabled = locked_out = auto_locked = is_admin = False @@ -1638,7 +1753,8 @@ def dump(self): auto_locked = bool(grouped_data & 0x0400) locked_out = locked - userAccount = USER_ACCOUNT_V(self.getValue(ntpath.join(usersKey, rid, 'V'))[1]) + raw_v = self.getValue(ntpath.join(usersKey, rid, 'V'))[1] + userAccount = USER_ACCOUNT_V(raw_v) rid = int(rid, 16) V = userAccount['Data'] @@ -1686,6 +1802,30 @@ def dump(self): self.__itemsFound[rid] = answer self.__perSecretCallback(answer) + + if self.__history: + try: + history = self.__extract_local_history(rid, newStyle, userAccount) + lm_hist = history.get('lm', []) + nt_hist = history.get('nt', []) + while lm_hist and nt_hist and lm_hist[-1] == nt_hist[-1]: + lm_hist.pop() + nt_hist.pop() + LOG.debug('History lengths for %s (RID %d): lm=%d nt=%d', userName, rid, + len(lm_hist), len(nt_hist)) + count = max(len(lm_hist), len(nt_hist)) + for idx in range(count): + lm_val = lm_hist[idx] if idx < len(lm_hist) else b'' + nt_val = nt_hist[idx] if idx < len(nt_hist) else b'' + lm_hex = hexlify(lm_val).decode('utf-8') if lm_val else empty_lm_hex + nt_hex = hexlify(nt_val).decode('utf-8') if nt_val else empty_nt_hex + if lm_hex == empty_lm_hex and nt_hex == empty_nt_hex: + continue + history_line = f"{userName}_history{idx}:{rid}:{lm_hex}:{nt_hex}:::" + self.__historyItems.append(history_line) + self.__perSecretCallback(history_line) + except Exception as exc: + LOG.debug('SAM history parsing failed for RID %d: %s', rid, exc, exc_info=True) def edit(self, user, newNTHash, newLMHash=b''): NTPASSWORD = b"NTPASSWORD\0" @@ -1814,6 +1954,8 @@ def export(self, baseFileName, openFileFunc = None): fd = openFile(fileName, openFileFunc=openFileFunc) for item in items: fd.write(self.__itemsFound[item]+'\n') + for line in self.__historyItems: + fd.write(line+'\n') fd.close() return fileName