dissect.target 3.15.dev33__py3-none-any.whl → 3.15.dev38__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/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
|