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.
- dissect/target/container.py +1 -0
- dissect/target/containers/fortifw.py +190 -0
- dissect/target/filesystem.py +192 -67
- dissect/target/filesystems/dir.py +14 -1
- dissect/target/filesystems/overlay.py +103 -0
- dissect/target/helpers/compat/path_common.py +19 -5
- dissect/target/helpers/configutil.py +30 -7
- dissect/target/helpers/network_managers.py +101 -73
- dissect/target/helpers/record_modifier.py +4 -1
- dissect/target/loader.py +3 -1
- dissect/target/loaders/dir.py +23 -5
- dissect/target/loaders/itunes.py +3 -3
- dissect/target/loaders/mqtt.py +309 -0
- dissect/target/loaders/overlay.py +31 -0
- dissect/target/loaders/target.py +12 -9
- dissect/target/loaders/vb.py +2 -2
- dissect/target/loaders/velociraptor.py +5 -4
- dissect/target/plugin.py +1 -1
- dissect/target/plugins/apps/browser/brave.py +10 -0
- dissect/target/plugins/apps/browser/browser.py +43 -0
- dissect/target/plugins/apps/browser/chrome.py +10 -0
- dissect/target/plugins/apps/browser/chromium.py +234 -12
- dissect/target/plugins/apps/browser/edge.py +10 -0
- dissect/target/plugins/apps/browser/firefox.py +512 -19
- dissect/target/plugins/apps/browser/iexplore.py +2 -2
- dissect/target/plugins/apps/container/docker.py +24 -4
- dissect/target/plugins/apps/ssh/openssh.py +4 -0
- dissect/target/plugins/apps/ssh/putty.py +45 -14
- dissect/target/plugins/apps/ssh/ssh.py +40 -0
- dissect/target/plugins/apps/vpn/openvpn.py +115 -93
- dissect/target/plugins/child/docker.py +24 -0
- dissect/target/plugins/filesystem/ntfs/mft.py +1 -1
- dissect/target/plugins/filesystem/walkfs.py +2 -2
- dissect/target/plugins/general/users.py +6 -0
- dissect/target/plugins/os/unix/bsd/__init__.py +0 -0
- dissect/target/plugins/os/unix/esxi/_os.py +2 -2
- dissect/target/plugins/os/unix/linux/debian/vyos/_os.py +1 -1
- dissect/target/plugins/os/unix/linux/fortios/_os.py +9 -9
- dissect/target/plugins/os/unix/linux/services.py +1 -0
- dissect/target/plugins/os/unix/linux/sockets.py +2 -2
- dissect/target/plugins/os/unix/log/messages.py +53 -8
- dissect/target/plugins/os/windows/_os.py +10 -1
- dissect/target/plugins/os/windows/catroot.py +178 -63
- dissect/target/plugins/os/windows/credhist.py +210 -0
- dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
- dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
- dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
- dissect/target/plugins/os/windows/regf/runkeys.py +6 -4
- dissect/target/plugins/os/windows/sam.py +10 -1
- dissect/target/target.py +1 -1
- dissect/target/tools/dump/run.py +23 -28
- dissect/target/tools/dump/state.py +11 -8
- dissect/target/tools/dump/utils.py +5 -4
- dissect/target/tools/query.py +3 -15
- dissect/target/tools/shell.py +48 -8
- dissect/target/tools/utils.py +23 -0
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/METADATA +7 -3
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/RECORD +63 -56
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/WHEEL +1 -1
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/COPYRIGHT +0 -0
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/LICENSE +0 -0
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/entry_points.txt +0 -0
- {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
|
-
("
|
20
|
+
("uint16", "local_port"),
|
21
21
|
("string", "remote_ip"),
|
22
|
-
("
|
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
|
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
|
-
|
31
|
-
|
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
|
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
|
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
|
-
|
60
|
-
|
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")
|
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
|
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
|
-
|
10
|
-
|
11
|
-
|
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
|
-
("
|
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.
|
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
|
46
|
-
raise UnsupportedPluginError("
|
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
|
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
|
-
|
64
|
-
|
111
|
+
hints (string[]): File hints, if present.
|
112
|
+
catroot_name (string): Catroot name.
|
113
|
+
source (path): Source of the catroot record.
|
65
114
|
"""
|
66
|
-
#
|
67
|
-
#
|
68
|
-
#
|
69
|
-
#
|
70
|
-
for
|
71
|
-
if not
|
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
|
-
|
75
|
-
buf =
|
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
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
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
|
-
|
84
|
-
|
85
|
-
|
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
|
90
|
-
|
91
|
-
|
92
|
-
|
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=
|
121
|
-
|
122
|
-
|
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
|