dissect.target 3.17.dev29__py3-none-any.whl → 3.17.dev33__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) 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/_os.py +1 -1
  19. dissect/target/plugins/os/windows/catroot.py +11 -2
  20. dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
  21. dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
  22. dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
  23. dissect/target/plugins/os/windows/sam.py +10 -1
  24. {dissect.target-3.17.dev29.dist-info → dissect.target-3.17.dev33.dist-info}/METADATA +1 -1
  25. {dissect.target-3.17.dev29.dist-info → dissect.target-3.17.dev33.dist-info}/RECORD +30 -27
  26. {dissect.target-3.17.dev29.dist-info → dissect.target-3.17.dev33.dist-info}/COPYRIGHT +0 -0
  27. {dissect.target-3.17.dev29.dist-info → dissect.target-3.17.dev33.dist-info}/LICENSE +0 -0
  28. {dissect.target-3.17.dev29.dist-info → dissect.target-3.17.dev33.dist-info}/WHEEL +0 -0
  29. {dissect.target-3.17.dev29.dist-info → dissect.target-3.17.dev33.dist-info}/entry_points.txt +0 -0
  30. {dissect.target-3.17.dev29.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
+ )