dissect.target 3.16.dev44__py3-none-any.whl → 3.17__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- dissect/target/container.py +1 -0
- dissect/target/containers/fortifw.py +190 -0
- dissect/target/filesystem.py +192 -67
- 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/helpers/configutil.py +30 -7
- dissect/target/helpers/network_managers.py +101 -73
- dissect/target/helpers/record_modifier.py +4 -1
- dissect/target/loader.py +3 -1
- dissect/target/loaders/dir.py +23 -5
- dissect/target/loaders/itunes.py +3 -3
- dissect/target/loaders/mqtt.py +309 -0
- dissect/target/loaders/overlay.py +31 -0
- dissect/target/loaders/target.py +12 -9
- dissect/target/loaders/vb.py +2 -2
- dissect/target/loaders/velociraptor.py +5 -4
- dissect/target/plugin.py +1 -1
- 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 +512 -19
- dissect/target/plugins/apps/browser/iexplore.py +2 -2
- dissect/target/plugins/apps/container/docker.py +24 -4
- dissect/target/plugins/apps/ssh/openssh.py +4 -0
- dissect/target/plugins/apps/ssh/putty.py +45 -14
- dissect/target/plugins/apps/ssh/ssh.py +40 -0
- dissect/target/plugins/apps/vpn/openvpn.py +115 -93
- dissect/target/plugins/child/docker.py +24 -0
- dissect/target/plugins/filesystem/ntfs/mft.py +1 -1
- dissect/target/plugins/filesystem/walkfs.py +2 -2
- dissect/target/plugins/general/users.py +6 -0
- dissect/target/plugins/os/unix/bsd/__init__.py +0 -0
- dissect/target/plugins/os/unix/esxi/_os.py +2 -2
- dissect/target/plugins/os/unix/linux/debian/vyos/_os.py +1 -1
- dissect/target/plugins/os/unix/linux/fortios/_os.py +9 -9
- dissect/target/plugins/os/unix/linux/services.py +1 -0
- dissect/target/plugins/os/unix/linux/sockets.py +2 -2
- dissect/target/plugins/os/unix/log/messages.py +53 -8
- dissect/target/plugins/os/windows/_os.py +10 -1
- dissect/target/plugins/os/windows/catroot.py +178 -63
- dissect/target/plugins/os/windows/credhist.py +210 -0
- 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/regf/runkeys.py +6 -4
- dissect/target/plugins/os/windows/sam.py +10 -1
- dissect/target/target.py +1 -1
- dissect/target/tools/dump/run.py +23 -28
- dissect/target/tools/dump/state.py +11 -8
- dissect/target/tools/dump/utils.py +5 -4
- dissect/target/tools/query.py +3 -15
- dissect/target/tools/shell.py +48 -8
- dissect/target/tools/utils.py +23 -0
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/METADATA +7 -3
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/RECORD +63 -56
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/WHEEL +1 -1
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/COPYRIGHT +0 -0
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/LICENSE +0 -0
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/entry_points.txt +0 -0
- {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,9 @@
|
|
1
|
+
import hmac
|
1
2
|
import json
|
2
|
-
|
3
|
+
import logging
|
4
|
+
from base64 import b64decode
|
5
|
+
from hashlib import pbkdf2_hmac, sha1
|
6
|
+
from typing import Iterator, Optional
|
3
7
|
|
4
8
|
from dissect.sql import sqlite3
|
5
9
|
from dissect.sql.exceptions import Error as SQLError
|
@@ -8,15 +12,41 @@ from dissect.util.ts import from_unix_ms, from_unix_us
|
|
8
12
|
|
9
13
|
from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError
|
10
14
|
from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension
|
15
|
+
from dissect.target.helpers.fsutil import TargetPath
|
11
16
|
from dissect.target.helpers.record import create_extended_descriptor
|
12
17
|
from dissect.target.plugin import export
|
13
18
|
from dissect.target.plugins.apps.browser.browser import (
|
14
19
|
GENERIC_COOKIE_FIELDS,
|
15
20
|
GENERIC_DOWNLOAD_RECORD_FIELDS,
|
21
|
+
GENERIC_EXTENSION_RECORD_FIELDS,
|
16
22
|
GENERIC_HISTORY_RECORD_FIELDS,
|
23
|
+
GENERIC_PASSWORD_RECORD_FIELDS,
|
17
24
|
BrowserPlugin,
|
18
25
|
try_idna,
|
19
26
|
)
|
27
|
+
from dissect.target.plugins.general.users import UserDetails
|
28
|
+
|
29
|
+
try:
|
30
|
+
from asn1crypto import algos, core
|
31
|
+
|
32
|
+
HAS_ASN1 = True
|
33
|
+
|
34
|
+
except ImportError:
|
35
|
+
HAS_ASN1 = False
|
36
|
+
|
37
|
+
|
38
|
+
try:
|
39
|
+
from Crypto.Cipher import AES, DES3
|
40
|
+
from Crypto.Util.Padding import unpad
|
41
|
+
|
42
|
+
HAS_CRYPTO = True
|
43
|
+
|
44
|
+
except ImportError:
|
45
|
+
HAS_CRYPTO = False
|
46
|
+
|
47
|
+
FIREFOX_EXTENSION_RECORD_FIELDS = [("uri", "source_uri"), ("string[]", "optional_permissions")]
|
48
|
+
|
49
|
+
log = logging.getLogger(__name__)
|
20
50
|
|
21
51
|
|
22
52
|
class FirefoxPlugin(BrowserPlugin):
|
@@ -48,21 +78,37 @@ class FirefoxPlugin(BrowserPlugin):
|
|
48
78
|
"browser/firefox/download", GENERIC_DOWNLOAD_RECORD_FIELDS
|
49
79
|
)
|
50
80
|
|
81
|
+
BrowserExtensionRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
|
82
|
+
"browser/firefox/extension", GENERIC_EXTENSION_RECORD_FIELDS + FIREFOX_EXTENSION_RECORD_FIELDS
|
83
|
+
)
|
84
|
+
|
85
|
+
BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
|
86
|
+
"browser/firefox/password", GENERIC_PASSWORD_RECORD_FIELDS
|
87
|
+
)
|
88
|
+
|
51
89
|
def __init__(self, target):
|
52
90
|
super().__init__(target)
|
53
|
-
self.users_dirs = []
|
91
|
+
self.users_dirs: list[tuple[UserDetails, TargetPath]] = []
|
54
92
|
for user_details in self.target.user_details.all_with_home():
|
55
93
|
for directory in self.DIRS:
|
56
94
|
cur_dir = user_details.home_path.joinpath(directory)
|
57
95
|
if not cur_dir.exists():
|
58
96
|
continue
|
59
|
-
self.users_dirs.append((user_details
|
97
|
+
self.users_dirs.append((user_details, cur_dir))
|
60
98
|
|
61
99
|
def check_compatible(self) -> None:
|
62
100
|
if not len(self.users_dirs):
|
63
101
|
raise UnsupportedPluginError("No Firefox directories found")
|
64
102
|
|
65
|
-
def
|
103
|
+
def _iter_profiles(self) -> Iterator[tuple[UserDetails, TargetPath, TargetPath]]:
|
104
|
+
"""Yield user directories."""
|
105
|
+
for user, cur_dir in self.users_dirs:
|
106
|
+
for profile_dir in cur_dir.iterdir():
|
107
|
+
if not profile_dir.is_dir():
|
108
|
+
continue
|
109
|
+
yield user, cur_dir, profile_dir
|
110
|
+
|
111
|
+
def _iter_db(self, filename: str) -> Iterator[tuple[UserDetails, SQLite3]]:
|
66
112
|
"""Yield opened history database files of all users.
|
67
113
|
|
68
114
|
Args:
|
@@ -71,16 +117,15 @@ class FirefoxPlugin(BrowserPlugin):
|
|
71
117
|
Yields:
|
72
118
|
Opened SQLite3 databases.
|
73
119
|
"""
|
74
|
-
for user, cur_dir in self.
|
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)
|
120
|
+
for user, cur_dir, profile_dir in self._iter_profiles():
|
121
|
+
db_file = profile_dir.joinpath(filename)
|
122
|
+
try:
|
123
|
+
yield user, db_file, sqlite3.SQLite3(db_file.open())
|
124
|
+
except FileNotFoundError:
|
125
|
+
self.target.log.warning("Could not find %s file: %s", filename, db_file)
|
126
|
+
except SQLError as e:
|
127
|
+
self.target.log.warning("Could not open %s file: %s", filename, db_file)
|
128
|
+
self.target.log.debug("", exc_info=e)
|
84
129
|
|
85
130
|
@export(record=BrowserHistoryRecord)
|
86
131
|
def history(self) -> Iterator[BrowserHistoryRecord]:
|
@@ -135,7 +180,7 @@ class FirefoxPlugin(BrowserPlugin):
|
|
135
180
|
from_url=try_idna(from_place.url) if from_place else None,
|
136
181
|
source=db_file,
|
137
182
|
_target=self.target,
|
138
|
-
_user=user,
|
183
|
+
_user=user.user,
|
139
184
|
)
|
140
185
|
except SQLError as e:
|
141
186
|
self.target.log.warning("Error processing history file: %s", db_file, exc_info=e)
|
@@ -167,7 +212,7 @@ class FirefoxPlugin(BrowserPlugin):
|
|
167
212
|
yield self.BrowserCookieRecord(
|
168
213
|
ts_created=from_unix_us(cookie.creationTime),
|
169
214
|
ts_last_accessed=from_unix_us(cookie.lastAccessed),
|
170
|
-
browser="
|
215
|
+
browser="firefox",
|
171
216
|
name=cookie.name,
|
172
217
|
value=cookie.value,
|
173
218
|
host=cookie.host,
|
@@ -177,7 +222,7 @@ class FirefoxPlugin(BrowserPlugin):
|
|
177
222
|
is_http_only=bool(cookie.isHttpOnly),
|
178
223
|
same_site=bool(cookie.sameSite),
|
179
224
|
source=db_file,
|
180
|
-
_user=user,
|
225
|
+
_user=user.user,
|
181
226
|
)
|
182
227
|
except SQLError as e:
|
183
228
|
self.target.log.warning("Error processing cookie file: %s", db_file, exc_info=e)
|
@@ -261,8 +306,456 @@ class FirefoxPlugin(BrowserPlugin):
|
|
261
306
|
state=state,
|
262
307
|
source=db_file,
|
263
308
|
_target=self.target,
|
264
|
-
_user=user,
|
309
|
+
_user=user.user,
|
265
310
|
)
|
266
311
|
except SQLError as e:
|
267
312
|
self.target.log.warning("Error processing history file: %s", db_file, exc_info=e)
|
268
|
-
|
313
|
+
|
314
|
+
@export(record=BrowserExtensionRecord)
|
315
|
+
def extensions(self) -> Iterator[BrowserExtensionRecord]:
|
316
|
+
"""Return browser extension records for Firefox.
|
317
|
+
|
318
|
+
Yields BrowserExtensionRecord with the following fields::
|
319
|
+
ts_install (datetime): Extension install timestamp.
|
320
|
+
ts_update (datetime): Extension update timestamp.
|
321
|
+
browser (string): The browser from which the records are generated.
|
322
|
+
id (string): Extension unique identifier.
|
323
|
+
name (string): Name of the extension.
|
324
|
+
short_name (string): Short name of the extension.
|
325
|
+
default_title (string): Default title of the extension.
|
326
|
+
description (string): Description of the extension.
|
327
|
+
version (string): Version of the extension.
|
328
|
+
ext_path (path): Relative path of the extension.
|
329
|
+
from_webstore (boolean): Extension from webstore.
|
330
|
+
permissions (string[]): Permissions of the extension.
|
331
|
+
manifest (varint): Version of the extensions' manifest.
|
332
|
+
optional_permissions (string[]): Optional permissions of the extension.
|
333
|
+
source_uri (path): Source path from which the extension was downloaded.
|
334
|
+
source (path): The source file of the download record.
|
335
|
+
"""
|
336
|
+
for user, _, profile_dir in self._iter_profiles():
|
337
|
+
extension_file = profile_dir.joinpath("extensions.json")
|
338
|
+
|
339
|
+
if not extension_file.exists():
|
340
|
+
self.target.log.warning(
|
341
|
+
"No 'extensions.json' addon file found for user %s in directory %s", user, profile_dir
|
342
|
+
)
|
343
|
+
continue
|
344
|
+
|
345
|
+
try:
|
346
|
+
extensions = json.load(extension_file.open())
|
347
|
+
|
348
|
+
for extension in extensions.get("addons", []):
|
349
|
+
yield self.BrowserExtensionRecord(
|
350
|
+
ts_install=extension.get("installDate", 0) // 1000,
|
351
|
+
ts_update=extension.get("updateDate", 0) // 1000,
|
352
|
+
browser="firefox",
|
353
|
+
id=extension.get("id"),
|
354
|
+
name=extension.get("defaultLocale", {}).get("name"),
|
355
|
+
short_name=None,
|
356
|
+
default_title=None,
|
357
|
+
description=extension.get("defaultLocale", {}).get("description"),
|
358
|
+
version=extension.get("version"),
|
359
|
+
ext_path=extension.get("path"),
|
360
|
+
from_webstore=None,
|
361
|
+
permissions=extension.get("userPermissions", {}).get("permissions"),
|
362
|
+
manifest_version=extension.get("manifestVersion"),
|
363
|
+
source_uri=extension.get("sourceURI"),
|
364
|
+
optional_permissions=extension.get("optionalPermissions", {}).get("permissions"),
|
365
|
+
source=extension_file,
|
366
|
+
_target=self.target,
|
367
|
+
_user=user.user,
|
368
|
+
)
|
369
|
+
|
370
|
+
except FileNotFoundError:
|
371
|
+
self.target.log.info(
|
372
|
+
"No 'extensions.json' addon file found for user %s in directory %s", user, profile_dir
|
373
|
+
)
|
374
|
+
except json.JSONDecodeError:
|
375
|
+
self.target.log.warning(
|
376
|
+
"extensions.json file in directory %s is malformed, consider inspecting the file manually",
|
377
|
+
profile_dir,
|
378
|
+
)
|
379
|
+
|
380
|
+
@export(record=BrowserPasswordRecord)
|
381
|
+
def passwords(self) -> Iterator[BrowserPasswordRecord]:
|
382
|
+
"""Return Firefox browser password records.
|
383
|
+
|
384
|
+
Automatically decrypts passwords from Firefox 58 onwards (2018) if no primary password is set.
|
385
|
+
Alternatively, you can supply a primary password through the keychain to access the Firefox password store.
|
386
|
+
|
387
|
+
``PASSPHRASE`` passwords in the keychain with providers ``browser``, ``firefox``, ``user`` and no provider
|
388
|
+
can be used to decrypt secrets for this plugin.
|
389
|
+
|
390
|
+
Resources:
|
391
|
+
- https://github.com/lclevy/firepwd
|
392
|
+
"""
|
393
|
+
for user, _, profile_dir in self._iter_profiles():
|
394
|
+
login_file = profile_dir.joinpath("logins.json")
|
395
|
+
key3_file = profile_dir.joinpath("key3.db")
|
396
|
+
key4_file = profile_dir.joinpath("key4.db")
|
397
|
+
|
398
|
+
if not login_file.exists():
|
399
|
+
self.target.log.warning(
|
400
|
+
"No 'logins.json' password file found for user %s in directory %s", user, profile_dir
|
401
|
+
)
|
402
|
+
continue
|
403
|
+
|
404
|
+
if key3_file.exists() and not key4_file.exists():
|
405
|
+
self.target.log.warning("Unsupported file 'key3.db' found in %s", profile_dir)
|
406
|
+
continue
|
407
|
+
|
408
|
+
if not key4_file.exists():
|
409
|
+
self.target.log.warning("No 'key4.db' found in %s", profile_dir)
|
410
|
+
continue
|
411
|
+
|
412
|
+
try:
|
413
|
+
logins = json.load(login_file.open())
|
414
|
+
|
415
|
+
for login in logins.get("logins", []):
|
416
|
+
decrypted_username = None
|
417
|
+
decrypted_password = None
|
418
|
+
|
419
|
+
for password in self.keychain():
|
420
|
+
try:
|
421
|
+
decrypted_username, decrypted_password = decrypt(
|
422
|
+
login.get("encryptedUsername"),
|
423
|
+
login.get("encryptedPassword"),
|
424
|
+
key4_file,
|
425
|
+
password,
|
426
|
+
)
|
427
|
+
except ValueError as e:
|
428
|
+
self.target.log.warning("Exception while trying to decrypt")
|
429
|
+
self.target.log.debug("", exc_info=e)
|
430
|
+
|
431
|
+
if decrypted_password and decrypted_username:
|
432
|
+
break
|
433
|
+
|
434
|
+
yield self.BrowserPasswordRecord(
|
435
|
+
ts_created=login.get("timeCreated", 0) // 1000,
|
436
|
+
ts_last_used=login.get("timeLastUsed", 0) // 1000,
|
437
|
+
ts_last_changed=login.get("timePasswordChanged", 0) // 1000,
|
438
|
+
browser="firefox",
|
439
|
+
id=login.get("id"),
|
440
|
+
url=login.get("hostname"),
|
441
|
+
encrypted_username=login.get("encryptedUsername"),
|
442
|
+
encrypted_password=login.get("encryptedPassword"),
|
443
|
+
decrypted_username=decrypted_username,
|
444
|
+
decrypted_password=decrypted_password,
|
445
|
+
source=login_file,
|
446
|
+
_target=self.target,
|
447
|
+
_user=user.user,
|
448
|
+
)
|
449
|
+
|
450
|
+
except FileNotFoundError:
|
451
|
+
self.target.log.info("No password file found for user %s in directory %s", user, profile_dir)
|
452
|
+
except json.JSONDecodeError:
|
453
|
+
self.target.log.warning(
|
454
|
+
"logins.json file in directory %s is malformed, consider inspecting the file manually", profile_dir
|
455
|
+
)
|
456
|
+
|
457
|
+
|
458
|
+
# Define separately because it is not defined in asn1crypto
|
459
|
+
pbeWithSha1AndTripleDES_CBC = "1.2.840.113549.1.12.5.1.3"
|
460
|
+
CKA_ID = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"
|
461
|
+
|
462
|
+
|
463
|
+
def decrypt_moz_3des(global_salt: bytes, primary_password: bytes, entry_salt: str, encrypted: bytes) -> bytes:
|
464
|
+
if not HAS_CRYPTO:
|
465
|
+
raise ValueError("Missing pycryptodome dependency")
|
466
|
+
|
467
|
+
hp = sha1(global_salt + primary_password).digest()
|
468
|
+
pes = entry_salt + b"\x00" * (20 - len(entry_salt))
|
469
|
+
chp = sha1(hp + entry_salt).digest()
|
470
|
+
k1 = hmac.new(chp, pes + entry_salt, sha1).digest()
|
471
|
+
tk = hmac.new(chp, pes, sha1).digest()
|
472
|
+
k2 = hmac.new(chp, tk + entry_salt, sha1).digest()
|
473
|
+
k = k1 + k2
|
474
|
+
iv = k[-8:]
|
475
|
+
key = k[:24]
|
476
|
+
return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encrypted)
|
477
|
+
|
478
|
+
|
479
|
+
def decode_login_data(data: str) -> tuple[bytes, bytes, bytes]:
|
480
|
+
"""Decode Firefox login data.
|
481
|
+
|
482
|
+
Args:
|
483
|
+
data: Base64 encoded data in string format.
|
484
|
+
|
485
|
+
Raises:
|
486
|
+
ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
|
487
|
+
|
488
|
+
Returns:
|
489
|
+
Tuple of bytes with ``key_id``, ``iv`` and ``ciphertext``
|
490
|
+
"""
|
491
|
+
|
492
|
+
# SEQUENCE {
|
493
|
+
# KEY_ID
|
494
|
+
# SEQUENCE {
|
495
|
+
# OBJECT_IDENTIFIER
|
496
|
+
# IV
|
497
|
+
# }
|
498
|
+
# CIPHERTEXT
|
499
|
+
# }
|
500
|
+
|
501
|
+
if not HAS_CRYPTO:
|
502
|
+
raise ValueError("Missing pycryptodome dependency")
|
503
|
+
|
504
|
+
if not HAS_ASN1:
|
505
|
+
raise ValueError("Missing asn1crypto dependency")
|
506
|
+
|
507
|
+
decoded = core.load(b64decode(data))
|
508
|
+
key_id = decoded[0].native
|
509
|
+
iv = decoded[1][1].native
|
510
|
+
ciphertext = decoded[2].native
|
511
|
+
return key_id, iv, ciphertext
|
512
|
+
|
513
|
+
|
514
|
+
def decrypt_pbes2(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> bytes:
|
515
|
+
"""Decrypt an item with the given primary password and salt.
|
516
|
+
|
517
|
+
Args:
|
518
|
+
decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below.
|
519
|
+
primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with.
|
520
|
+
global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key.
|
521
|
+
|
522
|
+
Raises:
|
523
|
+
ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
|
524
|
+
|
525
|
+
Returns:
|
526
|
+
Bytes of decrypted AES ciphertext.
|
527
|
+
"""
|
528
|
+
|
529
|
+
# SEQUENCE {
|
530
|
+
# SEQUENCE {
|
531
|
+
# OBJECTIDENTIFIER 1.2.840.113549.1.5.13 => pkcs5 pbes2
|
532
|
+
# SEQUENCE {
|
533
|
+
# SEQUENCE {
|
534
|
+
# OBJECTIDENTIFIER 1.2.840.113549.1.5.12 => pbkdf2
|
535
|
+
# SEQUENCE {
|
536
|
+
# OCTETSTRING 32 bytes, entrySalt
|
537
|
+
# INTEGER 01
|
538
|
+
# INTEGER 20
|
539
|
+
# SEQUENCE {
|
540
|
+
# OBJECTIDENTIFIER 1.2.840.113549.2.9 => hmacWithSHA256
|
541
|
+
# }
|
542
|
+
# }
|
543
|
+
# }
|
544
|
+
# SEQUENCE {
|
545
|
+
# OBJECTIDENTIFIER 2.16.840.1.101.3.4.1.42 => aes256-CBC
|
546
|
+
# OCTETSTRING 14 bytes, iv
|
547
|
+
# }
|
548
|
+
# }
|
549
|
+
# }
|
550
|
+
# OCTETSTRING encrypted
|
551
|
+
# }
|
552
|
+
|
553
|
+
if not HAS_CRYPTO:
|
554
|
+
raise ValueError("Missing pycryptodome dependency")
|
555
|
+
|
556
|
+
if not HAS_ASN1:
|
557
|
+
raise ValueError("Missing asn1crypto dependency")
|
558
|
+
|
559
|
+
pkcs5_oid = decoded_item[0][1][0][0].dotted
|
560
|
+
if algos.KdfAlgorithmId.map(pkcs5_oid) != "pbkdf2":
|
561
|
+
raise ValueError(f"Expected pbkdf2 object identifier, got: {pkcs5_oid}")
|
562
|
+
|
563
|
+
sha256_oid = decoded_item[0][1][0][1][3][0].dotted
|
564
|
+
if algos.HmacAlgorithmId.map(sha256_oid) != "sha256":
|
565
|
+
raise ValueError(f"Expected SHA256 object identifier, got: {pkcs5_oid}")
|
566
|
+
|
567
|
+
aes256_cbc_oid = decoded_item[0][1][1][0].dotted
|
568
|
+
if algos.EncryptionAlgorithmId.map(aes256_cbc_oid) != "aes256_cbc":
|
569
|
+
raise ValueError(f"Expected AES256-CBC object identifier, got: {pkcs5_oid}")
|
570
|
+
|
571
|
+
entry_salt = decoded_item[0][1][0][1][0].native
|
572
|
+
iteration_count = decoded_item[0][1][0][1][1].native
|
573
|
+
key_length = decoded_item[0][1][0][1][2].native
|
574
|
+
|
575
|
+
if key_length != 32:
|
576
|
+
raise ValueError(f"Expected key_length to be 32, got: {key_length}")
|
577
|
+
|
578
|
+
k = sha1(global_salt + primary_password).digest()
|
579
|
+
key = pbkdf2_hmac("sha256", k, entry_salt, iteration_count, dklen=key_length)
|
580
|
+
|
581
|
+
iv = b"\x04\x0e" + decoded_item[0][1][1][1].native
|
582
|
+
cipher_text = decoded_item[1].native
|
583
|
+
return AES.new(key, AES.MODE_CBC, iv).decrypt(cipher_text)
|
584
|
+
|
585
|
+
|
586
|
+
def decrypt_sha1_triple_des_cbc(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> bytes:
|
587
|
+
"""Decrypt an item with the given Firefox primary password and salt.
|
588
|
+
|
589
|
+
Args:
|
590
|
+
decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below.
|
591
|
+
primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with.
|
592
|
+
global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key.
|
593
|
+
|
594
|
+
Raises:
|
595
|
+
ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
|
596
|
+
|
597
|
+
Returns:
|
598
|
+
Bytes of decrypted 3DES ciphertext.
|
599
|
+
"""
|
600
|
+
|
601
|
+
# SEQUENCE {
|
602
|
+
# SEQUENCE {
|
603
|
+
# OBJECTIDENTIFIER 1.2.840.113549.1.12.5.1.3
|
604
|
+
# SEQUENCE {
|
605
|
+
# OCTETSTRING entry_salt
|
606
|
+
# INTEGER 01
|
607
|
+
# }
|
608
|
+
# }
|
609
|
+
# OCTETSTRING encrypted
|
610
|
+
# }
|
611
|
+
|
612
|
+
entry_salt = decoded_item[0][1][0].native
|
613
|
+
cipher_text = decoded_item[1].native
|
614
|
+
key = decrypt_moz_3des(global_salt, primary_password, entry_salt, cipher_text)
|
615
|
+
return key[:24]
|
616
|
+
|
617
|
+
|
618
|
+
def decrypt_master_key(decoded_item: core.Sequence, primary_password: bytes, global_salt: bytes) -> tuple[bytes, str]:
|
619
|
+
"""Decrypt the provided ``core.Sequence`` with the provided Firefox primary password and salt.
|
620
|
+
|
621
|
+
At this stage we are not yet sure of the structure of ``decoded_item``. The structure will depend on the
|
622
|
+
``core.Sequence`` object identifier at ``decoded_item[0][0]``, hence we extract it. This function will
|
623
|
+
then call the apropriate ``decrypt_pbes2``or ``decrypt_sha1_triple_des_cbc`` functions to decrypt the item.
|
624
|
+
|
625
|
+
Args:
|
626
|
+
decoded_item: ``core.Sequence`` is a ``list`` representation of ``SEQUENCE`` as described below.
|
627
|
+
primary_password: ``bytes`` of Firefox primary password to decrypt ciphertext with.
|
628
|
+
global_salt: ``bytes`` of salt to prepend to primary password when calculating AES key.
|
629
|
+
|
630
|
+
Raises:
|
631
|
+
ValueError: When missing ``pycryptodome`` or ``asn1crypto`` dependencies.
|
632
|
+
|
633
|
+
Returns:
|
634
|
+
Tuple of decrypted bytes and a string representation of the identified encryption algorithm.
|
635
|
+
"""
|
636
|
+
|
637
|
+
# SEQUENCE {
|
638
|
+
# SEQUENCE {
|
639
|
+
# OBJECTIDENTIFIER ???
|
640
|
+
# ...
|
641
|
+
# }
|
642
|
+
# ...
|
643
|
+
# }
|
644
|
+
|
645
|
+
if not HAS_CRYPTO:
|
646
|
+
raise ValueError("Missing pycryptodome dependency")
|
647
|
+
|
648
|
+
if not HAS_ASN1:
|
649
|
+
raise ValueError("Missing asn1crypto depdendency")
|
650
|
+
|
651
|
+
object_identifier = decoded_item[0][0]
|
652
|
+
algorithm = object_identifier.dotted
|
653
|
+
|
654
|
+
if algos.EncryptionAlgorithmId.map(algorithm) == "pbes2":
|
655
|
+
return decrypt_pbes2(decoded_item, primary_password, global_salt), algorithm
|
656
|
+
elif algorithm == pbeWithSha1AndTripleDES_CBC:
|
657
|
+
return decrypt_sha1_triple_des_cbc(decoded_item, primary_password, global_salt), algorithm
|
658
|
+
else:
|
659
|
+
# Firefox supports other algorithms (i.e. Firefox before 2018), but decrypting these is not (yet) supported.
|
660
|
+
return b"", algorithm
|
661
|
+
|
662
|
+
|
663
|
+
def query_global_salt(key4_file: TargetPath) -> tuple[str, str]:
|
664
|
+
with key4_file.open("rb") as fh:
|
665
|
+
db = sqlite3.SQLite3(fh)
|
666
|
+
for row in db.table("metadata").rows():
|
667
|
+
if row.get("id") == "password":
|
668
|
+
return row.get("item1", ""), row.get("item2", "")
|
669
|
+
|
670
|
+
|
671
|
+
def query_master_key(key4_file: TargetPath) -> tuple[str, str]:
|
672
|
+
with key4_file.open("rb") as fh:
|
673
|
+
db = sqlite3.SQLite3(fh)
|
674
|
+
for row in db.table("nssPrivate").rows():
|
675
|
+
return row.get("a11", ""), row.get("a102", "")
|
676
|
+
|
677
|
+
|
678
|
+
def retrieve_master_key(primary_password: bytes, key4_file: TargetPath) -> tuple[bytes, str]:
|
679
|
+
if not HAS_CRYPTO:
|
680
|
+
raise ValueError("Missing pycryptodome dependency")
|
681
|
+
|
682
|
+
if not HAS_ASN1:
|
683
|
+
raise ValueError("Missing asn1crypto dependency")
|
684
|
+
|
685
|
+
global_salt, password_check = query_global_salt(key4_file)
|
686
|
+
decoded_password_check = core.load(password_check)
|
687
|
+
|
688
|
+
try:
|
689
|
+
decrypted_password_check, algorithm = decrypt_master_key(decoded_password_check, primary_password, global_salt)
|
690
|
+
except EOFError:
|
691
|
+
raise ValueError("No primary password provided")
|
692
|
+
|
693
|
+
if not decrypted_password_check:
|
694
|
+
raise ValueError(f"Encountered unknown algorithm {algorithm} while decrypting master key")
|
695
|
+
|
696
|
+
expected_password_check = b"password-check\x02\x02"
|
697
|
+
if decrypted_password_check != b"password-check\x02\x02":
|
698
|
+
log.debug("Expected %s but got %s", expected_password_check, decrypted_password_check)
|
699
|
+
raise ValueError("Master key decryption failed. Provided password could be missing or incorrect")
|
700
|
+
|
701
|
+
master_key, master_key_cka = query_master_key(key4_file)
|
702
|
+
if master_key == b"":
|
703
|
+
raise ValueError("Password master key is not defined")
|
704
|
+
|
705
|
+
if master_key_cka != CKA_ID:
|
706
|
+
raise ValueError(f"Password master key CKA_ID '{master_key_cka}' is not equal to expected value '{CKA_ID}'")
|
707
|
+
|
708
|
+
decoded_master_key = core.load(master_key)
|
709
|
+
decrypted, algorithm = decrypt_master_key(decoded_master_key, primary_password, global_salt)
|
710
|
+
return decrypted[:24], algorithm
|
711
|
+
|
712
|
+
|
713
|
+
def decrypt_field(key: bytes, field: tuple[bytes, bytes, bytes]) -> bytes:
|
714
|
+
if not HAS_CRYPTO:
|
715
|
+
raise ValueError("Missing pycryptodome dependency")
|
716
|
+
|
717
|
+
cka, iv, ciphertext = field
|
718
|
+
|
719
|
+
if cka != CKA_ID:
|
720
|
+
raise ValueError(f"Expected cka to equal '{CKA_ID}' but got '{cka}'")
|
721
|
+
|
722
|
+
return unpad(DES3.new(key, DES3.MODE_CBC, iv).decrypt(ciphertext), 8)
|
723
|
+
|
724
|
+
|
725
|
+
def decrypt(
|
726
|
+
username: str, password: str, key4_file: TargetPath, primary_password: str = ""
|
727
|
+
) -> tuple[Optional[str], Optional[str]]:
|
728
|
+
"""Decrypt a stored username and password using provided credentials and key4 file.
|
729
|
+
|
730
|
+
Args:
|
731
|
+
username: Encoded and encrypted password.
|
732
|
+
password Encoded and encrypted password.
|
733
|
+
key4_file: Path to key4.db file.
|
734
|
+
primary_password: Password to use for decryption routine.
|
735
|
+
|
736
|
+
Returns:
|
737
|
+
A tuple of decoded username and password strings.
|
738
|
+
|
739
|
+
Resources:
|
740
|
+
- https://github.com/lclevy/firepwd
|
741
|
+
"""
|
742
|
+
if not HAS_CRYPTO:
|
743
|
+
raise ValueError("Missing pycryptodome dependency")
|
744
|
+
|
745
|
+
if not HAS_ASN1:
|
746
|
+
raise ValueError("Missing asn1crypto dependency")
|
747
|
+
|
748
|
+
try:
|
749
|
+
username = decode_login_data(username)
|
750
|
+
password = decode_login_data(password)
|
751
|
+
|
752
|
+
primary_password_bytes = primary_password.encode()
|
753
|
+
key, algorithm = retrieve_master_key(primary_password_bytes, key4_file)
|
754
|
+
|
755
|
+
if algorithm == pbeWithSha1AndTripleDES_CBC or algos.EncryptionAlgorithmId.map(algorithm) == "pbes2":
|
756
|
+
username = decrypt_field(key, username)
|
757
|
+
password = decrypt_field(key, password)
|
758
|
+
return username.decode(), password.decode()
|
759
|
+
|
760
|
+
except ValueError as e:
|
761
|
+
raise ValueError(f"Failed to decrypt password using keyfile: {key4_file}, password: {primary_password}") from e
|
@@ -26,7 +26,7 @@ class WebCache:
|
|
26
26
|
self.target = target
|
27
27
|
self.db = esedb.EseDB(fh)
|
28
28
|
|
29
|
-
def find_containers(self, name: str) -> table.Table:
|
29
|
+
def find_containers(self, name: str) -> Iterator[table.Table]:
|
30
30
|
"""Look up all ``ContainerId`` values for a given container name.
|
31
31
|
|
32
32
|
Args:
|
@@ -212,7 +212,7 @@ class InternetExplorerPlugin(BrowserPlugin):
|
|
212
212
|
ts_end=ts_end,
|
213
213
|
browser="iexplore",
|
214
214
|
id=container_record.EntryId,
|
215
|
-
path=self.target.fs.path(down_path),
|
215
|
+
path=self.target.fs.path(down_path) if down_path else None,
|
216
216
|
url=down_url,
|
217
217
|
size=None,
|
218
218
|
state=None,
|
@@ -23,16 +23,18 @@ DockerContainerRecord = TargetRecordDescriptor(
|
|
23
23
|
[
|
24
24
|
("string", "container_id"),
|
25
25
|
("string", "image"),
|
26
|
+
("string", "image_id"),
|
26
27
|
("string", "command"),
|
27
28
|
("datetime", "created"),
|
28
|
-
("
|
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
|
|