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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Cypher query templates.
|
|
2
|
+
|
|
3
|
+
BH's Cypher endpoint does not accept bound parameters, so every value is
|
|
4
|
+
embedded as an escaped string literal via `lit`. Queries return paths or
|
|
5
|
+
nodes (not scalars) so that results come back as a graph.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
from bhshell.queries.edges import EXEC_EDGES, HAS_SESSION, MEMBER_OF
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def lit(value: str) -> str:
|
|
17
|
+
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
|
|
18
|
+
return f"'{escaped}'"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_RETURN_LIMIT = re.compile(r"\s+RETURN\s+(\w+)\s+LIMIT\s+(\d+)\s*$", re.IGNORECASE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def limit_of(query: str) -> int | None:
|
|
25
|
+
"""The LIMIT of a `... RETURN x LIMIT n` query (`None` for other shapes)."""
|
|
26
|
+
m = _RETURN_LIMIT.search(query)
|
|
27
|
+
return int(m.group(2)) if m else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_count(query: str) -> str | None:
|
|
31
|
+
"""Turn `... RETURN x LIMIT n` into `... RETURN count(x)` (`None` for other shapes)."""
|
|
32
|
+
m = _RETURN_LIMIT.search(query)
|
|
33
|
+
return f"{query[:m.start()]} RETURN count({m.group(1)})" if m else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def where(*conds: str) -> str:
|
|
37
|
+
"""` WHERE a AND b` from the non-empty conditions, or ''."""
|
|
38
|
+
present = [c for c in conds if c]
|
|
39
|
+
return f" WHERE {' AND '.join(present)}" if present else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def domain_condition(alias: str, domain: str) -> str:
|
|
43
|
+
"""Restrict `alias` to `domain`: an FQDN matches exactly, a NetBIOS name any FQDN it starts ('' = all)."""
|
|
44
|
+
if not domain:
|
|
45
|
+
return ""
|
|
46
|
+
d = domain.upper()
|
|
47
|
+
if "." in d:
|
|
48
|
+
return f"{alias}.domain = {lit(d)}"
|
|
49
|
+
return f"({alias}.domain = {lit(d)} OR {alias}.domain STARTS WITH {lit(d + '.')})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def edge_types(edges: Iterable[str]) -> str:
|
|
53
|
+
"""`A|B|C`, for a relationship pattern."""
|
|
54
|
+
return "|".join(sorted(edges))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _label(kind: str | None) -> str:
|
|
58
|
+
return f":{kind}" if kind else ":Base"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def by_objectid(objectid: str) -> str:
|
|
62
|
+
return f"MATCH (n:Base) WHERE n.objectid = {lit(objectid)} RETURN n"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def by_name(conditions: list[str], kind: str | None, limit: int) -> str:
|
|
66
|
+
return f"MATCH (n{_label(kind)}) WHERE {' OR '.join(conditions)} RETURN n LIMIT {limit}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def name_conditions(sam: str, fqdn: str | None, kind: str | None) -> list[str]:
|
|
70
|
+
"""WHERE conditions matching an object by samaccountname or FQDN.
|
|
71
|
+
|
|
72
|
+
BloodHound stores `name` upper-cased as `SAM@DOMAIN` (users, groups)
|
|
73
|
+
or `HOST.DOMAIN` (computers); `samaccountname` keeps the original case.
|
|
74
|
+
"""
|
|
75
|
+
upper = sam.upper()
|
|
76
|
+
host = upper.rstrip("$")
|
|
77
|
+
conds: list[str] = []
|
|
78
|
+
if fqdn:
|
|
79
|
+
conds.append(f"n.name = {lit(fqdn.upper())}")
|
|
80
|
+
if kind in (None, "User", "Group"):
|
|
81
|
+
conds.append(f"n.name STARTS WITH {lit(upper + '@')}")
|
|
82
|
+
if kind in (None, "Computer"):
|
|
83
|
+
conds += [
|
|
84
|
+
f"n.name STARTS WITH {lit(host + '.')}",
|
|
85
|
+
f"n.name = {lit(host)}",
|
|
86
|
+
f"n.samaccountname = {lit(host + '$')}",
|
|
87
|
+
]
|
|
88
|
+
conds.append(f"n.samaccountname = {lit(sam)}")
|
|
89
|
+
return list(dict.fromkeys(conds))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def member_of(objectid: str) -> str:
|
|
93
|
+
return f"MATCH p=(n:Base)-[:{MEMBER_OF}*1..]->(g:Group) WHERE n.objectid = {lit(objectid)} RETURN p"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def members(objectid: str, limit: int) -> str:
|
|
97
|
+
return f"MATCH p=(m:Base)-[:{MEMBER_OF}*1..]->(g:Group) WHERE g.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _rel(edges: Iterable[str] | None) -> str:
|
|
101
|
+
return f"[r:{edge_types(edges)}]" if edges else "[r]"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def outgoing_direct(objectid: str, limit: int, edges: Iterable[str] | None = None) -> str:
|
|
105
|
+
"""Outgoing edges of the node (all kinds when `edges` is `None`)."""
|
|
106
|
+
return f"MATCH p=(n:Base)-{_rel(edges)}->(t:Base) WHERE n.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def outgoing_inherited(objectid: str, limit: int, edges: Iterable[str] | None = None) -> str:
|
|
110
|
+
"""Edges held by any group the node is (transitively) a member of."""
|
|
111
|
+
return (
|
|
112
|
+
f"MATCH p=(n:Base)-[:{MEMBER_OF}*1..]->(g:Group)-{_rel(edges)}->(t:Base) "
|
|
113
|
+
f"WHERE n.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def incoming(objectid: str, edges: Iterable[str], limit: int) -> str:
|
|
118
|
+
return (
|
|
119
|
+
f"MATCH p=(s:Base)-[r:{edge_types(edges)}]->(n:Base) "
|
|
120
|
+
f"WHERE n.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def incoming_any(objectid: str, limit: int) -> str:
|
|
125
|
+
"""Fallback for `incoming` when a kind in the filter is unknown to the server."""
|
|
126
|
+
return f"MATCH p=(s:Base)-[r]->(n:Base) WHERE n.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def exec_rights_inbound_unrolled(objectid: str, limit: int, edges: Iterable[str] = EXEC_EDGES) -> str:
|
|
130
|
+
return (
|
|
131
|
+
f"MATCH p=(m:Base)-[:{MEMBER_OF}*1..]->(g:Group)-[r:{edge_types(edges)}]->(c:Computer) "
|
|
132
|
+
f"WHERE c.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def sessions_of_user(objectid: str, limit: int) -> str:
|
|
137
|
+
return (
|
|
138
|
+
f"MATCH p=(c:Computer)-[:{HAS_SESSION}]->(u:User) "
|
|
139
|
+
f"WHERE u.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sessions_on_computer(objectid: str, limit: int) -> str:
|
|
144
|
+
return (
|
|
145
|
+
f"MATCH p=(c:Computer)-[:{HAS_SESSION}]->(u:User) "
|
|
146
|
+
f"WHERE c.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def sessions_of_group_members(objectid: str, limit: int) -> str:
|
|
151
|
+
return (
|
|
152
|
+
f"MATCH p=(c:Computer)-[:{HAS_SESSION}]->(u:User)-[:{MEMBER_OF}*1..]->(g:Group) "
|
|
153
|
+
f"WHERE g.objectid = {lit(objectid)} RETURN p LIMIT {limit}"
|
|
154
|
+
)
|
|
155
|
+
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Domain overview and trust analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from bhshell.errors import NotFoundError
|
|
9
|
+
from bhshell.models.entities import TIER_ZERO_LABEL, Graph, Node, opt_bool
|
|
10
|
+
from bhshell.queries import cypher as Q
|
|
11
|
+
from bhshell.queries.edges import TRUST_ABUSE_EDGES, TRUST_EDGES
|
|
12
|
+
from bhshell.queries.service import EntityService
|
|
13
|
+
|
|
14
|
+
TRUSTS_QUERY = "MATCH p=(a:Domain)-[r]->(b:Domain) RETURN p"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DomainInfo:
|
|
19
|
+
name: str
|
|
20
|
+
sid: str
|
|
21
|
+
functional_level: str = ""
|
|
22
|
+
collected: bool | None = None
|
|
23
|
+
last_collected: str = ""
|
|
24
|
+
counts: dict[str, int | None] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def as_dict(self) -> dict[str, Any]:
|
|
27
|
+
return {**{k: v for k, v in asdict(self).items() if k != "counts"}, **self.counts}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TrustInfo:
|
|
32
|
+
"""One trust: `trusting` trusts `trusted`; principals of `trusted` can access `trusting`."""
|
|
33
|
+
|
|
34
|
+
trusting: str
|
|
35
|
+
trusted: str
|
|
36
|
+
kind: str
|
|
37
|
+
trust_type: str = ""
|
|
38
|
+
transitive: bool | None = None
|
|
39
|
+
sid_filtering: bool | None = None
|
|
40
|
+
tgt_delegation: bool | None = None
|
|
41
|
+
abuse: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def same_forest(self) -> bool:
|
|
45
|
+
return self.kind == "SameForestTrust"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def issues(self) -> list[str]:
|
|
49
|
+
"""Weaknesses the collected data shows.
|
|
50
|
+
|
|
51
|
+
SID filtering is on by default for cross-forest (and external) trusts and off within a
|
|
52
|
+
forest, so a missing `spoofsidhistoryblocked` property on a cross-forest trust is not
|
|
53
|
+
reported: only an explicit `false`, a same-forest trust, or a BloodHound abuse edge is.
|
|
54
|
+
"""
|
|
55
|
+
out = [f"abusable: {a}" for a in self.abuse]
|
|
56
|
+
if self.same_forest:
|
|
57
|
+
out.append("same forest: SID filtering not enforced")
|
|
58
|
+
elif self.sid_filtering is False:
|
|
59
|
+
out.append("SID filtering disabled")
|
|
60
|
+
if self.tgt_delegation:
|
|
61
|
+
out.append("TGT delegation enabled")
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
def as_dict(self) -> dict[str, Any]:
|
|
65
|
+
return {**asdict(self), "abuse": ", ".join(self.abuse), "issues": "; ".join(self.issues)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def trusts_from_graph(graph: Graph) -> list[TrustInfo]:
|
|
69
|
+
trusts: dict[tuple[str, str], TrustInfo] = {}
|
|
70
|
+
abuse: dict[tuple[str, str], list[str]] = {}
|
|
71
|
+
for e in graph.edges:
|
|
72
|
+
src, dst = graph.nodes[e.source].name, graph.nodes[e.target].name
|
|
73
|
+
if e.kind in TRUST_EDGES:
|
|
74
|
+
trusts[(src, dst)] = TrustInfo(
|
|
75
|
+
trusting=src, trusted=dst, kind=e.kind,
|
|
76
|
+
trust_type=str(e.properties.get("trusttype") or ""),
|
|
77
|
+
transitive=opt_bool(e.properties, "transitive"),
|
|
78
|
+
sid_filtering=opt_bool(e.properties, "spoofsidhistoryblocked"),
|
|
79
|
+
tgt_delegation=opt_bool(e.properties, "tgtdelegation"),
|
|
80
|
+
)
|
|
81
|
+
elif e.kind in TRUST_ABUSE_EDGES:
|
|
82
|
+
# Abuse edges point in the direction of access: trusted -> trusting.
|
|
83
|
+
abuse.setdefault((dst, src), []).append(e.kind)
|
|
84
|
+
for key, kinds in abuse.items():
|
|
85
|
+
trust = trusts.setdefault(key, TrustInfo(trusting=key[0], trusted=key[1], kind="(abuse only)"))
|
|
86
|
+
trust.abuse = sorted(set(kinds))
|
|
87
|
+
return sorted(trusts.values(), key=lambda t: (not t.issues, t.trusting, t.trusted))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DomainService:
|
|
91
|
+
def __init__(self, entities: EntityService) -> None:
|
|
92
|
+
self.entities = entities
|
|
93
|
+
self.client = entities.client
|
|
94
|
+
|
|
95
|
+
def domains(self) -> list[DomainInfo]:
|
|
96
|
+
nodes = {n.objectid: n for n in self.entities.run_cypher("MATCH (n:Domain) RETURN n").nodes.values()}
|
|
97
|
+
infos: list[DomainInfo] = []
|
|
98
|
+
try:
|
|
99
|
+
listed = (self.client.get("api/v2/available-domains") or {}).get("data") or []
|
|
100
|
+
except NotFoundError:
|
|
101
|
+
listed = []
|
|
102
|
+
seen: set[str] = set()
|
|
103
|
+
for item in listed:
|
|
104
|
+
if item.get("type", "active-directory") != "active-directory":
|
|
105
|
+
continue
|
|
106
|
+
sid = item.get("id", "")
|
|
107
|
+
seen.add(sid)
|
|
108
|
+
infos.append(self._info(item.get("name", sid), sid, nodes.get(sid), item.get("collected")))
|
|
109
|
+
for sid, node in nodes.items():
|
|
110
|
+
if sid not in seen:
|
|
111
|
+
infos.append(self._info(node.name, sid, node, None))
|
|
112
|
+
return sorted(infos, key=lambda d: d.name)
|
|
113
|
+
|
|
114
|
+
def _info(self, name: str, sid: str, node: Node | None, collected: bool | None) -> DomainInfo:
|
|
115
|
+
props = node.properties if node is not None else {}
|
|
116
|
+
info = DomainInfo(
|
|
117
|
+
name=name, sid=sid, collected=collected,
|
|
118
|
+
functional_level=str(props.get("functionallevel") or ""),
|
|
119
|
+
last_collected=str(props.get("lastcollected") or props.get("lastseen") or "")[:19],
|
|
120
|
+
)
|
|
121
|
+
in_domain, enabled = f"n.domain = {Q.lit(name.upper())}", "n.enabled = true"
|
|
122
|
+
info.counts = {
|
|
123
|
+
"users": self._count("(n:User)", in_domain),
|
|
124
|
+
"enabled_users": self._count("(n:User)", in_domain, enabled),
|
|
125
|
+
"computers": self._count("(n:Computer)", in_domain),
|
|
126
|
+
"enabled_computers": self._count("(n:Computer)", in_domain, enabled),
|
|
127
|
+
"groups": self._count("(n:Group)", in_domain),
|
|
128
|
+
"tier_zero": self._count(f"(n:{TIER_ZERO_LABEL})", in_domain),
|
|
129
|
+
}
|
|
130
|
+
return info
|
|
131
|
+
|
|
132
|
+
def _count(self, match: str, *conditions: str) -> int | None:
|
|
133
|
+
return self.entities.count(f"MATCH {match}{Q.where(*conditions)} RETURN count(n)")
|
|
134
|
+
|
|
135
|
+
def trusts(self) -> list[TrustInfo]:
|
|
136
|
+
return trusts_from_graph(self.entities.run_cypher(TRUSTS_QUERY))
|
bhshell/queries/edges.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Edge (relationship) taxonomy used by the relationship views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
MEMBER_OF = "MemberOf"
|
|
8
|
+
HAS_SESSION = "HasSession"
|
|
9
|
+
|
|
10
|
+
# Direct object control (ACL abuse).
|
|
11
|
+
ACL_EDGES: frozenset[str] = frozenset({
|
|
12
|
+
"GenericAll", "GenericWrite", "WriteDacl", "WriteOwner", "Owns",
|
|
13
|
+
"ForceChangePassword", "AddMember", "AddSelf", "AllExtendedRights",
|
|
14
|
+
"AddKeyCredentialLink", "WriteSPN", "WriteAccountRestrictions",
|
|
15
|
+
"WriteGPLink", "WriteOwnerLimitedRights", "OwnsLimitedRights",
|
|
16
|
+
}) # OwnsRaw / WriteOwnerRaw are intentionally excluded: they duplicate Owns / WriteOwner.
|
|
17
|
+
|
|
18
|
+
# Rights that let the holder re-enable a disabled account (write its userAccountControl, directly
|
|
19
|
+
# or by first granting themselves the right). WriteAccountRestrictions covers userAccountControl.
|
|
20
|
+
ENABLE_EDGES: frozenset[str] = frozenset({
|
|
21
|
+
"GenericAll", "GenericWrite", "WriteAccountRestrictions", "Owns", "WriteDacl", "WriteOwner",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
# Credential access primitives.
|
|
25
|
+
CREDENTIAL_EDGES: frozenset[str] = frozenset({
|
|
26
|
+
"ReadLAPSPassword", "ReadGMSAPassword", "SyncLAPSPassword", "DumpSMSAPassword",
|
|
27
|
+
"DCSync", "GetChanges", "GetChangesAll", "GetChangesInFilteredSet", "HasSIDHistory",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
# Kerberos delegation.
|
|
31
|
+
DELEGATION_EDGES: frozenset[str] = frozenset({
|
|
32
|
+
"AllowedToDelegate", "AllowedToAct", "AddAllowedToAct", "CoerceToTGT",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
# AD CS abuse.
|
|
36
|
+
ADCS_EDGES: frozenset[str] = frozenset({
|
|
37
|
+
"Enroll", "ManageCA", "ManageCertificates", "GoldenCert", "WritePKIEnrollmentFlag",
|
|
38
|
+
"WritePKINameFlag", "ADCSESC1", "ADCSESC3", "ADCSESC4", "ADCSESC6a", "ADCSESC6b",
|
|
39
|
+
"ADCSESC9a", "ADCSESC9b", "ADCSESC10a", "ADCSESC10b", "ADCSESC13",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
# NTLM relay / coercion.
|
|
43
|
+
COERCION_EDGES: frozenset[str] = frozenset({
|
|
44
|
+
"CoerceAndRelayNTLMToSMB", "CoerceAndRelayNTLMToLDAP", "CoerceAndRelayNTLMToLDAPS",
|
|
45
|
+
"CoerceAndRelayNTLMToADCS",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# Code execution / logon rights on computers.
|
|
49
|
+
EXEC_EDGES: frozenset[str] = frozenset({"AdminTo", "CanRDP", "ExecuteDCOM", "CanPSRemote", "SQLAdmin"})
|
|
50
|
+
|
|
51
|
+
# Domain trust relationships (`A -[trust]-> B`: A trusts B, principals of B can access A).
|
|
52
|
+
TRUST_EDGES: frozenset[str] = frozenset({"SameForestTrust", "CrossForestTrust"})
|
|
53
|
+
# Trust edges BloodHound adds when a trust's configuration is abusable.
|
|
54
|
+
TRUST_ABUSE_EDGES: frozenset[str] = frozenset({"SpoofSIDHistory", "AbuseTGTDelegation"})
|
|
55
|
+
|
|
56
|
+
# Everything shown by the `outbound` / `inbound` views.
|
|
57
|
+
CONTROL_EDGES: frozenset[str] = ACL_EDGES | CREDENTIAL_EDGES | DELEGATION_EDGES | ADCS_EDGES | COERCION_EDGES
|
|
58
|
+
|
|
59
|
+
# Edges BloodHound treats as traversable for attack-path finding.
|
|
60
|
+
TRAVERSABLE_EDGES: frozenset[str] = (
|
|
61
|
+
(CONTROL_EDGES - {"GetChanges", "GetChangesAll", "GetChangesInFilteredSet", "Enroll", "ManageCA",
|
|
62
|
+
"ManageCertificates", "WritePKIEnrollmentFlag", "WritePKINameFlag"})
|
|
63
|
+
| EXEC_EDGES | TRUST_EDGES | TRUST_ABUSE_EDGES
|
|
64
|
+
| {MEMBER_OF, HAS_SESSION, "Contains", "GPLink", "DCFor", "WriteSPN"}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Hops that need no action: rights flow along them by themselves.
|
|
68
|
+
PASSIVE_EDGES: frozenset[str] = frozenset({MEMBER_OF, "Contains", "GPLink", "DCFor"})
|
|
69
|
+
|
|
70
|
+
# Names accepted by `path --exclude` for groups of edges.
|
|
71
|
+
EDGE_GROUPS: dict[str, frozenset[str]] = {
|
|
72
|
+
"sessions": frozenset({HAS_SESSION}),
|
|
73
|
+
"acl": ACL_EDGES,
|
|
74
|
+
"exec": EXEC_EDGES,
|
|
75
|
+
"delegation": DELEGATION_EDGES,
|
|
76
|
+
"adcs": (ADCS_EDGES | {"CoerceAndRelayNTLMToADCS"}) & TRAVERSABLE_EDGES,
|
|
77
|
+
"relay": COERCION_EDGES,
|
|
78
|
+
"trusts": TRUST_EDGES | TRUST_ABUSE_EDGES,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Severity = Literal["critical", "high", "medium", "low"]
|
|
82
|
+
SEVERITIES: tuple[Severity, ...] = ("critical", "high", "medium", "low") # worst first
|
|
83
|
+
|
|
84
|
+
_CRITICAL = frozenset({
|
|
85
|
+
"GenericAll", "WriteDacl", "WriteOwner", "Owns", "DCSync", "GetChangesAll", "AllExtendedRights",
|
|
86
|
+
"AdminTo", "GoldenCert", "ADCSESC1", "ADCSESC3", "ADCSESC4", "ADCSESC6a", "ADCSESC6b",
|
|
87
|
+
"ADCSESC9a", "ADCSESC9b", "ADCSESC10a", "ADCSESC10b", "ADCSESC13", "HasSIDHistory",
|
|
88
|
+
})
|
|
89
|
+
_HIGH = frozenset({
|
|
90
|
+
"GenericWrite", "ForceChangePassword", "AddMember", "AddSelf", "AddKeyCredentialLink",
|
|
91
|
+
"ReadLAPSPassword", "ReadGMSAPassword", "SyncLAPSPassword", "DumpSMSAPassword",
|
|
92
|
+
"AllowedToDelegate", "AllowedToAct", "AddAllowedToAct", "WriteAccountRestrictions",
|
|
93
|
+
"CanPSRemote", "SQLAdmin", "WriteGPLink", "ManageCA", "CoerceToTGT",
|
|
94
|
+
*COERCION_EDGES,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def severity(edge: str) -> Severity:
|
|
99
|
+
"""Rough offensive impact of an edge, used for colouring."""
|
|
100
|
+
if edge in _CRITICAL:
|
|
101
|
+
return "critical"
|
|
102
|
+
if edge in _HIGH:
|
|
103
|
+
return "high"
|
|
104
|
+
return "medium"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def severity_rank(edge: str) -> int:
|
|
108
|
+
"""Sort key: 0 for critical edges, 1 for high, 2 for medium."""
|
|
109
|
+
return SEVERITIES.index(severity(edge))
|