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,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,39 @@ 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,
16
21
  GENERIC_HISTORY_RECORD_FIELDS,
22
+ GENERIC_PASSWORD_RECORD_FIELDS,
17
23
  BrowserPlugin,
18
24
  try_idna,
19
25
  )
26
+ from dissect.target.plugins.general.users import UserDetails
27
+
28
+ try:
29
+ from asn1crypto import algos, core
30
+
31
+ HAS_ASN1 = True
32
+
33
+ except ImportError:
34
+ HAS_ASN1 = False
35
+
36
+
37
+ try:
38
+ from Crypto.Cipher import AES, DES3
39
+ from Crypto.Util.Padding import unpad
40
+
41
+ HAS_CRYPTO = True
42
+
43
+ except ImportError:
44
+ HAS_CRYPTO = False
45
+
46
+
47
+ log = logging.getLogger(__name__)
20
48
 
21
49
 
22
50
  class FirefoxPlugin(BrowserPlugin):
@@ -48,21 +76,33 @@ class FirefoxPlugin(BrowserPlugin):
48
76
  "browser/firefox/download", GENERIC_DOWNLOAD_RECORD_FIELDS
49
77
  )
50
78
 
79
+ BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
80
+ "browser/firefox/password", GENERIC_PASSWORD_RECORD_FIELDS
81
+ )
82
+
51
83
  def __init__(self, target):
52
84
  super().__init__(target)
53
- self.users_dirs = []
85
+ self.users_dirs: list[tuple[UserDetails, TargetPath]] = []
54
86
  for user_details in self.target.user_details.all_with_home():
55
87
  for directory in self.DIRS:
56
88
  cur_dir = user_details.home_path.joinpath(directory)
57
89
  if not cur_dir.exists():
58
90
  continue
59
- self.users_dirs.append((user_details.user, cur_dir))
91
+ self.users_dirs.append((user_details, cur_dir))
60
92
 
61
93
  def check_compatible(self) -> None:
62
94
  if not len(self.users_dirs):
63
95
  raise UnsupportedPluginError("No Firefox directories found")
64
96
 
65
- def _iter_db(self, filename: str) -> Iterator[SQLite3]:
97
+ def _iter_profiles(self) -> Iterator[tuple[UserDetails, TargetPath, TargetPath]]:
98
+ """Yield user directories."""
99
+ for user, cur_dir in self.users_dirs:
100
+ for profile_dir in cur_dir.iterdir():
101
+ if not profile_dir.is_dir():
102
+ continue
103
+ yield user, cur_dir, profile_dir
104
+
105
+ def _iter_db(self, filename: str) -> Iterator[tuple[UserDetails, SQLite3]]:
66
106
  """Yield opened history database files of all users.
67
107
 
68
108
  Args:
@@ -71,16 +111,15 @@ class FirefoxPlugin(BrowserPlugin):
71
111
  Yields:
72
112
  Opened SQLite3 databases.
73
113
  """
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)
114
+ for user, cur_dir, profile_dir in self._iter_profiles():
115
+ db_file = profile_dir.joinpath(filename)
116
+ try:
117
+ yield user, db_file, sqlite3.SQLite3(db_file.open())
118
+ except FileNotFoundError:
119
+ self.target.log.warning("Could not find %s file: %s", filename, db_file)
120
+ except SQLError as e:
121
+ self.target.log.warning("Could not open %s file: %s", filename, db_file)
122
+ self.target.log.debug("", exc_info=e)
84
123
 
85
124
  @export(record=BrowserHistoryRecord)
86
125
  def history(self) -> Iterator[BrowserHistoryRecord]:
@@ -135,7 +174,7 @@ class FirefoxPlugin(BrowserPlugin):
135
174
  from_url=try_idna(from_place.url) if from_place else None,
136
175
  source=db_file,
137
176
  _target=self.target,
138
- _user=user,
177
+ _user=user.user,
139
178
  )
140
179
  except SQLError as e:
141
180
  self.target.log.warning("Error processing history file: %s", db_file, exc_info=e)
@@ -167,7 +206,7 @@ class FirefoxPlugin(BrowserPlugin):
167
206
  yield self.BrowserCookieRecord(
168
207
  ts_created=from_unix_us(cookie.creationTime),
169
208
  ts_last_accessed=from_unix_us(cookie.lastAccessed),
170
- browser="Firefox",
209
+ browser="firefox",
171
210
  name=cookie.name,
172
211
  value=cookie.value,
173
212
  host=cookie.host,
@@ -177,7 +216,7 @@ class FirefoxPlugin(BrowserPlugin):
177
216
  is_http_only=bool(cookie.isHttpOnly),
178
217
  same_site=bool(cookie.sameSite),
179
218
  source=db_file,
180
- _user=user,
219
+ _user=user.user,
181
220
  )
182
221
  except SQLError as e:
183
222
  self.target.log.warning("Error processing cookie file: %s", db_file, exc_info=e)
@@ -261,8 +300,390 @@ class FirefoxPlugin(BrowserPlugin):
261
300
  state=state,
262
301
  source=db_file,
263
302
  _target=self.target,
264
- _user=user,
303
+ _user=user.user,
265
304
  )
266
305
  except SQLError as e:
267
306
  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)
307
+
308
+ @export(record=BrowserPasswordRecord)
309
+ def passwords(self) -> Iterator[BrowserPasswordRecord]:
310
+ """Return Firefox browser password records.
311
+
312
+ Automatically decrypts passwords from Firefox 58 onwards (2018) if no primary password is set.
313
+ Alternatively, you can supply a primary password through the keychain to access the Firefox password store.
314
+
315
+ ``PASSPHRASE`` passwords in the keychain with providers ``browser``, ``firefox``, ``user`` and no provider
316
+ can be used to decrypt secrets for this plugin.
317
+
318
+ Resources:
319
+ - https://github.com/lclevy/firepwd
320
+ """
321
+ for user, _, profile_dir in self._iter_profiles():
322
+ login_file = profile_dir.joinpath("logins.json")
323
+ key3_file = profile_dir.joinpath("key3.db")
324
+ key4_file = profile_dir.joinpath("key4.db")
325
+
326
+ if not login_file.exists():
327
+ self.target.log.warning(
328
+ "No 'logins.json' password file found for user %s in directory %s", user, profile_dir
329
+ )
330
+ continue
331
+
332
+ if key3_file.exists() and not key4_file.exists():
333
+ self.target.log.warning("Unsupported file 'key3.db' found in %s", profile_dir)
334
+ continue
335
+
336
+ if not key4_file.exists():
337
+ self.target.log.warning("No 'key4.db' found in %s", profile_dir)
338
+ continue
339
+
340
+ try:
341
+ logins = json.load(login_file.open())
342
+
343
+ for login in logins.get("logins", []):
344
+ decrypted_username = None
345
+ decrypted_password = None
346
+
347
+ for password in self.keychain():
348
+ try:
349
+ decrypted_username, decrypted_password = decrypt(
350
+ login.get("encryptedUsername"),
351
+ login.get("encryptedPassword"),
352
+ key4_file,
353
+ password,
354
+ )
355
+ except ValueError as e:
356
+ self.target.log.warning("Exception while trying to decrypt")
357
+ self.target.log.debug("", exc_info=e)
358
+
359
+ if decrypted_password and decrypted_username:
360
+ break
361
+
362
+ yield self.BrowserPasswordRecord(
363
+ ts_created=login.get("timeCreated", 0) // 1000,
364
+ ts_last_used=login.get("timeLastUsed", 0) // 1000,
365
+ ts_last_changed=login.get("timePasswordChanged", 0) // 1000,
366
+ browser="firefox",
367
+ id=login.get("id"),
368
+ url=login.get("hostname"),
369
+ encrypted_username=login.get("encryptedUsername"),
370
+ encrypted_password=login.get("encryptedPassword"),
371
+ decrypted_username=decrypted_username,
372
+ decrypted_password=decrypted_password,
373
+ source=login_file,
374
+ _target=self.target,
375
+ _user=user.user,
376
+ )
377
+
378
+ except FileNotFoundError:
379
+ self.target.log.info("No password file found for user %s in directory %s", user, profile_dir)
380
+ except json.JSONDecodeError:
381
+ self.target.log.warning(
382
+ "logins.json file in directory %s is malformed, consider inspecting the file manually", profile_dir
383
+ )
384
+
385
+
386
+ # Define separately because it is not defined in asn1crypto
387
+ pbeWithSha1AndTripleDES_CBC = "1.2.840.113549.1.12.5.1.3"
388
+ CKA_ID = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"
389
+
390
+
391
+ def decrypt_moz_3des(global_salt: bytes, primary_password: bytes, entry_salt: str, encrypted: bytes) -> bytes:
392
+ if not HAS_CRYPTO:
393
+ raise ValueError("Missing pycryptodome dependency")
394
+
395
+ hp = sha1(global_salt + primary_password).digest()
396
+ pes = entry_salt + b"\x00" * (20 - len(entry_salt))
397
+ chp = sha1(hp + entry_salt).digest()
398
+ k1 = hmac.new(chp, pes + entry_salt, sha1).digest()
399
+ tk = hmac.new(chp, pes, sha1).digest()
400
+ k2 = hmac.new(chp, tk + entry_salt, sha1).digest()
401
+ k = k1 + k2
402
+ iv = k[-8:]
403
+ key = k[:24]
404
+ return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encrypted)
405
+
406
+
407
+ def decode_login_data(data: str) -> tuple[bytes, bytes, bytes]:
408
+ """Decode Firefox login data.
409
+
410
+ Args:
411
+ data: Base64 encoded data in string format.
412
+
413
+ Raises:
414
+ ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
415
+
416
+ Returns:
417
+ Tuple of bytes with ``key_id``, ``iv`` and ``ciphertext``
418
+ """
419
+
420
+ # SEQUENCE {
421
+ # KEY_ID
422
+ # SEQUENCE {
423
+ # OBJECT_IDENTIFIER
424
+ # IV
425
+ # }
426
+ # CIPHERTEXT
427
+ # }
428
+
429
+ if not HAS_CRYPTO:
430
+ raise ValueError("Missing pycryptodome dependency")
431
+
432
+ if not HAS_ASN1:
433
+ raise ValueError("Missing asn1crypto dependency")
434
+
435
+ decoded = core.load(b64decode(data))
436
+ key_id = decoded[0].native
437
+ iv = decoded[1][1].native
438
+ ciphertext = decoded[2].native
439
+ return key_id, iv, ciphertext
440
+
441
+
442
+ def decrypt_pbes2(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> bytes:
443
+ """Decrypt an item with the given primary password and salt.
444
+
445
+ Args:
446
+ decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below.
447
+ primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with.
448
+ global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key.
449
+
450
+ Raises:
451
+ ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
452
+
453
+ Returns:
454
+ Bytes of decrypted AES ciphertext.
455
+ """
456
+
457
+ # SEQUENCE {
458
+ # SEQUENCE {
459
+ # OBJECTIDENTIFIER 1.2.840.113549.1.5.13 => pkcs5 pbes2
460
+ # SEQUENCE {
461
+ # SEQUENCE {
462
+ # OBJECTIDENTIFIER 1.2.840.113549.1.5.12 => pbkdf2
463
+ # SEQUENCE {
464
+ # OCTETSTRING 32 bytes, entrySalt
465
+ # INTEGER 01
466
+ # INTEGER 20
467
+ # SEQUENCE {
468
+ # OBJECTIDENTIFIER 1.2.840.113549.2.9 => hmacWithSHA256
469
+ # }
470
+ # }
471
+ # }
472
+ # SEQUENCE {
473
+ # OBJECTIDENTIFIER 2.16.840.1.101.3.4.1.42 => aes256-CBC
474
+ # OCTETSTRING 14 bytes, iv
475
+ # }
476
+ # }
477
+ # }
478
+ # OCTETSTRING encrypted
479
+ # }
480
+
481
+ if not HAS_CRYPTO:
482
+ raise ValueError("Missing pycryptodome dependency")
483
+
484
+ if not HAS_ASN1:
485
+ raise ValueError("Missing asn1crypto dependency")
486
+
487
+ pkcs5_oid = decoded_item[0][1][0][0].dotted
488
+ if algos.KdfAlgorithmId.map(pkcs5_oid) != "pbkdf2":
489
+ raise ValueError(f"Expected pbkdf2 object identifier, got: {pkcs5_oid}")
490
+
491
+ sha256_oid = decoded_item[0][1][0][1][3][0].dotted
492
+ if algos.HmacAlgorithmId.map(sha256_oid) != "sha256":
493
+ raise ValueError(f"Expected SHA256 object identifier, got: {pkcs5_oid}")
494
+
495
+ aes256_cbc_oid = decoded_item[0][1][1][0].dotted
496
+ if algos.EncryptionAlgorithmId.map(aes256_cbc_oid) != "aes256_cbc":
497
+ raise ValueError(f"Expected AES256-CBC object identifier, got: {pkcs5_oid}")
498
+
499
+ entry_salt = decoded_item[0][1][0][1][0].native
500
+ iteration_count = decoded_item[0][1][0][1][1].native
501
+ key_length = decoded_item[0][1][0][1][2].native
502
+
503
+ if key_length != 32:
504
+ raise ValueError(f"Expected key_length to be 32, got: {key_length}")
505
+
506
+ k = sha1(global_salt + primary_password).digest()
507
+ key = pbkdf2_hmac("sha256", k, entry_salt, iteration_count, dklen=key_length)
508
+
509
+ iv = b"\x04\x0e" + decoded_item[0][1][1][1].native
510
+ cipher_text = decoded_item[1].native
511
+ return AES.new(key, AES.MODE_CBC, iv).decrypt(cipher_text)
512
+
513
+
514
+ def decrypt_sha1_triple_des_cbc(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> bytes:
515
+ """Decrypt an item with the given Firefox 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 3DES ciphertext.
527
+ """
528
+
529
+ # SEQUENCE {
530
+ # SEQUENCE {
531
+ # OBJECTIDENTIFIER 1.2.840.113549.1.12.5.1.3
532
+ # SEQUENCE {
533
+ # OCTETSTRING entry_salt
534
+ # INTEGER 01
535
+ # }
536
+ # }
537
+ # OCTETSTRING encrypted
538
+ # }
539
+
540
+ entry_salt = decoded_item[0][1][0].native
541
+ cipher_text = decoded_item[1].native
542
+ key = decrypt_moz_3des(global_salt, primary_password, entry_salt, cipher_text)
543
+ return key[:24]
544
+
545
+
546
+ def decrypt_master_key(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> tuple[bytes, str]:
547
+ """Decrypt the provided ``core.Sequence`` with the provided Firefox primary password and salt.
548
+
549
+ At this stage we are not yet sure of the structure of ``decoded_item``. The structure will depend on the
550
+ ``core.Sequence`` object identifier at ``decoded_item[0][0]``, hence we extract it. This function will
551
+ then call the apropriate ``decrypt_pbes2``or ``decrypt_sha1_triple_des_cbc`` functions to decrypt the item.
552
+
553
+ Args:
554
+ decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below.
555
+ primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with.
556
+ global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key.
557
+
558
+ Raises:
559
+ ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
560
+
561
+ Returns:
562
+ Tuple of decrypted bytes and a string representation of the identified encryption algorithm.
563
+ """
564
+
565
+ # SEQUENCE {
566
+ # SEQUENCE {
567
+ # OBJECTIDENTIFIER ???
568
+ # ...
569
+ # }
570
+ # ...
571
+ # }
572
+
573
+ if not HAS_CRYPTO:
574
+ raise ValueError("Missing pycryptodome dependency")
575
+
576
+ if not HAS_ASN1:
577
+ raise ValueError("Missing asn1crypto depdendency")
578
+
579
+ object_identifier = decoded_item[0][0]
580
+ algorithm = object_identifier.dotted
581
+
582
+ if algos.EncryptionAlgorithmId.map(algorithm) == "pbes2":
583
+ return decrypt_pbes2(decoded_item, primary_password, global_salt), algorithm
584
+ elif algorithm == pbeWithSha1AndTripleDES_CBC:
585
+ return decrypt_sha1_triple_des_cbc(decoded_item, primary_password, global_salt), algorithm
586
+ else:
587
+ # Firefox supports other algorithms (i.e. Firefox before 2018), but decrypting these is not (yet) supported.
588
+ return b"", algorithm
589
+
590
+
591
+ def query_global_salt(key4_file: TargetPath) -> tuple[str, str]:
592
+ with key4_file.open("rb") as fh:
593
+ db = sqlite3.SQLite3(fh)
594
+ for row in db.table("metadata").rows():
595
+ if row.get("id") == "password":
596
+ return row.get("item1", ""), row.get("item2", "")
597
+
598
+
599
+ def query_master_key(key4_file: TargetPath) -> tuple[str, str]:
600
+ with key4_file.open("rb") as fh:
601
+ db = sqlite3.SQLite3(fh)
602
+ for row in db.table("nssPrivate").rows():
603
+ return row.get("a11", ""), row.get("a102", "")
604
+
605
+
606
+ def retrieve_master_key(primary_password: bytes, key4_file: TargetPath) -> tuple[bytes, str]:
607
+ if not HAS_CRYPTO:
608
+ raise ValueError("Missing pycryptodome dependency")
609
+
610
+ if not HAS_ASN1:
611
+ raise ValueError("Missing asn1crypto dependency")
612
+
613
+ global_salt, password_check = query_global_salt(key4_file)
614
+ decoded_password_check = core.load(password_check)
615
+
616
+ try:
617
+ decrypted_password_check, algorithm = decrypt_master_key(decoded_password_check, primary_password, global_salt)
618
+ except EOFError:
619
+ raise ValueError("No primary password provided")
620
+
621
+ if not decrypted_password_check:
622
+ raise ValueError(f"Encountered unknown algorithm {algorithm} while decrypting master key")
623
+
624
+ expected_password_check = b"password-check\x02\x02"
625
+ if decrypted_password_check != b"password-check\x02\x02":
626
+ log.debug("Expected %s but got %s", expected_password_check, decrypted_password_check)
627
+ raise ValueError("Master key decryption failed. Provided password could be missing or incorrect")
628
+
629
+ master_key, master_key_cka = query_master_key(key4_file)
630
+ if master_key == b"":
631
+ raise ValueError("Password master key is not defined")
632
+
633
+ if master_key_cka != CKA_ID:
634
+ raise ValueError(f"Password master key CKA_ID '{master_key_cka}' is not equal to expected value '{CKA_ID}'")
635
+
636
+ decoded_master_key = core.load(master_key)
637
+ decrypted, algorithm = decrypt_master_key(decoded_master_key, primary_password, global_salt)
638
+ return decrypted[:24], algorithm
639
+
640
+
641
+ def decrypt_field(key: bytes, field: tuple[bytes, bytes, bytes]) -> bytes:
642
+ if not HAS_CRYPTO:
643
+ raise ValueError("Missing pycryptodome dependency")
644
+
645
+ cka, iv, ciphertext = field
646
+
647
+ if cka != CKA_ID:
648
+ raise ValueError(f"Expected cka to equal '{CKA_ID}' but got '{cka}'")
649
+
650
+ return unpad(DES3.new(key, DES3.MODE_CBC, iv).decrypt(ciphertext), 8)
651
+
652
+
653
+ def decrypt(
654
+ username: str, password: str, key4_file: TargetPath, primary_password: str = ""
655
+ ) -> tuple[Optional[str], Optional[str]]:
656
+ """Decrypt a stored username and password using provided credentials and key4 file.
657
+
658
+ Args:
659
+ username: Encoded and encrypted password.
660
+ password Encoded and encrypted password.
661
+ key4_file: Path to key4.db file.
662
+ primary_password: Password to use for decryption routine.
663
+
664
+ Returns:
665
+ A tuple of decoded username and password strings.
666
+
667
+ Resources:
668
+ - https://github.com/lclevy/firepwd
669
+ """
670
+ if not HAS_CRYPTO:
671
+ raise ValueError("Missing pycryptodome dependency")
672
+
673
+ if not HAS_ASN1:
674
+ raise ValueError("Missing asn1crypto dependency")
675
+
676
+ try:
677
+ username = decode_login_data(username)
678
+ password = decode_login_data(password)
679
+
680
+ primary_password_bytes = primary_password.encode()
681
+ key, algorithm = retrieve_master_key(primary_password_bytes, key4_file)
682
+
683
+ if algorithm == pbeWithSha1AndTripleDES_CBC or algos.EncryptionAlgorithmId.map(algorithm) == "pbes2":
684
+ username = decrypt_field(key, username)
685
+ password = decrypt_field(key, password)
686
+ return username.decode(), password.decode()
687
+
688
+ except ValueError as e:
689
+ 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:
@@ -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
 
@@ -4,7 +4,13 @@ from datetime import datetime
4
4
  from pathlib import Path
5
5
  from typing import Iterator, Optional, Union
6
6
 
7
- from Crypto.PublicKey import ECC, RSA
7
+ try:
8
+ from Crypto.PublicKey import ECC, RSA
9
+
10
+ HAS_CRYPTO = True
11
+ except ImportError:
12
+ HAS_CRYPTO = False
13
+
8
14
  from flow.record.fieldtypes import posix_path, windows_path
9
15
 
10
16
  from dissect.target.exceptions import RegistryKeyNotFoundError, UnsupportedPluginError
@@ -225,6 +231,9 @@ def construct_public_key(key_type: str, iv: str) -> tuple[str, tuple[str, str, s
225
231
  - https://pycryptodome.readthedocs.io/en/latest/src/public_key/ecc.html
226
232
  - https://github.com/mkorthof/reg2kh
227
233
  """
234
+ if not HAS_CRYPTO:
235
+ log.warning("Could not reconstruct public key: missing pycryptodome dependency")
236
+ return iv
228
237
 
229
238
  if not isinstance(key_type, str) or not isinstance(iv, str):
230
239
  raise ValueError("Invalid key_type or iv")
@@ -0,0 +1,24 @@
1
+ from typing import Iterator
2
+
3
+ from dissect.target.exceptions import UnsupportedPluginError
4
+ from dissect.target.helpers.record import ChildTargetRecord
5
+ from dissect.target.plugin import ChildTargetPlugin
6
+
7
+
8
+ class DockerChildTargetPlugin(ChildTargetPlugin):
9
+ """Child target plugin that yields from Docker overlay2fs containers."""
10
+
11
+ __type__ = "docker"
12
+
13
+ def check_compatible(self) -> None:
14
+ if not self.target.has_function("docker"):
15
+ raise UnsupportedPluginError("No Docker data root folder(s) found!")
16
+
17
+ def list_children(self) -> Iterator[ChildTargetRecord]:
18
+ for container in self.target.docker.containers():
19
+ if container.mount_path:
20
+ yield ChildTargetRecord(
21
+ type=self.__type__,
22
+ path=container.mount_path,
23
+ _target=self.target,
24
+ )