dissect.target 3.17.dev31__py3-none-any.whl → 3.17.dev33__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/filesystems/dir.py +14 -1
- dissect/target/filesystems/overlay.py +103 -0
- dissect/target/helpers/compat/path_common.py +19 -5
- dissect/target/loader.py +1 -0
- dissect/target/loaders/itunes.py +3 -3
- dissect/target/loaders/overlay.py +31 -0
- 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 +440 -19
- dissect/target/plugins/apps/browser/iexplore.py +1 -1
- dissect/target/plugins/apps/container/docker.py +24 -4
- dissect/target/plugins/apps/ssh/putty.py +10 -1
- dissect/target/plugins/child/docker.py +24 -0
- dissect/target/plugins/os/unix/linux/fortios/_os.py +6 -6
- dissect/target/plugins/os/windows/catroot.py +11 -2
- 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/sam.py +10 -1
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/METADATA +1 -1
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/RECORD +29 -26
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/COPYRIGHT +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/LICENSE +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/WHEEL +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/entry_points.txt +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,4 @@
|
|
1
|
+
import base64
|
1
2
|
import itertools
|
2
3
|
import json
|
3
4
|
from collections import defaultdict
|
@@ -12,17 +13,28 @@ from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError
|
|
12
13
|
from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension
|
13
14
|
from dissect.target.helpers.fsutil import TargetPath, join
|
14
15
|
from dissect.target.helpers.record import create_extended_descriptor
|
15
|
-
from dissect.target.plugin import export
|
16
|
+
from dissect.target.plugin import OperatingSystem, export
|
16
17
|
from dissect.target.plugins.apps.browser.browser import (
|
17
18
|
GENERIC_COOKIE_FIELDS,
|
18
19
|
GENERIC_DOWNLOAD_RECORD_FIELDS,
|
19
20
|
GENERIC_EXTENSION_RECORD_FIELDS,
|
20
21
|
GENERIC_HISTORY_RECORD_FIELDS,
|
22
|
+
GENERIC_PASSWORD_RECORD_FIELDS,
|
21
23
|
BrowserPlugin,
|
22
24
|
try_idna,
|
23
25
|
)
|
24
26
|
from dissect.target.plugins.general.users import UserDetails
|
25
27
|
|
28
|
+
try:
|
29
|
+
from Crypto.Cipher import AES
|
30
|
+
from Crypto.Protocol.KDF import PBKDF2
|
31
|
+
|
32
|
+
HAS_CRYPTO = True
|
33
|
+
|
34
|
+
except ImportError:
|
35
|
+
HAS_CRYPTO = False
|
36
|
+
|
37
|
+
|
26
38
|
CHROMIUM_DOWNLOAD_RECORD_FIELDS = [
|
27
39
|
("uri", "tab_url"),
|
28
40
|
("uri", "tab_referrer_url"),
|
@@ -51,6 +63,10 @@ class ChromiumMixin:
|
|
51
63
|
"browser/chromium/extension", GENERIC_EXTENSION_RECORD_FIELDS
|
52
64
|
)
|
53
65
|
|
66
|
+
BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
|
67
|
+
"browser/chromium/password", GENERIC_PASSWORD_RECORD_FIELDS
|
68
|
+
)
|
69
|
+
|
54
70
|
def _build_userdirs(self, hist_paths: list[str]) -> list[tuple[UserDetails, TargetPath]]:
|
55
71
|
"""Join the selected browser dirs with the user home path.
|
56
72
|
|
@@ -65,12 +81,14 @@ class ChromiumMixin:
|
|
65
81
|
for d in hist_paths:
|
66
82
|
cur_dir: TargetPath = user_details.home_path.joinpath(d)
|
67
83
|
cur_dir = cur_dir.resolve()
|
68
|
-
if not cur_dir.exists() or (user_details
|
84
|
+
if not cur_dir.exists() or (user_details, cur_dir) in users_dirs:
|
69
85
|
continue
|
70
|
-
users_dirs.append((user_details
|
86
|
+
users_dirs.append((user_details, cur_dir))
|
71
87
|
return users_dirs
|
72
88
|
|
73
|
-
def _iter_db(
|
89
|
+
def _iter_db(
|
90
|
+
self, filename: str, subdirs: Optional[list[str]] = None
|
91
|
+
) -> Iterator[tuple[UserDetails, TargetPath, SQLite3]]:
|
74
92
|
"""Generate a connection to a sqlite database file.
|
75
93
|
|
76
94
|
Args:
|
@@ -98,9 +116,9 @@ class ChromiumMixin:
|
|
98
116
|
except SQLError as e:
|
99
117
|
self.target.log.warning("Could not open %s file: %s", filename, db_file, exc_info=e)
|
100
118
|
|
101
|
-
def _iter_json(self, filename: str) -> Iterator[tuple[
|
119
|
+
def _iter_json(self, filename: str) -> Iterator[tuple[UserDetails, TargetPath, dict]]:
|
102
120
|
"""Iterate over all JSON files in the user directories, yielding a tuple
|
103
|
-
of
|
121
|
+
of username, JSON file path, and the parsed JSON data.
|
104
122
|
|
105
123
|
Args:
|
106
124
|
filename (str): The name of the JSON file to search for in each
|
@@ -120,7 +138,7 @@ class ChromiumMixin:
|
|
120
138
|
self.target.log.warning("Could not find %s file: %s", filename, json_file)
|
121
139
|
|
122
140
|
def check_compatible(self) -> None:
|
123
|
-
if not
|
141
|
+
if not self._build_userdirs(self.DIRS):
|
124
142
|
raise UnsupportedPluginError("No Chromium-based browser directories found")
|
125
143
|
|
126
144
|
def history(self, browser_name: Optional[str] = None) -> Iterator[BrowserHistoryRecord]:
|
@@ -179,7 +197,7 @@ class ChromiumMixin:
|
|
179
197
|
from_url=try_idna(from_url.url) if from_url else None,
|
180
198
|
source=db_file,
|
181
199
|
_target=self.target,
|
182
|
-
_user=user,
|
200
|
+
_user=user.user,
|
183
201
|
)
|
184
202
|
except SQLError as e:
|
185
203
|
self.target.log.warning("Error processing history file: %s", db_file, exc_info=e)
|
@@ -205,14 +223,48 @@ class ChromiumMixin:
|
|
205
223
|
same_site (bool): Cookie same site flag.
|
206
224
|
"""
|
207
225
|
for user, db_file, db in self._iter_db("Cookies", subdirs=["Network"]):
|
226
|
+
decrypted_key = None
|
227
|
+
|
228
|
+
if self.target.os == OperatingSystem.WINDOWS.value:
|
229
|
+
try:
|
230
|
+
local_state_parent = db_file.parent.parent
|
231
|
+
if db_file.parent.name == "Network":
|
232
|
+
local_state_parent = local_state_parent.parent
|
233
|
+
local_state_path = local_state_parent.joinpath("Local State")
|
234
|
+
|
235
|
+
decrypted_key = self._get_local_state_key(local_state_path, user.user.name)
|
236
|
+
except ValueError:
|
237
|
+
self.target.log.warning("Failed to decrypt local state key")
|
238
|
+
|
208
239
|
try:
|
209
240
|
for cookie in db.table("cookies").rows():
|
241
|
+
cookie_value = cookie.value
|
242
|
+
|
243
|
+
if (
|
244
|
+
not cookie_value
|
245
|
+
and decrypted_key
|
246
|
+
and (enc_value := cookie.get("encrypted_value"))
|
247
|
+
and enc_value.startswith(b"v10")
|
248
|
+
):
|
249
|
+
try:
|
250
|
+
if self.target.os == OperatingSystem.LINUX.value:
|
251
|
+
cookie_value = decrypt_v10(enc_value)
|
252
|
+
elif self.target.os == OperatingSystem.WINDOWS.value:
|
253
|
+
cookie_value = decrypt_v10_2(enc_value, decrypted_key)
|
254
|
+
except (ValueError, UnicodeDecodeError):
|
255
|
+
pass
|
256
|
+
|
257
|
+
if not cookie_value:
|
258
|
+
self.target.log.warning(
|
259
|
+
"Failed to decrypt cookie value for %s %s", cookie.host_key, cookie.name
|
260
|
+
)
|
261
|
+
|
210
262
|
yield self.BrowserCookieRecord(
|
211
263
|
ts_created=webkittimestamp(cookie.creation_utc),
|
212
264
|
ts_last_accessed=webkittimestamp(cookie.last_access_utc),
|
213
265
|
browser=browser_name,
|
214
266
|
name=cookie.name,
|
215
|
-
value=
|
267
|
+
value=cookie_value,
|
216
268
|
host=cookie.host_key,
|
217
269
|
path=cookie.path,
|
218
270
|
expiry=int(cookie.has_expires),
|
@@ -220,7 +272,7 @@ class ChromiumMixin:
|
|
220
272
|
is_http_only=bool(cookie.is_httponly),
|
221
273
|
same_site=bool(cookie.samesite),
|
222
274
|
source=db_file,
|
223
|
-
_user=user,
|
275
|
+
_user=user.user,
|
224
276
|
)
|
225
277
|
except SQLError as e:
|
226
278
|
self.target.log.warning("Error processing cookie file: %s", db_file, exc_info=e)
|
@@ -280,7 +332,7 @@ class ChromiumMixin:
|
|
280
332
|
state=row.get("state"),
|
281
333
|
source=db_file,
|
282
334
|
_target=self.target,
|
283
|
-
_user=user,
|
335
|
+
_user=user.user,
|
284
336
|
)
|
285
337
|
except SQLError as e:
|
286
338
|
self.target.log.warning("Error processing history file: %s", db_file, exc_info=e)
|
@@ -366,11 +418,135 @@ class ChromiumMixin:
|
|
366
418
|
manifest_version=manifest_version,
|
367
419
|
source=json_file,
|
368
420
|
_target=self.target,
|
369
|
-
_user=user,
|
421
|
+
_user=user.user,
|
370
422
|
)
|
371
423
|
except (AttributeError, KeyError) as e:
|
372
424
|
self.target.log.info("No browser extensions found in: %s", json_file, exc_info=e)
|
373
425
|
|
426
|
+
def _get_local_state_key(self, local_state_path: TargetPath, username: str) -> Optional[bytes]:
|
427
|
+
"""Get the Chromium ``os_crypt`` ``encrypted_key`` and decrypt it using DPAPI."""
|
428
|
+
|
429
|
+
if not local_state_path.exists():
|
430
|
+
self.target.log.warning("File %s does not exist.", local_state_path)
|
431
|
+
return None
|
432
|
+
|
433
|
+
try:
|
434
|
+
local_state_conf = json.loads(local_state_path.read_text())
|
435
|
+
except json.JSONDecodeError:
|
436
|
+
self.target.log.warning("File %s does not contain valid JSON.", local_state_path)
|
437
|
+
return None
|
438
|
+
|
439
|
+
if "os_crypt" not in local_state_conf:
|
440
|
+
self.target.log.warning(
|
441
|
+
"File %s does not contain os_crypt, Chrome is likely older than v80.", local_state_path
|
442
|
+
)
|
443
|
+
return None
|
444
|
+
|
445
|
+
encrypted_key = base64.b64decode(local_state_conf["os_crypt"]["encrypted_key"])[5:]
|
446
|
+
decrypted_key = self.target.dpapi.decrypt_user_blob(encrypted_key, username)
|
447
|
+
return decrypted_key
|
448
|
+
|
449
|
+
def passwords(self, browser_name: str = None) -> Iterator[BrowserPasswordRecord]:
|
450
|
+
"""Return browser password records from Chromium browsers.
|
451
|
+
|
452
|
+
Chromium on Linux has ``basic``, ``gnome`` and ``kwallet`` methods for password storage:
|
453
|
+
- ``basic`` ciphertext prefixed with ``v10`` and encrypted with hard coded parameters.
|
454
|
+
- ``gnome`` and ``kwallet`` ciphertext prefixed with ``v11`` which is not implemented (yet).
|
455
|
+
|
456
|
+
Chromium on Windows uses DPAPI user encryption.
|
457
|
+
|
458
|
+
The SHA1 hash of the user's password or the plaintext password is required to decrypt passwords
|
459
|
+
when dealing with encrypted passwords created with Chromium v80 (February 2020) and newer.
|
460
|
+
|
461
|
+
You can supply a SHA1 hash or plaintext password using the keychain.
|
462
|
+
|
463
|
+
Resources:
|
464
|
+
- https://chromium.googlesource.com/chromium/src/+/master/docs/linux/password_storage.md
|
465
|
+
- https://chromium.googlesource.com/chromium/src/+/master/components/os_crypt/sync/os_crypt_linux.cc#40
|
466
|
+
"""
|
467
|
+
|
468
|
+
for user, db_file, db in self._iter_db("Login Data"):
|
469
|
+
decrypted_key = None
|
470
|
+
|
471
|
+
if self.target.os == OperatingSystem.WINDOWS.value:
|
472
|
+
try:
|
473
|
+
local_state_path = db_file.parent.parent.joinpath("Local State")
|
474
|
+
decrypted_key = self._get_local_state_key(local_state_path, user.user.name)
|
475
|
+
except ValueError:
|
476
|
+
self.target.log.warning("Failed to decrypt local state key")
|
477
|
+
|
478
|
+
for row in db.table("logins").rows():
|
479
|
+
encrypted_password: bytes = row.password_value
|
480
|
+
decrypted_password = None
|
481
|
+
|
482
|
+
# 1. Windows DPAPI encrypted password. Chrome > 80
|
483
|
+
# For passwords saved after Chromium v80, we have to use DPAPI to decrypt the AES key
|
484
|
+
# stored by Chromium to encrypt and decrypt passwords.
|
485
|
+
if self.target.os == OperatingSystem.WINDOWS.value and encrypted_password.startswith(b"v10"):
|
486
|
+
if not decrypted_key:
|
487
|
+
self.target.log.warning("Cannot decrypt password, no decrypted_key could be calculated")
|
488
|
+
|
489
|
+
else:
|
490
|
+
try:
|
491
|
+
decrypted_password = decrypt_v10_2(encrypted_password, decrypted_key)
|
492
|
+
except Exception as e:
|
493
|
+
self.target.log.warning("Failed to decrypt AES Chromium password")
|
494
|
+
self.target.log.debug("", exc_info=e)
|
495
|
+
|
496
|
+
# 2. Windows DPAPI encrypted password. Chrome < 80
|
497
|
+
# For passwords saved before Chromium v80, we use DPAPI directly for each entry.
|
498
|
+
elif self.target.os == OperatingSystem.WINDOWS.value and encrypted_password.startswith(
|
499
|
+
b"\x01\x00\x00\x00"
|
500
|
+
):
|
501
|
+
try:
|
502
|
+
decrypted_password = self.target.dpapi.decrypt_blob(encrypted_password)
|
503
|
+
except ValueError as e:
|
504
|
+
self.target.log.warning("Failed to decrypt DPAPI Chromium password")
|
505
|
+
self.target.log.debug("", exc_info=e)
|
506
|
+
except UnsupportedPluginError as e:
|
507
|
+
self.target.log.warning("Target is missing required registry keys for DPAPI")
|
508
|
+
self.target.log.debug("", exc_info=e)
|
509
|
+
|
510
|
+
# 3. Linux 'basic' v10 encrypted password.
|
511
|
+
elif self.target.os != OperatingSystem.WINDOWS.value and encrypted_password.startswith(b"v10"):
|
512
|
+
try:
|
513
|
+
decrypted_password = decrypt_v10(encrypted_password)
|
514
|
+
except Exception as e:
|
515
|
+
self.target.log.warning("Failed to decrypt AES Chromium password")
|
516
|
+
self.target.log.debug("", exc_info=e)
|
517
|
+
|
518
|
+
# 4. Linux 'gnome' or 'kwallet' encrypted password.
|
519
|
+
elif self.target.os != OperatingSystem.WINDOWS.value and encrypted_password.startswith(b"v11"):
|
520
|
+
self.target.log.warning(
|
521
|
+
"Unable to decrypt %s password in '%s': unsupported format", browser_name, db_file
|
522
|
+
)
|
523
|
+
|
524
|
+
# 5. Unsupported.
|
525
|
+
else:
|
526
|
+
prefix = encrypted_password[:10]
|
527
|
+
self.target.log.warning(
|
528
|
+
"Unsupported %s encrypted password found in '%s' with prefix '%s'",
|
529
|
+
browser_name,
|
530
|
+
db_file,
|
531
|
+
prefix,
|
532
|
+
)
|
533
|
+
|
534
|
+
yield self.BrowserPasswordRecord(
|
535
|
+
ts_created=webkittimestamp(row.date_created),
|
536
|
+
ts_last_used=webkittimestamp(row.date_last_used),
|
537
|
+
ts_last_changed=webkittimestamp(row.date_password_modified or 0),
|
538
|
+
browser=browser_name,
|
539
|
+
id=row.id,
|
540
|
+
url=row.origin_url,
|
541
|
+
encrypted_username=None,
|
542
|
+
encrypted_password=base64.b64encode(row.password_value),
|
543
|
+
decrypted_username=row.username_value,
|
544
|
+
decrypted_password=decrypted_password,
|
545
|
+
source=db_file,
|
546
|
+
_target=self.target,
|
547
|
+
_user=user.user,
|
548
|
+
)
|
549
|
+
|
374
550
|
|
375
551
|
class ChromiumPlugin(ChromiumMixin, BrowserPlugin):
|
376
552
|
"""Chromium browser plugin."""
|
@@ -405,3 +581,49 @@ class ChromiumPlugin(ChromiumMixin, BrowserPlugin):
|
|
405
581
|
def extensions(self) -> Iterator[ChromiumMixin.BrowserExtensionRecord]:
|
406
582
|
"""Return browser extension records for Chromium browser."""
|
407
583
|
yield from super().extensions("chromium")
|
584
|
+
|
585
|
+
@export(record=ChromiumMixin.BrowserPasswordRecord)
|
586
|
+
def passwords(self) -> Iterator[ChromiumMixin.BrowserPasswordRecord]:
|
587
|
+
"""Return browser password records for Chromium browser."""
|
588
|
+
yield from super().passwords("chromium")
|
589
|
+
|
590
|
+
|
591
|
+
def remove_padding(decrypted: bytes) -> bytes:
|
592
|
+
number_of_padding_bytes = decrypted[-1]
|
593
|
+
return decrypted[:-number_of_padding_bytes]
|
594
|
+
|
595
|
+
|
596
|
+
def decrypt_v10(encrypted_password: bytes) -> str:
|
597
|
+
if not HAS_CRYPTO:
|
598
|
+
raise ValueError("Missing pycryptodome dependency for AES operation")
|
599
|
+
|
600
|
+
encrypted_password = encrypted_password[3:]
|
601
|
+
|
602
|
+
salt = b"saltysalt"
|
603
|
+
iv = b" " * 16
|
604
|
+
pbkdf_password = "peanuts"
|
605
|
+
|
606
|
+
key = PBKDF2(pbkdf_password, salt, 16, 1)
|
607
|
+
cipher = AES.new(key, AES.MODE_CBC, IV=iv)
|
608
|
+
|
609
|
+
decrypted = cipher.decrypt(encrypted_password)
|
610
|
+
return remove_padding(decrypted).decode()
|
611
|
+
|
612
|
+
|
613
|
+
def decrypt_v10_2(encrypted_password: bytes, key: bytes) -> str:
|
614
|
+
"""
|
615
|
+
struct chrome_pass {
|
616
|
+
byte signature[3] = 'v10';
|
617
|
+
byte iv[12];
|
618
|
+
byte ciphertext[EOF];
|
619
|
+
}
|
620
|
+
"""
|
621
|
+
|
622
|
+
if not HAS_CRYPTO:
|
623
|
+
raise ValueError("Missing pycryptodome dependency for AES operation")
|
624
|
+
|
625
|
+
iv = encrypted_password[3:15]
|
626
|
+
ciphertext = encrypted_password[15:]
|
627
|
+
cipher = AES.new(key, AES.MODE_GCM, iv)
|
628
|
+
plaintext = cipher.decrypt(ciphertext)
|
629
|
+
return plaintext[:-16].decode(errors="backslashreplace")
|
@@ -8,6 +8,7 @@ from dissect.target.plugins.apps.browser.browser import (
|
|
8
8
|
GENERIC_DOWNLOAD_RECORD_FIELDS,
|
9
9
|
GENERIC_EXTENSION_RECORD_FIELDS,
|
10
10
|
GENERIC_HISTORY_RECORD_FIELDS,
|
11
|
+
GENERIC_PASSWORD_RECORD_FIELDS,
|
11
12
|
BrowserPlugin,
|
12
13
|
)
|
13
14
|
from dissect.target.plugins.apps.browser.chromium import (
|
@@ -47,6 +48,10 @@ class EdgePlugin(ChromiumMixin, BrowserPlugin):
|
|
47
48
|
"browser/edge/extension", GENERIC_EXTENSION_RECORD_FIELDS
|
48
49
|
)
|
49
50
|
|
51
|
+
BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
|
52
|
+
"browser/edge/password", GENERIC_PASSWORD_RECORD_FIELDS
|
53
|
+
)
|
54
|
+
|
50
55
|
@export(record=BrowserHistoryRecord)
|
51
56
|
def history(self) -> Iterator[BrowserHistoryRecord]:
|
52
57
|
"""Return browser history records for Microsoft Edge."""
|
@@ -66,3 +71,8 @@ class EdgePlugin(ChromiumMixin, BrowserPlugin):
|
|
66
71
|
def extensions(self) -> Iterator[BrowserExtensionRecord]:
|
67
72
|
"""Return browser extension records for Microsoft Edge."""
|
68
73
|
yield from super().extensions("edge")
|
74
|
+
|
75
|
+
@export(record=BrowserPasswordRecord)
|
76
|
+
def passwords(self) -> Iterator[BrowserPasswordRecord]:
|
77
|
+
"""Return browser password records for Microsoft Edge."""
|
78
|
+
yield from super().passwords("edge")
|