dissect.target 3.14.dev29__py3-none-any.whl → 3.15__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (86) hide show
  1. dissect/target/containers/ewf.py +1 -1
  2. dissect/target/containers/vhd.py +5 -2
  3. dissect/target/filesystem.py +36 -18
  4. dissect/target/filesystems/dir.py +10 -4
  5. dissect/target/filesystems/jffs.py +122 -0
  6. dissect/target/helpers/compat/path_310.py +506 -0
  7. dissect/target/helpers/compat/path_311.py +539 -0
  8. dissect/target/helpers/compat/path_312.py +443 -0
  9. dissect/target/helpers/compat/path_39.py +545 -0
  10. dissect/target/helpers/compat/path_common.py +223 -0
  11. dissect/target/helpers/cyber.py +512 -0
  12. dissect/target/helpers/fsutil.py +128 -666
  13. dissect/target/helpers/hashutil.py +17 -57
  14. dissect/target/helpers/keychain.py +9 -3
  15. dissect/target/helpers/loaderutil.py +1 -1
  16. dissect/target/helpers/mount.py +47 -4
  17. dissect/target/helpers/polypath.py +73 -0
  18. dissect/target/helpers/record_modifier.py +100 -0
  19. dissect/target/loader.py +2 -1
  20. dissect/target/loaders/asdf.py +2 -0
  21. dissect/target/loaders/cyber.py +37 -0
  22. dissect/target/loaders/log.py +14 -3
  23. dissect/target/loaders/raw.py +2 -0
  24. dissect/target/loaders/remote.py +12 -0
  25. dissect/target/loaders/tar.py +13 -0
  26. dissect/target/loaders/targetd.py +2 -0
  27. dissect/target/loaders/velociraptor.py +12 -3
  28. dissect/target/loaders/vmwarevm.py +2 -0
  29. dissect/target/plugin.py +272 -143
  30. dissect/target/plugins/apps/ssh/openssh.py +11 -54
  31. dissect/target/plugins/apps/ssh/opensshd.py +4 -3
  32. dissect/target/plugins/apps/ssh/putty.py +236 -0
  33. dissect/target/plugins/apps/ssh/ssh.py +58 -0
  34. dissect/target/plugins/apps/vpn/openvpn.py +6 -0
  35. dissect/target/plugins/apps/webserver/apache.py +309 -95
  36. dissect/target/plugins/apps/webserver/caddy.py +5 -2
  37. dissect/target/plugins/apps/webserver/citrix.py +82 -0
  38. dissect/target/plugins/apps/webserver/iis.py +9 -12
  39. dissect/target/plugins/apps/webserver/nginx.py +5 -2
  40. dissect/target/plugins/apps/webserver/webserver.py +25 -41
  41. dissect/target/plugins/child/wsl.py +1 -1
  42. dissect/target/plugins/filesystem/ntfs/mft.py +10 -0
  43. dissect/target/plugins/filesystem/ntfs/mft_timeline.py +10 -0
  44. dissect/target/plugins/filesystem/ntfs/usnjrnl.py +10 -0
  45. dissect/target/plugins/filesystem/ntfs/utils.py +28 -5
  46. dissect/target/plugins/filesystem/resolver.py +6 -4
  47. dissect/target/plugins/general/default.py +0 -2
  48. dissect/target/plugins/general/example.py +0 -1
  49. dissect/target/plugins/general/loaders.py +3 -5
  50. dissect/target/plugins/os/unix/_os.py +3 -3
  51. dissect/target/plugins/os/unix/bsd/citrix/_os.py +68 -28
  52. dissect/target/plugins/os/unix/bsd/citrix/history.py +130 -0
  53. dissect/target/plugins/os/unix/generic.py +17 -12
  54. dissect/target/plugins/os/unix/linux/fortios/__init__.py +0 -0
  55. dissect/target/plugins/os/unix/linux/fortios/_os.py +534 -0
  56. dissect/target/plugins/os/unix/linux/fortios/generic.py +30 -0
  57. dissect/target/plugins/os/unix/linux/fortios/locale.py +109 -0
  58. dissect/target/plugins/os/windows/log/evt.py +1 -1
  59. dissect/target/plugins/os/windows/log/schedlgu.py +155 -0
  60. dissect/target/plugins/os/windows/regf/firewall.py +1 -1
  61. dissect/target/plugins/os/windows/regf/shimcache.py +1 -1
  62. dissect/target/plugins/os/windows/regf/trusteddocs.py +1 -1
  63. dissect/target/plugins/os/windows/registry.py +1 -1
  64. dissect/target/plugins/os/windows/sam.py +3 -0
  65. dissect/target/plugins/os/windows/sru.py +41 -28
  66. dissect/target/plugins/os/windows/tasks.py +5 -2
  67. dissect/target/target.py +7 -3
  68. dissect/target/tools/dd.py +7 -1
  69. dissect/target/tools/fs.py +8 -1
  70. dissect/target/tools/info.py +22 -16
  71. dissect/target/tools/mount.py +28 -3
  72. dissect/target/tools/query.py +146 -117
  73. dissect/target/tools/reg.py +21 -16
  74. dissect/target/tools/shell.py +30 -6
  75. dissect/target/tools/utils.py +28 -0
  76. dissect/target/volumes/bde.py +14 -10
  77. dissect/target/volumes/luks.py +18 -10
  78. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/METADATA +4 -3
  79. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/RECORD +85 -67
  80. dissect/target/plugins/os/unix/linux/fortigate/_os.py +0 -175
  81. /dissect/target/{plugins/os/unix/linux/fortigate → helpers/compat}/__init__.py +0 -0
  82. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/COPYRIGHT +0 -0
  83. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/LICENSE +0 -0
  84. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/WHEEL +0 -0
  85. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/entry_points.txt +0 -0
  86. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,534 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import hashlib
5
+ from base64 import b64decode
6
+ from datetime import datetime
7
+ from io import BytesIO
8
+ from tarfile import ReadError
9
+ from typing import BinaryIO, Iterator, Optional, TextIO, Union
10
+
11
+ from dissect.util import cpio
12
+ from dissect.util.compression import xz
13
+
14
+ from dissect.target.filesystem import Filesystem
15
+ from dissect.target.filesystems.tar import TarFilesystem
16
+ from dissect.target.helpers.fsutil import open_decompress
17
+ from dissect.target.helpers.record import TargetRecordDescriptor, UnixUserRecord
18
+ from dissect.target.plugin import OperatingSystem, export
19
+ from dissect.target.plugins.os.unix.linux._os import LinuxPlugin
20
+ from dissect.target.target import Target
21
+
22
+ try:
23
+ from Crypto.Cipher import AES, ChaCha20
24
+
25
+ HAS_PYCRYPTODOME = True
26
+ except ImportError:
27
+ HAS_PYCRYPTODOME = False
28
+
29
+ FortiOSUserRecord = TargetRecordDescriptor(
30
+ "fortios/user",
31
+ [
32
+ ("string", "name"),
33
+ ("string[]", "groups"),
34
+ ("string", "password"),
35
+ ("path", "home"),
36
+ ],
37
+ )
38
+
39
+
40
+ class FortiOSPlugin(LinuxPlugin):
41
+ """FortiOS plugin for various Fortinet appliances."""
42
+
43
+ def __init__(self, target: Target):
44
+ super().__init__(target)
45
+ self._version = None
46
+ self._config = self._load_config()
47
+
48
+ def _load_config(self) -> dict:
49
+ CONFIG_FILES = {
50
+ "/data/system.conf": "global-config", # FortiManager
51
+ "/data/config/daemon.conf.gz": "daemon", # FortiOS 4.x
52
+ "/data/config/sys_global.conf.gz": "global-config", # Seen in FortiOS 5.x - 7.x
53
+ "/data/config/sys_vd_root.conf.gz": "root-config", # FortiOS 4.x
54
+ "/data/config/sys_vd_root+root.conf.gz": "root-config", # Seen in FortiOS 6.x - 7.x
55
+ "/data/config/global_system_interface.gz": "interfaces", # Seen in FortiOS 5.x - 7.x
56
+ }
57
+
58
+ config = {}
59
+ for conf_file, section in CONFIG_FILES.items():
60
+ if (conf_path := self.target.fs.path(conf_file)).exists():
61
+ if conf_file.endswith("gz"):
62
+ fh = gzip.open(conf_path.open("rb"), "rt")
63
+ else:
64
+ fh = conf_path.open("rt")
65
+
66
+ if not self._version and section in ["global-config", "root-config"]:
67
+ self._version = fh.readline().split("=", 1)[1]
68
+
69
+ parsed = FortiOSConfig.from_fh(fh)
70
+ config |= {section: parsed} if section else parsed
71
+
72
+ return config
73
+
74
+ @classmethod
75
+ def detect(cls, target: Target) -> Optional[Filesystem]:
76
+ for fs in target.filesystems:
77
+ # Tested on FortiGate and FortiAnalyzer, other Fortinet devices may look different.
78
+ if fs.exists("/rootfs.gz") and (fs.exists("/.fgtsum") or fs.exists("/.fmg_sign") or fs.exists("/flatkc")):
79
+ return fs
80
+
81
+ @classmethod
82
+ def create(cls, target: Target, sysvol: Filesystem) -> FortiOSPlugin:
83
+ target.log.warning("Attempting to load rootfs.gz, this can take a while.")
84
+ rootfs = sysvol.path("/rootfs.gz")
85
+ vfs = None
86
+
87
+ try:
88
+ if open_decompress(rootfs).read(4) == b"0707":
89
+ vfs = TarFilesystem(rootfs.open(), tarinfo=cpio.CpioInfo)
90
+ else:
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:
107
+ target.fs.mount("/", vfs)
108
+
109
+ target.fs.mount("/data", sysvol)
110
+
111
+ # FortiGate
112
+ if (datafs_tar := sysvol.path("/datafs.tar.gz")).exists():
113
+ target.fs.add_layer().mount("/data", TarFilesystem(datafs_tar.open("rb")))
114
+
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():
126
+ fh = xz.repair_checksum(tar.open("rb"))
127
+ target.fs.add_layer().mount("/", TarFilesystem(fh))
128
+
129
+ # FortiAnalyzer and FortiManager
130
+ if (rootfs_ext_tar := sysvol.path("rootfs-ext.tar.xz")).exists():
131
+ target.fs.add_layer().mount("/", TarFilesystem(rootfs_ext_tar.open("rb")))
132
+
133
+ # Filesystem mounts can be discovered in the FortiCare debug report
134
+ # or using ``fnsysctl ls`` and ``fnsysctl df`` in the cli.
135
+ for fs in target.filesystems:
136
+ # log partition
137
+ if fs.__type__ == "ext" and (
138
+ fs.extfs.volume_name.startswith("LOGUSEDX") or fs.path("/root/clog").is_symlink()
139
+ ):
140
+ target.fs.mount("/var/log", fs)
141
+
142
+ # EFI partition
143
+ if fs.__type__ == "fat" and fs.path("/EFI").exists():
144
+ target.fs.mount("/boot", fs)
145
+
146
+ # data2 partition
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
+ ):
151
+ target.fs.mount("/data2", fs)
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
+
159
+ return cls(target)
160
+
161
+ @export(property=True)
162
+ def hostname(self) -> str | None:
163
+ """Return configured hostname."""
164
+ try:
165
+ return self._config["global-config"]["system"]["global"]["hostname"][0]
166
+ except KeyError:
167
+ return None
168
+
169
+ @export(property=True)
170
+ def ips(self) -> list[str]:
171
+ """Return IP addresses of configured interfaces."""
172
+ result = []
173
+
174
+ try:
175
+ # FortiOS 6 and 7 (from global_system_interface.gz)
176
+ for key, iface in self._config["interfaces"].items():
177
+ if not key.startswith("port"):
178
+ continue
179
+ result += [ip for ip in iface["ip"] if not ip.startswith("255")]
180
+ except KeyError as e:
181
+ self.target.log.debug("Exception while parsing FortiOS interfaces", exc_info=e)
182
+
183
+ try:
184
+ # Other versions
185
+ for conf in self._config["global-config"]["system"]["interface"].values():
186
+ if "ip" in conf:
187
+ result.append(conf.ip[0])
188
+ except KeyError as e:
189
+ self.target.log.debug("Exception while parsing FortiOS system interfaces", exc_info=e)
190
+
191
+ return result
192
+
193
+ @export(property=True)
194
+ def dns(self) -> list[str]:
195
+ """Return configured WAN DNS servers."""
196
+ entries = []
197
+ try:
198
+ for entry in self._config["global-config"]["system"]["dns"].values():
199
+ entries.append(entry[0])
200
+ except KeyError:
201
+ pass
202
+ return entries
203
+
204
+ @export(property=True)
205
+ def version(self) -> str:
206
+ """Return FortiOS version."""
207
+ if self._version:
208
+ return parse_version(self._version)
209
+ return "FortiOS Unknown"
210
+
211
+ @export(record=FortiOSUserRecord)
212
+ def users(self) -> Iterator[Union[FortiOSUserRecord, UnixUserRecord]]:
213
+ """Return local users of the FortiOS system."""
214
+
215
+ # Possible unix-like users
216
+ yield from super().users()
217
+
218
+ # FortiGate administrative users
219
+ try:
220
+ for username, entry in self._config["global-config"]["system"]["admin"].items():
221
+ yield FortiOSUserRecord(
222
+ name=username,
223
+ password=":".join(entry.get("password", [])),
224
+ groups=[entry["accprofile"][0]],
225
+ home="/root",
226
+ _target=self.target,
227
+ )
228
+ except KeyError as e:
229
+ self.target.log.warning("Exception while parsing FortiOS admin users")
230
+ self.target.log.debug("", exc_info=e)
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
+
246
+ # Local users
247
+ try:
248
+ local_groups = local_groups_to_users(self._config["root-config"]["user"]["group"])
249
+ for username, entry in self._config["root-config"]["user"].get("local", {}).items():
250
+ try:
251
+ password = decrypt_password(entry["passwd"][-1])
252
+ except (ValueError, RuntimeError):
253
+ password = ":".join(entry.get("passwd", []))
254
+
255
+ yield FortiOSUserRecord(
256
+ name=username,
257
+ password=password,
258
+ groups=local_groups.get(username, []),
259
+ home=None,
260
+ _target=self.target,
261
+ )
262
+ except KeyError as e:
263
+ self.target.log.warning("Exception while parsing FortiOS local users")
264
+ self.target.log.debug("", exc_info=e)
265
+
266
+ # Temporary guest users
267
+ try:
268
+ for _, entry in self._config["root-config"]["user"]["group"].get("guestgroup", {}).get("guest", {}).items():
269
+ try:
270
+ password = decrypt_password(entry.get("password")[-1])
271
+ except (ValueError, RuntimeError):
272
+ password = ":".join(entry.get("password"))
273
+
274
+ yield FortiOSUserRecord(
275
+ name=entry["user-id"][0],
276
+ password=password,
277
+ groups=["guestgroup"],
278
+ home=None,
279
+ _target=self.target,
280
+ )
281
+ except KeyError as e:
282
+ self.target.log.warning("Exception while parsing FortiOS temporary guest users")
283
+ self.target.log.debug("", exc_info=e)
284
+
285
+ @export(property=True)
286
+ def os(self) -> str:
287
+ return OperatingSystem.FORTIOS.value
288
+
289
+ @export(property=True)
290
+ def architecture(self) -> Optional[str]:
291
+ """Return architecture FortiOS runs on."""
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)
296
+
297
+
298
+ class ConfigNode(dict):
299
+ def set(self, path: list[str], value: str) -> None:
300
+ node = self
301
+
302
+ for part in path[:-1]:
303
+ if part not in node:
304
+ node[part] = ConfigNode()
305
+ node = node[part]
306
+
307
+ node[path[-1]] = value
308
+
309
+ def __getattr__(self, attr: str) -> ConfigNode | str:
310
+ return self[attr]
311
+
312
+
313
+ class FortiOSConfig(ConfigNode):
314
+ @classmethod
315
+ def from_fh(cls, fh: TextIO) -> FortiOSConfig:
316
+ root = cls()
317
+
318
+ stack = []
319
+ for parts in _parse_config(fh):
320
+ cmd = parts[0]
321
+
322
+ if cmd == "config":
323
+ if parts[1] == "vdom" and stack == [["vdom"]]:
324
+ continue
325
+
326
+ stack.append(parts[1:])
327
+
328
+ elif cmd == "edit":
329
+ stack.append(parts[1:])
330
+
331
+ elif cmd == "end":
332
+ if stack:
333
+ stack.pop()
334
+
335
+ elif cmd == "next":
336
+ if stack:
337
+ stack.pop()
338
+
339
+ elif cmd == "set":
340
+ path = []
341
+ for part in stack:
342
+ path += part
343
+
344
+ path.append(parts[1])
345
+ root.set(path, parts[2:])
346
+
347
+ return root
348
+
349
+
350
+ def _parse_config(fh: TextIO) -> Iterator[list[str]]:
351
+ parts = []
352
+ string = None
353
+
354
+ for line in fh:
355
+ if not (line := line.strip()) or line.startswith("#"):
356
+ continue
357
+
358
+ for part in line.split(" "):
359
+ if part.startswith('"'):
360
+ if part.endswith('"'):
361
+ parts.append(part[1:-1])
362
+ else:
363
+ string = [part[1:]]
364
+ elif part.endswith('"') and part[-2] != "\\":
365
+ string.append(part[:-1])
366
+ parts.append(" ".join(string))
367
+ string = None
368
+ elif string:
369
+ string.append(part)
370
+ else:
371
+ parts.append(part)
372
+
373
+ if string:
374
+ string.append("\n")
375
+
376
+ if parts and not string:
377
+ yield parts
378
+ parts = []
379
+
380
+
381
+ def parse_version(input: str) -> str:
382
+ """Attempt to parse the config FortiOS version to a readable format.
383
+
384
+ The input ``FGVM64-7.4.1-FW-build2463-230830:opmode=0:vdom=0`` would
385
+ return the following output: ``FortiGate VM 7.4.1 (build 2463, 2023-08-30)``.
386
+
387
+ Resources:
388
+ - https://support.fortinet.com/Download/VMImages.aspx
389
+ """
390
+
391
+ PREFIXES = {
392
+ "FGV": "FortiGate VM", # FGVM64
393
+ "FGT": "FortiGate", # can also be FGT-VM in 4.x/5.x
394
+ "FMG": "FortiManager",
395
+ "FAZ": "FortiAnalyzer",
396
+ "FFW": "FortiFirewall",
397
+ "FOS": "FortiOS",
398
+ "FWB": "FortiWeb",
399
+ "FAD": "FortiADC",
400
+ }
401
+
402
+ try:
403
+ version_str = input.split(":", 1)[0].strip()
404
+ type, version, _, build_num, build_date = version_str.rsplit("-", 4)
405
+
406
+ build_num = build_num.replace("build", "build ", 1)
407
+ build_date = datetime.strptime(build_date, "%y%m%d").strftime("%Y-%m-%d")
408
+ type = PREFIXES.get(type[:3], type)
409
+
410
+ return f"{type} {version} ({build_num}, {build_date})"
411
+ except ValueError:
412
+ return input
413
+
414
+
415
+ def local_groups_to_users(config_groups: dict) -> dict:
416
+ """Map FortiOS groups to a dict with usernames as key."""
417
+ user_groups = {}
418
+ for group, items in config_groups.items():
419
+ for user in items.get("member", []):
420
+ if user in user_groups:
421
+ user_groups[user].append(group)
422
+ else:
423
+ user_groups[user] = [group]
424
+ return user_groups
425
+
426
+
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")
444
+
445
+ if input[:3] in ["SH2", "AK1"]:
446
+ raise ValueError("Password is a hash (SHA-256 or SHA-1) and cannot be decrypted.")
447
+
448
+ ciphertext = b64decode(input)
449
+ iv = ciphertext[:4] + b"\x00" * 12
450
+ key = b"Mary had a littl"
451
+ cipher = AES.new(key, iv=iv, mode=AES.MODE_CBC)
452
+ plaintext = cipher.decrypt(ciphertext[4:])
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)
@@ -0,0 +1,30 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+
4
+ from dissect.util import ts
5
+
6
+ from dissect.target.exceptions import UnsupportedPluginError
7
+ from dissect.target.plugin import Plugin, export
8
+ from dissect.target.plugins.os.unix.generic import calculate_last_activity
9
+
10
+
11
+ class GenericPlugin(Plugin):
12
+ def check_compatible(self) -> None:
13
+ if self.target.os != "fortios":
14
+ raise UnsupportedPluginError("FortiOS specific plugin loaded on non-FortiOS target")
15
+
16
+ @export(property=True)
17
+ def install_date(self) -> Optional[datetime]:
18
+ """Return the likely install date of FortiOS."""
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)
23
+
24
+ @export(property=True)
25
+ def activity(self) -> Optional[datetime]:
26
+ """Return last seen activity based on filesystem timestamps."""
27
+ log_dirs = ["/var/log/log/root", "/var/log/root", "/data"]
28
+ for log_dir in log_dirs:
29
+ if (var_log := self.target.fs.path(log_dir)).exists():
30
+ return calculate_last_activity(var_log)
@@ -0,0 +1,109 @@
1
+ from typing import Optional
2
+
3
+ from dissect.target.exceptions import UnsupportedPluginError
4
+ from dissect.target.plugin import Plugin, export
5
+
6
+
7
+ class LocalePlugin(Plugin):
8
+ def check_compatible(self) -> None:
9
+ if self.target.os != "fortios":
10
+ raise UnsupportedPluginError("FortiOS specific plugin loaded on non-FortiOS target")
11
+
12
+ @export(property=True)
13
+ def timezone(self) -> Optional[str]:
14
+ """Return configured UI/system timezone."""
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
20
+
21
+ @export(property=True)
22
+ def language(self) -> Optional[str]:
23
+ """Return configured UI language."""
24
+ LANG_MAP = {
25
+ "english": "en_US",
26
+ "french": "fr_FR",
27
+ "spanish": "es_ES",
28
+ "portuguese": "pt_PT",
29
+ "japanese": "ja_JP",
30
+ "trach": "zh_TW",
31
+ "simch": "zh_CN",
32
+ "korean": "ko_KR",
33
+ }
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
39
+
40
+
41
+ def translate_timezone(timezone_num: str) -> str:
42
+ """Translate a FortiOS timezone number to IANA TZ.
43
+
44
+ Resources:
45
+ - https://<fortios>/ng/system/settings
46
+ """
47
+
48
+ TZ_MAP = {
49
+ "01": "Etc/GMT+11", # (GMT-11:00) Midway Island, Samoa
50
+ "02": "Pacific/Honolulu", # (GMT-10:00) Hawaii
51
+ "03": "America/Anchorage", # (GMT-9:00) Alaska
52
+ "04": "America/Los_Angeles", # (GMT-8:00) Pacific Time (US & Canada)
53
+ "05": "America/Phoenix", # (GMT-7:00) Arizona
54
+ "81": "America/Chihuahua", # (GMT-7:00) Baja California Sur, Chihuahua
55
+ "06": "America/Denver", # (GMT-7:00) Mountain Time (US & Canada)
56
+ "07": "America/Guatemala", # (GMT-6:00) Central America
57
+ "08": "America/Chicago", # (GMT-6:00) Central Time (US & Canada)
58
+ "09": "America/Mexico_City", # (GMT-6:00) Mexico City
59
+ "10": "America/Regina", # (GMT-6:00) Saskatchewan
60
+ "11": "America/Bogota", # (GMT-5:00) Bogota, Lima,Quito
61
+ "12": "America/New_York", # (GMT-5:00) Eastern Time (US & Canada)
62
+ "13": "America/Indianapolis", # (GMT-5:00) Indiana (East)
63
+ "74": "America/Caracas", # (GMT-4:00) Caracas
64
+ "14": "America/Halifax", # (GMT-4:00) Atlantic Time (Canada)
65
+ "77": "Etc/GMT+4", # (GMT-4:00) Georgetown
66
+ "15": "America/La_Paz", # (GMT-4:00) La Paz
67
+ "87": "America/Asuncion", # (GMT-4:00) Paraguay
68
+ "16": "America/Santiago", # (GMT-3:00) Santiago
69
+ "17": "America/St_Johns", # (GMT-3:30) Newfoundland
70
+ "18": "America/Sao_Paulo", # (GMT-3:00) Brasilia
71
+ "19": "America/Buenos_Aires", # (GMT-3:00) Buenos Aires
72
+ "20": "America/Godthab", # (GMT-3:00) Nuuk (Greenland)
73
+ "75": "America/Montevideo", # (GMT-3:00) Uruguay
74
+ "21": "Etc/GMT+2", # (GMT-2:00) Mid-Atlantic
75
+ "22": "Atlantic/Azores", # (GMT-1:00) Azores
76
+ "23": "Atlantic/Cape_Verde", # (GMT-1:00) Cape Verde Is.
77
+ "24": "Atlantic/Reykjavik", # (GMT) Monrovia
78
+ "80": "Europe/London", # (GMT) Greenwich Mean Time
79
+ "79": "Africa/Casablanca", # (GMT) Casablanca
80
+ "25": "Etc/UTC", # (GMT) Dublin, Edinburgh, Lisbon, London, Canary Is.
81
+ "26": "Europe/Berlin", # (GMT+1:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna
82
+ "27": "Europe/Budapest", # (GMT+1:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague
83
+ "28": "Europe/Paris", # (GMT+1:00) Brussels, Copenhagen, Madrid, Paris
84
+ "78": "Africa/Windhoek", # (GMT+1:00) Namibia
85
+ "29": "Europe/Warsaw", # (GMT+1:00) Sarajevo, Skopje, Warsaw, Zagreb
86
+ "30": "Africa/Lagos", # (GMT+1:00) West Central Africa
87
+ "31": "Europe/Kiev", # (GMT+2:00) Athens, Sofia, Vilnius
88
+ "32": "Europe/Bucharest", # (GMT+2:00) Bucharest
89
+ "33": "Africa/Cairo", # (GMT+2:00) Cairo
90
+ "34": "Africa/Johannesburg", # (GMT+2:00) Harare, Pretoria
91
+ "35": "Europe/Helsinki", # (GMT+2:00) Helsinki, Riga, Tallinn
92
+ "36": "Asia/Jerusalem", # (GMT+2:00) Jerusalem
93
+ "37": "Asia/Baghdad", # (GMT+3:00) Baghdad
94
+ "38": "Asia/Riyadh", # (GMT+3:00) Kuwait, Riyadh
95
+ "83": "Europe/Moscow", # (GMT+3:00) Moscow
96
+ "84": "Europe/Minsk", # (GMT+3:00) Minsk
97
+ "40": "Africa/Nairobi", # (GMT+3:00) Nairobi
98
+ "85": "Europe/Istanbul", # (GMT+3:00) Istanbul
99
+ "41": "Asia/Tehran", # (GMT+3:30) Tehran
100
+ "42": "Asia/Dubai", # (GMT+4:00) Abu Dhabi, Muscat
101
+ "43": "Asia/Baku", # (GMT+4:00) Baku
102
+ "39": "Europe/Volgograd", # (GMT+3:00) St. Petersburg, Volgograd
103
+ "44": "Asia/Kabul", # (GMT+4:30) Kabul
104
+ "46": "Asia/Karachi", # (GMT+5:00) Islamabad, Karachi, Tashkent
105
+ "47": "Asia/Calcutta", # (GMT+5:30) Kolkata, Chennai, Mumbai, New Delhi
106
+ "51": "Asia/Colombo", # (GMT+5:30) Sri Jayawardenepara
107
+ }
108
+
109
+ return TZ_MAP.get(timezone_num, timezone_num)
@@ -100,7 +100,7 @@ class WindowsEventlogsMixin:
100
100
 
101
101
  # resolve aliases (like `%systemroot%`) in the paths
102
102
  file_paths = [self.target.resolve(p) for p in file_paths]
103
- file_paths = [self.target.fs.path(path) for path in file_paths if filename_regex.match(path)]
103
+ file_paths = [path for path in file_paths if filename_regex.match(str(path))]
104
104
 
105
105
  self.target.log.debug("Log files found in '%s': %d", self.EVENTLOG_REGISTRY_KEY, len(file_paths))
106
106