trazas 0.1.0__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.
trazas/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """trazas - realistic, labelled attack log generator for detection engineering."""
2
+
3
+ __version__ = "0.1.0"
trazas/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from trazas.cli import main
4
+
5
+ sys.exit(main())
trazas/cli.py ADDED
@@ -0,0 +1,117 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import secrets
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from trazas import __version__, engine
13
+ from trazas.scenarios import REGISTRY
14
+ from trazas.writers import FORMATS, UnsupportedFormatError, write
15
+
16
+
17
+ def _parse_start(value: str) -> datetime:
18
+ try:
19
+ ts = datetime.fromisoformat(value.replace("Z", "+00:00"))
20
+ except ValueError as exc:
21
+ raise argparse.ArgumentTypeError(f"invalid ISO 8601 timestamp: {value!r}") from exc
22
+ return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
23
+
24
+
25
+ def _non_negative_int(value: str) -> int:
26
+ n = int(value)
27
+ if n < 0:
28
+ raise argparse.ArgumentTypeError("must be >= 0")
29
+ return n
30
+
31
+
32
+ def build_parser() -> argparse.ArgumentParser:
33
+ parser = argparse.ArgumentParser(
34
+ prog="trazas",
35
+ description="Generate realistic, labelled attack logs for testing detections.",
36
+ )
37
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
38
+ sub = parser.add_subparsers(dest="command", required=True)
39
+
40
+ sub.add_parser("list", help="list available scenarios")
41
+
42
+ desc = sub.add_parser("describe", help="show details of a scenario")
43
+ desc.add_argument("scenario", choices=sorted(REGISTRY))
44
+
45
+ gen = sub.add_parser("generate", help="generate a dataset")
46
+ gen.add_argument("scenarios", nargs="+", choices=sorted(REGISTRY), metavar="SCENARIO")
47
+ gen.add_argument("-s", "--seed", type=int, help="PRNG seed (random if omitted, printed to stderr)")
48
+ gen.add_argument("--start", type=_parse_start, help="ISO 8601 start time (default: now, UTC)")
49
+ gen.add_argument("-n", "--noise", type=_non_negative_int, default=200,
50
+ help="benign events per platform (default: 200)") # fmt: skip
51
+ gen.add_argument("-f", "--format", choices=FORMATS, default="jsonl")
52
+ gen.add_argument("-o", "--output", type=Path, help="output file (default: stdout)")
53
+ gen.add_argument("--force", action="store_true", help="overwrite the output file if it exists")
54
+ gen.add_argument("--labels", action="store_true",
55
+ help="add a _trazas ground-truth field to each JSON record") # fmt: skip
56
+ return parser
57
+
58
+
59
+ def _cmd_list() -> int:
60
+ width = max(len(sid) for sid in REGISTRY)
61
+ for sid, cls in sorted(REGISTRY.items()):
62
+ print(f"{sid:<{width}} {cls.platform:<8} {','.join(cls.techniques):<22} {cls.name}")
63
+ return 0
64
+
65
+
66
+ def _cmd_describe(scenario_id: str) -> int:
67
+ cls = REGISTRY[scenario_id]
68
+ print(f"{cls.name} ({cls.id})")
69
+ print(f"Platform: {cls.platform}")
70
+ print(f"Techniques: {', '.join(cls.techniques)}")
71
+ for t in cls.techniques:
72
+ print(f" https://attack.mitre.org/techniques/{t.replace('.', '/')}/")
73
+ print(f"\n{cls.description}")
74
+ return 0
75
+
76
+
77
+ def _cmd_generate(args: argparse.Namespace) -> int:
78
+ seed: int = args.seed if args.seed is not None else secrets.randbits(63)
79
+ start: datetime = args.start or datetime.now(timezone.utc).replace(microsecond=0)
80
+ events = engine.generate(args.scenarios, seed=seed, start=start, noise=args.noise)
81
+
82
+ if args.format == "raw" and any(e.raw is None for e in events):
83
+ print("error: --format raw only supports Linux scenarios; use jsonl", file=sys.stderr)
84
+ return 2
85
+
86
+ if args.output is None:
87
+ count = write(events, sys.stdout, args.format, labels=args.labels)
88
+ else:
89
+ mode = "w" if args.force else "x" # "x" refuses to clobber existing files
90
+ try:
91
+ with args.output.open(mode, encoding="utf-8", newline="\n") as fh:
92
+ count = write(events, fh, args.format, labels=args.labels)
93
+ except FileExistsError:
94
+ print(f"error: {args.output} exists (use --force to overwrite)", file=sys.stderr)
95
+ return 1
96
+
97
+ print(
98
+ f"trazas: {count} events, seed={seed}, start={start.isoformat()}",
99
+ file=sys.stderr,
100
+ )
101
+ return 0
102
+
103
+
104
+ def main(argv: Sequence[str] | None = None) -> int:
105
+ args = build_parser().parse_args(argv)
106
+ try:
107
+ if args.command == "list":
108
+ return _cmd_list()
109
+ if args.command == "describe":
110
+ return _cmd_describe(args.scenario)
111
+ return _cmd_generate(args)
112
+ except (ValueError, UnsupportedFormatError) as exc:
113
+ print(f"error: {exc}", file=sys.stderr)
114
+ return 2
115
+ except BrokenPipeError: # pragma: no cover - e.g. `trazas generate ... | head`
116
+ sys.stderr.close()
117
+ return 0
trazas/context.py ADDED
@@ -0,0 +1,98 @@
1
+ """Deterministic generation context.
2
+
3
+ All randomness flows through a single seeded :class:`random.Random` so that the
4
+ same ``seed`` + ``start`` always produce byte-identical output. This is a
5
+ simulation PRNG, not a security primitive.
6
+
7
+ Every address and domain produced here comes from ranges reserved for
8
+ documentation or private use (RFC 5737, RFC 1918, RFC 2606), so generated
9
+ datasets never point at real infrastructure.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import random
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timedelta
17
+
18
+ # RFC 5737 documentation ranges, used for "external" / attacker addresses.
19
+ _EXTERNAL_NETS = ("203.0.113", "198.51.100", "192.0.2")
20
+ # RFC 1918 range used for internal hosts.
21
+ _INTERNAL_NET = "10.20"
22
+
23
+ DOMAIN = "CORP"
24
+ DNS_SUFFIX = "corp.example.com" # RFC 2606 reserved
25
+
26
+ _FIRST_NAMES = (
27
+ "ana", "luis", "maria", "jose", "carla", "diego", "sofia", "pablo",
28
+ "laura", "andres", "valeria", "jorge", "elena", "mateo", "paula", "ricardo",
29
+ ) # fmt: skip
30
+ _LAST_NAMES = (
31
+ "mora", "rojas", "vargas", "jimenez", "solis", "castro", "araya", "chaves",
32
+ "quesada", "alfaro", "brenes", "campos",
33
+ ) # fmt: skip
34
+
35
+ COMMON_BAD_USERNAMES = (
36
+ "administrator", "admin", "user", "test", "guest", "scanner",
37
+ "backup", "support", "sql", "ftp", "oracle", "postgres", "root", "ubuntu",
38
+ ) # fmt: skip
39
+
40
+
41
+ @dataclass
42
+ class GenContext:
43
+ """Shared state for one generation run."""
44
+
45
+ seed: int
46
+ start: datetime
47
+ rng: random.Random = field(init=False, repr=False)
48
+
49
+ def __post_init__(self) -> None:
50
+ if self.start.tzinfo is None:
51
+ raise ValueError("start must be timezone-aware")
52
+ self.rng = random.Random(self.seed) # noqa: S311 - simulation, not crypto
53
+
54
+ # --- time -----------------------------------------------------------------
55
+ def at(self, seconds: float) -> datetime:
56
+ return self.start + timedelta(seconds=seconds)
57
+
58
+ def jitter(self, low: float, high: float) -> float:
59
+ return self.rng.uniform(low, high)
60
+
61
+ # --- identities -----------------------------------------------------------
62
+ def username(self) -> str:
63
+ return f"{self.rng.choice(_FIRST_NAMES)}.{self.rng.choice(_LAST_NAMES)}"
64
+
65
+ def users(self, n: int) -> list[str]:
66
+ seen: dict[str, None] = {}
67
+ while len(seen) < n:
68
+ seen[self.username()] = None
69
+ return list(seen)
70
+
71
+ def bad_username(self) -> str:
72
+ return self.rng.choice(COMMON_BAD_USERNAMES)
73
+
74
+ def workstation(self) -> str:
75
+ return f"WS{self.rng.randint(1, 250):03d}"
76
+
77
+ def server(self, role: str = "SRV") -> str:
78
+ return f"{role}{self.rng.randint(1, 20):02d}"
79
+
80
+ def fqdn(self, host: str) -> str:
81
+ return f"{host}.{DNS_SUFFIX}"
82
+
83
+ # --- network --------------------------------------------------------------
84
+ def external_ip(self) -> str:
85
+ return f"{self.rng.choice(_EXTERNAL_NETS)}.{self.rng.randint(1, 254)}"
86
+
87
+ def internal_ip(self) -> str:
88
+ return f"{_INTERNAL_NET}.{self.rng.randint(0, 31)}.{self.rng.randint(2, 254)}"
89
+
90
+ def ephemeral_port(self) -> int:
91
+ return self.rng.randint(49152, 65535)
92
+
93
+ # --- misc -----------------------------------------------------------------
94
+ def pid(self) -> int:
95
+ return self.rng.randint(300, 65000)
96
+
97
+ def logon_id(self) -> str:
98
+ return f"0x{self.rng.getrandbits(32):x}"
trazas/engine.py ADDED
@@ -0,0 +1,47 @@
1
+ """Combine scenarios and noise into one time-ordered dataset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from datetime import datetime
7
+
8
+ from trazas import scenarios
9
+ from trazas.context import GenContext
10
+ from trazas.events import Event
11
+ from trazas.noise import noise_for
12
+
13
+ DEFAULT_WINDOW_S = 3600.0
14
+ MAX_NOISE = 1_000_000
15
+
16
+
17
+ def generate(
18
+ scenario_ids: Sequence[str],
19
+ *,
20
+ seed: int,
21
+ start: datetime,
22
+ noise: int = 200,
23
+ window_s: float = DEFAULT_WINDOW_S,
24
+ ) -> list[Event]:
25
+ """Generate a sorted list of events for the given scenarios.
26
+
27
+ ``noise`` is the number of benign events per platform involved.
28
+ """
29
+ if not scenario_ids:
30
+ raise ValueError("at least one scenario is required")
31
+ if not 0 <= noise <= MAX_NOISE:
32
+ raise ValueError(f"noise must be between 0 and {MAX_NOISE}")
33
+ if window_s <= 0:
34
+ raise ValueError("window must be positive")
35
+
36
+ ctx = GenContext(seed=seed, start=start)
37
+ selected = [scenarios.get(sid) for sid in dict.fromkeys(scenario_ids)]
38
+
39
+ events: list[Event] = []
40
+ for scenario in selected:
41
+ events.extend(scenario.generate(ctx))
42
+ for platform in sorted({s.platform for s in selected}):
43
+ events.extend(noise_for(platform, ctx, noise, window_s))
44
+
45
+ # Stable sort: ties keep generation order, so output stays deterministic.
46
+ events.sort(key=lambda e: e.timestamp)
47
+ return events
trazas/events.py ADDED
@@ -0,0 +1,45 @@
1
+ """Core event model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Literal
8
+
9
+ Source = Literal["windows_security", "windows_system", "sysmon", "linux_auth"]
10
+
11
+
12
+ def iso_utc(ts: datetime) -> str:
13
+ """Render a timezone-aware datetime as ISO 8601 UTC with millisecond precision."""
14
+ if ts.tzinfo is None:
15
+ raise ValueError("timestamps must be timezone-aware")
16
+ return ts.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Event:
21
+ """A single synthetic log event.
22
+
23
+ ``fields`` follows the column names of the closest well-known schema
24
+ (Microsoft Sentinel ``SecurityEvent`` for Windows, syslog/auth.log for Linux)
25
+ so the output can be loaded directly into tools that expect those names.
26
+ """
27
+
28
+ timestamp: datetime
29
+ source: Source
30
+ fields: dict[str, Any]
31
+ scenario: str
32
+ malicious: bool = False
33
+ technique: str | None = None
34
+ raw: str | None = field(default=None, compare=False)
35
+
36
+ def to_record(self, *, include_labels: bool = False) -> dict[str, Any]:
37
+ record: dict[str, Any] = {"TimeGenerated": iso_utc(self.timestamp), "Source": self.source}
38
+ record.update(self.fields)
39
+ if include_labels:
40
+ record["_trazas"] = {
41
+ "scenario": self.scenario,
42
+ "malicious": self.malicious,
43
+ "technique": self.technique,
44
+ }
45
+ return record
trazas/linux.py ADDED
@@ -0,0 +1,80 @@
1
+ """Builders for Linux ``auth.log`` (sshd) events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from trazas.context import GenContext
8
+ from trazas.events import Event
9
+
10
+
11
+ def _syslog_ts(ts: datetime) -> str:
12
+ # Classic BSD syslog format, e.g. "Nov 1 03:04:05".
13
+ return f"{ts:%b} {ts.day:>2} {ts:%H:%M:%S}"
14
+
15
+
16
+ def sshd(
17
+ ctx: GenContext,
18
+ ts: datetime,
19
+ *,
20
+ host: str,
21
+ message: str,
22
+ user: str,
23
+ src_ip: str,
24
+ outcome: str,
25
+ scenario: str,
26
+ pid: int | None = None,
27
+ malicious: bool = False,
28
+ technique: str | None = None,
29
+ ) -> Event:
30
+ pid = pid if pid is not None else ctx.pid()
31
+ raw = f"{_syslog_ts(ts)} {host} sshd[{pid}]: {message}"
32
+ fields = {
33
+ "HostName": host,
34
+ "ProcessName": "sshd",
35
+ "ProcessID": pid,
36
+ "SyslogMessage": message,
37
+ "User": user,
38
+ "SrcIp": src_ip,
39
+ "Outcome": outcome,
40
+ }
41
+ return Event(ts, "linux_auth", fields, scenario, malicious, technique, raw=raw)
42
+
43
+
44
+ def failed_password(
45
+ ctx: GenContext,
46
+ ts: datetime,
47
+ *,
48
+ host: str,
49
+ user: str,
50
+ user_exists: bool,
51
+ src_ip: str,
52
+ scenario: str,
53
+ malicious: bool = False,
54
+ technique: str | None = None,
55
+ ) -> Event:
56
+ who = user if user_exists else f"invalid user {user}"
57
+ msg = f"Failed password for {who} from {src_ip} port {ctx.ephemeral_port()} ssh2"
58
+ return sshd(
59
+ ctx, ts, host=host, message=msg, user=user, src_ip=src_ip, outcome="failure",
60
+ scenario=scenario, malicious=malicious, technique=technique,
61
+ ) # fmt: skip
62
+
63
+
64
+ def accepted(
65
+ ctx: GenContext,
66
+ ts: datetime,
67
+ *,
68
+ host: str,
69
+ user: str,
70
+ src_ip: str,
71
+ method: str,
72
+ scenario: str,
73
+ malicious: bool = False,
74
+ technique: str | None = None,
75
+ ) -> Event:
76
+ msg = f"Accepted {method} for {user} from {src_ip} port {ctx.ephemeral_port()} ssh2"
77
+ return sshd(
78
+ ctx, ts, host=host, message=msg, user=user, src_ip=src_ip, outcome="success",
79
+ scenario=scenario, malicious=malicious, technique=technique,
80
+ ) # fmt: skip
trazas/noise.py ADDED
@@ -0,0 +1,97 @@
1
+ """Benign background activity.
2
+
3
+ Noise matters: a detection that only works on attack-only data is untested.
4
+ It deliberately includes look-alikes (a user mistyping a password, admins
5
+ using SMB shares) so rules have to tell the difference.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from datetime import datetime
12
+
13
+ from trazas import linux, windows
14
+ from trazas.context import GenContext
15
+ from trazas.events import Event
16
+ from trazas.scenarios.base import Platform
17
+
18
+ NOISE_SCENARIO = "benign"
19
+
20
+ _DEFENDER_DIR = "C:\\ProgramData\\Microsoft\\Windows Defender\\Platform\\4.18.24090.11-0"
21
+ _SENSE_DIR = "C:\\Program Files\\Windows Defender Advanced Threat Protection"
22
+ # Legitimate processes that routinely open lsass.exe. Defender uses the same access
23
+ # mask as Mimikatz and the EDR sensor asks for full access, so a rule keyed only on
24
+ # GrantedAccess will fire on these.
25
+ _LSASS_READERS = (
26
+ (f"{_DEFENDER_DIR}\\MsMpEng.exe", "0x1010", f"{_DEFENDER_DIR}\\MpSvc.dll"),
27
+ (f"{_SENSE_DIR}\\MsSense.exe", "0x1fffff", f"{_SENSE_DIR}\\MsSense.exe"),
28
+ ("C:\\Windows\\System32\\svchost.exe", "0x1000", "C:\\Windows\\System32\\RPCRT4.dll"),
29
+ )
30
+
31
+
32
+ def _benign_lsass_access(ctx: GenContext, ts: datetime, host: str, lsass_pids: dict[str, int]) -> Event:
33
+ image, access, module = ctx.rng.choice(_LSASS_READERS)
34
+ lsass_pid = lsass_pids.setdefault(host, ctx.rng.randint(600, 1200))
35
+ return windows.sysmon_process_access(
36
+ ctx, ts, computer=host, source_image=image, source_pid=ctx.pid(),
37
+ source_user="NT AUTHORITY\\SYSTEM", target_image=windows.LSASS_IMAGE, target_pid=lsass_pid,
38
+ granted_access=access, trace=windows.call_trace(ctx, (windows.NTDLL, windows.KERNELBASE, module)),
39
+ scenario=NOISE_SCENARIO,
40
+ ) # fmt: skip
41
+
42
+
43
+ def windows_noise(ctx: GenContext, count: int, window_s: float) -> Iterator[Event]:
44
+ users = ctx.users(12)
45
+ hosts = [ctx.workstation() for _ in range(8)] + [ctx.server("FS"), ctx.server("DC")]
46
+ lsass_pids: dict[str, int] = {}
47
+ for _ in range(count):
48
+ ts = ctx.at(ctx.jitter(0, window_s))
49
+ user = ctx.rng.choice(users)
50
+ host = ctx.rng.choice(hosts)
51
+ roll = ctx.rng.random()
52
+ if roll < 0.08: # honest typo
53
+ yield windows.logon_failure(
54
+ ctx, ts, computer=host, user=user, user_exists=True, logon_type=2, ip="127.0.0.1",
55
+ workstation=host, scenario=NOISE_SCENARIO,
56
+ ) # fmt: skip
57
+ elif roll < 0.15:
58
+ yield windows.share_access(
59
+ ts, computer=ctx.rng.choice(hosts), user=user, share="IPC$",
60
+ ip=ctx.internal_ip(), scenario=NOISE_SCENARIO,
61
+ ) # fmt: skip
62
+ elif roll < 0.21: # AV/EDR touching lsass: look-alike for credential dumping
63
+ yield _benign_lsass_access(ctx, ts, host, lsass_pids)
64
+ elif roll < 0.35:
65
+ yield windows.logoff(ctx, ts, computer=host, user=user, logon_type=3, scenario=NOISE_SCENARIO)
66
+ else:
67
+ logon_type = ctx.rng.choice((2, 3, 3, 3, 7, 11))
68
+ ip = "-" if logon_type in (2, 7, 11) else ctx.internal_ip()
69
+ yield windows.logon_success(
70
+ ctx, ts, computer=host, user=user, logon_type=logon_type, ip=ip, workstation=host,
71
+ scenario=NOISE_SCENARIO, auth_package=ctx.rng.choice(("Kerberos", "Kerberos", "NTLM")),
72
+ ) # fmt: skip
73
+
74
+
75
+ def linux_noise(ctx: GenContext, count: int, window_s: float) -> Iterator[Event]:
76
+ admins = ["deploy", "ansible", ctx.username().split(".")[0]]
77
+ hosts = ["web01", "app02", "bastion", "db01"]
78
+ for _ in range(count):
79
+ ts = ctx.at(ctx.jitter(0, window_s))
80
+ user = ctx.rng.choice(admins)
81
+ src = ctx.internal_ip()
82
+ if ctx.rng.random() < 0.05: # someone fat-fingers a password once
83
+ yield linux.failed_password(
84
+ ctx, ts, host=ctx.rng.choice(hosts), user=user, user_exists=True, src_ip=src,
85
+ scenario=NOISE_SCENARIO,
86
+ ) # fmt: skip
87
+ else:
88
+ yield linux.accepted(
89
+ ctx, ts, host=ctx.rng.choice(hosts), user=user, src_ip=src, method="publickey",
90
+ scenario=NOISE_SCENARIO,
91
+ ) # fmt: skip
92
+
93
+
94
+ def noise_for(platform: Platform, ctx: GenContext, count: int, window_s: float) -> Iterator[Event]:
95
+ if platform == "windows":
96
+ return windows_noise(ctx, count, window_s)
97
+ return linux_noise(ctx, count, window_s)
trazas/py.typed ADDED
File without changes
@@ -0,0 +1,34 @@
1
+ """Scenario registry.
2
+
3
+ Scenarios are registered explicitly instead of discovered dynamically so that
4
+ running ``trazas`` never imports code from outside the package.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from trazas.scenarios.base import Platform, Scenario
10
+ from trazas.scenarios.lsass_dump import LsassCredentialDump
11
+ from trazas.scenarios.psexec_lateral import PsExecLateralMovement
12
+ from trazas.scenarios.ssh_bruteforce import SshBruteForce
13
+ from trazas.scenarios.windows_bruteforce import WindowsBruteForce
14
+ from trazas.scenarios.windows_spray import WindowsPasswordSpray
15
+
16
+ _ALL: tuple[type[Scenario], ...] = (
17
+ WindowsBruteForce,
18
+ WindowsPasswordSpray,
19
+ PsExecLateralMovement,
20
+ LsassCredentialDump,
21
+ SshBruteForce,
22
+ )
23
+
24
+ REGISTRY: dict[str, type[Scenario]] = {cls.id: cls for cls in _ALL}
25
+
26
+ __all__ = ["REGISTRY", "Platform", "Scenario", "get"]
27
+
28
+
29
+ def get(scenario_id: str) -> Scenario:
30
+ try:
31
+ return REGISTRY[scenario_id]()
32
+ except KeyError:
33
+ known = ", ".join(sorted(REGISTRY))
34
+ raise KeyError(f"unknown scenario {scenario_id!r}; available: {known}") from None
@@ -0,0 +1,30 @@
1
+ """Scenario contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Iterator
7
+ from typing import ClassVar, Literal
8
+
9
+ from trazas.context import GenContext
10
+ from trazas.events import Event
11
+
12
+ Platform = Literal["windows", "linux"]
13
+
14
+
15
+ class Scenario(ABC):
16
+ """An attack scenario that emits the malicious events of one technique chain.
17
+
18
+ Benign background noise is added separately by the engine, so scenarios only
19
+ need to describe what the attacker does.
20
+ """
21
+
22
+ id: ClassVar[str]
23
+ name: ClassVar[str]
24
+ description: ClassVar[str]
25
+ platform: ClassVar[Platform]
26
+ techniques: ClassVar[tuple[str, ...]]
27
+
28
+ @abstractmethod
29
+ def generate(self, ctx: GenContext) -> Iterator[Event]:
30
+ """Yield the scenario's events. Order does not matter; the engine sorts."""
@@ -0,0 +1,116 @@
1
+ """T1003.001 - Credential dumping from LSASS process memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass
7
+
8
+ from trazas import windows
9
+ from trazas.context import DOMAIN, GenContext
10
+ from trazas.events import Event
11
+ from trazas.scenarios.base import Scenario
12
+
13
+ TECHNIQUE = "T1003.001"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class _Tool:
18
+ image: str
19
+ # Formatted with ``pid`` (lsass PID), ``dump`` (dump path) and ``exe`` (image basename).
20
+ command: str
21
+ granted_access: str
22
+ modules: tuple[str, ...]
23
+ unknown_frame: bool # in-memory code shows up as UNKNOWN(...) in CallTrace
24
+ writes_dump: bool
25
+
26
+
27
+ _RUNDLL32 = "C:\\Windows\\System32\\rundll32.exe"
28
+ _PROCDUMP = "C:\\Users\\Public\\procdump64.exe"
29
+
30
+ _TOOLS = (
31
+ # LOLBin: comsvcs.dll exports MiniDump, so no tool needs to be dropped on disk.
32
+ _Tool(
33
+ image=_RUNDLL32,
34
+ command="rundll32.exe C:\\Windows\\System32\\comsvcs.dll, MiniDump {pid} {dump} full",
35
+ granted_access="0x1fffff",
36
+ modules=(
37
+ windows.NTDLL,
38
+ windows.KERNELBASE,
39
+ "C:\\Windows\\SYSTEM32\\dbgcore.DLL",
40
+ "C:\\Windows\\System32\\comsvcs.dll",
41
+ _RUNDLL32,
42
+ ),
43
+ unknown_frame=False,
44
+ writes_dump=True,
45
+ ),
46
+ # Signed Sysinternals binary abused to write a full memory dump.
47
+ _Tool(
48
+ image=_PROCDUMP,
49
+ command="{exe} -accepteula -ma lsass.exe {dump}",
50
+ granted_access="0x1fffff",
51
+ modules=(windows.NTDLL, windows.KERNELBASE, "C:\\Windows\\SYSTEM32\\dbghelp.dll", _PROCDUMP),
52
+ unknown_frame=False,
53
+ writes_dump=True,
54
+ ),
55
+ # Renamed Mimikatz reading credentials straight out of lsass memory.
56
+ _Tool(
57
+ image="C:\\Users\\Public\\svchelper.exe",
58
+ command='{exe} "privilege::debug" "sekurlsa::logonpasswords" exit',
59
+ granted_access="0x1010",
60
+ modules=(windows.NTDLL, windows.KERNELBASE, "C:\\Users\\Public\\svchelper.exe"),
61
+ unknown_frame=True,
62
+ writes_dump=False,
63
+ ),
64
+ )
65
+
66
+ _DUMP_NAMES = ("lsass", "debug", "report", "mem", "out")
67
+ _PARENTS = (
68
+ "C:\\Windows\\System32\\cmd.exe",
69
+ "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
70
+ )
71
+
72
+
73
+ class LsassCredentialDump(Scenario):
74
+ id = "lsass-dump"
75
+ name = "LSASS credential dumping"
76
+ description = (
77
+ "A local admin runs a dumping tool (comsvcs.dll MiniDump, procdump -ma, or a "
78
+ "renamed Mimikatz) that opens lsass.exe with read access (Sysmon 10) and, for the "
79
+ "dump variants, writes a .dmp file (Sysmon 11). Background noise contains benign "
80
+ "lsass access from Defender, the EDR sensor and svchost."
81
+ )
82
+ platform = "windows"
83
+ techniques = (TECHNIQUE,)
84
+
85
+ def generate(self, ctx: GenContext) -> Iterator[Event]:
86
+ tool = ctx.rng.choice(_TOOLS)
87
+ host = ctx.workstation()
88
+ user = f"{DOMAIN}\\{ctx.username()}"
89
+ lsass_pid = ctx.rng.randint(600, 1200)
90
+ tool_pid = ctx.pid()
91
+ dump = f"C:\\Windows\\Temp\\{ctx.rng.choice(_DUMP_NAMES)}{ctx.rng.randint(1, 99)}.dmp"
92
+ exe = tool.image.rsplit("\\", 1)[-1]
93
+ t = ctx.jitter(300, 2400)
94
+
95
+ yield windows.sysmon_process(
96
+ ctx, ctx.at(t), computer=host, image=tool.image,
97
+ command_line=tool.command.format(pid=lsass_pid, dump=dump, exe=exe),
98
+ parent_image=ctx.rng.choice(_PARENTS), user=user, scenario=self.id,
99
+ malicious=True, technique=TECHNIQUE, pid=tool_pid,
100
+ ) # fmt: skip
101
+
102
+ t += ctx.jitter(0.2, 1.5)
103
+ yield windows.sysmon_process_access(
104
+ ctx, ctx.at(t), computer=host, source_image=tool.image, source_pid=tool_pid,
105
+ source_user=user, target_image=windows.LSASS_IMAGE, target_pid=lsass_pid,
106
+ granted_access=tool.granted_access,
107
+ trace=windows.call_trace(ctx, tool.modules, unknown_frame=tool.unknown_frame),
108
+ scenario=self.id, malicious=True, technique=TECHNIQUE,
109
+ ) # fmt: skip
110
+
111
+ if tool.writes_dump:
112
+ t += ctx.jitter(1.0, 6.0)
113
+ yield windows.sysmon_file_create(
114
+ ctx.at(t), computer=host, image=tool.image, pid=tool_pid, user=user,
115
+ target_filename=dump, scenario=self.id, malicious=True, technique=TECHNIQUE,
116
+ ) # fmt: skip