dissect.target 3.15.dev33__py3-none-any.whl → 3.15.dev38__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- dissect/target/plugins/os/unix/linux/fortios/_os.py +180 -28
- dissect/target/plugins/os/unix/linux/fortios/generic.py +5 -3
- dissect/target/plugins/os/unix/linux/fortios/locale.py +14 -6
- dissect/target/plugins/os/windows/tasks.py +148 -2
- {dissect.target-3.15.dev33.dist-info → dissect.target-3.15.dev38.dist-info}/METADATA +1 -1
- {dissect.target-3.15.dev33.dist-info → dissect.target-3.15.dev38.dist-info}/RECORD +11 -11
- {dissect.target-3.15.dev33.dist-info → dissect.target-3.15.dev38.dist-info}/COPYRIGHT +0 -0
- {dissect.target-3.15.dev33.dist-info → dissect.target-3.15.dev38.dist-info}/LICENSE +0 -0
- {dissect.target-3.15.dev33.dist-info → dissect.target-3.15.dev38.dist-info}/WHEEL +0 -0
- {dissect.target-3.15.dev33.dist-info → dissect.target-3.15.dev38.dist-info}/entry_points.txt +0 -0
- {dissect.target-3.15.dev33.dist-info → dissect.target-3.15.dev38.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,13 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import gzip
|
4
|
+
import hashlib
|
4
5
|
from base64 import b64decode
|
5
6
|
from datetime import datetime
|
7
|
+
from io import BytesIO
|
6
8
|
from tarfile import ReadError
|
7
|
-
from typing import Iterator, Optional, TextIO, Union
|
9
|
+
from typing import BinaryIO, Iterator, Optional, TextIO, Union
|
8
10
|
|
9
|
-
from Crypto.Cipher import AES
|
10
11
|
from dissect.util import cpio
|
11
12
|
from dissect.util.compression import xz
|
12
13
|
|
@@ -18,6 +19,13 @@ from dissect.target.plugin import OperatingSystem, export
|
|
18
19
|
from dissect.target.plugins.os.unix.linux._os import LinuxPlugin
|
19
20
|
from dissect.target.target import Target
|
20
21
|
|
22
|
+
try:
|
23
|
+
from Crypto.Cipher import AES, ChaCha20
|
24
|
+
|
25
|
+
HAS_PYCRYPTODOME = True
|
26
|
+
except ImportError:
|
27
|
+
HAS_PYCRYPTODOME = False
|
28
|
+
|
21
29
|
FortiOSUserRecord = TargetRecordDescriptor(
|
22
30
|
"fortios/user",
|
23
31
|
[
|
@@ -39,7 +47,7 @@ class FortiOSPlugin(LinuxPlugin):
|
|
39
47
|
|
40
48
|
def _load_config(self) -> dict:
|
41
49
|
CONFIG_FILES = {
|
42
|
-
"/data/system.conf":
|
50
|
+
"/data/system.conf": "global-config", # FortiManager
|
43
51
|
"/data/config/daemon.conf.gz": "daemon", # FortiOS 4.x
|
44
52
|
"/data/config/sys_global.conf.gz": "global-config", # Seen in FortiOS 5.x - 7.x
|
45
53
|
"/data/config/sys_vd_root.conf.gz": "root-config", # FortiOS 4.x
|
@@ -55,7 +63,7 @@ class FortiOSPlugin(LinuxPlugin):
|
|
55
63
|
else:
|
56
64
|
fh = conf_path.open("rt")
|
57
65
|
|
58
|
-
if not self._version and section in [
|
66
|
+
if not self._version and section in ["global-config", "root-config"]:
|
59
67
|
self._version = fh.readline().split("=", 1)[1]
|
60
68
|
|
61
69
|
parsed = FortiOSConfig.from_fh(fh)
|
@@ -72,20 +80,31 @@ class FortiOSPlugin(LinuxPlugin):
|
|
72
80
|
|
73
81
|
@classmethod
|
74
82
|
def create(cls, target: Target, sysvol: Filesystem) -> FortiOSPlugin:
|
83
|
+
target.log.warning("Attempting to load rootfs.gz, this can take a while.")
|
75
84
|
rootfs = sysvol.path("/rootfs.gz")
|
85
|
+
vfs = None
|
76
86
|
|
77
87
|
try:
|
78
|
-
|
79
|
-
rfs_fh = open_decompress(rootfs)
|
80
|
-
if rfs_fh.read(4) == b"07" * 2:
|
88
|
+
if open_decompress(rootfs).read(4) == b"0707":
|
81
89
|
vfs = TarFilesystem(rootfs.open(), tarinfo=cpio.CpioInfo)
|
82
90
|
else:
|
83
91
|
vfs = TarFilesystem(rootfs.open())
|
92
|
+
except ReadError:
|
93
|
+
# The rootfs.gz file could be encrypted.
|
94
|
+
try:
|
95
|
+
rfs_fh = decrypt_rootfs(rootfs.open(), get_kernel_hash(sysvol))
|
96
|
+
vfs = TarFilesystem(rfs_fh, tarinfo=cpio.CpioInfo)
|
97
|
+
except RuntimeError:
|
98
|
+
target.log.warning("Could not decrypt rootfs.gz. Missing `pycryptodome` dependency.")
|
99
|
+
except ValueError as e:
|
100
|
+
target.log.warning("Could not decrypt rootfs.gz. Unsupported kernel version.")
|
101
|
+
target.log.debug("", exc_info=e)
|
102
|
+
except ReadError as e:
|
103
|
+
target.log.warning("Could not mount rootfs.gz. It could be corrupt.")
|
104
|
+
target.log.debug("", exc_info=e)
|
105
|
+
|
106
|
+
if vfs:
|
84
107
|
target.fs.mount("/", vfs)
|
85
|
-
except ReadError as e:
|
86
|
-
# Since FortiOS version ~7.4.1 the rootfs.gz file is encrypted.
|
87
|
-
target.log.warning("Could not mount FortiOS `/rootfs.gz`. It could be encrypted or corrupt.")
|
88
|
-
target.log.debug("", exc_info=e)
|
89
108
|
|
90
109
|
target.fs.mount("/data", sysvol)
|
91
110
|
|
@@ -93,13 +112,21 @@ class FortiOSPlugin(LinuxPlugin):
|
|
93
112
|
if (datafs_tar := sysvol.path("/datafs.tar.gz")).exists():
|
94
113
|
target.fs.add_layer().mount("/data", TarFilesystem(datafs_tar.open("rb")))
|
95
114
|
|
96
|
-
# Additional FortiGate tars with corrupt XZ streams
|
97
|
-
|
98
|
-
|
115
|
+
# Additional FortiGate or FortiManager tars with corrupt XZ streams
|
116
|
+
target.log.warning("Attempting to load XZ files, this can take a while.")
|
117
|
+
for path in (
|
118
|
+
"bin.tar.xz",
|
119
|
+
"usr.tar.xz",
|
120
|
+
"migadmin.tar.xz",
|
121
|
+
"node-scripts.tar.xz",
|
122
|
+
"docker.tar.xz",
|
123
|
+
"syntax.tar.xz",
|
124
|
+
):
|
125
|
+
if (tar := target.fs.path(path)).exists() or (tar := sysvol.path(path)).exists():
|
99
126
|
fh = xz.repair_checksum(tar.open("rb"))
|
100
127
|
target.fs.add_layer().mount("/", TarFilesystem(fh))
|
101
128
|
|
102
|
-
# FortiAnalyzer
|
129
|
+
# FortiAnalyzer and FortiManager
|
103
130
|
if (rootfs_ext_tar := sysvol.path("rootfs-ext.tar.xz")).exists():
|
104
131
|
target.fs.add_layer().mount("/", TarFilesystem(rootfs_ext_tar.open("rb")))
|
105
132
|
|
@@ -117,9 +144,18 @@ class FortiOSPlugin(LinuxPlugin):
|
|
117
144
|
target.fs.mount("/boot", fs)
|
118
145
|
|
119
146
|
# data2 partition
|
120
|
-
if fs.__type__ == "ext" and
|
147
|
+
if fs.__type__ == "ext" and (
|
148
|
+
(fs.path("/new_alert_msg").exists() and fs.path("/template").exists()) # FortiGate
|
149
|
+
or (fs.path("/swapfile").exists() and fs.path("/old_fmversion").exists()) # FortiManager
|
150
|
+
):
|
121
151
|
target.fs.mount("/data2", fs)
|
122
152
|
|
153
|
+
# Symlink unix-like paths
|
154
|
+
unix_paths = [("/data/passwd", "/etc/passwd")]
|
155
|
+
for src, dst in unix_paths:
|
156
|
+
if target.fs.path(src).exists() and not target.fs.path(dst).exists():
|
157
|
+
target.fs.symlink(src, dst)
|
158
|
+
|
123
159
|
return cls(target)
|
124
160
|
|
125
161
|
@export(property=True)
|
@@ -158,8 +194,11 @@ class FortiOSPlugin(LinuxPlugin):
|
|
158
194
|
def dns(self) -> list[str]:
|
159
195
|
"""Return configured WAN DNS servers."""
|
160
196
|
entries = []
|
161
|
-
|
162
|
-
|
197
|
+
try:
|
198
|
+
for entry in self._config["global-config"]["system"]["dns"].values():
|
199
|
+
entries.append(entry[0])
|
200
|
+
except KeyError:
|
201
|
+
pass
|
163
202
|
return entries
|
164
203
|
|
165
204
|
@export(property=True)
|
@@ -176,7 +215,7 @@ class FortiOSPlugin(LinuxPlugin):
|
|
176
215
|
# Possible unix-like users
|
177
216
|
yield from super().users()
|
178
217
|
|
179
|
-
#
|
218
|
+
# FortiGate administrative users
|
180
219
|
try:
|
181
220
|
for username, entry in self._config["global-config"]["system"]["admin"].items():
|
182
221
|
yield FortiOSUserRecord(
|
@@ -190,13 +229,27 @@ class FortiOSPlugin(LinuxPlugin):
|
|
190
229
|
self.target.log.warning("Exception while parsing FortiOS admin users")
|
191
230
|
self.target.log.debug("", exc_info=e)
|
192
231
|
|
232
|
+
# FortiManager administrative users
|
233
|
+
try:
|
234
|
+
for username, entry in self._config["global-config"]["system"]["admin"]["user"].items():
|
235
|
+
yield FortiOSUserRecord(
|
236
|
+
name=username,
|
237
|
+
password=":".join(entry.get("password", [])),
|
238
|
+
groups=[entry["profileid"][0]],
|
239
|
+
home="/root",
|
240
|
+
_target=self.target,
|
241
|
+
)
|
242
|
+
except KeyError as e:
|
243
|
+
self.target.log.warning("Exception while parsing FortiManager admin users")
|
244
|
+
self.target.log.debug("", exc_info=e)
|
245
|
+
|
193
246
|
# Local users
|
194
247
|
try:
|
195
248
|
local_groups = local_groups_to_users(self._config["root-config"]["user"]["group"])
|
196
249
|
for username, entry in self._config["root-config"]["user"].get("local", {}).items():
|
197
250
|
try:
|
198
251
|
password = decrypt_password(entry["passwd"][-1])
|
199
|
-
except ValueError:
|
252
|
+
except (ValueError, RuntimeError):
|
200
253
|
password = ":".join(entry.get("passwd", []))
|
201
254
|
|
202
255
|
yield FortiOSUserRecord(
|
@@ -215,7 +268,7 @@ class FortiOSPlugin(LinuxPlugin):
|
|
215
268
|
for _, entry in self._config["root-config"]["user"]["group"].get("guestgroup", {}).get("guest", {}).items():
|
216
269
|
try:
|
217
270
|
password = decrypt_password(entry.get("password")[-1])
|
218
|
-
except ValueError:
|
271
|
+
except (ValueError, RuntimeError):
|
219
272
|
password = ":".join(entry.get("password"))
|
220
273
|
|
221
274
|
yield FortiOSUserRecord(
|
@@ -236,7 +289,10 @@ class FortiOSPlugin(LinuxPlugin):
|
|
236
289
|
@export(property=True)
|
237
290
|
def architecture(self) -> Optional[str]:
|
238
291
|
"""Return architecture FortiOS runs on."""
|
239
|
-
|
292
|
+
paths = ["/lib/libav.so", "/bin/ctr"]
|
293
|
+
for path in paths:
|
294
|
+
if self.target.fs.path(path).exists():
|
295
|
+
return self._get_architecture(path=path)
|
240
296
|
|
241
297
|
|
242
298
|
class ConfigNode(dict):
|
@@ -344,7 +400,7 @@ def parse_version(input: str) -> str:
|
|
344
400
|
}
|
345
401
|
|
346
402
|
try:
|
347
|
-
version_str = input.split(":", 1)[0]
|
403
|
+
version_str = input.split(":", 1)[0].strip()
|
348
404
|
type, version, _, build_num, build_date = version_str.rsplit("-", 4)
|
349
405
|
|
350
406
|
build_num = build_num.replace("build", "build ", 1)
|
@@ -368,15 +424,111 @@ def local_groups_to_users(config_groups: dict) -> dict:
|
|
368
424
|
return user_groups
|
369
425
|
|
370
426
|
|
371
|
-
def decrypt_password(
|
372
|
-
"""Decrypt FortiOS
|
427
|
+
def decrypt_password(input: str) -> str:
|
428
|
+
"""Decrypt FortiOS encrypted secrets.
|
429
|
+
|
430
|
+
Works for FortiGate 5.x, 6.x and 7.x (CVE-2019-6693).
|
431
|
+
|
432
|
+
NOTE:
|
433
|
+
- FortiManager uses a 16-byte IV and is not supported (CVE-2020-9289).
|
434
|
+
- FortiGate 4.x uses DES and a static 8-byte key and is not supported.
|
435
|
+
|
436
|
+
Returns decoded plaintext or original input ciphertext when decryption failed.
|
437
|
+
|
438
|
+
Resources:
|
439
|
+
- https://www.fortiguard.com/psirt/FG-IR-19-007
|
440
|
+
"""
|
441
|
+
|
442
|
+
if not HAS_PYCRYPTODOME:
|
443
|
+
raise RuntimeError("PyCryptodome module not available")
|
373
444
|
|
374
|
-
if
|
445
|
+
if input[:3] in ["SH2", "AK1"]:
|
375
446
|
raise ValueError("Password is a hash (SHA-256 or SHA-1) and cannot be decrypted.")
|
376
447
|
|
377
|
-
ciphertext = b64decode(
|
448
|
+
ciphertext = b64decode(input)
|
378
449
|
iv = ciphertext[:4] + b"\x00" * 12
|
379
450
|
key = b"Mary had a littl"
|
380
451
|
cipher = AES.new(key, iv=iv, mode=AES.MODE_CBC)
|
381
452
|
plaintext = cipher.decrypt(ciphertext[4:])
|
382
|
-
|
453
|
+
|
454
|
+
try:
|
455
|
+
return plaintext.split(b"\x00", 1)[0].decode()
|
456
|
+
except UnicodeDecodeError:
|
457
|
+
return "ENC:" + input
|
458
|
+
|
459
|
+
|
460
|
+
def decrypt_rootfs(fh: BinaryIO, kernel_hash: str) -> BinaryIO:
|
461
|
+
"""Attempt to decrypt an encrypted ``rootfs.gz`` file.
|
462
|
+
|
463
|
+
FortiOS releases as of 7.4.1 / 2023-08-31, have ChaCha20 encrypted ``rootfs.gz`` files.
|
464
|
+
This function attempts to decrypt a ``rootfs.gz`` file using a static key and IV
|
465
|
+
which can be found in the kernel.
|
466
|
+
|
467
|
+
Currently supported versions (each release has a new key):
|
468
|
+
- FortiGate VM 7.0.13
|
469
|
+
- FortiGate VM 7.4.1
|
470
|
+
- FortiGate VM 7.4.2
|
471
|
+
|
472
|
+
Resources:
|
473
|
+
- https://docs.fortinet.com/document/fortimanager/7.4.2/release-notes/519207/special-notices
|
474
|
+
- Reversing kernel (fgt_verifier_iv, fgt_verifier_decrypt, fgt_verifier_initrd)
|
475
|
+
"""
|
476
|
+
|
477
|
+
if not HAS_PYCRYPTODOME:
|
478
|
+
raise RuntimeError("PyCryptodome module not available")
|
479
|
+
|
480
|
+
# SHA256 hashes of kernel files
|
481
|
+
KERNEL_KEY_MAP = {
|
482
|
+
# FortiGate VM 7.0.13
|
483
|
+
"25cb2c8a419cde1f42d38fc6cbc95cf8b53db41096d0648015674d8220eba6bf": (
|
484
|
+
bytes.fromhex("c87e13e1f7d21c1aca81dc13329c3a948d6e420d3a859f3958bd098747873d08"),
|
485
|
+
bytes.fromhex("87486a24637e9a66f09ec182eee25594"),
|
486
|
+
),
|
487
|
+
# FortiGate VM 7.4.1
|
488
|
+
"a008b47327293e48502a121ee8709f243ad5da4e63d6f663c253db27bd01ea28": _kdf_7_4_x(
|
489
|
+
"366486c0f2c6322ec23e4f33a98caa1b19d41c74bb4f25f6e8e2087b0655b30f"
|
490
|
+
),
|
491
|
+
# FortiGate VM 7.4.2
|
492
|
+
"c392cf83ab484e0b2419b2711b02cdc88a73db35634c10340037243394a586eb": _kdf_7_4_x(
|
493
|
+
"480767be539de28ee773497fa731dd6368adc9946df61da8e1253fa402ba0302"
|
494
|
+
),
|
495
|
+
}
|
496
|
+
|
497
|
+
if not (key_data := KERNEL_KEY_MAP.get(kernel_hash)):
|
498
|
+
raise ValueError("Failed to decrypt: Unknown kernel hash.")
|
499
|
+
|
500
|
+
key, iv = key_data
|
501
|
+
# First 8 bytes = counter, last 8 bytes = nonce
|
502
|
+
# PyCryptodome interally divides this seek by 64 to get a (position, offset) tuple
|
503
|
+
# We're interested in updating the position in the ChaCha20 internal state, so to make
|
504
|
+
# PyCryptodome "OpenSSL-compatible" we have to multiply the counter by 64
|
505
|
+
cipher = ChaCha20.new(key=key, nonce=iv[8:])
|
506
|
+
cipher.seek(int.from_bytes(iv[:8], "little") * 64)
|
507
|
+
result = cipher.decrypt(fh.read())
|
508
|
+
|
509
|
+
if result[0:2] != b"\x1f\x8b":
|
510
|
+
raise ValueError("Failed to decrypt: No gzip magic header found.")
|
511
|
+
|
512
|
+
return BytesIO(result)
|
513
|
+
|
514
|
+
|
515
|
+
def _kdf_7_4_x(key_data: Union[str, bytes]) -> tuple[bytes, bytes]:
|
516
|
+
"""Derive 32 byte key and 16 byte IV from 32 byte seed.
|
517
|
+
|
518
|
+
As the IV needs to be 16 bytes, we return the first 16 bytes of the sha256 hash.
|
519
|
+
"""
|
520
|
+
|
521
|
+
if isinstance(key_data, str):
|
522
|
+
key_data = bytes.fromhex(key_data)
|
523
|
+
|
524
|
+
key = hashlib.sha256(key_data[4:32] + key_data[:4]).digest()
|
525
|
+
iv = hashlib.sha256(key_data[5:32] + key_data[:5]).digest()[:16]
|
526
|
+
return key, iv
|
527
|
+
|
528
|
+
|
529
|
+
def get_kernel_hash(sysvol: Filesystem) -> Optional[str]:
|
530
|
+
"""Return the SHA256 hash of the (compressed) kernel."""
|
531
|
+
kernel_files = ["flatkc", "vmlinuz", "vmlinux"]
|
532
|
+
for k in kernel_files:
|
533
|
+
if sysvol.path(k).exists():
|
534
|
+
return sysvol.sha256(k)
|
@@ -16,13 +16,15 @@ class GenericPlugin(Plugin):
|
|
16
16
|
@export(property=True)
|
17
17
|
def install_date(self) -> Optional[datetime]:
|
18
18
|
"""Return the likely install date of FortiOS."""
|
19
|
-
|
20
|
-
|
19
|
+
files = ["/data/etc/cloudinit.log", "/data/.vm_provisioned", "/data/etc/ssh/ssh_host_dsa_key"]
|
20
|
+
for file in files:
|
21
|
+
if (fp := self.target.fs.path(file)).exists():
|
22
|
+
return ts.from_unix(fp.stat().st_mtime)
|
21
23
|
|
22
24
|
@export(property=True)
|
23
25
|
def activity(self) -> Optional[datetime]:
|
24
26
|
"""Return last seen activity based on filesystem timestamps."""
|
25
|
-
log_dirs = ["/var/log/log/root", "/var/log/root"]
|
27
|
+
log_dirs = ["/var/log/log/root", "/var/log/root", "/data"]
|
26
28
|
for log_dir in log_dirs:
|
27
29
|
if (var_log := self.target.fs.path(log_dir)).exists():
|
28
30
|
return calculate_last_activity(var_log)
|
@@ -1,3 +1,5 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
1
3
|
from dissect.target.exceptions import UnsupportedPluginError
|
2
4
|
from dissect.target.plugin import Plugin, export
|
3
5
|
|
@@ -8,13 +10,16 @@ class LocalePlugin(Plugin):
|
|
8
10
|
raise UnsupportedPluginError("FortiOS specific plugin loaded on non-FortiOS target")
|
9
11
|
|
10
12
|
@export(property=True)
|
11
|
-
def timezone(self) -> str:
|
13
|
+
def timezone(self) -> Optional[str]:
|
12
14
|
"""Return configured UI/system timezone."""
|
13
|
-
|
14
|
-
|
15
|
+
try:
|
16
|
+
timezone_num = self.target._os._config["global-config"]["system"]["global"]["timezone"][0]
|
17
|
+
return translate_timezone(timezone_num)
|
18
|
+
except KeyError:
|
19
|
+
pass
|
15
20
|
|
16
21
|
@export(property=True)
|
17
|
-
def language(self) -> str:
|
22
|
+
def language(self) -> Optional[str]:
|
18
23
|
"""Return configured UI language."""
|
19
24
|
LANG_MAP = {
|
20
25
|
"english": "en_US",
|
@@ -26,8 +31,11 @@ class LocalePlugin(Plugin):
|
|
26
31
|
"simch": "zh_CN",
|
27
32
|
"korean": "ko_KR",
|
28
33
|
}
|
29
|
-
|
30
|
-
|
34
|
+
try:
|
35
|
+
lang_str = self.target._os._config["global-config"]["system"]["global"].get("language", ["english"])[0]
|
36
|
+
return LANG_MAP.get(lang_str, lang_str)
|
37
|
+
except KeyError:
|
38
|
+
pass
|
31
39
|
|
32
40
|
|
33
41
|
def translate_timezone(timezone_num: str) -> str:
|
@@ -1,16 +1,23 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import logging
|
4
|
+
import re
|
1
5
|
import warnings
|
2
|
-
from
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from datetime import datetime
|
8
|
+
from typing import Iterator, Optional, Union
|
3
9
|
|
4
10
|
from flow.record import GroupedRecord
|
5
11
|
|
12
|
+
from dissect.target import Target
|
6
13
|
from dissect.target.exceptions import UnsupportedPluginError
|
7
14
|
from dissect.target.helpers.record import DynamicDescriptor, TargetRecordDescriptor
|
8
15
|
from dissect.target.plugin import Plugin, export
|
9
16
|
from dissect.target.plugins.os.windows.task_helpers.tasks_job import AtTask
|
10
17
|
from dissect.target.plugins.os.windows.task_helpers.tasks_xml import ScheduledTasks
|
11
|
-
from dissect.target.target import Target
|
12
18
|
|
13
19
|
warnings.simplefilter(action="ignore", category=FutureWarning)
|
20
|
+
log = logging.getLogger(__name__)
|
14
21
|
|
15
22
|
TaskRecord = TargetRecordDescriptor(
|
16
23
|
"filesystem/windows/task",
|
@@ -71,6 +78,92 @@ TaskRecord = TargetRecordDescriptor(
|
|
71
78
|
],
|
72
79
|
)
|
73
80
|
|
81
|
+
SchedLgURecord = TargetRecordDescriptor(
|
82
|
+
"windows/tasks/log/schedlgu",
|
83
|
+
[
|
84
|
+
("datetime", "ts"),
|
85
|
+
("string", "job"),
|
86
|
+
("string", "command"),
|
87
|
+
("string", "status"),
|
88
|
+
("uint32", "exit_code"),
|
89
|
+
("string", "version"),
|
90
|
+
],
|
91
|
+
)
|
92
|
+
|
93
|
+
JOB_REGEX_PATTERN = re.compile(r"\"(.*?)\" \((.*?)\)")
|
94
|
+
SCHEDLGU_REGEX_PATTERN = re.compile(r"\".+\n.+\n\s{4}.+\n|\".+\n.+", re.MULTILINE)
|
95
|
+
|
96
|
+
|
97
|
+
@dataclass(order=True)
|
98
|
+
class SchedLgU:
|
99
|
+
ts: datetime = None
|
100
|
+
job: str = None
|
101
|
+
status: str = None
|
102
|
+
command: str = None
|
103
|
+
exit_code: int = None
|
104
|
+
version: str = None
|
105
|
+
|
106
|
+
@staticmethod
|
107
|
+
def _sanitize_ts(ts: str) -> datetime:
|
108
|
+
# sometimes "at" exists before the timestamp
|
109
|
+
ts = ts.strip("at ")
|
110
|
+
try:
|
111
|
+
ts = datetime.strptime(ts, "%m/%d/%Y %I:%M:%S %p")
|
112
|
+
except ValueError:
|
113
|
+
ts = datetime.strptime(ts, "%d-%m-%Y %H:%M:%S")
|
114
|
+
|
115
|
+
return ts
|
116
|
+
|
117
|
+
@staticmethod
|
118
|
+
def _parse_job(line: str) -> tuple[str, Optional[str]]:
|
119
|
+
matches = JOB_REGEX_PATTERN.match(line)
|
120
|
+
if matches:
|
121
|
+
return matches.groups()
|
122
|
+
|
123
|
+
log.warning("SchedLgU failed to parse job and command from line: '%s'. Returning line.", line)
|
124
|
+
return line, None
|
125
|
+
|
126
|
+
@classmethod
|
127
|
+
def from_line(cls, line: str) -> SchedLgU:
|
128
|
+
"""Parse a group of SchedLgU.txt lines."""
|
129
|
+
event = cls()
|
130
|
+
lines = line.splitlines()
|
131
|
+
|
132
|
+
# Events can have 2 or 3 lines as a group in total. An example of a complete task job event is:
|
133
|
+
# "Symantec NetDetect.job" (NDETECT.EXE)
|
134
|
+
# Finished 14-9-2003 13:21:01
|
135
|
+
# Result: The task completed with an exit code of (65).
|
136
|
+
if len(lines) == 3:
|
137
|
+
event.job, event.command = cls._parse_job(lines[0])
|
138
|
+
event.status, event.ts = lines[1].split(maxsplit=1)
|
139
|
+
event.exit_code = int(lines[2].split("(")[1].rstrip(")."))
|
140
|
+
|
141
|
+
# Events that have 2 lines as a group can be started task job event or the Task Scheduler Service. Examples:
|
142
|
+
# "Symantec NetDetect.job" (NDETECT.EXE)
|
143
|
+
# Started at 14-9-2003 13:26:00
|
144
|
+
elif len(lines) == 2 and ".job" in lines[0]:
|
145
|
+
event.job, event.command = cls._parse_job(lines[0])
|
146
|
+
event.status, event.ts = lines[1].split(maxsplit=1)
|
147
|
+
|
148
|
+
# Events without a task job event are the Task Scheduler Service events. Which can look like this:
|
149
|
+
# "Task Scheduler Service"
|
150
|
+
# Exited at 14-9-2003 13:40:24
|
151
|
+
# OR
|
152
|
+
# "Task Scheduler Service"
|
153
|
+
# 6.0.6000.16386 (vista_rtm.061101-2205)
|
154
|
+
elif len(lines) == 2:
|
155
|
+
event.job = lines[0].strip('"')
|
156
|
+
|
157
|
+
if lines[1].startswith("\t") or lines[1].startswith(" "):
|
158
|
+
event.status, event.ts = lines[1].split(maxsplit=1)
|
159
|
+
else:
|
160
|
+
event.version = lines[1]
|
161
|
+
|
162
|
+
if event.ts:
|
163
|
+
event.ts = cls._sanitize_ts(event.ts)
|
164
|
+
|
165
|
+
return event
|
166
|
+
|
74
167
|
|
75
168
|
class TasksPlugin(Plugin):
|
76
169
|
"""Plugin for retrieving scheduled tasks on a Windows system.
|
@@ -149,3 +242,56 @@ class TasksPlugin(Plugin):
|
|
149
242
|
for trigger in task_object.get_triggers():
|
150
243
|
grouped = GroupedRecord("filesystem/windows/task/grouped", [record, trigger])
|
151
244
|
yield grouped
|
245
|
+
|
246
|
+
|
247
|
+
class SchedLgUPlugin(Plugin):
|
248
|
+
"""Plugin for parsing the Task Scheduler Service transaction log file (SchedLgU.txt)."""
|
249
|
+
|
250
|
+
PATHS = {
|
251
|
+
"sysvol/SchedLgU.txt",
|
252
|
+
"sysvol/windows/SchedLgU.txt",
|
253
|
+
"sysvol/windows/tasks/SchedLgU.txt",
|
254
|
+
"sysvol/winnt/tasks/SchedLgU.txt",
|
255
|
+
}
|
256
|
+
|
257
|
+
def __init__(self, target: Target) -> None:
|
258
|
+
self.target = target
|
259
|
+
self.paths = [self.target.fs.path(path) for path in self.PATHS if self.target.fs.path(path).exists()]
|
260
|
+
|
261
|
+
def check_compatible(self) -> None:
|
262
|
+
if len(self.paths) == 0:
|
263
|
+
raise UnsupportedPluginError("No SchedLgU.txt file found.")
|
264
|
+
|
265
|
+
@export(record=SchedLgURecord)
|
266
|
+
def schedlgu(self) -> Iterator[SchedLgURecord]:
|
267
|
+
"""Return all events in the Task Scheduler Service transaction log file (SchedLgU.txt).
|
268
|
+
|
269
|
+
Older Windows systems may log ``.job`` tasks that get started remotely in the SchedLgU.txt file.
|
270
|
+
In addition, this log file records when the Task Scheduler service starts and stops.
|
271
|
+
|
272
|
+
Adversaries may use malicious ``.job`` files to gain persistence on a system.
|
273
|
+
|
274
|
+
Yield:
|
275
|
+
ts (datetime): The timestamp of the event.
|
276
|
+
job (str): The name of the ``.job`` file.
|
277
|
+
command (str): The command executed.
|
278
|
+
status (str): The status of the event (finished, completed, exited, stopped).
|
279
|
+
exit_code (int): The exit code of the event.
|
280
|
+
version (str): The version of the Task Scheduler service.
|
281
|
+
"""
|
282
|
+
|
283
|
+
for path in self.paths:
|
284
|
+
content = path.read_text(encoding="UTF-16", errors="surrogateescape")
|
285
|
+
|
286
|
+
for match in re.findall(SCHEDLGU_REGEX_PATTERN, content):
|
287
|
+
event = SchedLgU.from_line(match)
|
288
|
+
|
289
|
+
yield SchedLgURecord(
|
290
|
+
ts=event.ts,
|
291
|
+
job=event.job,
|
292
|
+
command=event.command,
|
293
|
+
status=event.status,
|
294
|
+
exit_code=event.exit_code,
|
295
|
+
version=event.version,
|
296
|
+
_target=self.target,
|
297
|
+
)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: dissect.target
|
3
|
-
Version: 3.15.
|
3
|
+
Version: 3.15.dev38
|
4
4
|
Summary: This module ties all other Dissect modules together, it provides a programming API and command line tools which allow easy access to various data sources inside disk images or file collections (a.k.a. targets)
|
5
5
|
Author-email: Dissect Team <dissect@fox-it.com>
|
6
6
|
License: Affero General Public License v3
|
@@ -218,9 +218,9 @@ dissect/target/plugins/os/unix/linux/debian/dpkg.py,sha256=DPBLQiHAF7ZS8IorRsGAi
|
|
218
218
|
dissect/target/plugins/os/unix/linux/debian/vyos/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
219
219
|
dissect/target/plugins/os/unix/linux/debian/vyos/_os.py,sha256=q8qG2FLJhUbpjfwlNCmWAhFdTWMzSWUh7s7H8m4x7Fw,1741
|
220
220
|
dissect/target/plugins/os/unix/linux/fortios/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
221
|
-
dissect/target/plugins/os/unix/linux/fortios/_os.py,sha256=
|
222
|
-
dissect/target/plugins/os/unix/linux/fortios/generic.py,sha256=
|
223
|
-
dissect/target/plugins/os/unix/linux/fortios/locale.py,sha256=
|
221
|
+
dissect/target/plugins/os/unix/linux/fortios/_os.py,sha256=mYwmGAeY1GQdPdFbGxwNhlRuMD2hTuL1nlEAaXhao4o,19091
|
222
|
+
dissect/target/plugins/os/unix/linux/fortios/generic.py,sha256=tT4-lE0Z_DeDIN3zHrQbE8JB3cRJop1_TiEst-Au0bs,1230
|
223
|
+
dissect/target/plugins/os/unix/linux/fortios/locale.py,sha256=VDdk60sqe2JTfftssO05C667-_BpI3kcqKOTVzO3ueU,5209
|
224
224
|
dissect/target/plugins/os/unix/linux/redhat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
225
225
|
dissect/target/plugins/os/unix/linux/redhat/_os.py,sha256=l_SygO1WMBTvaLvAvhe08yPHLBpUZ9wizW28a9_JhJE,578
|
226
226
|
dissect/target/plugins/os/unix/linux/redhat/yum.py,sha256=kEvB-C2CNoqxSbgGRZiuo6CMPBo_hMWy2KQIE4SNkdQ,2134
|
@@ -258,7 +258,7 @@ dissect/target/plugins/os/windows/services.py,sha256=_6YkuoZD8LUxk72R3n1p1bOBab3
|
|
258
258
|
dissect/target/plugins/os/windows/sru.py,sha256=sOM7CyMkW8XIXzI75GL69WoqUrSK2X99TFIfdQR2D64,17767
|
259
259
|
dissect/target/plugins/os/windows/startupinfo.py,sha256=kl8Y7M4nVfmJ71I33VCegtbHj-ZOeEsYAdlNbgwtUOA,3406
|
260
260
|
dissect/target/plugins/os/windows/syscache.py,sha256=WBDx6rixaVnCRsJHLLN_9YWoTDbzkKGbTnk3XmHSSUM,3443
|
261
|
-
dissect/target/plugins/os/windows/tasks.py,sha256=
|
261
|
+
dissect/target/plugins/os/windows/tasks.py,sha256=2BKxxdd-xn9aAtCIYHqGVcBqeyRbDW8cbuc4N0w1R5g,10828
|
262
262
|
dissect/target/plugins/os/windows/thumbcache.py,sha256=23YjOjTNoE7BYITmg8s9Zs8Wih2e73BkJJEaKlfotcI,4133
|
263
263
|
dissect/target/plugins/os/windows/ual.py,sha256=TYF-R46klEa_HHb86UJd6mPrXwHlAMOUTzC0pZ8uiq0,9787
|
264
264
|
dissect/target/plugins/os/windows/wer.py,sha256=OId9gnqU-z2D_Xl51J9THWTIegre06QsftWnGz7IQb4,7563
|
@@ -321,10 +321,10 @@ dissect/target/volumes/luks.py,sha256=OmCMsw6rCUXG1_plnLVLTpsvE1n_6WtoRUGQbpmu1z
|
|
321
321
|
dissect/target/volumes/lvm.py,sha256=wwQVR9I3G9YzmY6UxFsH2Y4MXGBcKL9aayWGCDTiWMU,2269
|
322
322
|
dissect/target/volumes/md.py,sha256=j1K1iKmspl0C_OJFc7-Q1BMWN2OCC5EVANIgVlJ_fIE,1673
|
323
323
|
dissect/target/volumes/vmfs.py,sha256=-LoUbn9WNwTtLi_4K34uV_-wDw2W5hgaqxZNj4UmqAQ,1730
|
324
|
-
dissect.target-3.15.
|
325
|
-
dissect.target-3.15.
|
326
|
-
dissect.target-3.15.
|
327
|
-
dissect.target-3.15.
|
328
|
-
dissect.target-3.15.
|
329
|
-
dissect.target-3.15.
|
330
|
-
dissect.target-3.15.
|
324
|
+
dissect.target-3.15.dev38.dist-info/COPYRIGHT,sha256=m-9ih2RVhMiXHI2bf_oNSSgHgkeIvaYRVfKTwFbnJPA,301
|
325
|
+
dissect.target-3.15.dev38.dist-info/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
|
326
|
+
dissect.target-3.15.dev38.dist-info/METADATA,sha256=hXL7MrjO-icCXVn6ZBQgh0xFBZsV4sePxr5u-eeICUo,11113
|
327
|
+
dissect.target-3.15.dev38.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
328
|
+
dissect.target-3.15.dev38.dist-info/entry_points.txt,sha256=tvFPa-Ap-gakjaPwRc6Fl6mxHzxEZ_arAVU-IUYeo_s,447
|
329
|
+
dissect.target-3.15.dev38.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
|
330
|
+
dissect.target-3.15.dev38.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
{dissect.target-3.15.dev33.dist-info → dissect.target-3.15.dev38.dist-info}/entry_points.txt
RENAMED
File without changes
|
File without changes
|