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.
- dissect/target/filesystems/dir.py +14 -1
- dissect/target/filesystems/overlay.py +103 -0
- dissect/target/helpers/compat/path_common.py +19 -5
- dissect/target/loader.py +1 -0
- dissect/target/loaders/itunes.py +3 -3
- dissect/target/loaders/overlay.py +31 -0
- dissect/target/plugins/apps/browser/brave.py +10 -0
- dissect/target/plugins/apps/browser/browser.py +43 -0
- dissect/target/plugins/apps/browser/chrome.py +10 -0
- dissect/target/plugins/apps/browser/chromium.py +234 -12
- dissect/target/plugins/apps/browser/edge.py +10 -0
- dissect/target/plugins/apps/browser/firefox.py +440 -19
- dissect/target/plugins/apps/browser/iexplore.py +1 -1
- dissect/target/plugins/apps/container/docker.py +24 -4
- dissect/target/plugins/apps/ssh/putty.py +10 -1
- dissect/target/plugins/child/docker.py +24 -0
- dissect/target/plugins/os/unix/linux/fortios/_os.py +6 -6
- dissect/target/plugins/os/windows/catroot.py +11 -2
- dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
- dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
- dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
- dissect/target/plugins/os/windows/sam.py +10 -1
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/METADATA +1 -1
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/RECORD +29 -26
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/COPYRIGHT +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/LICENSE +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/WHEEL +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
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
|
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
|
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.
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
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="
|
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
|
-
|
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
|
-
("
|
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
|
-
("
|
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=
|
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
|
-
|
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
|
-
|
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
|
+
)
|