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 +3 -0
- trazas/__main__.py +5 -0
- trazas/cli.py +117 -0
- trazas/context.py +98 -0
- trazas/engine.py +47 -0
- trazas/events.py +45 -0
- trazas/linux.py +80 -0
- trazas/noise.py +97 -0
- trazas/py.typed +0 -0
- trazas/scenarios/__init__.py +34 -0
- trazas/scenarios/base.py +30 -0
- trazas/scenarios/lsass_dump.py +116 -0
- trazas/scenarios/psexec_lateral.py +70 -0
- trazas/scenarios/ssh_bruteforce.py +42 -0
- trazas/scenarios/windows_bruteforce.py +43 -0
- trazas/scenarios/windows_spray.py +46 -0
- trazas/windows.py +256 -0
- trazas/writers.py +31 -0
- trazas-0.1.0.dist-info/METADATA +114 -0
- trazas-0.1.0.dist-info/RECORD +23 -0
- trazas-0.1.0.dist-info/WHEEL +4 -0
- trazas-0.1.0.dist-info/entry_points.txt +2 -0
- trazas-0.1.0.dist-info/licenses/LICENSE +21 -0
trazas/__init__.py
ADDED
trazas/__main__.py
ADDED
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
|
trazas/scenarios/base.py
ADDED
|
@@ -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
|