dissect.target 3.16.dev45__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 (62) hide show
  1. dissect/target/container.py +1 -0
  2. dissect/target/containers/fortifw.py +190 -0
  3. dissect/target/filesystem.py +192 -67
  4. dissect/target/filesystems/dir.py +14 -1
  5. dissect/target/filesystems/overlay.py +103 -0
  6. dissect/target/helpers/compat/path_common.py +19 -5
  7. dissect/target/helpers/configutil.py +30 -7
  8. dissect/target/helpers/network_managers.py +101 -73
  9. dissect/target/helpers/record_modifier.py +4 -1
  10. dissect/target/loader.py +3 -1
  11. dissect/target/loaders/dir.py +23 -5
  12. dissect/target/loaders/itunes.py +3 -3
  13. dissect/target/loaders/mqtt.py +309 -0
  14. dissect/target/loaders/overlay.py +31 -0
  15. dissect/target/loaders/target.py +12 -9
  16. dissect/target/loaders/vb.py +2 -2
  17. dissect/target/loaders/velociraptor.py +5 -4
  18. dissect/target/plugin.py +1 -1
  19. dissect/target/plugins/apps/browser/brave.py +10 -0
  20. dissect/target/plugins/apps/browser/browser.py +43 -0
  21. dissect/target/plugins/apps/browser/chrome.py +10 -0
  22. dissect/target/plugins/apps/browser/chromium.py +234 -12
  23. dissect/target/plugins/apps/browser/edge.py +10 -0
  24. dissect/target/plugins/apps/browser/firefox.py +512 -19
  25. dissect/target/plugins/apps/browser/iexplore.py +2 -2
  26. dissect/target/plugins/apps/container/docker.py +24 -4
  27. dissect/target/plugins/apps/ssh/openssh.py +4 -0
  28. dissect/target/plugins/apps/ssh/putty.py +45 -14
  29. dissect/target/plugins/apps/ssh/ssh.py +40 -0
  30. dissect/target/plugins/apps/vpn/openvpn.py +115 -93
  31. dissect/target/plugins/child/docker.py +24 -0
  32. dissect/target/plugins/filesystem/ntfs/mft.py +1 -1
  33. dissect/target/plugins/filesystem/walkfs.py +2 -2
  34. dissect/target/plugins/os/unix/bsd/__init__.py +0 -0
  35. dissect/target/plugins/os/unix/esxi/_os.py +2 -2
  36. dissect/target/plugins/os/unix/linux/debian/vyos/_os.py +1 -1
  37. dissect/target/plugins/os/unix/linux/fortios/_os.py +9 -9
  38. dissect/target/plugins/os/unix/linux/services.py +1 -0
  39. dissect/target/plugins/os/unix/linux/sockets.py +2 -2
  40. dissect/target/plugins/os/unix/log/messages.py +53 -8
  41. dissect/target/plugins/os/windows/_os.py +10 -1
  42. dissect/target/plugins/os/windows/catroot.py +178 -63
  43. dissect/target/plugins/os/windows/credhist.py +210 -0
  44. dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
  45. dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
  46. dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
  47. dissect/target/plugins/os/windows/regf/runkeys.py +6 -4
  48. dissect/target/plugins/os/windows/sam.py +10 -1
  49. dissect/target/target.py +1 -1
  50. dissect/target/tools/dump/run.py +23 -28
  51. dissect/target/tools/dump/state.py +11 -8
  52. dissect/target/tools/dump/utils.py +5 -4
  53. dissect/target/tools/query.py +3 -15
  54. dissect/target/tools/shell.py +48 -8
  55. dissect/target/tools/utils.py +23 -0
  56. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/METADATA +7 -3
  57. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/RECORD +62 -55
  58. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/WHEEL +1 -1
  59. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/COPYRIGHT +0 -0
  60. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/LICENSE +0 -0
  61. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/entry_points.txt +0 -0
  62. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,9 @@
1
+ import hmac
1
2
  import json
2
- from typing import Iterator
3
+ import logging
4
+ from base64 import b64decode
5
+ from hashlib import pbkdf2_hmac, sha1
6
+ from typing import Iterator, Optional
3
7
 
4
8
  from dissect.sql import sqlite3
5
9
  from dissect.sql.exceptions import Error as SQLError
@@ -8,15 +12,41 @@ from dissect.util.ts import from_unix_ms, from_unix_us
8
12
 
9
13
  from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError
10
14
  from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension
15
+ from dissect.target.helpers.fsutil import TargetPath
11
16
  from dissect.target.helpers.record import create_extended_descriptor
12
17
  from dissect.target.plugin import export
13
18
  from dissect.target.plugins.apps.browser.browser import (
14
19
  GENERIC_COOKIE_FIELDS,
15
20
  GENERIC_DOWNLOAD_RECORD_FIELDS,
21
+ GENERIC_EXTENSION_RECORD_FIELDS,
16
22
  GENERIC_HISTORY_RECORD_FIELDS,
23
+ GENERIC_PASSWORD_RECORD_FIELDS,
17
24
  BrowserPlugin,
18
25
  try_idna,
19
26
  )
27
+ from dissect.target.plugins.general.users import UserDetails
28
+
29
+ try:
30
+ from asn1crypto import algos, core
31
+
32
+ HAS_ASN1 = True
33
+
34
+ except ImportError:
35
+ HAS_ASN1 = False
36
+
37
+
38
+ try:
39
+ from Crypto.Cipher import AES, DES3
40
+ from Crypto.Util.Padding import unpad
41
+
42
+ HAS_CRYPTO = True
43
+
44
+ except ImportError:
45
+ HAS_CRYPTO = False
46
+
47
+ FIREFOX_EXTENSION_RECORD_FIELDS = [("uri", "source_uri"), ("string[]", "optional_permissions")]
48
+
49
+ log = logging.getLogger(__name__)
20
50
 
21
51
 
22
52
  class FirefoxPlugin(BrowserPlugin):
@@ -48,21 +78,37 @@ class FirefoxPlugin(BrowserPlugin):
48
78
  "browser/firefox/download", GENERIC_DOWNLOAD_RECORD_FIELDS
49
79
  )
50
80
 
81
+ BrowserExtensionRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
82
+ "browser/firefox/extension", GENERIC_EXTENSION_RECORD_FIELDS + FIREFOX_EXTENSION_RECORD_FIELDS
83
+ )
84
+
85
+ BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
86
+ "browser/firefox/password", GENERIC_PASSWORD_RECORD_FIELDS
87
+ )
88
+
51
89
  def __init__(self, target):
52
90
  super().__init__(target)
53
- self.users_dirs = []
91
+ self.users_dirs: list[tuple[UserDetails, TargetPath]] = []
54
92
  for user_details in self.target.user_details.all_with_home():
55
93
  for directory in self.DIRS:
56
94
  cur_dir = user_details.home_path.joinpath(directory)
57
95
  if not cur_dir.exists():
58
96
  continue
59
- self.users_dirs.append((user_details.user, cur_dir))
97
+ self.users_dirs.append((user_details, cur_dir))
60
98
 
61
99
  def check_compatible(self) -> None:
62
100
  if not len(self.users_dirs):
63
101
  raise UnsupportedPluginError("No Firefox directories found")
64
102
 
65
- def _iter_db(self, filename: str) -> Iterator[SQLite3]:
103
+ def _iter_profiles(self) -> Iterator[tuple[UserDetails, TargetPath, TargetPath]]:
104
+ """Yield user directories."""
105
+ for user, cur_dir in self.users_dirs:
106
+ for profile_dir in cur_dir.iterdir():
107
+ if not profile_dir.is_dir():
108
+ continue
109
+ yield user, cur_dir, profile_dir
110
+
111
+ def _iter_db(self, filename: str) -> Iterator[tuple[UserDetails, SQLite3]]:
66
112
  """Yield opened history database files of all users.
67
113
 
68
114
  Args:
@@ -71,16 +117,15 @@ class FirefoxPlugin(BrowserPlugin):
71
117
  Yields:
72
118
  Opened SQLite3 databases.
73
119
  """
74
- for user, cur_dir in self.users_dirs:
75
- for profile_dir in cur_dir.iterdir():
76
- if profile_dir.is_dir():
77
- db_file = profile_dir.joinpath(filename)
78
- try:
79
- yield user, db_file, sqlite3.SQLite3(db_file.open())
80
- except FileNotFoundError:
81
- self.target.log.warning("Could not find %s file: %s", filename, db_file)
82
- except SQLError as e:
83
- self.target.log.warning("Could not open %s file: %s", filename, db_file, exc_info=e)
120
+ for user, cur_dir, profile_dir in self._iter_profiles():
121
+ db_file = profile_dir.joinpath(filename)
122
+ try:
123
+ yield user, db_file, sqlite3.SQLite3(db_file.open())
124
+ except FileNotFoundError:
125
+ self.target.log.warning("Could not find %s file: %s", filename, db_file)
126
+ except SQLError as e:
127
+ self.target.log.warning("Could not open %s file: %s", filename, db_file)
128
+ self.target.log.debug("", exc_info=e)
84
129
 
85
130
  @export(record=BrowserHistoryRecord)
86
131
  def history(self) -> Iterator[BrowserHistoryRecord]:
@@ -135,7 +180,7 @@ class FirefoxPlugin(BrowserPlugin):
135
180
  from_url=try_idna(from_place.url) if from_place else None,
136
181
  source=db_file,
137
182
  _target=self.target,
138
- _user=user,
183
+ _user=user.user,
139
184
  )
140
185
  except SQLError as e:
141
186
  self.target.log.warning("Error processing history file: %s", db_file, exc_info=e)
@@ -167,7 +212,7 @@ class FirefoxPlugin(BrowserPlugin):
167
212
  yield self.BrowserCookieRecord(
168
213
  ts_created=from_unix_us(cookie.creationTime),
169
214
  ts_last_accessed=from_unix_us(cookie.lastAccessed),
170
- browser="Firefox",
215
+ browser="firefox",
171
216
  name=cookie.name,
172
217
  value=cookie.value,
173
218
  host=cookie.host,
@@ -177,7 +222,7 @@ class FirefoxPlugin(BrowserPlugin):
177
222
  is_http_only=bool(cookie.isHttpOnly),
178
223
  same_site=bool(cookie.sameSite),
179
224
  source=db_file,
180
- _user=user,
225
+ _user=user.user,
181
226
  )
182
227
  except SQLError as e:
183
228
  self.target.log.warning("Error processing cookie file: %s", db_file, exc_info=e)
@@ -261,8 +306,456 @@ class FirefoxPlugin(BrowserPlugin):
261
306
  state=state,
262
307
  source=db_file,
263
308
  _target=self.target,
264
- _user=user,
309
+ _user=user.user,
265
310
  )
266
311
  except SQLError as e:
267
312
  self.target.log.warning("Error processing history file: %s", db_file, exc_info=e)
268
- self.target.log.warning("Error processing history file: %s", db_file, exc_info=e)
313
+
314
+ @export(record=BrowserExtensionRecord)
315
+ def extensions(self) -> Iterator[BrowserExtensionRecord]:
316
+ """Return browser extension records for Firefox.
317
+
318
+ Yields BrowserExtensionRecord with the following fields::
319
+ ts_install (datetime): Extension install timestamp.
320
+ ts_update (datetime): Extension update timestamp.
321
+ browser (string): The browser from which the records are generated.
322
+ id (string): Extension unique identifier.
323
+ name (string): Name of the extension.
324
+ short_name (string): Short name of the extension.
325
+ default_title (string): Default title of the extension.
326
+ description (string): Description of the extension.
327
+ version (string): Version of the extension.
328
+ ext_path (path): Relative path of the extension.
329
+ from_webstore (boolean): Extension from webstore.
330
+ permissions (string[]): Permissions of the extension.
331
+ manifest (varint): Version of the extensions' manifest.
332
+ optional_permissions (string[]): Optional permissions of the extension.
333
+ source_uri (path): Source path from which the extension was downloaded.
334
+ source (path): The source file of the download record.
335
+ """
336
+ for user, _, profile_dir in self._iter_profiles():
337
+ extension_file = profile_dir.joinpath("extensions.json")
338
+
339
+ if not extension_file.exists():
340
+ self.target.log.warning(
341
+ "No 'extensions.json' addon file found for user %s in directory %s", user, profile_dir
342
+ )
343
+ continue
344
+
345
+ try:
346
+ extensions = json.load(extension_file.open())
347
+
348
+ for extension in extensions.get("addons", []):
349
+ yield self.BrowserExtensionRecord(
350
+ ts_install=extension.get("installDate", 0) // 1000,
351
+ ts_update=extension.get("updateDate", 0) // 1000,
352
+ browser="firefox",
353
+ id=extension.get("id"),
354
+ name=extension.get("defaultLocale", {}).get("name"),
355
+ short_name=None,
356
+ default_title=None,
357
+ description=extension.get("defaultLocale", {}).get("description"),
358
+ version=extension.get("version"),
359
+ ext_path=extension.get("path"),
360
+ from_webstore=None,
361
+ permissions=extension.get("userPermissions", {}).get("permissions"),
362
+ manifest_version=extension.get("manifestVersion"),
363
+ source_uri=extension.get("sourceURI"),
364
+ optional_permissions=extension.get("optionalPermissions", {}).get("permissions"),
365
+ source=extension_file,
366
+ _target=self.target,
367
+ _user=user.user,
368
+ )
369
+
370
+ except FileNotFoundError:
371
+ self.target.log.info(
372
+ "No 'extensions.json' addon file found for user %s in directory %s", user, profile_dir
373
+ )
374
+ except json.JSONDecodeError:
375
+ self.target.log.warning(
376
+ "extensions.json file in directory %s is malformed, consider inspecting the file manually",
377
+ profile_dir,
378
+ )
379
+
380
+ @export(record=BrowserPasswordRecord)
381
+ def passwords(self) -> Iterator[BrowserPasswordRecord]:
382
+ """Return Firefox browser password records.
383
+
384
+ Automatically decrypts passwords from Firefox 58 onwards (2018) if no primary password is set.
385
+ Alternatively, you can supply a primary password through the keychain to access the Firefox password store.
386
+
387
+ ``PASSPHRASE`` passwords in the keychain with providers ``browser``, ``firefox``, ``user`` and no provider
388
+ can be used to decrypt secrets for this plugin.
389
+
390
+ Resources:
391
+ - https://github.com/lclevy/firepwd
392
+ """
393
+ for user, _, profile_dir in self._iter_profiles():
394
+ login_file = profile_dir.joinpath("logins.json")
395
+ key3_file = profile_dir.joinpath("key3.db")
396
+ key4_file = profile_dir.joinpath("key4.db")
397
+
398
+ if not login_file.exists():
399
+ self.target.log.warning(
400
+ "No 'logins.json' password file found for user %s in directory %s", user, profile_dir
401
+ )
402
+ continue
403
+
404
+ if key3_file.exists() and not key4_file.exists():
405
+ self.target.log.warning("Unsupported file 'key3.db' found in %s", profile_dir)
406
+ continue
407
+
408
+ if not key4_file.exists():
409
+ self.target.log.warning("No 'key4.db' found in %s", profile_dir)
410
+ continue
411
+
412
+ try:
413
+ logins = json.load(login_file.open())
414
+
415
+ for login in logins.get("logins", []):
416
+ decrypted_username = None
417
+ decrypted_password = None
418
+
419
+ for password in self.keychain():
420
+ try:
421
+ decrypted_username, decrypted_password = decrypt(
422
+ login.get("encryptedUsername"),
423
+ login.get("encryptedPassword"),
424
+ key4_file,
425
+ password,
426
+ )
427
+ except ValueError as e:
428
+ self.target.log.warning("Exception while trying to decrypt")
429
+ self.target.log.debug("", exc_info=e)
430
+
431
+ if decrypted_password and decrypted_username:
432
+ break
433
+
434
+ yield self.BrowserPasswordRecord(
435
+ ts_created=login.get("timeCreated", 0) // 1000,
436
+ ts_last_used=login.get("timeLastUsed", 0) // 1000,
437
+ ts_last_changed=login.get("timePasswordChanged", 0) // 1000,
438
+ browser="firefox",
439
+ id=login.get("id"),
440
+ url=login.get("hostname"),
441
+ encrypted_username=login.get("encryptedUsername"),
442
+ encrypted_password=login.get("encryptedPassword"),
443
+ decrypted_username=decrypted_username,
444
+ decrypted_password=decrypted_password,
445
+ source=login_file,
446
+ _target=self.target,
447
+ _user=user.user,
448
+ )
449
+
450
+ except FileNotFoundError:
451
+ self.target.log.info("No password file found for user %s in directory %s", user, profile_dir)
452
+ except json.JSONDecodeError:
453
+ self.target.log.warning(
454
+ "logins.json file in directory %s is malformed, consider inspecting the file manually", profile_dir
455
+ )
456
+
457
+
458
+ # Define separately because it is not defined in asn1crypto
459
+ pbeWithSha1AndTripleDES_CBC = "1.2.840.113549.1.12.5.1.3"
460
+ CKA_ID = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"
461
+
462
+
463
+ def decrypt_moz_3des(global_salt: bytes, primary_password: bytes, entry_salt: str, encrypted: bytes) -> bytes:
464
+ if not HAS_CRYPTO:
465
+ raise ValueError("Missing pycryptodome dependency")
466
+
467
+ hp = sha1(global_salt + primary_password).digest()
468
+ pes = entry_salt + b"\x00" * (20 - len(entry_salt))
469
+ chp = sha1(hp + entry_salt).digest()
470
+ k1 = hmac.new(chp, pes + entry_salt, sha1).digest()
471
+ tk = hmac.new(chp, pes, sha1).digest()
472
+ k2 = hmac.new(chp, tk + entry_salt, sha1).digest()
473
+ k = k1 + k2
474
+ iv = k[-8:]
475
+ key = k[:24]
476
+ return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encrypted)
477
+
478
+
479
+ def decode_login_data(data: str) -> tuple[bytes, bytes, bytes]:
480
+ """Decode Firefox login data.
481
+
482
+ Args:
483
+ data: Base64 encoded data in string format.
484
+
485
+ Raises:
486
+ ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
487
+
488
+ Returns:
489
+ Tuple of bytes with ``key_id``, ``iv`` and ``ciphertext``
490
+ """
491
+
492
+ # SEQUENCE {
493
+ # KEY_ID
494
+ # SEQUENCE {
495
+ # OBJECT_IDENTIFIER
496
+ # IV
497
+ # }
498
+ # CIPHERTEXT
499
+ # }
500
+
501
+ if not HAS_CRYPTO:
502
+ raise ValueError("Missing pycryptodome dependency")
503
+
504
+ if not HAS_ASN1:
505
+ raise ValueError("Missing asn1crypto dependency")
506
+
507
+ decoded = core.load(b64decode(data))
508
+ key_id = decoded[0].native
509
+ iv = decoded[1][1].native
510
+ ciphertext = decoded[2].native
511
+ return key_id, iv, ciphertext
512
+
513
+
514
+ def decrypt_pbes2(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> bytes:
515
+ """Decrypt an item with the given primary password and salt.
516
+
517
+ Args:
518
+ decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below.
519
+ primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with.
520
+ global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key.
521
+
522
+ Raises:
523
+ ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
524
+
525
+ Returns:
526
+ Bytes of decrypted AES ciphertext.
527
+ """
528
+
529
+ # SEQUENCE {
530
+ # SEQUENCE {
531
+ # OBJECTIDENTIFIER 1.2.840.113549.1.5.13 => pkcs5 pbes2
532
+ # SEQUENCE {
533
+ # SEQUENCE {
534
+ # OBJECTIDENTIFIER 1.2.840.113549.1.5.12 => pbkdf2
535
+ # SEQUENCE {
536
+ # OCTETSTRING 32 bytes, entrySalt
537
+ # INTEGER 01
538
+ # INTEGER 20
539
+ # SEQUENCE {
540
+ # OBJECTIDENTIFIER 1.2.840.113549.2.9 => hmacWithSHA256
541
+ # }
542
+ # }
543
+ # }
544
+ # SEQUENCE {
545
+ # OBJECTIDENTIFIER 2.16.840.1.101.3.4.1.42 => aes256-CBC
546
+ # OCTETSTRING 14 bytes, iv
547
+ # }
548
+ # }
549
+ # }
550
+ # OCTETSTRING encrypted
551
+ # }
552
+
553
+ if not HAS_CRYPTO:
554
+ raise ValueError("Missing pycryptodome dependency")
555
+
556
+ if not HAS_ASN1:
557
+ raise ValueError("Missing asn1crypto dependency")
558
+
559
+ pkcs5_oid = decoded_item[0][1][0][0].dotted
560
+ if algos.KdfAlgorithmId.map(pkcs5_oid) != "pbkdf2":
561
+ raise ValueError(f"Expected pbkdf2 object identifier, got: {pkcs5_oid}")
562
+
563
+ sha256_oid = decoded_item[0][1][0][1][3][0].dotted
564
+ if algos.HmacAlgorithmId.map(sha256_oid) != "sha256":
565
+ raise ValueError(f"Expected SHA256 object identifier, got: {pkcs5_oid}")
566
+
567
+ aes256_cbc_oid = decoded_item[0][1][1][0].dotted
568
+ if algos.EncryptionAlgorithmId.map(aes256_cbc_oid) != "aes256_cbc":
569
+ raise ValueError(f"Expected AES256-CBC object identifier, got: {pkcs5_oid}")
570
+
571
+ entry_salt = decoded_item[0][1][0][1][0].native
572
+ iteration_count = decoded_item[0][1][0][1][1].native
573
+ key_length = decoded_item[0][1][0][1][2].native
574
+
575
+ if key_length != 32:
576
+ raise ValueError(f"Expected key_length to be 32, got: {key_length}")
577
+
578
+ k = sha1(global_salt + primary_password).digest()
579
+ key = pbkdf2_hmac("sha256", k, entry_salt, iteration_count, dklen=key_length)
580
+
581
+ iv = b"\x04\x0e" + decoded_item[0][1][1][1].native
582
+ cipher_text = decoded_item[1].native
583
+ return AES.new(key, AES.MODE_CBC, iv).decrypt(cipher_text)
584
+
585
+
586
+ def decrypt_sha1_triple_des_cbc(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> bytes:
587
+ """Decrypt an item with the given Firefox primary password and salt.
588
+
589
+ Args:
590
+ decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below.
591
+ primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with.
592
+ global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key.
593
+
594
+ Raises:
595
+ ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
596
+
597
+ Returns:
598
+ Bytes of decrypted 3DES ciphertext.
599
+ """
600
+
601
+ # SEQUENCE {
602
+ # SEQUENCE {
603
+ # OBJECTIDENTIFIER 1.2.840.113549.1.12.5.1.3
604
+ # SEQUENCE {
605
+ # OCTETSTRING entry_salt
606
+ # INTEGER 01
607
+ # }
608
+ # }
609
+ # OCTETSTRING encrypted
610
+ # }
611
+
612
+ entry_salt = decoded_item[0][1][0].native
613
+ cipher_text = decoded_item[1].native
614
+ key = decrypt_moz_3des(global_salt, primary_password, entry_salt, cipher_text)
615
+ return key[:24]
616
+
617
+
618
+ def decrypt_master_key(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> tuple[bytes, str]:
619
+ """Decrypt the provided ``core.Sequence`` with the provided Firefox primary password and salt.
620
+
621
+ At this stage we are not yet sure of the structure of ``decoded_item``. The structure will depend on the
622
+ ``core.Sequence`` object identifier at ``decoded_item[0][0]``, hence we extract it. This function will
623
+ then call the apropriate ``decrypt_pbes2``or ``decrypt_sha1_triple_des_cbc`` functions to decrypt the item.
624
+
625
+ Args:
626
+ decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below.
627
+ primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with.
628
+ global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key.
629
+
630
+ Raises:
631
+ ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
632
+
633
+ Returns:
634
+ Tuple of decrypted bytes and a string representation of the identified encryption algorithm.
635
+ """
636
+
637
+ # SEQUENCE {
638
+ # SEQUENCE {
639
+ # OBJECTIDENTIFIER ???
640
+ # ...
641
+ # }
642
+ # ...
643
+ # }
644
+
645
+ if not HAS_CRYPTO:
646
+ raise ValueError("Missing pycryptodome dependency")
647
+
648
+ if not HAS_ASN1:
649
+ raise ValueError("Missing asn1crypto depdendency")
650
+
651
+ object_identifier = decoded_item[0][0]
652
+ algorithm = object_identifier.dotted
653
+
654
+ if algos.EncryptionAlgorithmId.map(algorithm) == "pbes2":
655
+ return decrypt_pbes2(decoded_item, primary_password, global_salt), algorithm
656
+ elif algorithm == pbeWithSha1AndTripleDES_CBC:
657
+ return decrypt_sha1_triple_des_cbc(decoded_item, primary_password, global_salt), algorithm
658
+ else:
659
+ # Firefox supports other algorithms (i.e. Firefox before 2018), but decrypting these is not (yet) supported.
660
+ return b"", algorithm
661
+
662
+
663
+ def query_global_salt(key4_file: TargetPath) -> tuple[str, str]:
664
+ with key4_file.open("rb") as fh:
665
+ db = sqlite3.SQLite3(fh)
666
+ for row in db.table("metadata").rows():
667
+ if row.get("id") == "password":
668
+ return row.get("item1", ""), row.get("item2", "")
669
+
670
+
671
+ def query_master_key(key4_file: TargetPath) -> tuple[str, str]:
672
+ with key4_file.open("rb") as fh:
673
+ db = sqlite3.SQLite3(fh)
674
+ for row in db.table("nssPrivate").rows():
675
+ return row.get("a11", ""), row.get("a102", "")
676
+
677
+
678
+ def retrieve_master_key(primary_password: bytes, key4_file: TargetPath) -> tuple[bytes, str]:
679
+ if not HAS_CRYPTO:
680
+ raise ValueError("Missing pycryptodome dependency")
681
+
682
+ if not HAS_ASN1:
683
+ raise ValueError("Missing asn1crypto dependency")
684
+
685
+ global_salt, password_check = query_global_salt(key4_file)
686
+ decoded_password_check = core.load(password_check)
687
+
688
+ try:
689
+ decrypted_password_check, algorithm = decrypt_master_key(decoded_password_check, primary_password, global_salt)
690
+ except EOFError:
691
+ raise ValueError("No primary password provided")
692
+
693
+ if not decrypted_password_check:
694
+ raise ValueError(f"Encountered unknown algorithm {algorithm} while decrypting master key")
695
+
696
+ expected_password_check = b"password-check\x02\x02"
697
+ if decrypted_password_check != b"password-check\x02\x02":
698
+ log.debug("Expected %s but got %s", expected_password_check, decrypted_password_check)
699
+ raise ValueError("Master key decryption failed. Provided password could be missing or incorrect")
700
+
701
+ master_key, master_key_cka = query_master_key(key4_file)
702
+ if master_key == b"":
703
+ raise ValueError("Password master key is not defined")
704
+
705
+ if master_key_cka != CKA_ID:
706
+ raise ValueError(f"Password master key CKA_ID '{master_key_cka}' is not equal to expected value '{CKA_ID}'")
707
+
708
+ decoded_master_key = core.load(master_key)
709
+ decrypted, algorithm = decrypt_master_key(decoded_master_key, primary_password, global_salt)
710
+ return decrypted[:24], algorithm
711
+
712
+
713
+ def decrypt_field(key: bytes, field: tuple[bytes, bytes, bytes]) -> bytes:
714
+ if not HAS_CRYPTO:
715
+ raise ValueError("Missing pycryptodome dependency")
716
+
717
+ cka, iv, ciphertext = field
718
+
719
+ if cka != CKA_ID:
720
+ raise ValueError(f"Expected cka to equal '{CKA_ID}' but got '{cka}'")
721
+
722
+ return unpad(DES3.new(key, DES3.MODE_CBC, iv).decrypt(ciphertext), 8)
723
+
724
+
725
+ def decrypt(
726
+ username: str, password: str, key4_file: TargetPath, primary_password: str = ""
727
+ ) -> tuple[Optional[str], Optional[str]]:
728
+ """Decrypt a stored username and password using provided credentials and key4 file.
729
+
730
+ Args:
731
+ username: Encoded and encrypted password.
732
+ password Encoded and encrypted password.
733
+ key4_file: Path to key4.db file.
734
+ primary_password: Password to use for decryption routine.
735
+
736
+ Returns:
737
+ A tuple of decoded username and password strings.
738
+
739
+ Resources:
740
+ - https://github.com/lclevy/firepwd
741
+ """
742
+ if not HAS_CRYPTO:
743
+ raise ValueError("Missing pycryptodome dependency")
744
+
745
+ if not HAS_ASN1:
746
+ raise ValueError("Missing asn1crypto dependency")
747
+
748
+ try:
749
+ username = decode_login_data(username)
750
+ password = decode_login_data(password)
751
+
752
+ primary_password_bytes = primary_password.encode()
753
+ key, algorithm = retrieve_master_key(primary_password_bytes, key4_file)
754
+
755
+ if algorithm == pbeWithSha1AndTripleDES_CBC or algos.EncryptionAlgorithmId.map(algorithm) == "pbes2":
756
+ username = decrypt_field(key, username)
757
+ password = decrypt_field(key, password)
758
+ return username.decode(), password.decode()
759
+
760
+ except ValueError as e:
761
+ raise ValueError(f"Failed to decrypt password using keyfile: {key4_file}, password: {primary_password}") from e
@@ -26,7 +26,7 @@ class WebCache:
26
26
  self.target = target
27
27
  self.db = esedb.EseDB(fh)
28
28
 
29
- def find_containers(self, name: str) -> table.Table:
29
+ def find_containers(self, name: str) -> Iterator[table.Table]:
30
30
  """Look up all ``ContainerId`` values for a given container name.
31
31
 
32
32
  Args:
@@ -212,7 +212,7 @@ class InternetExplorerPlugin(BrowserPlugin):
212
212
  ts_end=ts_end,
213
213
  browser="iexplore",
214
214
  id=container_record.EntryId,
215
- path=self.target.fs.path(down_path),
215
+ path=self.target.fs.path(down_path) if down_path else None,
216
216
  url=down_url,
217
217
  size=None,
218
218
  state=None,
@@ -23,16 +23,18 @@ DockerContainerRecord = TargetRecordDescriptor(
23
23
  [
24
24
  ("string", "container_id"),
25
25
  ("string", "image"),
26
+ ("string", "image_id"),
26
27
  ("string", "command"),
27
28
  ("datetime", "created"),
28
- ("string", "running"),
29
+ ("boolean", "running"),
29
30
  ("varint", "pid"),
30
31
  ("datetime", "started"),
31
32
  ("datetime", "finished"),
32
33
  ("string", "ports"),
33
34
  ("string", "names"),
34
35
  ("stringlist", "volumes"),
35
- ("string", "source"),
36
+ ("path", "mount_path"),
37
+ ("path", "config_path"),
36
38
  ],
37
39
  )
38
40
 
@@ -159,6 +161,9 @@ class DockerPlugin(Plugin):
159
161
  for data_root in self.installs:
160
162
  for config_path in data_root.joinpath("containers").glob("**/config.v2.json"):
161
163
  config = json.loads(config_path.read_text())
164
+ container_id = config.get("ID")
165
+
166
+ # determine state
162
167
  running = config.get("State").get("Running")
163
168
  if running:
164
169
  ports = config.get("NetworkSettings").get("Ports", {})
@@ -166,14 +171,28 @@ class DockerPlugin(Plugin):
166
171
  else:
167
172
  ports = config.get("Config").get("ExposedPorts", {})
168
173
  pid = None
174
+
175
+ # parse volumes
169
176
  volumes = []
170
177
  if mount_points := config.get("MountPoints"):
171
178
  for mp in mount_points:
172
179
  mount_point = mount_points[mp]
173
180
  volumes.append(f"{mount_point.get('Source')}:{mount_point.get('Destination')}")
181
+
182
+ # determine mount point
183
+ mount_path = None
184
+ if config.get("Driver") == "overlay2":
185
+ mount_path = data_root.joinpath("image/overlay2/layerdb/mounts", container_id)
186
+ if not mount_path.exists():
187
+ self.target.log.warning("Overlay2 mount path for container %s does not exist!", container_id)
188
+
189
+ else:
190
+ self.target.log.warning("Encountered unsupported container filesystem %s", config.get("Driver"))
191
+
174
192
  yield DockerContainerRecord(
175
- container_id=config.get("ID"),
193
+ container_id=container_id,
176
194
  image=config.get("Config").get("Image"),
195
+ image_id=config.get("Image").split(":")[-1],
177
196
  command=config.get("Config").get("Cmd"),
178
197
  created=convert_timestamp(config.get("Created")),
179
198
  running=running,
@@ -183,7 +202,8 @@ class DockerPlugin(Plugin):
183
202
  ports=convert_ports(ports),
184
203
  names=config.get("Name").replace("/", "", 1),
185
204
  volumes=volumes,
186
- source=config_path,
205
+ mount_path=mount_path,
206
+ config_path=config_path,
187
207
  _target=self.target,
188
208
  )
189
209