dissect.target 3.17.dev31__py3-none-any.whl → 3.17.dev34__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- 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/dir.py +23 -5
- dissect/target/loaders/itunes.py +3 -3
- dissect/target/loaders/overlay.py +31 -0
- dissect/target/loaders/velociraptor.py +5 -4
- 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.dev34.dist-info}/METADATA +1 -1
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/RECORD +31 -28
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/COPYRIGHT +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/LICENSE +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/WHEEL +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/entry_points.txt +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.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
|
+
)
|