dissect.target 3.16.dev45__py3-none-any.whl → 3.17__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. dissect/target/container.py +1 -0
  2. dissect/target/containers/fortifw.py +190 -0
  3. dissect/target/filesystem.py +192 -67
  4. dissect/target/filesystems/dir.py +14 -1
  5. dissect/target/filesystems/overlay.py +103 -0
  6. dissect/target/helpers/compat/path_common.py +19 -5
  7. dissect/target/helpers/configutil.py +30 -7
  8. dissect/target/helpers/network_managers.py +101 -73
  9. dissect/target/helpers/record_modifier.py +4 -1
  10. dissect/target/loader.py +3 -1
  11. dissect/target/loaders/dir.py +23 -5
  12. dissect/target/loaders/itunes.py +3 -3
  13. dissect/target/loaders/mqtt.py +309 -0
  14. dissect/target/loaders/overlay.py +31 -0
  15. dissect/target/loaders/target.py +12 -9
  16. dissect/target/loaders/vb.py +2 -2
  17. dissect/target/loaders/velociraptor.py +5 -4
  18. dissect/target/plugin.py +1 -1
  19. dissect/target/plugins/apps/browser/brave.py +10 -0
  20. dissect/target/plugins/apps/browser/browser.py +43 -0
  21. dissect/target/plugins/apps/browser/chrome.py +10 -0
  22. dissect/target/plugins/apps/browser/chromium.py +234 -12
  23. dissect/target/plugins/apps/browser/edge.py +10 -0
  24. dissect/target/plugins/apps/browser/firefox.py +512 -19
  25. dissect/target/plugins/apps/browser/iexplore.py +2 -2
  26. dissect/target/plugins/apps/container/docker.py +24 -4
  27. dissect/target/plugins/apps/ssh/openssh.py +4 -0
  28. dissect/target/plugins/apps/ssh/putty.py +45 -14
  29. dissect/target/plugins/apps/ssh/ssh.py +40 -0
  30. dissect/target/plugins/apps/vpn/openvpn.py +115 -93
  31. dissect/target/plugins/child/docker.py +24 -0
  32. dissect/target/plugins/filesystem/ntfs/mft.py +1 -1
  33. dissect/target/plugins/filesystem/walkfs.py +2 -2
  34. dissect/target/plugins/os/unix/bsd/__init__.py +0 -0
  35. dissect/target/plugins/os/unix/esxi/_os.py +2 -2
  36. dissect/target/plugins/os/unix/linux/debian/vyos/_os.py +1 -1
  37. dissect/target/plugins/os/unix/linux/fortios/_os.py +9 -9
  38. dissect/target/plugins/os/unix/linux/services.py +1 -0
  39. dissect/target/plugins/os/unix/linux/sockets.py +2 -2
  40. dissect/target/plugins/os/unix/log/messages.py +53 -8
  41. dissect/target/plugins/os/windows/_os.py +10 -1
  42. dissect/target/plugins/os/windows/catroot.py +178 -63
  43. dissect/target/plugins/os/windows/credhist.py +210 -0
  44. dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
  45. dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
  46. dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
  47. dissect/target/plugins/os/windows/regf/runkeys.py +6 -4
  48. dissect/target/plugins/os/windows/sam.py +10 -1
  49. dissect/target/target.py +1 -1
  50. dissect/target/tools/dump/run.py +23 -28
  51. dissect/target/tools/dump/state.py +11 -8
  52. dissect/target/tools/dump/utils.py +5 -4
  53. dissect/target/tools/query.py +3 -15
  54. dissect/target/tools/shell.py +48 -8
  55. dissect/target/tools/utils.py +23 -0
  56. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/METADATA +7 -3
  57. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/RECORD +62 -55
  58. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/WHEEL +1 -1
  59. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/COPYRIGHT +0 -0
  60. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/LICENSE +0 -0
  61. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/entry_points.txt +0 -0
  62. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,8 @@
1
1
  import re
2
- from itertools import chain
2
+ from pathlib import Path
3
3
  from typing import Iterator
4
4
 
5
+ from dissect.target import Target
5
6
  from dissect.target.exceptions import UnsupportedPluginError
6
7
  from dissect.target.helpers.record import TargetRecordDescriptor
7
8
  from dissect.target.helpers.utils import year_rollover_helper
@@ -23,17 +24,28 @@ RE_TS = re.compile(r"(\w+\s{1,2}\d+\s\d{2}:\d{2}:\d{2})")
23
24
  RE_DAEMON = re.compile(r"^[^:]+:\d+:\d+[^\[\]:]+\s([^\[:]+)[\[|:]{1}")
24
25
  RE_PID = re.compile(r"\w\[(\d+)\]")
25
26
  RE_MSG = re.compile(r"[^:]+:\d+:\d+[^:]+:\s(.*)$")
27
+ RE_CLOUD_INIT_LINE = re.compile(r"(?P<ts>.*) - (?P<daemon>.*)\[(?P<log_level>\w+)\]\: (?P<message>.*)$")
26
28
 
27
29
 
28
30
  class MessagesPlugin(Plugin):
31
+ def __init__(self, target: Target):
32
+ super().__init__(target)
33
+ self.log_files = set(self._find_log_files())
34
+
35
+ def _find_log_files(self) -> Iterator[Path]:
36
+ log_dirs = ["/var/log/", "/var/log/installer/"]
37
+ file_globs = ["syslog*", "messages*", "cloud-init.log*"]
38
+ for log_dir in log_dirs:
39
+ for glob in file_globs:
40
+ yield from self.target.fs.path(log_dir).glob(glob)
41
+
29
42
  def check_compatible(self) -> None:
30
- var_log = self.target.fs.path("/var/log")
31
- if not any(var_log.glob("syslog*")) and not any(var_log.glob("messages*")):
32
- raise UnsupportedPluginError("No message files found")
43
+ if not self.log_files:
44
+ raise UnsupportedPluginError("No log files found")
33
45
 
34
46
  @export(record=MessagesRecord)
35
47
  def syslog(self) -> Iterator[MessagesRecord]:
36
- """Return contents of /var/log/messages* and /var/log/syslog*.
48
+ """Return contents of /var/log/messages*, /var/log/syslog* and cloud-init logs.
37
49
 
38
50
  See ``messages`` for more information.
39
51
  """
@@ -41,7 +53,7 @@ class MessagesPlugin(Plugin):
41
53
 
42
54
  @export(record=MessagesRecord)
43
55
  def messages(self) -> Iterator[MessagesRecord]:
44
- """Return contents of /var/log/messages* and /var/log/syslog*.
56
+ """Return contents of /var/log/messages*, /var/log/syslog* and cloud-init logs.
45
57
 
46
58
  Note: due to year rollover detection, the contents of the files are returned in reverse.
47
59
 
@@ -52,12 +64,16 @@ class MessagesPlugin(Plugin):
52
64
  References:
53
65
  - https://geek-university.com/linux/var-log-messages-file/
54
66
  - https://www.geeksforgeeks.org/file-timestamps-mtime-ctime-and-atime-in-linux/
67
+ - https://cloudinit.readthedocs.io/en/latest/development/logging.html#logging-command-output
55
68
  """
56
69
 
57
70
  tzinfo = self.target.datetime.tzinfo
58
71
 
59
- var_log = self.target.fs.path("/var/log")
60
- for log_file in chain(var_log.glob("syslog*"), var_log.glob("messages*")):
72
+ for log_file in self.log_files:
73
+ if "cloud-init" in log_file.name:
74
+ yield from self._parse_cloud_init_log(log_file)
75
+ continue
76
+
61
77
  for ts, line in year_rollover_helper(log_file, RE_TS, DEFAULT_TS_LOG_FORMAT, tzinfo):
62
78
  daemon = dict(enumerate(RE_DAEMON.findall(line))).get(0)
63
79
  pid = dict(enumerate(RE_PID.findall(line))).get(0)
@@ -71,3 +87,32 @@ class MessagesPlugin(Plugin):
71
87
  source=log_file,
72
88
  _target=self.target,
73
89
  )
90
+
91
+ def _parse_cloud_init_log(self, log_file: Path) -> Iterator[MessagesRecord]:
92
+ """Parse a cloud-init.log file.
93
+
94
+ Lines are structured in the following format:
95
+ ``YYYY-MM-DD HH:MM:SS,000 - dhcp.py[DEBUG]: Received dhcp lease on IFACE for IP/MASK``
96
+
97
+ NOTE: ``cloud-init-output.log`` files are not supported as they do not contain structured logs.
98
+
99
+ Args:
100
+ ``log_file``: path to cloud-init.log file.
101
+
102
+ Returns: ``MessagesRecord``
103
+ """
104
+ for line in log_file.open("rt").readlines():
105
+ if line := line.strip():
106
+ if match := RE_CLOUD_INIT_LINE.match(line):
107
+ match = match.groupdict()
108
+ yield MessagesRecord(
109
+ ts=match["ts"].split(",")[0],
110
+ daemon=match["daemon"],
111
+ pid=None,
112
+ message=match["message"],
113
+ source=log_file,
114
+ _target=self.target,
115
+ )
116
+ else:
117
+ self.target.log.warning("Could not match cloud-init log line")
118
+ self.target.log.debug("No match for line '%s'", line)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import operator
3
4
  import struct
4
5
  from typing import Any, Iterator, Optional
5
6
 
@@ -40,7 +41,7 @@ class WindowsPlugin(OSPlugin):
40
41
 
41
42
  if not sysvol.exists("boot/BCD"):
42
43
  for fs in target.filesystems:
43
- if fs.exists("boot") and fs.exists("boot/BCD"):
44
+ if fs.exists("boot/BCD") or fs.exists("EFI/Microsoft/Boot/BCD"):
44
45
  target.fs.mount("efi", fs)
45
46
 
46
47
  return cls(target)
@@ -77,6 +78,14 @@ class WindowsPlugin(OSPlugin):
77
78
  self.target.log.warning("Failed to map drive letters")
78
79
  self.target.log.debug("", exc_info=e)
79
80
 
81
+ # Fallback mount the sysvol to C: if we didn't manage to mount it to any other drive letter
82
+ if operator.countOf(self.target.fs.mounts.values(), self.target.fs.mounts["sysvol"]) == 1:
83
+ if "c:" not in self.target.fs.mounts:
84
+ self.target.log.debug("Unable to determine drive letter of sysvol, falling back to C:")
85
+ self.target.fs.mount("c:", self.target.fs.mounts["sysvol"])
86
+ else:
87
+ self.target.log.warning("Unknown drive letter for sysvol")
88
+
80
89
  @export(property=True)
81
90
  def hostname(self) -> Optional[str]:
82
91
  key = "HKLM\\SYSTEM\\ControlSet001\\Control\\Computername\\Computername"
@@ -1,26 +1,42 @@
1
- from asn1crypto import algos, core
1
+ from typing import Iterator, Optional
2
+
3
+ from dissect.esedb import EseDB
2
4
  from flow.record.fieldtypes import digest
3
5
 
4
6
  from dissect.target.exceptions import UnsupportedPluginError
5
7
  from dissect.target.helpers.record import TargetRecordDescriptor
6
8
  from dissect.target.plugin import Plugin, export
7
9
 
10
+ try:
11
+ from asn1crypto.cms import ContentInfo
12
+ from asn1crypto.core import Sequence
13
+
14
+ HAS_ASN1 = True
15
+ except ImportError:
16
+ HAS_ASN1 = False
17
+
8
18
  HINT_NEEDLE = b"\x1e\x08\x00H\x00i\x00n\x00t"
9
- MD5_NEEDLE = b"\x06\x08\x2a\x86\x48\x86\xf7\x0d\x02\x05"
10
- SHA1_NEEDLE = b"\x06\x05\x2b\x0e\x03\x02\x1a"
11
- SHA_GENERIC_NEEDLE = b"\x06\x09\x08\x86\x48\x01\x65\x03\x04\x02"
19
+ PACKAGE_NAME_NEEDLE = b"\x06\n+\x06\x01\x04\x01\x827\x0c\x02\x01"
20
+ DIGEST_NEEDLES = {
21
+ "md5": b"\x06\x08\x2a\x86\x48\x86\xf7\x0d\x02\x05",
22
+ "sha1": b"\x06\x05\x2b\x0e\x03\x02\x1a",
23
+ "sha_generic": b"\x06\x09\x08\x86\x48\x01\x65\x03\x04\x02",
24
+ "sha256": b"\x06\t`\x86H\x01e\x03\x04\x02\x01\x05\x00",
25
+ }
26
+
12
27
 
13
28
  CatrootRecord = TargetRecordDescriptor(
14
29
  "windows/catroot",
15
30
  [
16
31
  ("digest", "digest"),
17
- ("path", "hint"),
32
+ ("string[]", "hints"),
33
+ ("string", "catroot_name"),
18
34
  ("path", "source"),
19
35
  ],
20
36
  )
21
37
 
22
38
 
23
- def findall(buf, needle):
39
+ def findall(buf: bytes, needle: bytes) -> Iterator[int]:
24
40
  offset = 0
25
41
  while True:
26
42
  offset = buf.find(needle, offset)
@@ -31,27 +47,59 @@ def findall(buf, needle):
31
47
  offset += 1
32
48
 
33
49
 
50
+ def _get_package_name(sequence: Sequence) -> str:
51
+ """Parse sequences within a sequence and return the 'PackageName' value if it exists."""
52
+ for value in sequence.native.values():
53
+ # Value is an ordered dict that contains a sequence on index 1
54
+ inner_sequence = Sequence.load(value.get("1"))
55
+ # Key value is stored at index 0, value at index 2
56
+ if "PackageName" in inner_sequence[0].native:
57
+ return inner_sequence[2].native.decode("utf-16-le").strip("\x00")
58
+
59
+
60
+ def find_package_name(hint_buf: bytes) -> Optional[str]:
61
+ """Find a sequence that contains the 'PackageName' key and return the value if present."""
62
+ for hint_offset in findall(hint_buf, PACKAGE_NAME_NEEDLE):
63
+ # 7, 6 or 5 bytes before the package_name needle, a sequence starts (starts with b"0\x82" or b"0\x81").
64
+ for sequence_needle in [b"0\x82", b"0\x81"]:
65
+ if (sequence_offset := hint_buf.find(sequence_needle, hint_offset - 8, hint_offset)) == -1:
66
+ continue
67
+
68
+ hint_sequence = Sequence.load(hint_buf[sequence_offset:])
69
+ return _get_package_name(hint_sequence)
70
+
71
+
34
72
  class CatrootPlugin(Plugin):
35
73
  """Catroot plugin.
36
74
 
37
75
  Parses catroot files for hashes and file hints.
38
76
  """
39
77
 
78
+ __namespace__ = "catroot"
79
+
40
80
  def __init__(self, target):
41
81
  super().__init__(target)
42
- self.catrootdir = self.target.fs.path("sysvol/windows/system32/catroot")
82
+ self.catroot_dir = self.target.fs.path("sysvol/windows/system32/catroot")
83
+ self.catroot2_dir = self.target.fs.path("sysvol/windows/system32/catroot2")
43
84
 
44
85
  def check_compatible(self) -> None:
45
- if len(list(self.catrootdir.iterdir())) == 0:
46
- raise UnsupportedPluginError("No catroot dirs found")
86
+ if not HAS_ASN1:
87
+ raise UnsupportedPluginError("Missing asn1crypto dependency")
88
+
89
+ if next(self.catroot2_dir.rglob("catdb"), None) is None and next(self.catroot_dir.rglob("*.cat"), None) is None:
90
+ raise UnsupportedPluginError("No catroot files or catroot ESE databases found")
47
91
 
48
92
  @export(record=CatrootRecord)
49
- def catroot(self):
93
+ def files(self) -> Iterator[CatrootRecord]:
50
94
  """Return the content of the catalog files in the CatRoot folder.
51
95
 
52
96
  A catalog file contains a collection of cryptographic hashes, or thumbprints. These files are generally used to
53
97
  verify the integrity of Windows operating system files, instead of per-file authenticode signatures.
54
98
 
99
+ At the moment, parsing catalog files is done on best effort. ``asn1crypto`` is not able to fully parse the
100
+ ``encap_content_info``, highly likely because Microsoft uses its own format. Future research should result in
101
+ a more resilient and complete implementation of the ``catroot.files`` plugin.
102
+
55
103
  References:
56
104
  - https://www.thewindowsclub.com/catroot-catroot2-folder-reset-windows
57
105
  - https://docs.microsoft.com/en-us/windows-hardware/drivers/install/catalog-files
@@ -60,67 +108,134 @@ class CatrootPlugin(Plugin):
60
108
  hostname (string): The target hostname.
61
109
  domain (string): The target domain.
62
110
  digest (digest): The parsed digest.
63
- hint (path): File hint, if present.
64
- source (path): Source catroot file.
111
+ hints (string[]): File hints, if present.
112
+ catroot_name (string): Catroot name.
113
+ source (path): Source of the catroot record.
65
114
  """
66
- # So asn1crypt dies when parsing these files, so we kinda bruteforce it
67
- # Look for the object identifiers of various hash types, and parse from there
68
- # We don't do any further checking, just traverse according to a known structure
69
- # If an exception occurs, we're not looking at the right structure.
70
- for d in self.catrootdir.iterdir():
71
- if not d.is_dir():
115
+ # As far as known, Microsoft uses its own implementation to store the digest in the
116
+ # encap_content_info along with an optional file hint. Here we parse the digest values
117
+ # ourselves by looking for the corresponding digest needles in the raw encap_content_info
118
+ # data. Furthermore, we try to find the file hint if it is present in that same raw data.
119
+ for file in self.catroot_dir.rglob("*.cat"):
120
+ if not file.is_file():
72
121
  continue
73
122
 
74
- for f in d.iterdir():
75
- buf = f.open().read()
123
+ try:
124
+ buf = file.read_bytes()
125
+
126
+ # TODO: Parse other data in the content info
127
+ content_info = ContentInfo.load(buf)["content"]
128
+
129
+ digest_type = content_info["digest_algorithms"].native[0].get("algorithm")
130
+ encap_contents = content_info["encap_content_info"].contents
131
+ needle = DIGEST_NEEDLES[digest_type]
132
+
133
+ digests = []
134
+ offset = None
135
+ for offset in findall(encap_contents, needle):
136
+ # 4 bytes before the digest type, a sequence starts
137
+ objseq = Sequence.load(encap_contents[offset - 4 :])
138
+ # The second entry in the sequence is the digest string
139
+ raw_digest = objseq[1].native
140
+ hexdigest = raw_digest.hex()
141
+
142
+ file_digest = digest()
143
+ if len(hexdigest) == 32:
144
+ file_digest.md5 = hexdigest
145
+ elif len(hexdigest) == 40:
146
+ file_digest.sha1 = hexdigest
147
+ elif len(hexdigest) == 64:
148
+ file_digest.sha256 = hexdigest
149
+
150
+ digests.append(file_digest)
151
+
152
+ # Finding the hint in encap_content_info is on best effort. In most of the cases,
153
+ # there is a key "PackageName" available. We first try to parse the corresponding
154
+ # value if it is present. If this does not succeed, we might be dealing with catroot
155
+ # files containing the "hint" needle.
156
+ # If both methods do not result in a file hint, there is either no hint available or
157
+ # the format is not yet known and therefore not supported.
158
+ hints = []
159
+ try:
160
+ if offset:
161
+ # As far as known, the PackageName data is only present in the encap_content_info
162
+ # after the last digest.
163
+ hint_buf = encap_contents[offset + len(needle) + len(raw_digest) + 2 :]
164
+
165
+ # First try to find to find the "PackageName" value, if it's present.
166
+ hint = find_package_name(hint_buf)
167
+ if hint:
168
+ hints.append(hint)
169
+
170
+ # If the package_name needle is not found or it's not present in the first 7 bytes of the hint_buf
171
+ # We are probably dealing with a catroot file that contains "hint" needles.
172
+ if not hints:
173
+ for hint_offset in findall(encap_contents, HINT_NEEDLE):
174
+ # Either 3 or 4 bytes before the needle, a sequence starts
175
+ bytes_before_needle = 3 if encap_contents[hint_offset - 3] == 48 else 4
176
+ name_sequence = Sequence.load(encap_contents[hint_offset - bytes_before_needle :])
177
+
178
+ hint = name_sequence[2].native.decode("utf-16-le").strip("\x00")
179
+ hints.append(hint)
180
+
181
+ except Exception as e:
182
+ self.target.log.debug("", exc_info=e)
183
+
184
+ # Currently, it is not known how the file hints are related to the digests. Therefore, each digest
185
+ # is yielded as a record with all of the file hints found.
186
+ # TODO: find the correlation between the file hints and the digests in catroot files.
187
+ for file_digest in digests:
188
+ yield CatrootRecord(
189
+ digest=file_digest,
190
+ hints=hints,
191
+ catroot_name=file.name,
192
+ source=file,
193
+ _target=self.target,
194
+ )
195
+
196
+ except Exception as error:
197
+ self.target.log.error("An error occurred while parsing the catroot file %s: %s", file, error)
76
198
 
77
- for needle in [MD5_NEEDLE, SHA1_NEEDLE, SHA_GENERIC_NEEDLE]:
78
- # There's an identifier early on in the file that specifies the hash type for this file
79
- offset = buf.find(needle, 0, 100)
80
- if offset == -1:
81
- continue
199
+ @export(record=CatrootRecord)
200
+ def catdb(self) -> Iterator[CatrootRecord]:
201
+ """Return the hash values present in the catdb files in the catroot2 folder.
202
+
203
+ The catdb file is an ESE database file that contains the digests of the catalog files present on the system.
204
+ This database is used to speed up the process of validating a Portable Executable (PE) file.
205
+
206
+ Note: catalog files can include file hints, however these seem not to be present in the catdb files.
207
+
208
+ References:
209
+ - https://www.thewindowsclub.com/catroot-catroot2-folder-reset-windows
210
+ - https://docs.microsoft.com/en-us/windows-hardware/drivers/install/catalog-files
211
+
212
+ Yields CatrootRecords with the following fields:
213
+ hostname (string): The target hostname.
214
+ domain (string): The target domain.
215
+ digest (digest): The parsed digest.
216
+ hints (string[]): File hints, if present.
217
+ catroot_name (string): Catroot name.
218
+ source (path): Source of the catroot record.
219
+ """
220
+ for ese_file in self.catroot2_dir.rglob("catdb"):
221
+ with ese_file.open("rb") as fh:
222
+ ese_db = EseDB(fh)
82
223
 
83
- try:
84
- # Sanity check
85
- algos.DigestAlgorithmId.load(buf[offset:])
86
- except TypeError:
224
+ tables = [table.name for table in ese_db.tables()]
225
+ for hash_type, table_name in [("sha256", "HashCatNameTableSHA256"), ("sha1", "HashCatNameTableSHA1")]:
226
+ if table_name not in tables:
87
227
  continue
88
228
 
89
- for offset in findall(buf, needle):
90
- try:
91
- digestid = algos.DigestAlgorithmId.load(buf[offset:])
92
- # 4 bytes before the digest type, a sequence starts
93
- objseq = core.Sequence.load(buf[offset - 4 :])
94
- # The second entry in the sequence is the digest string
95
- hexdigest = objseq[1].native.hex()
96
-
97
- # Later versions of windows also have a file hint
98
- # Try to find it
99
- digestlen = len(digestid.contents)
100
- hintoffset = buf.find(HINT_NEEDLE, offset + digestlen, offset + digestlen + 64)
101
-
102
- filehint = None
103
- if hintoffset != -1:
104
- try:
105
- file_buf = buf[hintoffset + len(HINT_NEEDLE) + 6 :]
106
- # There's an INTEGER after the Hint BMPString of size 6
107
- filehint = core.OctetString.load(file_buf).native.decode("utf-16-le")
108
- except Exception:
109
- pass
110
-
111
- fdigest = digest()
112
- if len(hexdigest) == 32:
113
- fdigest.md5 = hexdigest
114
- elif len(hexdigest) == 40:
115
- fdigest.sha1 = hexdigest
116
- elif len(hexdigest) == 64:
117
- fdigest.sha256 = hexdigest
229
+ for record in ese_db.table(table_name).records():
230
+ file_digest = digest()
231
+ setattr(file_digest, hash_type, record.get("HashCatNameTable_HashCol").hex())
232
+ catroot_names = record.get("HashCatNameTable_CatNameCol").decode().rstrip("|").split("|")
118
233
 
234
+ for catroot_name in catroot_names:
119
235
  yield CatrootRecord(
120
- digest=fdigest,
121
- hint=self.target.fs.path(filehint) if filehint else None,
122
- source=f,
236
+ digest=file_digest,
237
+ hints=None,
238
+ catroot_name=catroot_name,
239
+ source=ese_file,
123
240
  _target=self.target,
124
241
  )
125
- except Exception:
126
- continue
@@ -0,0 +1,210 @@
1
+ import hashlib
2
+ import logging
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import BinaryIO, Iterator, Optional
6
+ from uuid import UUID
7
+
8
+ from dissect.cstruct import cstruct
9
+ from dissect.util.sid import read_sid
10
+
11
+ from dissect.target.exceptions import UnsupportedPluginError
12
+ from dissect.target.helpers import keychain
13
+ from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension
14
+ from dissect.target.helpers.record import create_extended_descriptor
15
+ from dissect.target.plugin import Plugin, export
16
+ from dissect.target.plugins.general.users import UserDetails
17
+ from dissect.target.plugins.os.windows.dpapi.crypto import (
18
+ CipherAlgorithm,
19
+ HashAlgorithm,
20
+ derive_password_hash,
21
+ )
22
+ from dissect.target.target import Target
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ CredHistRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
28
+ "windows/credential/history",
29
+ [
30
+ ("string", "guid"),
31
+ ("boolean", "decrypted"),
32
+ ("string", "sha1"),
33
+ ("string", "nt"),
34
+ ],
35
+ )
36
+
37
+
38
+ credhist_def = """
39
+ struct entry {
40
+ DWORD dwVersion;
41
+ CHAR guidLink[16];
42
+ DWORD dwNextLinkSize;
43
+ DWORD dwCredLinkType;
44
+ DWORD algHash; // ALG_ID
45
+ DWORD dwPbkdf2IterationCount;
46
+ DWORD dwSidSize;
47
+ DWORD algCrypt; // ALG_ID
48
+ DWORD dwShaHashSize;
49
+ DWORD dwNtHashSize;
50
+ CHAR pSalt[16];
51
+ CHAR pSid[dwSidSize];
52
+ CHAR encrypted[0];
53
+ };
54
+ """
55
+
56
+ c_credhist = cstruct()
57
+ c_credhist.load(credhist_def)
58
+
59
+
60
+ @dataclass
61
+ class CredHistEntry:
62
+ version: int
63
+ guid: str
64
+ user_sid: str
65
+ sha1: Optional[bytes]
66
+ nt: Optional[bytes]
67
+ hash_alg: HashAlgorithm = field(repr=False)
68
+ cipher_alg: CipherAlgorithm = field(repr=False)
69
+ raw: c_credhist.entry = field(repr=False)
70
+ decrypted: bool = False
71
+
72
+ def decrypt(self, password_hash: bytes) -> None:
73
+ """Decrypt this CREDHIST entry using the provided password hash. Modifies ``CredHistEntry.sha1``
74
+ and ``CredHistEntry.nt`` values.
75
+
76
+ If the decrypted ``nt`` value is 16 bytes we assume the decryption was successful.
77
+
78
+ Args:
79
+ password_hash: Bytes of SHA1 password hash digest.
80
+
81
+ Raises:
82
+ ValueError: If the decryption seems to have failed.
83
+ """
84
+ data = self.cipher_alg.decrypt_with_hmac(
85
+ data=self.raw.encrypted,
86
+ key=derive_password_hash(password_hash, self.user_sid),
87
+ iv=self.raw.pSalt,
88
+ hash_algorithm=self.hash_alg,
89
+ rounds=self.raw.dwPbkdf2IterationCount,
90
+ )
91
+
92
+ sha_size = self.raw.dwShaHashSize
93
+ nt_size = self.raw.dwNtHashSize
94
+
95
+ sha1 = data[:sha_size]
96
+ nt = data[sha_size : sha_size + nt_size].rstrip(b"\x00")
97
+
98
+ if len(nt) != 16:
99
+ raise ValueError("Decrypting failed, invalid password hash?")
100
+
101
+ self.decrypted = True
102
+ self.sha1 = sha1
103
+ self.nt = nt
104
+
105
+
106
+ class CredHistFile:
107
+ def __init__(self, fh: BinaryIO) -> None:
108
+ self.fh = fh
109
+ self.entries = list(self._parse())
110
+
111
+ def __repr__(self) -> str:
112
+ return f"<CredHistFile fh='{self.fh}' entries={len(self.entries)}>"
113
+
114
+ def _parse(self) -> Iterator[CredHistEntry]:
115
+ self.fh.seek(0)
116
+ try:
117
+ while True:
118
+ entry = c_credhist.entry(self.fh)
119
+
120
+ # determine size of encrypted data and add to entry
121
+ cipher_alg = CipherAlgorithm.from_id(entry.algCrypt)
122
+ enc_size = entry.dwShaHashSize + entry.dwNtHashSize
123
+ enc_size += enc_size % cipher_alg.block_length
124
+ entry.encrypted = self.fh.read(enc_size)
125
+
126
+ yield CredHistEntry(
127
+ version=entry.dwVersion,
128
+ guid=UUID(bytes_le=entry.guidLink),
129
+ user_sid=read_sid(entry.pSid),
130
+ hash_alg=HashAlgorithm.from_id(entry.algHash),
131
+ cipher_alg=cipher_alg,
132
+ sha1=None,
133
+ nt=None,
134
+ raw=entry,
135
+ )
136
+ except EOFError:
137
+ pass
138
+
139
+ def decrypt(self, password_hash: bytes) -> None:
140
+ """Decrypt a CREDHIST chain using the provided password SHA1 hash."""
141
+
142
+ for entry in reversed(self.entries):
143
+ try:
144
+ entry.decrypt(password_hash)
145
+ except ValueError as e:
146
+ log.warning("Could not decrypt entry %s with password %s", entry.guid, password_hash.hex())
147
+ log.debug("", exc_info=e)
148
+ continue
149
+ password_hash = entry.sha1
150
+
151
+
152
+ class CredHistPlugin(Plugin):
153
+ """Windows CREDHIST file parser.
154
+
155
+ Windows XP: ``C:\\Documents and Settings\\username\\Application Data\\Microsoft\\Protect\\CREDHIST``
156
+ Windows 7 and up: ``C:\\Users\\username\\AppData\\Roaming\\Microsoft\\Protect\\CREDHIST``
157
+
158
+ Resources:
159
+ - https://www.passcape.com/index.php?section=docsys&cmd=details&id=28#41
160
+ """
161
+
162
+ def __init__(self, target: Target):
163
+ super().__init__(target)
164
+ self.files = list(self._find_files())
165
+
166
+ def _find_files(self) -> Iterator[tuple[UserDetails, Path]]:
167
+ hashes = set()
168
+ for user_details in self.target.user_details.all_with_home():
169
+ for path in ["AppData/Roaming/Microsoft/Protect/CREDHIST", "Application Data/Microsoft/Protect/CREDHIST"]:
170
+ credhist_path = user_details.home_path.joinpath(path)
171
+ if credhist_path.exists() and (hash := credhist_path.get().hash()) not in hashes:
172
+ hashes.add(hash)
173
+ yield user_details.user, credhist_path
174
+
175
+ def check_compatible(self) -> None:
176
+ if not self.files:
177
+ raise UnsupportedPluginError("No CREDHIST files found on target.")
178
+
179
+ @export(record=CredHistRecord)
180
+ def credhist(self) -> Iterator[CredHistRecord]:
181
+ """Yield and decrypt all Windows CREDHIST entries on the target."""
182
+ passwords = keychain_passwords()
183
+
184
+ if not passwords:
185
+ self.target.log.warning("No passwords provided in keychain, cannot decrypt CREDHIST hashes")
186
+
187
+ for user, path in self.files:
188
+ credhist = CredHistFile(path.open("rb"))
189
+
190
+ for password in passwords:
191
+ credhist.decrypt(hashlib.sha1(password.encode("utf-16-le")).digest())
192
+
193
+ for entry in credhist.entries:
194
+ yield CredHistRecord(
195
+ guid=entry.guid,
196
+ decrypted=entry.decrypted,
197
+ sha1=entry.sha1.hex() if entry.sha1 else None,
198
+ nt=entry.nt.hex() if entry.nt else None,
199
+ _user=user,
200
+ _target=self.target,
201
+ )
202
+
203
+
204
+ def keychain_passwords() -> set:
205
+ passphrases = set()
206
+ for key in keychain.get_keys_for_provider("user") + keychain.get_keys_without_provider():
207
+ if key.key_type == keychain.KeyType.PASSPHRASE:
208
+ passphrases.add(key.value)
209
+ passphrases.add("")
210
+ return passphrases