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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) 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/general/users.py +6 -0
  35. dissect/target/plugins/os/unix/bsd/__init__.py +0 -0
  36. dissect/target/plugins/os/unix/esxi/_os.py +2 -2
  37. dissect/target/plugins/os/unix/linux/debian/vyos/_os.py +1 -1
  38. dissect/target/plugins/os/unix/linux/fortios/_os.py +9 -9
  39. dissect/target/plugins/os/unix/linux/services.py +1 -0
  40. dissect/target/plugins/os/unix/linux/sockets.py +2 -2
  41. dissect/target/plugins/os/unix/log/messages.py +53 -8
  42. dissect/target/plugins/os/windows/_os.py +10 -1
  43. dissect/target/plugins/os/windows/catroot.py +178 -63
  44. dissect/target/plugins/os/windows/credhist.py +210 -0
  45. dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
  46. dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
  47. dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
  48. dissect/target/plugins/os/windows/regf/runkeys.py +6 -4
  49. dissect/target/plugins/os/windows/sam.py +10 -1
  50. dissect/target/target.py +1 -1
  51. dissect/target/tools/dump/run.py +23 -28
  52. dissect/target/tools/dump/state.py +11 -8
  53. dissect/target/tools/dump/utils.py +5 -4
  54. dissect/target/tools/query.py +3 -15
  55. dissect/target/tools/shell.py +48 -8
  56. dissect/target/tools/utils.py +23 -0
  57. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/METADATA +7 -3
  58. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/RECORD +63 -56
  59. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/WHEEL +1 -1
  60. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/COPYRIGHT +0 -0
  61. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/LICENSE +0 -0
  62. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/entry_points.txt +0 -0
  63. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/top_level.txt +0 -0
@@ -17,9 +17,9 @@ NetSocketRecord = TargetRecordDescriptor(
17
17
  ("uint32", "rx_queue"),
18
18
  ("uint32", "tx_queue"),
19
19
  ("string", "local_ip"),
20
- ("string", "local_port"),
20
+ ("uint16", "local_port"),
21
21
  ("string", "remote_ip"),
22
- ("string", "remote_port"),
22
+ ("uint16", "remote_port"),
23
23
  ("string", "state"),
24
24
  ("string", "owner"),
25
25
  ("uint32", "inode"),
@@ -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