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.
Files changed (29) hide show
  1. dissect/target/filesystems/dir.py +14 -1
  2. dissect/target/filesystems/overlay.py +103 -0
  3. dissect/target/helpers/compat/path_common.py +19 -5
  4. dissect/target/loader.py +1 -0
  5. dissect/target/loaders/itunes.py +3 -3
  6. dissect/target/loaders/overlay.py +31 -0
  7. dissect/target/plugins/apps/browser/brave.py +10 -0
  8. dissect/target/plugins/apps/browser/browser.py +43 -0
  9. dissect/target/plugins/apps/browser/chrome.py +10 -0
  10. dissect/target/plugins/apps/browser/chromium.py +234 -12
  11. dissect/target/plugins/apps/browser/edge.py +10 -0
  12. dissect/target/plugins/apps/browser/firefox.py +440 -19
  13. dissect/target/plugins/apps/browser/iexplore.py +1 -1
  14. dissect/target/plugins/apps/container/docker.py +24 -4
  15. dissect/target/plugins/apps/ssh/putty.py +10 -1
  16. dissect/target/plugins/child/docker.py +24 -0
  17. dissect/target/plugins/os/unix/linux/fortios/_os.py +6 -6
  18. dissect/target/plugins/os/windows/catroot.py +11 -2
  19. dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
  20. dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
  21. dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
  22. dissect/target/plugins/os/windows/sam.py +10 -1
  23. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/METADATA +1 -1
  24. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/RECORD +29 -26
  25. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/COPYRIGHT +0 -0
  26. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/LICENSE +0 -0
  27. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/WHEEL +0 -0
  28. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/entry_points.txt +0 -0
  29. {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.user, cur_dir) in users_dirs:
84
+ if not cur_dir.exists() or (user_details, cur_dir) in users_dirs:
69
85
  continue
70
- users_dirs.append((user_details.user, cur_dir))
86
+ users_dirs.append((user_details, cur_dir))
71
87
  return users_dirs
72
88
 
73
- def _iter_db(self, filename: str, subdirs: Optional[list[str]] = None) -> Iterator[SQLite3]:
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[str, TargetPath, dict]]:
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 user name, JSON file path, and the parsed JSON data.
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 len(self._build_userdirs(self.DIRS)):
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=cookie.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")