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

Sign up to get free protection for your applications and to get access to all the features.
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