bhshell 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.
- bhshell/__init__.py +3 -0
- bhshell/__main__.py +3 -0
- bhshell/cli.py +55 -0
- bhshell/client/__init__.py +0 -0
- bhshell/client/auth.py +74 -0
- bhshell/client/client.py +337 -0
- bhshell/config.py +162 -0
- bhshell/errors.py +73 -0
- bhshell/library/asrep-roastable-users.cyp +3 -0
- bhshell/library/dcsync-principals.cyp +3 -0
- bhshell/library/domain-admins-members.cyp +3 -0
- bhshell/library/domain-trusts.cyp +3 -0
- bhshell/library/gpos-linked-to-domain-controllers.cyp +3 -0
- bhshell/library/kerberoastable-users.cyp +3 -0
- bhshell/library/owned-objects.cyp +3 -0
- bhshell/library/owned-to-tier-zero.cyp +3 -0
- bhshell/library/sid-history.cyp +3 -0
- bhshell/library/tier-zero-users.cyp +3 -0
- bhshell/library/unconstrained-delegation.cyp +3 -0
- bhshell/models/__init__.py +0 -0
- bhshell/models/entities.py +402 -0
- bhshell/models/results.py +83 -0
- bhshell/queries/__init__.py +0 -0
- bhshell/queries/cypher.py +155 -0
- bhshell/queries/domains.py +136 -0
- bhshell/queries/edges.py +109 -0
- bhshell/queries/hunt.py +675 -0
- bhshell/queries/library.py +94 -0
- bhshell/queries/marking.py +121 -0
- bhshell/queries/paths.py +369 -0
- bhshell/queries/search.py +196 -0
- bhshell/queries/service.py +529 -0
- bhshell/render/__init__.py +0 -0
- bhshell/render/export.py +79 -0
- bhshell/render/graph.py +326 -0
- bhshell/render/layout.py +138 -0
- bhshell/render/png.py +44 -0
- bhshell/render/styles.py +5 -0
- bhshell/render/svg.py +495 -0
- bhshell/render/views.py +337 -0
- bhshell/shell/__init__.py +0 -0
- bhshell/shell/app.py +189 -0
- bhshell/shell/commands/__init__.py +0 -0
- bhshell/shell/commands/base.py +60 -0
- bhshell/shell/commands/connection.py +236 -0
- bhshell/shell/commands/cypher.py +97 -0
- bhshell/shell/commands/domains.py +80 -0
- bhshell/shell/commands/entities.py +88 -0
- bhshell/shell/commands/hunt.py +151 -0
- bhshell/shell/commands/library.py +56 -0
- bhshell/shell/commands/marking.py +59 -0
- bhshell/shell/commands/paths.py +166 -0
- bhshell/shell/commands/search.py +125 -0
- bhshell-0.1.0.dist-info/METADATA +134 -0
- bhshell-0.1.0.dist-info/RECORD +59 -0
- bhshell-0.1.0.dist-info/WHEEL +5 -0
- bhshell-0.1.0.dist-info/entry_points.txt +2 -0
- bhshell-0.1.0.dist-info/licenses/LICENSE +21 -0
- bhshell-0.1.0.dist-info/top_level.txt +1 -0
bhshell/queries/hunt.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""Domain-wide weakness checks (`hunt`).
|
|
2
|
+
|
|
3
|
+
Each `Check` is declarative: a Cypher builder, the columns to display
|
|
4
|
+
and a function turning the returned graph into rows.
|
|
5
|
+
|
|
6
|
+
Checks marked `scoped` also run from a set of principals (`hunt --owned`,
|
|
7
|
+
`hunt --from NAME`): the owned objects or the named one, plus every group
|
|
8
|
+
they belong to. Their query builders restrict the principal side with
|
|
9
|
+
`HuntOptions.scope`, which is empty outside that mode.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
from bhshell.errors import ApiError, CypherDisabledError, UnsupportedFeature
|
|
21
|
+
from bhshell.models.entities import OWNED_LABEL, TIER_ZERO_LABEL, Edge, Graph, Node, last_logon
|
|
22
|
+
from bhshell.models.results import Column
|
|
23
|
+
from bhshell.queries import cypher as Q
|
|
24
|
+
from bhshell.queries.domains import TRUSTS_QUERY, trusts_from_graph
|
|
25
|
+
from bhshell.queries.edges import (
|
|
26
|
+
ACL_EDGES,
|
|
27
|
+
ADCS_EDGES,
|
|
28
|
+
COERCION_EDGES,
|
|
29
|
+
CONTROL_EDGES,
|
|
30
|
+
DELEGATION_EDGES,
|
|
31
|
+
ENABLE_EDGES,
|
|
32
|
+
EXEC_EDGES,
|
|
33
|
+
HAS_SESSION,
|
|
34
|
+
MEMBER_OF,
|
|
35
|
+
severity_rank,
|
|
36
|
+
)
|
|
37
|
+
from bhshell.queries.paths import PathService
|
|
38
|
+
from bhshell.queries.service import EntityService
|
|
39
|
+
|
|
40
|
+
Row = dict[str, Any]
|
|
41
|
+
HuntSeverity = Literal["critical", "high", "medium", "low", "info"]
|
|
42
|
+
|
|
43
|
+
# Well-known broad principals, matched by objectid suffix: Everyone, Anonymous Logon, Authenticated Users,
|
|
44
|
+
# Domain Users, Domain Computers, Users, Pre-Windows 2000 Compatible Access.
|
|
45
|
+
BROAD_SIDS = ("-S-1-1-0", "-S-1-5-7", "-S-1-5-11", "-513", "-515", "-S-1-5-32-545", "-S-1-5-32-554")
|
|
46
|
+
LEGACY_OS = ("2000", "2003", "2008", " XP", "VISTA", "WINDOWS 7", "2012", "WINDOWS 8")
|
|
47
|
+
# Windows 10 left support in October 2025; the LTSC/LTSB editions are still supported.
|
|
48
|
+
WINDOWS_10 = "WINDOWS 10"
|
|
49
|
+
LTSC = ("LTSC", "LTSB")
|
|
50
|
+
PROTECTED_USERS_RID = "-525"
|
|
51
|
+
DCSYNC_EDGES = ("DCSync", "GetChanges", "GetChangesAll")
|
|
52
|
+
ESC8_EDGE = "CoerceAndRelayNTLMToADCS"
|
|
53
|
+
ADCS_ABUSE_EDGES = frozenset(k for k in ADCS_EDGES if k.startswith("ADCSESC")) | {"GoldenCert", ESC8_EDGE}
|
|
54
|
+
BROAD_RIGHT_EDGES = (CONTROL_EDGES | EXEC_EDGES) - {"Enroll"}
|
|
55
|
+
CRED_READ_EDGES = frozenset({"ReadLAPSPassword", "ReadGMSAPassword", "SyncLAPSPassword", "DumpSMSAPassword"})
|
|
56
|
+
# Control of a Tier Zero object, other than the DCSync, SID history and AD CS edges reported by their own checks.
|
|
57
|
+
T0_CONTROL_EDGES = ACL_EDGES | DELEGATION_EDGES | CRED_READ_EDGES | EXEC_EDGES
|
|
58
|
+
TAKEOVER = {
|
|
59
|
+
"AddKeyCredentialLink": "Shadow Credentials",
|
|
60
|
+
"WriteSPN": "Targeted Kerberoasting",
|
|
61
|
+
"AddAllowedToAct": "RBCD",
|
|
62
|
+
"WriteAccountRestrictions": "RBCD",
|
|
63
|
+
}
|
|
64
|
+
TAKEOVER_EDGES = frozenset(TAKEOVER)
|
|
65
|
+
GPO_CONTROL_EDGES = frozenset({"GenericAll", "GenericWrite", "WriteDacl", "WriteOwner", "Owns", "WriteGPLink",
|
|
66
|
+
"OwnsLimitedRights", "WriteOwnerLimitedRights"})
|
|
67
|
+
CA_CONTROL_EDGES = frozenset({"ManageCA", "ManageCertificates"})
|
|
68
|
+
RELAY_EDGES = COERCION_EDGES - {ESC8_EDGE}
|
|
69
|
+
FOREIGN_EDGES = ACL_EDGES | EXEC_EDGES | {MEMBER_OF}
|
|
70
|
+
# Attributes that may hold a cleartext (or reversibly encoded) password.
|
|
71
|
+
PASSWORD_ATTRS = ("userpassword", "unixpassword", "unicodepassword", "sfupassword")
|
|
72
|
+
PASSWORD_WORDS = ("pass", "pwd", "pw:", "pw=", "mdp", "contrase", "clave", "kennwort", "senha", "wachtwoord",
|
|
73
|
+
"losenord", "salasana", "parola", "haslo")
|
|
74
|
+
# "(?<!by)": "bypass" is not a password.
|
|
75
|
+
_PASSWORD_RE = re.compile("|".join(("(?<!by)" if w == "pass" else "") + re.escape(w) for w in PASSWORD_WORDS),
|
|
76
|
+
re.IGNORECASE)
|
|
77
|
+
# Default descriptions of built-in AD / Exchange groups that mention passwords (found in every domain).
|
|
78
|
+
DEFAULT_DESCRIPTIONS = (
|
|
79
|
+
"members in this group can have their passwords replicated to all read-only domain controllers",
|
|
80
|
+
"members in this group cannot have their passwords replicated to any read-only domain controllers",
|
|
81
|
+
"this group contains all the exchange hosted organization mailboxes groups",
|
|
82
|
+
)
|
|
83
|
+
# Users scanned for a path to Tier Zero (kerberoastable, asreproast): one allShortestPaths query.
|
|
84
|
+
PATH_ENRICH_MAX = 500
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class HuntOptions:
|
|
89
|
+
domain: str = ""
|
|
90
|
+
days: int = 1825
|
|
91
|
+
limit: int = 5000
|
|
92
|
+
now: float = field(default_factory=time.time)
|
|
93
|
+
# Scoped mode: objectid of each principal checked -> the owned (or --from) objects holding its rights.
|
|
94
|
+
principals: dict[str, list[str]] | None = None
|
|
95
|
+
# Run the extra queries some checks use to enrich their rows (skipped for the summary).
|
|
96
|
+
enrich: bool = True
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def scoped(self) -> bool:
|
|
100
|
+
return self.principals is not None
|
|
101
|
+
|
|
102
|
+
def dom(self, alias: str) -> str:
|
|
103
|
+
"""A Cypher condition restricting `alias` to the selected domain ('' = all)."""
|
|
104
|
+
return Q.domain_condition(alias, self.domain)
|
|
105
|
+
|
|
106
|
+
def scope(self, alias: str) -> str:
|
|
107
|
+
"""A Cypher condition restricting `alias` to the scoped principals ('' outside scoped mode)."""
|
|
108
|
+
if self.principals is None:
|
|
109
|
+
return ""
|
|
110
|
+
return f"{alias}.objectid IN [{', '.join(Q.lit(i) for i in sorted(self.principals))}]"
|
|
111
|
+
|
|
112
|
+
def matches(self, domain: str) -> bool:
|
|
113
|
+
"""Whether `domain` (an FQDN) is in the selected domain, matched the same way as `dom`."""
|
|
114
|
+
if not self.domain:
|
|
115
|
+
return True
|
|
116
|
+
d, name = self.domain.upper(), domain.upper()
|
|
117
|
+
return name == d or ("." not in d and name.startswith(d + "."))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Extra queries run after a check, filling in its rows; returns notes for the table.
|
|
121
|
+
Enricher = Callable[[EntityService, list["Row"], HuntOptions], list[str]]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class Check:
|
|
126
|
+
id: str
|
|
127
|
+
title: str
|
|
128
|
+
severity: HuntSeverity
|
|
129
|
+
description: str
|
|
130
|
+
query: Callable[[HuntOptions], str]
|
|
131
|
+
columns: tuple[Column, ...]
|
|
132
|
+
rows: Callable[[Graph, HuntOptions], list[Row]]
|
|
133
|
+
fallback: Callable[[HuntOptions], str] | None = None
|
|
134
|
+
# Runs with --owned / --from (the query restricts its principals with `HuntOptions.scope`).
|
|
135
|
+
scoped: bool = False
|
|
136
|
+
# Columns shown instead of `columns` in scoped mode.
|
|
137
|
+
scoped_columns: tuple[Column, ...] | None = None
|
|
138
|
+
# Extra queries filling in the rows: only for details (not the summary).
|
|
139
|
+
enrich: Enricher | None = None
|
|
140
|
+
# Extra queries filtering the rows: always run.
|
|
141
|
+
refine: Enricher | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class Finding:
|
|
146
|
+
check: Check
|
|
147
|
+
rows: list[Row] = field(default_factory=list)
|
|
148
|
+
truncated: bool = False
|
|
149
|
+
error: str = ""
|
|
150
|
+
# Server-side total when the row list was truncated (`None` if the server cannot count it).
|
|
151
|
+
total: int | None = None
|
|
152
|
+
notes: list[str] = field(default_factory=list)
|
|
153
|
+
|
|
154
|
+
def as_dict(self) -> dict[str, Any]:
|
|
155
|
+
return {"check": self.check.id, "title": self.check.title, "severity": self.check.severity,
|
|
156
|
+
"count": len(self.rows), "total": self.total, "truncated": self.truncated,
|
|
157
|
+
"error": self.error, "notes": self.notes, "rows": self.rows}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def node_row(n: Node, **extra: Any) -> Row:
|
|
161
|
+
return {"name": n.name, "kind": n.kind, "domain": n.domain, "objectid": n.objectid,
|
|
162
|
+
"enabled": n.properties.get("enabled"), "tier_zero": n.tier_zero, "owned": n.owned, **extra}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def edge_row(g: Graph, e: Edge, **extra: Any) -> Row:
|
|
166
|
+
src, tgt = g.nodes[e.source], g.nodes[e.target]
|
|
167
|
+
return {"principal": src.name, "principal_kind": src.kind, "edge": e.kind, "target": tgt.name,
|
|
168
|
+
"target_kind": tgt.kind, "domain": src.domain, "objectid": src.objectid,
|
|
169
|
+
"tier_zero": tgt.tier_zero, "owned": src.owned, **extra}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def enctypes(props: dict[str, Any]) -> str:
|
|
173
|
+
"""Kerberos encryption types a user's tickets can use: RC4 hashes crack far faster than AES."""
|
|
174
|
+
raw = props.get("supportedencryptiontypes")
|
|
175
|
+
values = [str(v).upper() for v in (raw if isinstance(raw, list) else [raw] if raw else [])]
|
|
176
|
+
if not values or any("NOT DEFINED" in v for v in values):
|
|
177
|
+
return "default (RC4)"
|
|
178
|
+
if any("RC4" in v for v in values):
|
|
179
|
+
return "RC4"
|
|
180
|
+
if any("DES" in v for v in values):
|
|
181
|
+
return "DES"
|
|
182
|
+
return "AES only"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _user_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
186
|
+
rows = [node_row(n, pwdlastset=n.properties.get("pwdlastset"), lastlogon=last_logon(n.properties),
|
|
187
|
+
admincount=bool(n.properties.get("admincount")),
|
|
188
|
+
spns=len(n.properties.get("serviceprincipalnames") or []),
|
|
189
|
+
enctypes=enctypes(n.properties))
|
|
190
|
+
for n in g.nodes.values()
|
|
191
|
+
if n.kind == "User" and n.samaccountname.lower() != "krbtgt"]
|
|
192
|
+
# Oldest passwords first; unset (0 / missing) passwords last.
|
|
193
|
+
rows.sort(key=lambda r: (not r["tier_zero"], not r["admincount"], (r["pwdlastset"] or 0) <= 0,
|
|
194
|
+
r["pwdlastset"] or 0))
|
|
195
|
+
return rows
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _computer_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
199
|
+
rows = [node_row(n, os=n.properties.get("operatingsystem") or "", lastlogon=last_logon(n.properties))
|
|
200
|
+
for n in g.nodes.values()]
|
|
201
|
+
rows.sort(key=lambda r: (not r["tier_zero"], r["os"], r["name"]))
|
|
202
|
+
return rows
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
NAME = Column("Name", "name")
|
|
206
|
+
DOMAIN = Column("Domain", "domain")
|
|
207
|
+
KIND = Column("Type", "kind", "kind")
|
|
208
|
+
T0 = Column("T0", "tier_zero", "t0")
|
|
209
|
+
OWNED = Column("Owned", "owned", "owned")
|
|
210
|
+
PWD = Column("Pwd last set", "pwdlastset", "date")
|
|
211
|
+
LOGON = Column("Last logon", "lastlogon", "date")
|
|
212
|
+
ADMINCOUNT = Column("AdminCount", "admincount", "bool")
|
|
213
|
+
OS = Column("Operating system", "os")
|
|
214
|
+
PRINCIPAL = Column("Principal", "principal")
|
|
215
|
+
PRINCIPAL_KIND = Column("Type", "principal_kind", "kind")
|
|
216
|
+
EDGE = Column("Right", "edge", "edge")
|
|
217
|
+
TARGET = Column("Target", "target")
|
|
218
|
+
TARGET_KIND = Column("Target type", "target_kind", "kind")
|
|
219
|
+
PATH_T0 = Column("Path to T0", "path_to_t0", "risk")
|
|
220
|
+
ENCTYPES = Column("Enc types", "enctypes")
|
|
221
|
+
FROM_OWNED = Column("From", "from_owned")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _unconstrained_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
225
|
+
rows = [node_row(n, os=n.properties.get("operatingsystem") or "") for n in g.nodes.values()]
|
|
226
|
+
return sorted(rows, key=lambda r: (r["kind"] != "User", r["name"]))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _constrained(o: HuntOptions) -> str:
|
|
230
|
+
return (f"MATCH p=(n:Base)-[:AllowedToDelegate]->(t:Base){Q.where('n.enabled = true', o.scope('n'), o.dom('n'))} "
|
|
231
|
+
f"RETURN p LIMIT {o.limit}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _constrained_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
235
|
+
rows = [edge_row(g, e, protocol_transition=bool(g.nodes[e.source].properties.get("trustedtoauth")))
|
|
236
|
+
for e in g.edges if e.kind == "AllowedToDelegate"]
|
|
237
|
+
return sorted(rows, key=lambda r: (not r["tier_zero"], not r["protocol_transition"], r["principal"]))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _dcsync(o: HuntOptions) -> str:
|
|
241
|
+
return (f"MATCH p=(n:Base)-[:{Q.edge_types(DCSYNC_EDGES)}]->(d:Domain){Q.where(o.scope('n'), o.dom('d'))} "
|
|
242
|
+
f"RETURN p LIMIT {o.limit}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _dcsync_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
246
|
+
by_principal: dict[tuple[str, str], set[str]] = {}
|
|
247
|
+
for e in g.edges:
|
|
248
|
+
if e.kind in DCSYNC_EDGES and not g.nodes[e.source].tier_zero:
|
|
249
|
+
by_principal.setdefault((e.source, e.target), set()).add(e.kind)
|
|
250
|
+
rows = []
|
|
251
|
+
for (src, dst), rights in by_principal.items():
|
|
252
|
+
full = "DCSync" in rights or {"GetChanges", "GetChangesAll"} <= rights
|
|
253
|
+
rows.append(node_row(g.nodes[src], rights=", ".join(sorted(rights)), target=g.nodes[dst].name,
|
|
254
|
+
full_dcsync=full))
|
|
255
|
+
return sorted(rows, key=lambda r: (not r["full_dcsync"], r["name"]))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _broad(o: HuntOptions, edges: frozenset[str] | None = BROAD_RIGHT_EDGES) -> str:
|
|
259
|
+
sids = " OR ".join(f"g.objectid ENDS WITH {Q.lit(s)}" for s in BROAD_SIDS)
|
|
260
|
+
rel = f"[r:{Q.edge_types(edges)}]" if edges else "[r]"
|
|
261
|
+
return (f"MATCH p=(g:Base)-{rel}->(t:Base){Q.where(f'({sids})', o.scope('g'), o.dom('g'))} "
|
|
262
|
+
f"RETURN p LIMIT {o.limit}")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _broad_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
266
|
+
rows = [edge_row(g, e) for e in g.edges
|
|
267
|
+
if e.kind in BROAD_RIGHT_EDGES and g.nodes[e.source].objectid.upper().endswith(BROAD_SIDS)]
|
|
268
|
+
return sorted(rows, key=lambda r: (not r["tier_zero"], r["edge"], r["target"]))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _node_check(label: str, *conditions: str) -> Callable[[HuntOptions], str]:
|
|
272
|
+
def build(o: HuntOptions) -> str:
|
|
273
|
+
return f"MATCH (n:{label}){Q.where(*conditions, o.scope('n'), o.dom('n'))} RETURN n LIMIT {o.limit}"
|
|
274
|
+
return build
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _edge_check(kinds: Iterable[str] | None, *conditions: str, target: str = "Base",
|
|
278
|
+
dom: str = "n") -> Callable[[HuntOptions], str]:
|
|
279
|
+
"""`(n)-[kinds]->(t:target)` paths; `kinds=None` matches any relationship (a fallback for unknown kinds)."""
|
|
280
|
+
rel = f"[:{Q.edge_types(kinds)}]" if kinds is not None else "[]"
|
|
281
|
+
|
|
282
|
+
def build(o: HuntOptions) -> str:
|
|
283
|
+
return (f"MATCH p=(n:Base)-{rel}->(t:{target}){Q.where(*conditions, o.scope('n'), o.dom(dom))} "
|
|
284
|
+
f"RETURN p LIMIT {o.limit}")
|
|
285
|
+
return build
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _edge_rows(kinds: Iterable[str]) -> Callable[[Graph, HuntOptions], list[Row]]:
|
|
289
|
+
kinds = frozenset(kinds)
|
|
290
|
+
|
|
291
|
+
def rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
292
|
+
out = [edge_row(g, e) for e in g.edges if e.kind in kinds]
|
|
293
|
+
return sorted(out, key=lambda r: (not r["tier_zero"], severity_rank(r["edge"]), r["edge"], r["principal"]))
|
|
294
|
+
return rows
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _stale(o: HuntOptions) -> str:
|
|
298
|
+
cutoff = int(o.now - o.days * 86400)
|
|
299
|
+
where = Q.where("n.enabled = true", "n.pwdneverexpires = true", "n.pwdlastset > 0",
|
|
300
|
+
f"n.pwdlastset < {cutoff}", o.dom("n"))
|
|
301
|
+
return f"MATCH (n:User){where} RETURN n LIMIT {o.limit}"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _trusts(_: HuntOptions) -> str:
|
|
305
|
+
return TRUSTS_QUERY
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _trust_rows(g: Graph, o: HuntOptions) -> list[Row]:
|
|
309
|
+
rows = []
|
|
310
|
+
for t in trusts_from_graph(g):
|
|
311
|
+
if not t.issues:
|
|
312
|
+
continue
|
|
313
|
+
if not (o.matches(t.trusting) or o.matches(t.trusted)):
|
|
314
|
+
continue
|
|
315
|
+
rows.append({"trusting": t.trusting, "trusted": t.trusted, "kind": t.kind,
|
|
316
|
+
"trust_type": t.trust_type, "issues": "; ".join(t.issues)})
|
|
317
|
+
return rows
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
_NOT_T0 = f"NOT n:{TIER_ZERO_LABEL}"
|
|
321
|
+
_LEGACY_OS_CONDITION = "(" + " OR ".join(
|
|
322
|
+
[f"n.operatingsystem CONTAINS {Q.lit(v)}" for v in LEGACY_OS]
|
|
323
|
+
+ [f"(n.operatingsystem CONTAINS {Q.lit(WINDOWS_10)} AND "
|
|
324
|
+
+ " AND ".join(f"NOT n.operatingsystem CONTAINS {Q.lit(v)}" for v in LTSC) + ")"]) + ")"
|
|
325
|
+
_PRIVILEGED_USER = f"(u:{TIER_ZERO_LABEL} OR u.admincount = true)"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# Path to Tier Zero (kerberoastable, asreproast)
|
|
329
|
+
|
|
330
|
+
def _path_to_t0(entities: EntityService, rows: list[Row], _: HuntOptions) -> list[str]:
|
|
331
|
+
"""Mark the users with a path to Tier Zero, and list them first: their hashes are the ones to crack."""
|
|
332
|
+
candidates = [r["objectid"] for r in rows if not r["tier_zero"]]
|
|
333
|
+
if len(candidates) > PATH_ENRICH_MAX:
|
|
334
|
+
return [f"Path to Tier Zero not checked: more than {PATH_ENRICH_MAX} users."]
|
|
335
|
+
try:
|
|
336
|
+
reach, notes = PathService(entities).reaching_tier_zero(candidates)
|
|
337
|
+
except (ApiError, UnsupportedFeature) as exc:
|
|
338
|
+
return [f"Path to Tier Zero not checked: {exc}"]
|
|
339
|
+
for r in rows:
|
|
340
|
+
r["path_to_t0"] = r["tier_zero"] or r["objectid"] in reach
|
|
341
|
+
rows.sort(key=lambda r: (not r["path_to_t0"], r["enctypes"] == "AES only")) # stable: keeps the age order
|
|
342
|
+
return notes
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Credential exposure
|
|
346
|
+
|
|
347
|
+
def _t0_sessions(o: HuntOptions) -> str:
|
|
348
|
+
conds = (_PRIVILEGED_USER, f"NOT c:{TIER_ZERO_LABEL}", o.dom("c"))
|
|
349
|
+
if o.scoped:
|
|
350
|
+
return (f"MATCH p=(n:Base)-[:{Q.edge_types(EXEC_EDGES)}]->(c:Computer)-[:{HAS_SESSION}]->(u:User)"
|
|
351
|
+
f"{Q.where(o.scope('n'), *conds)} RETURN p LIMIT {o.limit}")
|
|
352
|
+
return f"MATCH p=(c:Computer)-[:{HAS_SESSION}]->(u:User){Q.where(*conds)} RETURN p LIMIT {o.limit}"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _t0_session_rows(g: Graph, o: HuntOptions) -> list[Row]:
|
|
356
|
+
sessions: dict[str, list[Node]] = {}
|
|
357
|
+
for e in g.edges:
|
|
358
|
+
if e.kind == HAS_SESSION:
|
|
359
|
+
sessions.setdefault(e.source, []).append(g.nodes[e.target])
|
|
360
|
+
|
|
361
|
+
def row(c: Node, u: Node, **extra: Any) -> Row:
|
|
362
|
+
return {"name": c.name, "os": c.properties.get("operatingsystem") or "", "user": u.name,
|
|
363
|
+
"tier_zero": u.tier_zero, "admincount": bool(u.properties.get("admincount")),
|
|
364
|
+
"domain": c.domain, "objectid": c.objectid, **extra}
|
|
365
|
+
|
|
366
|
+
if o.scoped:
|
|
367
|
+
rows = [row(g.nodes[e.target], u, access=e.kind, principal=g.nodes[e.source].name,
|
|
368
|
+
objectid=g.nodes[e.source].objectid)
|
|
369
|
+
for e in g.edges if e.kind in EXEC_EDGES for u in sessions.get(e.target, [])]
|
|
370
|
+
else:
|
|
371
|
+
rows = [row(g.nodes[c], u) for c, users in sessions.items() for u in users]
|
|
372
|
+
return sorted(rows, key=lambda r: (not r["tier_zero"], r["name"], r["user"]))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _password_attrs(o: HuntOptions, *, lower: bool = True) -> str:
|
|
376
|
+
if lower:
|
|
377
|
+
words = [f"toLower(n.description) CONTAINS {Q.lit(w)}" for w in PASSWORD_WORDS]
|
|
378
|
+
else: # servers without toLower(): the usual spellings
|
|
379
|
+
variants = dict.fromkeys(v for w in PASSWORD_WORDS for v in (w, w.capitalize(), w.upper()))
|
|
380
|
+
words = [f"n.description CONTAINS {Q.lit(v)}" for v in variants]
|
|
381
|
+
attrs = [f"n.{a} IS NOT NULL" for a in PASSWORD_ATTRS]
|
|
382
|
+
return f"MATCH (n:Base){Q.where('(' + ' OR '.join(attrs + words) + ')', o.dom('n'))} RETURN n LIMIT {o.limit}"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _password_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
386
|
+
rows = []
|
|
387
|
+
for n in g.nodes.values():
|
|
388
|
+
for attr in PASSWORD_ATTRS:
|
|
389
|
+
value = n.properties.get(attr)
|
|
390
|
+
if value not in (None, "", []):
|
|
391
|
+
rows.append(node_row(n, attribute=attr, value=str(value)))
|
|
392
|
+
description = str(n.properties.get("description") or "")
|
|
393
|
+
if _PASSWORD_RE.search(description) and not description.lower().startswith(DEFAULT_DESCRIPTIONS):
|
|
394
|
+
rows.append(node_row(n, attribute="description", value=description))
|
|
395
|
+
return sorted(rows, key=lambda r: (r["attribute"] == "description", not r["tier_zero"], r["name"]))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# NTLM relay
|
|
399
|
+
|
|
400
|
+
_NTLM_ISSUES = (
|
|
401
|
+
("smbsigning", False, "SMB signing not required"),
|
|
402
|
+
("webclientrunning", True, "WebClient running (HTTP coercion)"),
|
|
403
|
+
("ldapsigning", False, "LDAP signing not required"),
|
|
404
|
+
("ldapsepa", False, "LDAPS channel binding not required"),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _ntlm(o: HuntOptions) -> str:
|
|
409
|
+
issues = " OR ".join(f"n.{prop} = {str(bad).lower()}" for prop, bad, _ in _NTLM_ISSUES)
|
|
410
|
+
return f"MATCH (n:Computer){Q.where('n.enabled = true', f'({issues})', o.dom('n'))} RETURN n LIMIT {o.limit}"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _ntlm_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
414
|
+
rows = []
|
|
415
|
+
for n in g.nodes.values():
|
|
416
|
+
issues = [text for prop, bad, text in _NTLM_ISSUES if n.properties.get(prop) is bad]
|
|
417
|
+
rows.append(node_row(n, os=n.properties.get("operatingsystem") or "", issues="; ".join(issues)))
|
|
418
|
+
return sorted(rows, key=lambda r: (not r["tier_zero"], r["name"]))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# Object takeover
|
|
422
|
+
|
|
423
|
+
def _takeover_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
424
|
+
rows = [edge_row(g, e, technique=TAKEOVER[e.kind],
|
|
425
|
+
admincount=bool(g.nodes[e.target].properties.get("admincount")))
|
|
426
|
+
for e in g.edges if e.kind in TAKEOVER]
|
|
427
|
+
return sorted(rows, key=lambda r: (not r["admincount"], r["technique"], r["target"], r["principal"]))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _reenable_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
431
|
+
rows = [edge_row(g, e, admincount=bool(g.nodes[e.target].properties.get("admincount")))
|
|
432
|
+
for e in g.edges if e.kind in ENABLE_EDGES]
|
|
433
|
+
return sorted(rows, key=lambda r: (not r["tier_zero"], r["target"], severity_rank(r["edge"])))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _foreign_rows(g: Graph, _: HuntOptions) -> list[Row]:
|
|
437
|
+
rows = [edge_row(g, e, target_domain=g.nodes[e.target].domain)
|
|
438
|
+
for e in g.edges if e.kind in FOREIGN_EDGES and g.nodes[e.source].domain != g.nodes[e.target].domain]
|
|
439
|
+
return sorted(rows, key=lambda r: (not r["tier_zero"], r["edge"] == MEMBER_OF, r["principal"]))
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# OPSEC
|
|
443
|
+
|
|
444
|
+
def _decoys(o: HuntOptions) -> str:
|
|
445
|
+
never = ("(n.lastlogon IS NULL OR n.lastlogon <= 0)",
|
|
446
|
+
"(n.lastlogontimestamp IS NULL OR n.lastlogontimestamp <= 0)")
|
|
447
|
+
lure = "(n.hasspn = true OR n.dontreqpreauth = true OR n.admincount = true)"
|
|
448
|
+
where = Q.where("n.enabled = true", lure, *never, "n.samaccountname <> 'krbtgt'", o.dom("n"))
|
|
449
|
+
return f"MATCH (n:User){where} RETURN n LIMIT {o.limit}"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _decoy_rows(g: Graph, o: HuntOptions) -> list[Row]:
|
|
453
|
+
rows = _user_rows(g, o)
|
|
454
|
+
for r, n in zip(rows, (g.find(r["objectid"]) for r in rows), strict=True):
|
|
455
|
+
props = n.properties if n else {}
|
|
456
|
+
lures = [label for prop, label in (("hasspn", "SPN"), ("dontreqpreauth", "no preauth"),
|
|
457
|
+
("admincount", "adminCount")) if props.get(prop)]
|
|
458
|
+
r.update(lures=", ".join(lures), created=props.get("whencreated"))
|
|
459
|
+
return rows
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _drop_protected_users(entities: EntityService, rows: list[Row], o: HuntOptions) -> list[str]:
|
|
463
|
+
"""Drop members of Protected Users: their tickets cannot be delegated either."""
|
|
464
|
+
query = (f"MATCH p=(m:User)-[:{MEMBER_OF}*1..]->(g:Group) "
|
|
465
|
+
f"WHERE g.objectid ENDS WITH {Q.lit(PROTECTED_USERS_RID)} RETURN p")
|
|
466
|
+
try:
|
|
467
|
+
members = entities.run_cypher(query)
|
|
468
|
+
except ApiError as exc:
|
|
469
|
+
return [f"Protected Users members not excluded: {exc}"]
|
|
470
|
+
protected = {n.objectid for n in members.nodes.values() if n.kind == "User"}
|
|
471
|
+
kept = [r for r in rows if r["objectid"] not in protected]
|
|
472
|
+
dropped = len(rows) - len(kept)
|
|
473
|
+
rows[:] = kept
|
|
474
|
+
return [f"{dropped} Protected Users member(s) left out."] if dropped else []
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
CHECKS: tuple[Check, ...] = (
|
|
478
|
+
Check("dcsync", "Non-Tier-Zero principals with replication rights", "critical",
|
|
479
|
+
"DCSync / GetChanges(All) on a domain held by principals outside Tier Zero.",
|
|
480
|
+
_dcsync, (NAME, KIND, Column("Rights", "rights"), Column("Domain", "target"),
|
|
481
|
+
Column("Full DCSync", "full_dcsync", "risk"), OWNED), _dcsync_rows, scoped=True),
|
|
482
|
+
Check("tier-zero-control", "Control of Tier Zero from outside it", "critical",
|
|
483
|
+
"Non-Tier-Zero principals with ACL, delegation, credential-read or execution rights on Tier Zero "
|
|
484
|
+
"objects (DCSync, SID history and AD CS have their own checks).",
|
|
485
|
+
_edge_check(T0_CONTROL_EDGES, _NOT_T0, target=TIER_ZERO_LABEL, dom="t"),
|
|
486
|
+
(PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, TARGET_KIND, OWNED), _edge_rows(T0_CONTROL_EDGES),
|
|
487
|
+
fallback=_edge_check(None, _NOT_T0, target=TIER_ZERO_LABEL, dom="t"), scoped=True),
|
|
488
|
+
Check("broad-rights", "Dangerous rights held by broad groups", "critical",
|
|
489
|
+
"Everyone / Anonymous / Authenticated Users / Domain Users / Domain Computers / Users / "
|
|
490
|
+
"Pre-Windows 2000 Compatible Access holding control or execution rights.",
|
|
491
|
+
_broad, (PRINCIPAL, EDGE, TARGET, TARGET_KIND, T0), _broad_rows,
|
|
492
|
+
fallback=lambda o: _broad(o, None), scoped=True),
|
|
493
|
+
Check("adcs", "AD CS escalation paths (ESC*, GoldenCert)", "critical",
|
|
494
|
+
"Principals with a certificate-services escalation edge, including NTLM relay to web enrollment (ESC8).",
|
|
495
|
+
_edge_check(ADCS_ABUSE_EDGES), (PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, T0),
|
|
496
|
+
_edge_rows(ADCS_ABUSE_EDGES), scoped=True),
|
|
497
|
+
Check("adcs-ca-control", "Enterprise CA managed from outside Tier Zero (ESC7)", "high",
|
|
498
|
+
"ManageCA / ManageCertificates on an Enterprise CA held by principals outside Tier Zero.",
|
|
499
|
+
_edge_check(CA_CONTROL_EDGES, _NOT_T0, target="EnterpriseCA", dom="t"),
|
|
500
|
+
(PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, OWNED), _edge_rows(CA_CONTROL_EDGES), scoped=True),
|
|
501
|
+
Check("kerberoastable", "Kerberoastable users", "high",
|
|
502
|
+
"Enabled users with an SPN (excluding krbtgt); those with a path to Tier Zero first, then RC4, "
|
|
503
|
+
"then the oldest passwords.",
|
|
504
|
+
_node_check("User", "n.hasspn = true", "n.enabled = true"),
|
|
505
|
+
(NAME, DOMAIN, PWD, ADMINCOUNT, Column("SPNs", "spns"), ENCTYPES, PATH_T0, T0, OWNED), _user_rows,
|
|
506
|
+
enrich=_path_to_t0),
|
|
507
|
+
Check("asreproast", "AS-REP roastable users", "high",
|
|
508
|
+
"Enabled users without Kerberos pre-authentication; those with a path to Tier Zero first.",
|
|
509
|
+
_node_check("User", "n.dontreqpreauth = true", "n.enabled = true"),
|
|
510
|
+
(NAME, DOMAIN, PWD, ADMINCOUNT, ENCTYPES, PATH_T0, T0, OWNED), _user_rows, enrich=_path_to_t0),
|
|
511
|
+
Check("t0-sessions", "Privileged sessions on non-Tier-Zero computers", "high",
|
|
512
|
+
"Tier Zero or adminCount users logged on to computers outside Tier Zero (credential dumping "
|
|
513
|
+
"targets). Scoped: those computers where the principals can execute code.",
|
|
514
|
+
_t0_sessions, (NAME, OS, Column("Session user", "user"), T0, ADMINCOUNT), _t0_session_rows,
|
|
515
|
+
scoped=True, scoped_columns=(NAME, OS, Column("Session user", "user"), T0, ADMINCOUNT,
|
|
516
|
+
Column("Access", "access", "edge"), PRINCIPAL)),
|
|
517
|
+
Check("cred-readers", "Readable LAPS / gMSA / sMSA passwords", "high",
|
|
518
|
+
"Principals outside Tier Zero that can read LAPS, gMSA or sMSA passwords.",
|
|
519
|
+
_edge_check(CRED_READ_EDGES, _NOT_T0), (PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, TARGET_KIND, T0),
|
|
520
|
+
_edge_rows(CRED_READ_EDGES), scoped=True),
|
|
521
|
+
Check("passwords-in-attributes", "Passwords in AD attributes", "high",
|
|
522
|
+
"Objects with userPassword / unixUserPassword / unicodePwd / msSFU30Password set, or a description "
|
|
523
|
+
"mentioning a password.",
|
|
524
|
+
_password_attrs, (NAME, KIND, DOMAIN, Column("Attribute", "attribute"), Column("Value", "value"), T0,
|
|
525
|
+
OWNED), _password_rows, fallback=lambda o: _password_attrs(o, lower=False)),
|
|
526
|
+
Check("object-takeover", "Account and computer takeover primitives", "high",
|
|
527
|
+
"Shadow Credentials (AddKeyCredentialLink), targeted Kerberoasting (WriteSPN) and RBCD "
|
|
528
|
+
"(AddAllowedToAct, WriteAccountRestrictions) between non-Tier-Zero objects.",
|
|
529
|
+
_edge_check(TAKEOVER_EDGES, _NOT_T0, f"NOT t:{TIER_ZERO_LABEL}", "t.enabled = true"),
|
|
530
|
+
(PRINCIPAL, PRINCIPAL_KIND, EDGE, Column("Technique", "technique"), TARGET, TARGET_KIND,
|
|
531
|
+
Column("Target adminCount", "admincount", "bool")), _takeover_rows, scoped=True),
|
|
532
|
+
Check("gpo-control", "GPO and OU link control", "high",
|
|
533
|
+
"Principals outside Tier Zero that can edit a GPO or link GPOs to an OU (code execution on every "
|
|
534
|
+
"computer below it).",
|
|
535
|
+
_edge_check(GPO_CONTROL_EDGES, _NOT_T0, "(t:GPO OR t:OU)"),
|
|
536
|
+
(PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, TARGET_KIND, T0), _edge_rows(GPO_CONTROL_EDGES),
|
|
537
|
+
fallback=_edge_check(None, _NOT_T0, "(t:GPO OR t:OU)"), scoped=True),
|
|
538
|
+
Check("relay", "NTLM coercion and relay", "high",
|
|
539
|
+
"CoerceAndRelayNTLMTo SMB / LDAP / LDAPS edges (relay to AD CS is in 'adcs').",
|
|
540
|
+
_edge_check(RELAY_EDGES, dom="t"), (PRINCIPAL, EDGE, TARGET, TARGET_KIND, T0), _edge_rows(RELAY_EDGES)),
|
|
541
|
+
Check("foreign-access", "Cross-domain memberships and rights", "high",
|
|
542
|
+
"Non-Tier-Zero principals with membership, control or execution rights in another domain.",
|
|
543
|
+
_edge_check(FOREIGN_EDGES, _NOT_T0, "n.domain <> t.domain", dom="t"),
|
|
544
|
+
(PRINCIPAL, PRINCIPAL_KIND, DOMAIN, EDGE, TARGET, Column("Target domain", "target_domain"), T0),
|
|
545
|
+
_foreign_rows, scoped=True),
|
|
546
|
+
Check("unconstrained", "Unconstrained delegation outside Tier Zero", "high",
|
|
547
|
+
"Enabled users/computers trusted for unconstrained delegation (DCs excluded).",
|
|
548
|
+
_node_check("Base", "n.unconstraineddelegation = true", "n.enabled = true", _NOT_T0),
|
|
549
|
+
(NAME, KIND, DOMAIN, OS, OWNED), _unconstrained_rows, scoped=True),
|
|
550
|
+
Check("sidhistory", "SID history", "high",
|
|
551
|
+
"Principals carrying the SID of another object (HasSIDHistory).",
|
|
552
|
+
_edge_check(("HasSIDHistory",)), (PRINCIPAL, PRINCIPAL_KIND, TARGET, TARGET_KIND, T0),
|
|
553
|
+
_edge_rows(("HasSIDHistory",)), scoped=True),
|
|
554
|
+
Check("trust-sid-filtering", "Trusts with SID filtering disabled or abusable", "high",
|
|
555
|
+
"Same-forest trusts (no SID filtering by default), cross-forest trusts with SID filtering "
|
|
556
|
+
"explicitly disabled, or trusts with SpoofSIDHistory / AbuseTGTDelegation edges.",
|
|
557
|
+
_trusts, (Column("Trusting", "trusting"), Column("Trusted", "trusted"), Column("Kind", "kind"),
|
|
558
|
+
Column("Type", "trust_type"), Column("Issues", "issues")), _trust_rows),
|
|
559
|
+
Check("constrained", "Constrained delegation", "medium",
|
|
560
|
+
"AllowedToDelegate edges; protocol transition (trustedtoauth) needs no victim interaction.",
|
|
561
|
+
_constrained, (PRINCIPAL, PRINCIPAL_KIND, TARGET, Column("Protocol transition", "protocol_transition",
|
|
562
|
+
"risk"), T0), _constrained_rows, scoped=True),
|
|
563
|
+
Check("reenable", "Disabled privileged accounts that can be re-enabled", "medium",
|
|
564
|
+
"Disabled Tier Zero or adminCount users on which principals outside Tier Zero hold a right to "
|
|
565
|
+
"re-enable them.",
|
|
566
|
+
_edge_check(ENABLE_EDGES, _NOT_T0, "t.enabled = false", f"(t:{TIER_ZERO_LABEL} OR t.admincount = true)",
|
|
567
|
+
target="User", dom="t"),
|
|
568
|
+
(PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, T0, Column("Target adminCount", "admincount", "bool")),
|
|
569
|
+
_reenable_rows, scoped=True),
|
|
570
|
+
Check("impersonable-admins", "Tier Zero users open to delegation", "medium",
|
|
571
|
+
"Enabled Tier Zero users not marked sensitive and not in Protected Users: constrained delegation, "
|
|
572
|
+
"RBCD and S4U can impersonate them.",
|
|
573
|
+
_node_check("User", f"n:{TIER_ZERO_LABEL}", "n.enabled = true",
|
|
574
|
+
"(n.sensitive IS NULL OR n.sensitive = false)"),
|
|
575
|
+
(NAME, DOMAIN, PWD, LOGON, ADMINCOUNT), _user_rows, refine=_drop_protected_users),
|
|
576
|
+
Check("ntlm-hardening", "Missing NTLM relay protections", "medium",
|
|
577
|
+
"Enabled computers without SMB signing, running WebClient, or DCs not requiring LDAP signing or "
|
|
578
|
+
"LDAPS channel binding.",
|
|
579
|
+
_ntlm, (NAME, DOMAIN, OS, Column("Issues", "issues"), T0), _ntlm_rows),
|
|
580
|
+
Check("stale-passwords", "Old passwords that never expire", "medium",
|
|
581
|
+
"Enabled users with pwdneverexpires and a password older than --days (default 5 years).",
|
|
582
|
+
_stale, (NAME, DOMAIN, PWD, LOGON, ADMINCOUNT, T0), _user_rows),
|
|
583
|
+
Check("pwd-not-required", "Password not required", "medium",
|
|
584
|
+
"Enabled users with PASSWD_NOTREQD (may have an empty password).",
|
|
585
|
+
_node_check("User", "n.passwordnotreqd = true", "n.enabled = true"),
|
|
586
|
+
(NAME, DOMAIN, PWD, LOGON, T0), _user_rows),
|
|
587
|
+
Check("legacy-os", "End-of-life operating systems", "medium",
|
|
588
|
+
"Enabled computers running Windows 2000/2003/2008/2012, XP, Vista, 7, 8 or 10 (except LTSC).",
|
|
589
|
+
_node_check("Computer", "n.enabled = true", _LEGACY_OS_CONDITION),
|
|
590
|
+
(NAME, DOMAIN, OS, LOGON, T0), _computer_rows),
|
|
591
|
+
Check("admincount-orphans", "AdminCount users outside Tier Zero", "low",
|
|
592
|
+
"adminCount=1 users no longer in a protected group (stale ACLs, AdminSDHolder leftovers).",
|
|
593
|
+
_node_check("User", "n.admincount = true", _NOT_T0),
|
|
594
|
+
(NAME, DOMAIN, PWD, LOGON), _user_rows),
|
|
595
|
+
Check("no-laps", "Computers without LAPS", "low",
|
|
596
|
+
"Enabled non-Tier-Zero computers without LAPS (shared local admin passwords likely).",
|
|
597
|
+
_node_check("Computer", "n.enabled = true", "n.haslaps = false", _NOT_T0),
|
|
598
|
+
(NAME, DOMAIN, OS, LOGON), _computer_rows),
|
|
599
|
+
Check("decoys", "Possible honeytokens", "info",
|
|
600
|
+
"Enabled users that look attractive (SPN, no pre-authentication or adminCount) but have never "
|
|
601
|
+
"logged on: check before roasting or using them.",
|
|
602
|
+
_decoys, (NAME, DOMAIN, Column("Lures", "lures"), Column("Created", "created", "date"), PWD, T0),
|
|
603
|
+
_decoy_rows),
|
|
604
|
+
)
|
|
605
|
+
CHECKS_BY_ID: dict[str, Check] = {c.id: c for c in CHECKS}
|
|
606
|
+
SCOPED_CHECKS: tuple[Check, ...] = tuple(c for c in CHECKS if c.scoped)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class HuntService:
|
|
610
|
+
def __init__(self, entities: EntityService) -> None:
|
|
611
|
+
self.entities = entities
|
|
612
|
+
|
|
613
|
+
def run(self, check: Check, opts: HuntOptions) -> Finding:
|
|
614
|
+
if opts.scoped and not check.scoped:
|
|
615
|
+
raise UnsupportedFeature(f"'{check.id}' does not run with --owned / --from")
|
|
616
|
+
try:
|
|
617
|
+
graph = self._query(check, opts)
|
|
618
|
+
except CypherDisabledError as exc:
|
|
619
|
+
raise UnsupportedFeature("'hunt' needs the Cypher endpoint") from exc
|
|
620
|
+
except ApiError as exc:
|
|
621
|
+
return Finding(check, error=exc.message)
|
|
622
|
+
truncated, total = self.entities.truncation(graph)
|
|
623
|
+
finding = Finding(check, rows=check.rows(graph, opts), truncated=truncated, total=total)
|
|
624
|
+
if check.refine and finding.rows:
|
|
625
|
+
finding.notes += check.refine(self.entities, finding.rows, opts)
|
|
626
|
+
if check.enrich and opts.enrich and finding.rows:
|
|
627
|
+
finding.notes += check.enrich(self.entities, finding.rows, opts)
|
|
628
|
+
if opts.principals is not None:
|
|
629
|
+
for row in finding.rows:
|
|
630
|
+
row["from_owned"] = ", ".join(opts.principals.get(row.get("objectid", ""), []))
|
|
631
|
+
return finding
|
|
632
|
+
|
|
633
|
+
def _query(self, check: Check, opts: HuntOptions) -> Graph:
|
|
634
|
+
try:
|
|
635
|
+
return self.entities.run_cypher(check.query(opts))
|
|
636
|
+
except ApiError:
|
|
637
|
+
if check.fallback is None:
|
|
638
|
+
raise
|
|
639
|
+
return self.entities.run_cypher(check.fallback(opts))
|
|
640
|
+
|
|
641
|
+
def owned_scope(self) -> dict[str, list[str]]:
|
|
642
|
+
"""Owned objects and the groups they belong to, each mapped to the owned objects behind it."""
|
|
643
|
+
owned = self._cypher(f"MATCH (n:{OWNED_LABEL}) RETURN n")
|
|
644
|
+
memberships = self._cypher(f"MATCH p=(n:{OWNED_LABEL})-[:{MEMBER_OF}*1..]->(g:Group) RETURN p")
|
|
645
|
+
return _scope(owned.nodes.values(), memberships)
|
|
646
|
+
|
|
647
|
+
def principal_scope(self, node: Node) -> dict[str, list[str]]:
|
|
648
|
+
"""`node` and the groups it belongs to, each mapped to `node`."""
|
|
649
|
+
return _scope([node], self._cypher(Q.member_of(node.objectid)))
|
|
650
|
+
|
|
651
|
+
def _cypher(self, query: str) -> Graph:
|
|
652
|
+
try:
|
|
653
|
+
return self.entities.run_cypher(query)
|
|
654
|
+
except CypherDisabledError as exc:
|
|
655
|
+
raise UnsupportedFeature("'hunt' needs the Cypher endpoint") from exc
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _scope(starts: Iterable[Node], memberships: Graph) -> dict[str, list[str]]:
|
|
659
|
+
groups: dict[str, list[str]] = {}
|
|
660
|
+
for e in memberships.edges:
|
|
661
|
+
if e.kind == MEMBER_OF:
|
|
662
|
+
groups.setdefault(e.source, []).append(e.target)
|
|
663
|
+
by_objectid = {n.objectid: nid for nid, n in memberships.nodes.items()}
|
|
664
|
+
scope: dict[str, list[str]] = {}
|
|
665
|
+
for start in starts:
|
|
666
|
+
scope.setdefault(start.objectid, []).append(start.name)
|
|
667
|
+
seen: set[str] = set()
|
|
668
|
+
todo = [by_objectid[start.objectid]] if start.objectid in by_objectid else []
|
|
669
|
+
while todo:
|
|
670
|
+
for group in groups.get(todo.pop(), []):
|
|
671
|
+
if group not in seen:
|
|
672
|
+
seen.add(group)
|
|
673
|
+
todo.append(group)
|
|
674
|
+
scope.setdefault(memberships.nodes[group].objectid, []).append(start.name)
|
|
675
|
+
return scope
|