diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94dd26f..cd3c825 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -83,4 +83,4 @@ jobs: NEO4J_URI: bolt://localhost:7687 NEO4J_USER: neo4j NEO4J_PASSWORD: testpassword - run: pytest tests/checks -v --maxfail=5 -m "neo4j and not integration" --no-cov + run: pytest tests/ -v --maxfail=5 -m "neo4j and not integration" --no-cov diff --git a/.gitignore b/.gitignore index b53cea3..05dbb1f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,8 @@ config.ini .idea/ .vscode/ .DS_Store + +# Local notes / drafts +LAB_REFERENCE.md +BLOG_POST_V*.md +PRODUCTION_READINESS_PLAN.md diff --git a/README.md b/README.md index 383e2da..9fa9e90 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ ADPathfinder is an attack mapping tool for pentesters and red teamers. It analyses SharpHound data and unifies it with OpenGraph plugins to surface attack paths to high-value targets such as Domain Admins and Domain Controllers, starting from low-privileged users and computers. MSSQLHound and ConfigManBearPig are supported natively, extending coverage across AD, ADCS, SCCM, and MSSQL.

- - ADPathfinder demo video + + ADPathfinder demo video

@@ -59,16 +59,16 @@ This stores the BloodHound CE API settings used for import and delete operations
-Import BloodHound data +Import SharpHound data ```bash -adpathfinder --import BloodHound.zip +adpathfinder --import SharpHound.zip ``` -Multiple BloodHound and OpenGraph plugin zip files can be imported in one command: +Multiple SharpHound and OpenGraph plugin zip files can be imported in one command: ```bash -adpathfinder --import BloodHound.zip MSSQLHound.zip ConfigManBearPig.zip +adpathfinder --import SharpHound.zip MSSQLHound.zip ConfigManBearPig.zip ```
@@ -77,7 +77,7 @@ adpathfinder --import BloodHound.zip MSSQLHound.zip ConfigManBearPig.zip Import data and run a full audit ```bash -adpathfinder -i BloodHound.zip MSSQLHound.zip ConfigManBearPig.zip --ad --pwd Contoso,ContosoIT --ntds ntds.txt -p hashcat.potfile +adpathfinder -i SharpHound.zip MSSQLHound.zip ConfigManBearPig.zip --ad --pwd Contoso,ContosoIT --ntds ntds.txt -p hashcat.potfile ``` Use only the zip files you have. The `--pwd` value is not the AD domain; it is a comma separated list of company, brand, or organisation terms to flag in cracked passwords. @@ -117,7 +117,7 @@ adpathfinder --ad --pwd Contoso,ContosoIT --ntds ntds.txt -p hashcat.potfile --d | Stage | What happens | | --- | --- | -| **Ingest** | Merges BloodHound CE and supported OpenGraph zips into one graph. MSSQLHound and ConfigManBearPig data are supported directly | +| **Ingest** | Merges SharpHound and supported OpenGraph zips into one graph. MSSQLHound and ConfigManBearPig data are supported directly | | **Map** | Finds the shortest paths from users and computers to high-value targets. You can [configure the edge set](https://github.com/NetSPI/AD-PathFinder/wiki/Excluded-Relationships) to remove noisy edges like `HasSession` | | **Group** | Groups accounts that share a path, so repeated paths are reported as one finding instead of many near-duplicates | | **Pair** | Compares NTDS hashes and hashcat potfiles with graph data to show which paths are usable during the assessment | @@ -131,16 +131,16 @@ adpathfinder --ad --pwd Contoso,ContosoIT --ntds ntds.txt -p hashcat.potfile --d ## AD HTML Report

- - AD HTML report demo video + + AD HTML report demo video

## Password Audit HTML Report

- - Password audit HTML report demo video + + Password audit HTML report demo video

diff --git a/checks/core/base.py b/checks/core/base.py index 65ef41a..4684208 100644 --- a/checks/core/base.py +++ b/checks/core/base.py @@ -5,6 +5,22 @@ from .dependencies import CheckDependencies from .constants import DataTypes, EntityTypes, DisplayTypes +from .display.escalation import ( + EscalationPathDisplayHandler, + GroupedEscalationDisplayHandler, +) +from .display.group_analysis import GroupAnalysisDisplayHandler +from .display.shared_graph_paths import SharedGraphPathDisplayHandler +from .display.simple import SimpleDisplayHandler + +_DISPLAY_HANDLERS = { + DisplayTypes.GROUPED_ESCALATION_PATHS: GroupedEscalationDisplayHandler, + DisplayTypes.ESCALATION_PATHS: EscalationPathDisplayHandler, + DisplayTypes.GROUP_ANALYSIS: GroupAnalysisDisplayHandler, + DisplayTypes.SHARED_GRAPH_PATHS: SharedGraphPathDisplayHandler, + DisplayTypes.SIMPLE: SimpleDisplayHandler, +} + class VulnerabilityCheck: RISK_LEVEL = "High" @@ -187,15 +203,13 @@ def format_relay_finding(self, coerce_source: str | None, target: str, *, def get_display_method(self) -> Callable[..., Any]: suppress = getattr(self, 'suppress_terminal_output', False) - if self.DISPLAY_TYPE == DisplayTypes.GROUPED_ESCALATION_PATHS: - from checks.core.display.escalation import GroupedEscalationDisplayHandler - return GroupedEscalationDisplayHandler(suppress, self, self.sid_mapper).display - elif DataTypes.ESCALATION_PATHS in self.REQUIRED_DATA and self.DISPLAY_TYPE == DisplayTypes.ESCALATION_PATHS: - from checks.core.display.escalation import EscalationPathDisplayHandler - return EscalationPathDisplayHandler(suppress, self, self.sid_mapper).display - elif self.DISPLAY_TYPE == DisplayTypes.GROUP_ANALYSIS: - from checks.core.display.group_analysis import GroupAnalysisDisplayHandler - return GroupAnalysisDisplayHandler(suppress, self, self.sid_mapper).display - else: - from checks.core.display.simple import SimpleDisplayHandler - return SimpleDisplayHandler(suppress, self, self.sid_mapper).display \ No newline at end of file + display_type = DisplayTypes(self.DISPLAY_TYPE) + + if ( + display_type == DisplayTypes.ESCALATION_PATHS + and DataTypes.ESCALATION_PATHS not in self.REQUIRED_DATA + ): + display_type = DisplayTypes.SIMPLE + + handler_cls = _DISPLAY_HANDLERS[display_type] + return handler_cls(suppress, self, self.sid_mapper).display diff --git a/checks/core/constants.py b/checks/core/constants.py index 958178e..175137b 100644 --- a/checks/core/constants.py +++ b/checks/core/constants.py @@ -28,6 +28,7 @@ class DisplayTypes(StrEnum): ESCALATION_PATHS = 'escalation_paths' GROUPED_ESCALATION_PATHS = 'grouped_escalation_paths' GROUP_ANALYSIS = 'group_analysis' + SHARED_GRAPH_PATHS = 'shared_graph_paths' class DisplaySymbols: diff --git a/checks/core/display/base.py b/checks/core/display/base.py new file mode 100644 index 0000000..6e11251 --- /dev/null +++ b/checks/core/display/base.py @@ -0,0 +1,27 @@ +from colorama import Style + + +class BaseDisplayHandler: + def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None): + self.suppress_terminal_output = suppress_terminal_output + self.check_instance = check_instance + self.sid_mapper = sid_mapper + self.reset_color = "" + + def _emit_heading(self, category_color, category, content, count, reset_color): + if not self.suppress_terminal_output: + print(f"\n {Style.BRIGHT}{category_color}{category}: {count}{reset_color}") + content.append(f"\n {category}: {count}") + + def _emit(self, line, content): + if not self.suppress_terminal_output: + print(line) + content.append(line) + + def _output_line(self, line, content): + if not self.suppress_terminal_output: + print(f"{self.reset_color}{line}") + content.append(line) + + def display(self, results, category_color, category, content, count, reset_color): + raise NotImplementedError diff --git a/checks/core/display/escalation.py b/checks/core/display/escalation.py index 2d2bffc..2e9dfe1 100644 --- a/checks/core/display/escalation.py +++ b/checks/core/display/escalation.py @@ -1,53 +1,51 @@ from collections import defaultdict -from colorama import Style -from ..constants import DataTypes, DisplaySymbols, is_sid +from .base import BaseDisplayHandler +from ..constants import DataTypes, DisplaySymbols, EntityTypes, is_sid from modules.node_type_cache import extract_ad_report_type_from_labels -class EscalationPathDisplayHandler: - def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None): - self.suppress_terminal_output = suppress_terminal_output - self.check_instance = check_instance - self.sid_mapper = sid_mapper +def _extract_display_info(description): + if not description: + return "" + if " (" in description and description.endswith(")"): + return description.split(" (")[-1][:-1] + return description + +class EscalationPathDisplayHandler(BaseDisplayHandler): def display(self, results, category_color, category, content, count, reset_color): - if not self.suppress_terminal_output: - print(f"\n {Style.BRIGHT}{category_color}{category}: {count}{reset_color}") - content.append(f"\n {category}: {count}") + self._emit_heading(category_color, category, content, count, reset_color) + + total_items = len(results) + for index, (entity_identifier, item_data) in enumerate(results.items()): + if total_items > 1 and index > 0: + self._emit("", content) - for entity_identifier, item_data in results.items(): if isinstance(item_data, dict): description = item_data.get('description', '') else: description = item_data - display_info = description.split(" (")[-1][:-1] if " (" in description and description.endswith(")") else description if description else "" + display_info = _extract_display_info(description) display_name = self.sid_mapper.get_display_name(entity_identifier) if display_info: line = f"{DisplaySymbols.MAIN_ITEM}{display_name} ({display_info})" else: line = f"{DisplaySymbols.MAIN_ITEM}{display_name}" - if not self.suppress_terminal_output: - print(line) - content.append(line) + self._emit(line, content) escalation_paths = self._get_escalation_path_details(entity_identifier) if escalation_paths: - # path is [Node, Rel, Node, Rel, Node] — count relationships not elements steps = len(escalation_paths) // 2 final_target = escalation_paths[-1] if escalation_paths else "Unknown" leads_line = f" {DisplaySymbols.PATH_CONNECTOR} LEADS TO{DisplaySymbols.PATH_ARROW}{final_target} (via {steps} steps)" - if not self.suppress_terminal_output: - print(leads_line) - content.append(leads_line) + self._emit(leads_line, content) path_str = DisplaySymbols.PATH_ARROW.join(escalation_paths) path_line = f" {DisplaySymbols.PATH_CONNECTOR} {path_str}" - if not self.suppress_terminal_output: - print(path_line) - content.append(path_line) + self._emit(path_line, content) return content @@ -86,7 +84,6 @@ def _find_escalation_path_data(self, entity_sid): def _format_escalation_path(self, full_path): path_elements = [] - # fullPath is interleaved: node, rel, node, rel, ..., final_node for item in full_path: if isinstance(item, dict): formatted_node = self._format_path_node(item) @@ -115,22 +112,15 @@ def _format_path_node(self, node): return None -class GroupedEscalationDisplayHandler: - def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None): - self.suppress_terminal_output = suppress_terminal_output - self.check_instance = check_instance - self.sid_mapper = sid_mapper - +class GroupedEscalationDisplayHandler(BaseDisplayHandler): def display(self, results, category_color, category, content, count, reset_color): if not results: return content - if not self.suppress_terminal_output: - print(f"\n {Style.BRIGHT}{category_color}{category}: {count}{reset_color}") - content.append(f"\n {category}: {count}") + self._emit_heading(category_color, category, content, count, reset_color) entity_type = getattr(self.check_instance, 'ENTITY_TYPE', 'user') - entity_label = "Computers" if entity_type == 'computer' else "Users" + entity_label = "Computers" if entity_type == EntityTypes.COMPUTER else "Users" path_to_entities = defaultdict(set) @@ -154,20 +144,20 @@ def display(self, results, category_color, category, content, count, reset_color reverse=True ) - for path_str, entities_set in sorted_paths: + for index, (path_str, entities_set) in enumerate(sorted_paths): + if index > 0: + self._emit("", content) + sorted_entities = sorted(entities_set) entities_str = ", ".join(sorted_entities) entities_count = len(entities_set) line1 = f" {entity_label} with Shared Path ({entities_count}): {entities_str}" - if not self.suppress_terminal_output: - print(line1) - content.append(line1) - - line2 = f" Common Escalation Path: {path_str}\n" - if not self.suppress_terminal_output: - print(line2) - content.append(line2) + self._emit(line1, content) + + self._emit("", content) + line2 = f" Common Escalation Path: {path_str}" + self._emit(line2, content) return content diff --git a/checks/core/display/group_analysis.py b/checks/core/display/group_analysis.py index 0f60469..f8b42b0 100644 --- a/checks/core/display/group_analysis.py +++ b/checks/core/display/group_analysis.py @@ -1,12 +1,9 @@ -from colorama import Style +from .base import BaseDisplayHandler -class GroupAnalysisDisplayHandler: +class GroupAnalysisDisplayHandler(BaseDisplayHandler): def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None): - self.suppress_terminal_output = suppress_terminal_output - self.check_instance = check_instance - self.sid_mapper = sid_mapper - self.reset_color = "" + super().__init__(suppress_terminal_output, check_instance, sid_mapper) self._account_analysis = getattr(check_instance, 'account_analysis', None) def display(self, results, category_color, category, content, count, reset_color): @@ -15,9 +12,7 @@ def display(self, results, category_color, category, content, count, reset_color display_groups = {k: v for k, v in results.items() if not k.startswith("___")} group_count = len(display_groups) - if not self.suppress_terminal_output: - print(f"\n {Style.BRIGHT}{category_color}{category}: {group_count}{reset_color}") - content.append(f"\n {category}: {group_count}") + self._emit_heading(category_color, category, content, group_count, reset_color) enhanced_data_ci = {k.upper(): k for k in enhanced_data.keys()} @@ -42,6 +37,9 @@ def display(self, results, category_color, category, content, count, reset_color if not isinstance(path_obj, dict): continue + if path_count > 1 and idx > 0: + self._output_line("", content) + is_last = idx == path_count - 1 path_prefix = " └─" if is_last else " ├─" cont_prefix = " " if is_last else " │ " @@ -109,8 +107,3 @@ def _render_escalation(self, esc_path, has_esc, cont_prefix, content): content ) self._output_line(f"{cont_prefix} └─ {esc_str}", content) - - def _output_line(self, line, content): - if not self.suppress_terminal_output: - print(f"{self.reset_color}{line}") - content.append(line) diff --git a/checks/core/display/shared_graph_paths.py b/checks/core/display/shared_graph_paths.py new file mode 100644 index 0000000..0fb234e --- /dev/null +++ b/checks/core/display/shared_graph_paths.py @@ -0,0 +1,70 @@ +from .base import BaseDisplayHandler +from ..constants import DisplaySymbols + + +class SharedGraphPathDisplayHandler(BaseDisplayHandler): + def display(self, results, category_color, category, content, count, reset_color): + self._emit_heading(category_color, category, content, count, reset_color) + + total_items = len(results) + for index, (sid, item) in enumerate(results.items()): + if total_items > 1 and index > 0: + self._emit("", content) + + description = item.get('description') if isinstance(item, dict) else item + description = str(description) if description is not None else '' + display_name = self.sid_mapper.get_display_name(sid) + self._emit(f"{DisplaySymbols.MAIN_ITEM}{display_name}", content) + for line in description.split('\n'): + node_chain = self._node_chain(line) + if node_chain: + self._emit(f" {node_chain}", content) + + return content + + def _node_chain(self, line): + line = line.strip() + if not line: + return '' + path, scope = self._split_scope(line) + tokens = self._tokens(path) + scope_tokens = self._tokens(scope) + + normalized_nodes = { + self._normalize_node(token) for token in self._node_tokens(tokens) + } + for token in self._node_tokens(scope_tokens): + normalized = self._normalize_node(token) + if normalized and normalized not in normalized_nodes: + tokens.append(token) + normalized_nodes.add(normalized) + + return " > ".join(tokens) + + def _split_scope(self, line): + path, sep, scope = line.partition(' | ') + return path.strip(), scope.strip() if sep else '' + + def _tokens(self, chain): + tokens = [] + for token in [part.strip() for part in chain.split(' -> ')]: + if not token: + continue + tokens.append(token) + return tokens + + def _node_tokens(self, tokens): + if not tokens: + return [] + start = 0 if len(tokens) % 2 == 1 else 1 + return [ + token for index, token in enumerate(tokens) + if index >= start and (index - start) % 2 == 0 + ] + + def _normalize_node(self, node): + node = (node or '').strip().lower() + for wrapper in ('mssql_database(', 'sccm_site('): + if node.startswith(wrapper) and node.endswith(')'): + return node[len(wrapper):-1] + return node diff --git a/checks/core/display/simple.py b/checks/core/display/simple.py index 371eaa6..7e222cc 100644 --- a/checks/core/display/simple.py +++ b/checks/core/display/simple.py @@ -1,14 +1,8 @@ -from colorama import Style +from .base import BaseDisplayHandler from ..constants import DataTypes, DisplaySymbols -class SimpleDisplayHandler: - def __init__(self, suppress_terminal_output=False, check_instance=None, sid_mapper=None): - self.suppress_terminal_output = suppress_terminal_output - self.check_instance = check_instance - self.sid_mapper = sid_mapper - self.reset_color = "" - +class SimpleDisplayHandler(BaseDisplayHandler): def _format_entity_display(self, item_name, item_data): display_name = self.sid_mapper.get_display_name(item_name) display_name = self._format_with_password(display_name, item_name) @@ -46,7 +40,6 @@ def _format_with_password(self, display_name, entity_sid): return display_name if not hasattr(self.check_instance, 'account_analysis') or not self.check_instance.account_analysis: return display_name - # only show passwords with explicit --unsafe-report flag if not (hasattr(self.check_instance.account_analysis, 'output_format') and self.check_instance.account_analysis.output_format == 'unsafe'): return display_name @@ -55,19 +48,15 @@ def _format_with_password(self, display_name, entity_sid): return f"{display_name}:{password_display}" return display_name - def _output_line(self, line, content): - if not self.suppress_terminal_output: - print(f"{self.reset_color}{line}") - content.append(line) - def display(self, results, category_color, category, content, count, reset_color): self.reset_color = reset_color - if not self.suppress_terminal_output: - print(f"\n {Style.BRIGHT}{category_color}{category}: {count}{reset_color}") - content.append(f"\n {category}: {count}") + self._emit_heading(category_color, category, content, count, reset_color) - for item_name, item_data in results.items(): + total_items = len(results) + for index, (item_name, item_data) in enumerate(results.items()): + if total_items > 1 and index > 0: + self._output_line("", content) main_line, desc_line = self._format_entity_display(item_name, item_data) self._output_line(main_line, content) if desc_line: diff --git a/checks/core/high_value.py b/checks/core/high_value.py new file mode 100644 index 0000000..f3174b9 --- /dev/null +++ b/checks/core/high_value.py @@ -0,0 +1,96 @@ +from modules.hv_groups import HIGH_VALUE_GROUPS_UPPER + + +def name_variants(name): + if not name: + return set() + upper = str(name).upper() + variants = {upper} + account_part = upper.split('@')[0] + variants.add(account_part) + if '\\' in account_part: + variants.add(account_part.split('\\')[-1]) + return {variant for variant in variants if variant} + + +def _add_variants(target, names): + for name in names or []: + target.update(name_variants(name)) + + +def high_value_principal_sets(neo4j_data): + users = set() + computers = set() + groups = set(HIGH_VALUE_GROUPS_UPPER) + + admin_users, admin_computers = neo4j_data.get_admin_users_and_computers() + _add_variants(users, admin_users) + _add_variants(computers, admin_computers) + + _add_variants(users, getattr(neo4j_data, 'high_value_users_cache', None)) + _add_variants(computers, getattr(neo4j_data, 'high_value_computers_cache', None)) + + return users, computers, groups + + +def enrich_high_value_principal_sets( + users, + computers, + groups, + query, + domain_condition="", + query_name="high_value_graph_principals", +): + for row in query(f""" + MATCH (p) + WHERE (p:User OR p:Computer OR p:Group) + AND p.name IS NOT NULL + AND ( + p.highvalue = true + OR p.admincount = true + OR p:Tag_Tier_Zero + OR coalesce(p.system_tags, '') CONTAINS 'admin_tier_0' + ){domain_condition} + RETURN DISTINCT p.name AS name, + CASE + WHEN p:User THEN 'User' + WHEN p:Computer THEN 'Computer' + WHEN p:Group THEN 'Group' + END AS type + """, name=query_name) or []: + target = None + if row.get('type') == 'User': + target = users + elif row.get('type') == 'Computer': + target = computers + elif row.get('type') == 'Group': + target = groups + if target is not None: + target.update(name_variants(row.get('name'))) + + return users, computers, groups + + +def high_value_principal_sets_for_check(check, query_name="high_value_graph_principals"): + users, computers, groups = high_value_principal_sets(check.neo4j_data) + return enrich_high_value_principal_sets( + users, + computers, + groups, + check.query, + check._domain_condition("p"), + query_name, + ) + + +def is_high_value_principal(name, principal_type, users, computers, groups): + variants = name_variants(name) + if not variants: + return False + if principal_type == 'User': + return bool(variants & users) + if principal_type == 'Computer': + return bool(variants & computers) + if principal_type == 'Group': + return bool(variants & groups) + return False diff --git a/checks/core/manager.py b/checks/core/manager.py index a602288..acfe221 100644 --- a/checks/core/manager.py +++ b/checks/core/manager.py @@ -12,6 +12,7 @@ def __init__(self, neo4j_data, has_escalation_path_results=None, full_escalation self.account_analysis = account_analysis self._diagnostics = diagnostics self._diagnostic_domain = getattr(neo4j_data, '_domain_filter', None) + self._diagnostic_query_start_index = 0 self.shared_cache = { DataTypes.ESCALATION_PATHS: has_escalation_path_results or {}, DataTypes.FULL_ESCALATION_PATHS: full_escalation_cache or {} @@ -35,6 +36,7 @@ def run_all_checks(self): self._check_datasource_availability() if self._diagnostics: + self._diagnostic_query_start_index = len(self._diagnostics.queries) self._record_entity_summary() runnable = [c for c in CheckRegistry.get_all_checks() if not self._should_skip(c)] @@ -96,7 +98,7 @@ def _collect_required_data_types(self, check_classes): def _preload_data_type(self, data_type): if data_type in self.shared_cache: - return # Already cached + return loader_methods = { DataTypes.COMPUTERS: 'get_all_computers_with_attributes', @@ -278,15 +280,48 @@ def _record_platform_summary(self): if not mssql and not sccm: return domain_key = self._diagnostic_domain or self.neo4j_data.get_domain_name() + queries = self._diagnostics.queries[self._diagnostic_query_start_index:] bucket = {} if mssql: - bucket["mssql"] = { - "checks_run": len(mssql), - "total_findings": sum(c['findings_count'] for c in mssql), - } + bucket["mssql"] = self._platform_perf_summary( + mssql, + self._queries_matching(queries, ("mssql",)) + ) if sccm: - bucket["sccm"] = { - "checks_run": len(sccm), - "total_findings": sum(c['findings_count'] for c in sccm), - } + bucket["sccm"] = self._platform_perf_summary( + sccm, + self._queries_matching( + queries, + ("sccm", "takeover", "elevate", "pxe", "management_points") + ) + ) self._diagnostics.mssql_sccm[domain_key] = bucket + + def _queries_matching(self, queries, markers): + return [ + q for q in queries + if any(marker in (q.get("name") or "").lower() for marker in markers) + ] + + def _platform_perf_summary(self, checks, queries): + slowest_check = max(checks, key=lambda c: c.get('duration_ms') or 0) + summary = { + "checks_run": len(checks), + "total_findings": sum(c['findings_count'] for c in checks), + "check_duration_ms": round(sum(c.get('duration_ms') or 0 for c in checks), 1), + "slowest_check": { + "name": slowest_check.get("name"), + "duration_ms": slowest_check.get("duration_ms") or 0, + }, + "queries_run": len(queries), + "query_duration_ms": round(sum(q.get('duration_ms') or 0 for q in queries), 1), + "slowest_query": None, + } + if queries: + slowest_query = max(queries, key=lambda q: q.get('duration_ms') or 0) + summary["slowest_query"] = { + "name": slowest_query.get("name"), + "duration_ms": slowest_query.get("duration_ms") or 0, + "result_count": slowest_query.get("result_count") or 0, + } + return summary diff --git a/checks/core/mssql_common.py b/checks/core/mssql_common.py new file mode 100644 index 0000000..04cbea8 --- /dev/null +++ b/checks/core/mssql_common.py @@ -0,0 +1,77 @@ +COMMON_SQL_LOGIN_HOLDER_NAMES = [ + 'DOMAIN USERS', 'DOMAIN COMPUTERS', 'AUTHENTICATED USERS', + 'EVERYONE', 'BUILTIN\\USERS', 'USERS', +] + +COMMON_SQL_LOGIN_HOLDER_SIDS = [ + 'S-1-1-0', 'S-1-5-11', 'S-1-5-32-545', +] + +MSSQL_ABUSE_EDGES = [ + 'MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_IsMappedTo', 'MSSQL_MemberOf', + 'MSSQL_AddMember', 'MSSQL_ControlServer', 'MSSQL_ControlDB', 'MSSQL_Control', + 'MSSQL_AlterAnyLogin', 'MSSQL_AlterAnyDBRole', 'MSSQL_AlterAnyAppRole', + 'MSSQL_AlterAnyServerRole', 'MSSQL_GrantAnyPermission', 'MSSQL_GrantAnyDBPermission', + 'MSSQL_Impersonate', 'MSSQL_ImpersonateAnyLogin', 'MSSQL_ExecuteAs', + 'MSSQL_ExecuteAsOwner', 'MSSQL_TakeOwnership', 'MSSQL_ChangeOwner', + 'MSSQL_ChangePassword', 'MSSQL_LinkedAsAdmin', 'MSSQL_Owns', + 'MSSQL_HasMappedCred', 'MSSQL_HasProxyCred', 'MSSQL_HasDBScopedCred', +] + + +def sql_login_holder_parameters(): + return { + "_common_sql_login_holder_names": COMMON_SQL_LOGIN_HOLDER_NAMES, + "_common_sql_login_holder_sids": COMMON_SQL_LOGIN_HOLDER_SIDS, + } + + +def sql_login_report_holder_expr(holder_var="holder"): + return f"""{holder_var}:Group AND ( + toUpper(split(coalesce({holder_var}.name, ''), '@')[0]) IN $_common_sql_login_holder_names + OR coalesce({holder_var}.objectid, '') IN $_common_sql_login_holder_sids + OR coalesce({holder_var}.objectid, '') ENDS WITH '-513' + OR coalesce({holder_var}.objectid, '') ENDS WITH '-515' + )""" + + +def sql_login_report_holder_filter( + holder_domain_cond, + candidate_domain_cond, + holder_var="holder", + candidate_var="candidatePrincipal", +): + # Caller must alias sql_login_report_holder_expr() as reportHolder. + return f"""WHERE ((reportHolder OR {holder_var}:User OR {holder_var}:Computer){holder_domain_cond}) + OR ({holder_var}:Group AND NOT reportHolder AND EXISTS {{ + MATCH ({candidate_var})-[:MemberOf*1..6]->({holder_var}) + WHERE ({candidate_var}:User OR {candidate_var}:Computer){candidate_domain_cond} + }})""" + + +def primary_label_expr(var): + return ( + f"head([label IN labels({var}) " + f"WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', " + f"'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup'] " + f"AND NOT label STARTS WITH 'Tag_'])" + ) + + +def linked_server_target_resolution(real_var, stub_var): + # Match only exact names or delimiter-anchored host prefixes. + return ( + f"({stub_var}.name IS NOT NULL AND {stub_var}.name <> '' " + f"AND (toLower({real_var}.name) = toLower({stub_var}.name) " + f"OR toLower({real_var}.name) STARTS WITH toLower({stub_var}.name) + '.' " + f"OR toLower({real_var}.name) STARTS WITH toLower({stub_var}.name) + ':'))" + ) + + +def host_sid_resolves_to_single_server(sid_expr): + # Avoid bridging ambiguous host SID prefixes shared by sibling instances. + return ( + f"COUNT {{ MATCH (sidServer:MSSQL_Server) " + f"WHERE sidServer.objectid CONTAINS ':' " + f"AND split(sidServer.objectid, ':')[0] = {sid_expr} }} = 1" + ) diff --git a/checks/cross_domain/injector.py b/checks/cross_domain/injector.py index 82819ff..82e1f21 100644 --- a/checks/cross_domain/injector.py +++ b/checks/cross_domain/injector.py @@ -4,7 +4,10 @@ def _display_results(results, category_color, category, content, count, reset_color): content.append(f"\n {category}: {count}") - for display_key, description in results.items(): + total_items = len(results) + for index, (display_key, description) in enumerate(results.items()): + if total_items > 1 and index > 0: + content.append("") if not description: content.append(f" ▶ {display_key}") elif description.lstrip().startswith(('├─', '└─')): @@ -18,13 +21,15 @@ def _display_results(results, category_color, category, content, count, reset_co def _display_grouped_paths(results, category_color, category, content, count, reset_color): content.append(f"\n {category}: {count}") - for display_key, description in results.items(): + for index, (display_key, description) in enumerate(results.items()): + if index > 0: + content.append("") # display_key is "Users with Shared Path (N): user1, user2" # description is "Cross-Domain Path: MemberOf -> ..." content.append(f" {display_key}") if description: + content.append("") content.append(f" {description}") - content.append("") # Blank line between groups return content diff --git a/checks/mssql_logins.py b/checks/mssql_logins.py index b8432f0..5180756 100644 --- a/checks/mssql_logins.py +++ b/checks/mssql_logins.py @@ -136,8 +136,6 @@ def _format_entity(self, rows): if dinfo['permissions']: sub.append(f"Permissions: {', '.join(dinfo['permissions'])}") - if 'MSSQL_ControlDB' in dinfo['capabilities']: - sub.append("ATTACK PATH: Full database control") if 'MSSQL_GrantAnyDBPermission' in dinfo['capabilities']: sub.append("ATTACK PATH: Can grant any database permission") diff --git a/checks/mssql_ntlm_relay.py b/checks/mssql_ntlm_relay.py index ca8d035..391c2a0 100644 --- a/checks/mssql_ntlm_relay.py +++ b/checks/mssql_ntlm_relay.py @@ -1,9 +1,20 @@ from collections import defaultdict from checks.core import Check, check +from checks.core.high_value import high_value_principal_sets_for_check, is_high_value_principal from checks.core.platform_mixins import MSSQLDomainMixin from modules.opengraph_contracts import mssql_host_mapping_requirement +_COMMON_GROUP_NAMES = { + 'DOMAIN USERS', + 'DOMAIN COMPUTERS', + 'AUTHENTICATED USERS', + 'EVERYONE', + 'USERS', +} + +_MAX_RELAY_PRINCIPALS = 5 + @check(risk="High", category="MSSQL Server Vulnerable to NTLM Relay", entity="computer", data=[], requires=["mssql"]) class MSSQLNTLMRelayCheck(MSSQLDomainMixin, Check): @@ -18,8 +29,10 @@ def execute(self): login_info = self._get_login_info() relay_targets = self._get_relay_targets() + high_value_sets = self._high_value_start_sets() per_sid = defaultdict(list) + seen_by_sid = defaultdict(set) for row in servers: display_account = row.get('resolvedAccount') if not display_account: @@ -27,31 +40,33 @@ def execute(self): server_name = row.get('serverName') host_sid = row.get('hostSid') - host_name = row.get('hostName') - if not server_name or not host_sid or not host_name: + host_display = row.get('hostName') + if not server_name or not host_sid or not host_display: continue server_lower = server_name.lower() - host_name = host_name.lower() + host_name = host_display.lower() server_logins = login_info.get(server_lower) if not server_logins: continue - who_str = self._format_who_can_login(server_logins) - per_server_targets = [ display for target_name, display in relay_targets if target_name != host_name ] - if per_server_targets: - relay_str = f"Relay to {', '.join(per_server_targets)}" - else: - relay_str = "No current relay targets with SMB signing disabled" - - desc = f"{who_str} -> xp_dirtree coercion ({display_account}) -> {relay_str}" - if desc not in per_sid[host_sid]: - per_sid[host_sid].append(desc) + desc = self._format_relay_path( + self._login_principals(server_logins, high_value_sets), + host_display, + display_account, + per_server_targets, + ) + if not desc: + continue + if desc in seen_by_sid[host_sid]: + continue + seen_by_sid[host_sid].add(desc) + per_sid[host_sid].append(desc) return {sid: self.finding('\n'.join(descs)) for sid, descs in per_sid.items()} @@ -123,8 +138,12 @@ def _get_login_info(self): if server_lower not in info: info[server_lower] = {'users': [], 'computers': [], 'groups': []} + login_type = (row.get('type') or '').upper() + if name_upper.startswith('BUILTIN\\'): info[server_lower]['groups'].append(name.split('\\', 1)[1]) + elif login_type == 'WINDOWS_GROUP': + info[server_lower]['groups'].append(name) elif name.endswith('$'): info[server_lower]['computers'].append(name) else: @@ -155,38 +174,113 @@ def _get_relay_targets(self): for row in rows if row.get('name') ] - def _format_who_can_login(self, logins): - who = [] + def _high_value_start_sets(self): + return high_value_principal_sets_for_check( + self, + query_name="mssql_ntlm_high_value_start_principals", + ) + + def _format_relay_path(self, principals, host_name, service_account, relay_targets): + relay_target = ', '.join(relay_targets) if relay_targets else "no SMB signing-disabled relay targets" + if isinstance(principals, str): + principals = [principals] + if not principals: + return "" + shown = principals[:_MAX_RELAY_PRINCIPALS] + lines = [ + f"{principal} > MSSQL_Connect > {host_name} > xp_dirtree > " + f"{service_account} > NTLM_Relay > {relay_target}" + for principal in shown + ] + remaining = len(principals) - len(shown) + if remaining: + lines.append(f"(+{remaining} more principals)") + return '\n'.join(lines) + + def _login_principals(self, logins, high_value_sets=None): + principals = [] has_broad = False for group in logins.get('groups', []): - if 'DOMAIN USERS' in group.upper(): - who.append(f"{group} (all domain users)") + if self._is_filtered_start_principal(group, 'Group', high_value_sets): + continue + group_upper = group.upper() + if 'DOMAIN USERS' in group_upper: + principals.append(group) has_broad = True - elif 'DOMAIN COMPUTERS' in group.upper(): - who.append(f"{group} (all domain computers)") + elif 'DOMAIN COMPUTERS' in group_upper: + principals.append(group) has_broad = True - elif 'AUTHENTICATED USERS' in group.upper(): - who.append(f"{group} (all authenticated users)") + elif 'AUTHENTICATED USERS' in group_upper: + principals.append(group) has_broad = True else: - who.append(group) + principals.append(group) if not has_broad: - users = logins.get('users', []) - if users: - shown = users[:5] - if len(users) > 5: - who.append(f"{', '.join(shown)} (+{len(users)-5} more users)") - else: - who.append(', '.join(shown)) - - computers = logins.get('computers', []) - if computers: - shown = computers[:3] - if len(computers) > 3: - who.append(f"{', '.join(shown)} (+{len(computers)-3} more computers)") - else: - who.append(', '.join(shown)) - - return ' | '.join(who) if who else "Users" + principals.extend( + user for user in logins.get('users', []) + if not self._is_filtered_start_principal(user, 'User', high_value_sets) + ) + principals.extend( + computer for computer in logins.get('computers', []) + if not self._is_filtered_start_principal(computer, 'Computer', high_value_sets) + ) + + return self._dedupe(principals) + + def _is_filtered_start_principal(self, name, principal_type, high_value_sets): + if not high_value_sets: + return False + users, computers, groups = high_value_sets + if is_high_value_principal(name, principal_type, users, computers, groups): + return True + if principal_type != 'Group': + return is_high_value_principal(name, 'Group', users, computers, groups) + return False + + def _dedupe(self, values): + seen = set() + index_by_key = {} + deduped = [] + for value in values: + if not value: + continue + key = self._principal_dedupe_key(value) + if key in seen: + if self._prefer_principal_display(value, deduped[index_by_key[key]]): + deduped[index_by_key[key]] = value + continue + seen.add(key) + index_by_key[key] = len(deduped) + deduped.append(value) + return deduped + + def _principal_dedupe_key(self, value): + upper = value.upper() + account = upper.split('@', 1)[0] + domain = None + + if '@' in upper: + _account, domain = upper.split('@', 1) + elif '\\' in account: + domain, account = account.split('\\', 1) + + if account in _COMMON_GROUP_NAMES: + return f"GROUP:{self._normalize_domain_key(domain)}:{account}" + + return f"PRINCIPAL:{self._normalize_domain_key(domain)}:{account}" + + def _normalize_domain_key(self, domain): + if not domain: + return self._domain_filter.upper() if self._domain_filter else "" + if '.' in domain: + return domain + if self._domain_filter and self._domain_filter.upper().startswith(domain + '.'): + return self._domain_filter.upper() + return domain + + def _prefer_principal_display(self, candidate, current): + candidate_upper = candidate.upper() + current_upper = current.upper() + return '@' in candidate_upper and '@' not in current_upper diff --git a/checks/mssql_privilege_escalation.py b/checks/mssql_privilege_escalation.py index ff945e5..5aa48df 100644 --- a/checks/mssql_privilege_escalation.py +++ b/checks/mssql_privilege_escalation.py @@ -1,4 +1,14 @@ from checks.core import Check, check +from checks.core.high_value import high_value_principal_sets_for_check, is_high_value_principal +from checks.core.mssql_common import ( + MSSQL_ABUSE_EDGES, + host_sid_resolves_to_single_server, + linked_server_target_resolution, + primary_label_expr, + sql_login_holder_parameters, + sql_login_report_holder_expr, + sql_login_report_holder_filter, +) _INTERNAL_MSSQL_OBJECTS = { '##MS_POLICYSIGNINGCERTIFICATE##', @@ -33,7 +43,7 @@ _VALUABLE_NODE_TYPES = { 'MSSQL_ServerRole', 'MSSQL_Login', 'MSSQL_DatabaseRole', - 'MSSQL_DatabaseUser', 'MSSQL_Server', 'Computer', + 'MSSQL_DatabaseUser', } _SYSADMIN_SUBORDINATES = { @@ -41,13 +51,17 @@ 'BULKADMIN', 'DISKADMIN', 'DBCREATOR', } -@check(risk="High", category="MSSQL Privilege Escalation", entity="user", data=[], requires=["mssql"]) +@check(risk="High", category="MSSQL Privilege Escalation", entity="user", data=[], requires=["mssql"], + display="shared_graph_paths") class MSSQLPrivilegeEscalationCheck(Check): def execute(self): - admin_users, admin_computers = self.neo4j_data.get_admin_users_and_computers() - admin_users_set = set(u.upper() for u in admin_users) if admin_users else set() - admin_computers_set = set(c.upper() for c in admin_computers) if admin_computers else set() + high_value_users, high_value_computers, high_value_groups = ( + high_value_principal_sets_for_check( + self, + query_name="mssql_privilege_escalation_high_value_principals", + ) + ) paths_data = self._fetch_escalation_paths() if not paths_data: @@ -55,8 +69,13 @@ def execute(self): results = {} for entity_sid, entity_info in paths_data.items(): - entity_name = (entity_info.get('name') or '').upper() - if entity_name in admin_users_set or entity_name in admin_computers_set: + if is_high_value_principal( + entity_info.get('name') or '', + entity_info.get('principal_type') or '', + high_value_users, + high_value_computers, + high_value_groups, + ): continue if not entity_info.get('paths'): continue @@ -114,10 +133,13 @@ def execute(self): def _fetch_escalation_paths(self): domain_cond = self._domain_condition("principal") + holder_domain_cond = self._domain_condition("holder") + candidate_domain_cond = self._domain_condition("candidatePrincipal") + member_domain_cond = self._domain_condition("memberPrincipal") rows = self.query(f""" - MATCH (startLogin:MSSQL_Login)<-[:MSSQL_HasLogin]-(principal) - WHERE (principal:User OR principal:Computer){domain_cond} + MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login) + WHERE holder:User OR holder:Computer OR holder:Group MATCH (target) WHERE ( @@ -138,44 +160,68 @@ def _fetch_escalation_paths(self): 'CONTROL', 'ALTER', 'ALTER ANY DATABASE ROLE', 'ALTER ANY USER', 'IMPERSONATE', 'TAKE OWNERSHIP' ])) - OR (target:Computer) ) AND target <> startLogin - WITH principal, startLogin, target + WITH holder, startLogin, target, + {sql_login_report_holder_expr()} AS reportHolder + {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)} MATCH p = shortestPath((startLogin)-[rels*1..5]->(target)) - WHERE ALL(rel IN rels WHERE rel.traversable = true) + WHERE ALL(rel IN rels WHERE type(rel) IN $_abuse_edges) OPTIONAL MATCH (db:MSSQL_Database)-[:MSSQL_Contains]->(target) - OPTIONAL MATCH (server:MSSQL_Server {{name: target.SQLServer}}) - - WITH principal, startLogin, target, p, rels, db, server, - [node in nodes(p) | coalesce(node.name, '')] as pathNodes, - [rel in rels | type(rel)] as pathEdges, - length(p) as pathLength, - CASE WHEN principal:Computer THEN 'Computer' ELSE 'User' END as principalType + OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder) + WHERE holder:Group AND NOT reportHolder + AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond} + + WITH CASE + WHEN reportHolder OR holder:User OR holder:Computer THEN holder + ELSE memberPrincipal + END AS principal, + holder, startLogin, target, p, rels, db, reportHolder, memberPath + WHERE principal IS NOT NULL + AND (reportHolder OR principal:User OR principal:Computer){domain_cond} + AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true) + + WITH principal, holder, startLogin, target, p, rels, db, reportHolder, memberPath, + CASE + WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0]] + [node IN nodes(p) | coalesce(node.name, '')] + WHEN holder:Group AND memberPath IS NOT NULL + THEN {self._ad_member_path_node_names_expr('nodes(memberPath)')} + [node IN nodes(p) | coalesce(node.name, '')] + ELSE [node in nodes(p) | coalesce(node.name, '')] + END as pathNodes, + CASE + WHEN reportHolder THEN ['MSSQL_HasLogin'] + [rel in rels | type(rel)] + WHEN holder:Group AND memberPath IS NOT NULL + THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin'] + [rel in rels | type(rel)] + ELSE [rel in rels | type(rel)] + END as pathEdges, + CASE + WHEN reportHolder THEN length(p) + 1 + WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + length(p) + 1 + ELSE length(p) + END as pathLength, + CASE + WHEN reportHolder THEN 'Group' + WHEN principal:Computer THEN 'Computer' + ELSE 'User' + END as principalType - WITH principal, startLogin, target, p, rels, db, server, pathNodes, pathEdges, pathLength, principalType, + WITH principal, holder, startLogin, target, p, rels, db, reportHolder, + pathNodes, pathEdges, pathLength, principalType, CASE WHEN target:MSSQL_ServerRole THEN 'MSSQL_ServerRole' WHEN target:MSSQL_DatabaseRole THEN 'MSSQL_DatabaseRole' WHEN target:MSSQL_Login THEN 'MSSQL_Login' WHEN target:MSSQL_DatabaseUser THEN 'MSSQL_DatabaseUser' - WHEN target:MSSQL_Server THEN 'MSSQL_Server' WHEN target:MSSQL_Database THEN 'MSSQL_Database' - WHEN target:Computer THEN 'Computer' WHEN target:MSSQL_Base THEN 'MSSQL' WHEN target:SCCM_Base THEN 'SCCM' - ELSE coalesce( - head([label IN labels(target) - WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup'] - AND NOT label STARTS WITH 'Tag_']), - 'Unknown' - ) + ELSE coalesce({primary_label_expr('target')}, 'Unknown') END as targetTypeResolved - RETURN principal.objectid as userSid, - principal.name as userName, + RETURN CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END as userSid, + CASE WHEN reportHolder THEN holder.name ELSE principal.name END as userName, principalType, startLogin.name as loginName, startLogin.SQLServer as sqlServer, @@ -186,12 +232,16 @@ def _fetch_escalation_paths(self): db.name as databaseName, db.isTrustworthy as isTrustworthy, db.hasGuestEnabled as hasGuestEnabled, - server.xpCmdShellEnabled as xpCmdShellEnabled, pathNodes, pathEdges, pathLength ORDER BY userName, pathLength - """, name="mssql_privilege_escalation_paths") + """, parameters={ + "_abuse_edges": MSSQL_ABUSE_EDGES, + **sql_login_holder_parameters(), + }, name="mssql_privilege_escalation_paths") + + rows = list(rows) + self._fetch_linked_server_paths() paths_by_user = {} for row in rows: @@ -216,7 +266,6 @@ def _fetch_escalation_paths(self): 'database_name': row.get('databaseName') or '', 'is_trustworthy': row.get('isTrustworthy', False), 'has_guest_enabled': row.get('hasGuestEnabled', False), - 'xp_cmdshell_enabled': row.get('xpCmdShellEnabled', False), 'path_nodes': row.get('pathNodes') or [], 'path_edges': row.get('pathEdges') or [], 'path_length': row.get('pathLength') or 0, @@ -224,6 +273,77 @@ def _fetch_escalation_paths(self): return paths_by_user + def _fetch_linked_server_paths(self): + domain_cond = self._domain_condition("principal") + holder_domain_cond = self._domain_condition("holder") + candidate_domain_cond = self._domain_condition("candidatePrincipal") + member_domain_cond = self._domain_condition("memberPrincipal") + + rows = self.query(f""" + MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login) + WHERE holder:User OR holder:Computer OR holder:Group + + WITH holder, startLogin, + {sql_login_report_holder_expr()} AS reportHolder + {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)} + MATCH (startLogin)-[:MSSQL_Connect]->(srvA:MSSQL_Server) + MATCH (stub:MSSQL_Base)-[:MSSQL_LinkedAsAdmin]->(srvB_stub:MSSQL_Server) + WHERE toLower(stub.name) ENDS WITH toLower(':' + srvA.sqlServerName) + OR (stub.objectid IS NOT NULL AND srvA.objectid IS NOT NULL + AND stub.objectid CONTAINS ':' AND srvA.objectid CONTAINS ':' + AND split(stub.objectid, ':')[0] = split(srvA.objectid, ':')[0] + AND {host_sid_resolves_to_single_server("split(srvA.objectid, ':')[0]")}) + + MATCH (srvB:MSSQL_Server)-[:MSSQL_Contains]->(target:MSSQL_ServerRole) + WHERE {linked_server_target_resolution('srvB', 'srvB_stub')} + AND toUpper(target.name) IN [ + 'SYSADMIN', 'SECURITYADMIN', 'SERVERADMIN', 'PROCESSADMIN', + 'SETUPADMIN', 'BULKADMIN', 'DISKADMIN', 'DBCREATOR' + ] + OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder) + WHERE holder:Group AND NOT reportHolder + AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond} + WITH CASE + WHEN reportHolder OR holder:User OR holder:Computer THEN holder + ELSE memberPrincipal + END AS principal, + holder, startLogin, reportHolder, srvA, srvB, target, memberPath + WHERE principal IS NOT NULL + AND (reportHolder OR principal:User OR principal:Computer){domain_cond} + AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true) + RETURN DISTINCT + CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END as userSid, + CASE WHEN reportHolder THEN holder.name ELSE principal.name END as userName, + CASE + WHEN reportHolder THEN 'Group' + WHEN principal:Computer THEN 'Computer' + ELSE 'User' + END as principalType, + startLogin.name as loginName, + startLogin.SQLServer as sqlServer, + target.name as targetName, + 'MSSQL_ServerRole' as targetType, + srvB.name as targetServer, + CASE + WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0], startLogin.name, srvA.name, srvB.name, target.name] + WHEN holder:Group AND memberPath IS NOT NULL + THEN {self._ad_member_path_node_names_expr('nodes(memberPath)')} + [startLogin.name, srvA.name, srvB.name, target.name] + ELSE [startLogin.name, srvA.name, srvB.name, target.name] + END as pathNodes, + CASE + WHEN reportHolder THEN ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains'] + WHEN holder:Group AND memberPath IS NOT NULL + THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains'] + ELSE ['MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains'] + END as pathEdges, + CASE + WHEN reportHolder THEN 4 + WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + 4 + ELSE 3 + END as pathLength + """, parameters=sql_login_holder_parameters(), name="mssql_priv_esc_linked_server") + return list(rows) + def _normalize_server_name(self, server_name): if not server_name: return '' @@ -240,42 +360,36 @@ def _build_chain(self, path_info, server_display=''): return "" cleaned = [self._clean_node_name(n) for n in path_nodes] - - has_oscmd_tail = ( - target_type == 'Computer' - and len(path_edges) >= 2 - and path_edges[-2] == 'MSSQL_ControlServer' - and path_edges[-1] == 'MSSQL_ExecuteOnHost' - ) + if server_display and cleaned: + cleaned[-1] = self._target_display_name(cleaned[-1], target_type, server_display) parts = [cleaned[0]] for i, edge in enumerate(path_edges): next_name = cleaned[i + 1] if i + 1 < len(cleaned) else '?' parts.append(edge) - is_final_host = has_oscmd_tail and i == len(path_edges) - 1 - parts.append(next_name.lower() if is_final_host else self._display_node(next_name)) + parts.append(self._display_node(next_name)) chain = " -> ".join(parts) - if has_oscmd_tail: - return chain - if target_type in ('MSSQL_DatabaseRole', 'MSSQL_DatabaseUser') and database_name: chain = chain + f" ({database_name})" elif target_type == 'MSSQL_Login': perms = [p for p in target_permissions if p and p.upper() in _HIGH_VALUE_PERMISSIONS] if perms: chain = chain + f" ({', '.join(perms)})" - if server_display: - chain = chain + f" on {server_display}" return chain + def _target_display_name(self, name, target_type, server_display): + if target_type in ('MSSQL_ServerRole', 'MSSQL_Login') and '@' not in name: + return f"{name}@{server_display}" + return name + def _display_node(self, name): - upper = name.upper() - if upper in _HIGH_VALUE_SERVER_ROLES or upper in _HIGH_VALUE_DB_ROLES: - return name.lower() + role_name, sep, scope = name.partition('@') + if role_name.upper() in _HIGH_VALUE_SERVER_ROLES or role_name.upper() in _HIGH_VALUE_DB_ROLES: + return f"{role_name.lower()}{sep}{scope}" return name def _is_valuable_target(self, path_info): @@ -304,3 +418,6 @@ def _clean_node_name(self, node_name): node_name = node_name.split('\\')[-1] node_name = self._normalize_server_name(node_name) return node_name + + def _ad_member_path_node_names_expr(self, nodes_expr): + return f"[node IN {nodes_expr} | split(coalesce(node.name, node.objectid, ''), '@')[0]]" diff --git a/checks/mssql_sccm.py b/checks/mssql_sccm.py deleted file mode 100644 index 22ab4ec..0000000 --- a/checks/mssql_sccm.py +++ /dev/null @@ -1,328 +0,0 @@ -from checks.core import Check, check -from checks.core.platform_mixins import MSSQLDomainMixin - - -@check(risk="Critical", category="SCCM Database Compromise", entity="user", data=[], requires=["mssql"]) -class MSSQLSCCMCheck(MSSQLDomainMixin, Check): - - def execute(self): - admin_users, admin_computers = self.neo4j_data.get_admin_users_and_computers() - admin_users_set = set(u.upper() for u in admin_users) if admin_users else set() - admin_computers_set = set(c.upper() for c in admin_computers) if admin_computers else set() - - sccm_paths = self._fetch_sccm_paths() - if not sccm_paths: - return {} - - by_principal = {} - - for path in sccm_paths: - sid = path.get('principal_sid') - if not sid: - continue - - name = (path.get('principal_name') or '').upper() - ptype = path.get('principal_type') or '' - - attack_type = path.get('attack_type') or '' - - if attack_type == 'privilege_escalation': - if ptype == 'User' and name in admin_users_set: - continue - if ptype == 'Computer' and name in admin_computers_set: - continue - - if sid not in by_principal: - by_principal[sid] = {'name': name, 'type': ptype, 'paths': []} - by_principal[sid]['paths'].append(path) - - results = {} - for sid, info in by_principal.items(): - lines = self._format_principal(info) - if lines: - results[sid] = self.finding("\n".join(lines)) - - return results - - def _fetch_sccm_paths(self): - all_paths = [] - all_paths.extend(self._fetch_priv_esc_paths()) - all_paths.extend(self._fetch_coercion_paths()) - all_paths.extend(self._fetch_service_account_paths()) - return all_paths - - def _fetch_priv_esc_paths(self): - domain_cond = self._domain_condition("principal") - - rows = self.query(f""" - MATCH (principal)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login) - WHERE (principal:User OR principal:Computer){domain_cond} - - WITH principal, startLogin - MATCH (sccmDb:MSSQL_Database) - WHERE sccmDb.isTrustworthy = true - AND (sccmDb.name CONTAINS 'CM_' OR sccmDb.name CONTAINS 'SMS' OR sccmDb.name CONTAINS 'SCCM') - - WITH principal, startLogin, sccmDb - MATCH (target) - WHERE ( - (target:MSSQL_DatabaseRole - AND target.name IN ['db_owner', 'db_securityadmin', 'db_accessadmin', 'db_ddladmin'] - AND (sccmDb)-[:MSSQL_Contains]->(target)) - OR (target:MSSQL_DatabaseUser - AND target.explicitPermissions IS NOT NULL - AND ('CONTROL' IN target.explicitPermissions OR 'ALTER DATABASE' IN target.explicitPermissions) - AND (sccmDb)-[:MSSQL_Contains]->(target)) - OR (target = sccmDb) - OR (target:SCCM_Site AND (sccmDb)-[:SCCM_AssignAllPermissions]->(target)) - ) - - WITH principal, startLogin, target, sccmDb - MATCH p = shortestPath((startLogin)-[rels*1..5]->(target)) - WHERE ALL(rel IN rels WHERE rel.traversable = true) - AND length(p) > 0 - - WITH principal, startLogin, target, sccmDb, p, nodes(p) as pathNodes, - [rel in rels | type(rel)] as pathEdges, - length(p) as pathLength, - CASE - WHEN target:SCCM_Site THEN 'SCCM_Site' - WHEN target:MSSQL_Database THEN 'MSSQL_Database' - WHEN target:MSSQL_DatabaseUser THEN 'MSSQL_DatabaseUser' - WHEN target:MSSQL_DatabaseRole THEN 'MSSQL_DatabaseRole' - WHEN target:MSSQL_Base THEN 'MSSQL' - WHEN target:SCCM_Base THEN 'SCCM' - ELSE coalesce( - head([label IN labels(target) - WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup'] - AND NOT label STARTS WITH 'Tag_']), - 'Unknown' - ) - END as targetType - - WITH principal, startLogin, target, sccmDb, p, pathNodes, pathEdges, pathLength, targetType - WHERE ANY(node IN pathNodes WHERE - (node = sccmDb) OR - (node:MSSQL_DatabaseUser AND node.database = sccmDb.name) OR - (node:MSSQL_DatabaseRole AND node.database = sccmDb.name) - ) - - OPTIONAL MATCH (server:MSSQL_Server {{name: sccmDb.SQLServer}}) - - RETURN DISTINCT - principal.objectid as principalSid, - principal.name as principalName, - CASE WHEN principal:Computer THEN 'Computer' ELSE 'User' END as principalType, - target.name as targetName, - targetType, - target.explicitPermissions as targetPermissions, - sccmDb.name as databaseName, - sccmDb.SQLServer as serverName, - server.xpCmdShellEnabled as xpCmdShellEnabled, - pathEdges, - pathLength, - [node IN pathNodes | coalesce( - node.name, - head([label IN labels(node) - WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup'] - AND NOT label STARTS WITH 'Tag_']), - 'Unknown' - )] as pathNodeNames - """, name="sccm_privilege_escalation_paths") - - paths = [] - for row in rows: - paths.append({ - 'attack_type': 'privilege_escalation', - 'principal_sid': row.get('principalSid'), - 'principal_name': row.get('principalName') or '', - 'principal_type': row.get('principalType') or '', - 'target_name': row.get('targetName') or '', - 'target_type': row.get('targetType') or '', - 'target_permissions': row.get('targetPermissions') or [], - 'database_name': row.get('databaseName') or '', - 'server_name': row.get('serverName') or '', - 'xp_cmdshell_enabled': row.get('xpCmdShellEnabled', False), - 'path_edges': row.get('pathEdges') or [], - 'path_length': row.get('pathLength') or 0, - 'path_node_names': row.get('pathNodeNames') or [], - }) - return paths - - def _fetch_coercion_paths(self): - source_domain_cond = self._domain_condition("source") - source_filter = (f"\nWHERE true{source_domain_cond}") if source_domain_cond else "" - - rows = self.query(f""" - MATCH (source)-[:CoerceAndRelayToMSSQL]->(login:MSSQL_Login){source_filter} - - WITH source, login - MATCH (sccmDb:MSSQL_Database {{SQLServer: login.SQLServer}}) - WHERE sccmDb.isTrustworthy = true - AND (sccmDb.name CONTAINS 'CM_' OR sccmDb.name CONTAINS 'SMS' OR sccmDb.name CONTAINS 'SCCM') - - OPTIONAL MATCH (server:MSSQL_Server {{name: login.SQLServer}}) - - RETURN DISTINCT - CASE - WHEN source:Computer THEN 'Computer' - WHEN source:User THEN 'User' - WHEN source:MSSQL_Base THEN 'MSSQL' - WHEN source:SCCM_Base THEN 'SCCM' - ELSE coalesce( - head([label IN labels(source) - WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup'] - AND NOT label STARTS WITH 'Tag_']), - 'Unknown' - ) - END as sourceType, - source.name as sourceName, - source.objectid as sourceSid, - login.name as loginName, - login.SQLServer as sqlServer, - server.xpCmdShellEnabled as xpCmdShellEnabled, - collect(DISTINCT sccmDb.name) as sccmDatabases - """, name="sccm_coercion_paths") - - paths = [] - for row in rows: - paths.append({ - 'attack_type': 'coercion', - 'principal_sid': row.get('sourceSid'), - 'principal_name': row.get('sourceName') or '', - 'principal_type': row.get('sourceType') or '', - 'target_name': row.get('loginName') or '', - 'target_type': 'MSSQL_Login', - 'database_name': ', '.join(row.get('sccmDatabases') or []), - 'server_name': row.get('sqlServer') or '', - 'xp_cmdshell_enabled': row.get('xpCmdShellEnabled', False), - 'path_edges': ['CoerceAndRelayToMSSQL'], - 'path_length': 1, - }) - return paths - - def _fetch_service_account_paths(self): - sccm_server_filter = self._server_domain_condition("sccmDb.SQLServer") - - rows = self.query(f""" - MATCH (sccmDb:MSSQL_Database) - WHERE sccmDb.isTrustworthy = true - AND (sccmDb.name CONTAINS 'CM_' OR sccmDb.name CONTAINS 'SMS' OR sccmDb.name CONTAINS 'SCCM'){sccm_server_filter} - - WITH DISTINCT sccmDb.SQLServer as sqlServer, collect(DISTINCT sccmDb.name) as sccmDatabases - - MATCH (serviceAccount)-[:MSSQL_ServiceAccountFor]->(server:MSSQL_Server {{name: sqlServer}}) - - RETURN DISTINCT - CASE - WHEN serviceAccount:Computer THEN 'Computer' - WHEN serviceAccount:User THEN 'User' - WHEN serviceAccount:MSSQL_Base THEN 'MSSQL' - WHEN serviceAccount:SCCM_Base THEN 'SCCM' - ELSE coalesce( - head([label IN labels(serviceAccount) - WHERE NOT label IN ['Base', 'MSSQL_Base', 'SCCM_Base', 'OpenGraph_Stub', 'ADLocalGroup', 'LocalGroup'] - AND NOT label STARTS WITH 'Tag_']), - 'Unknown' - ) - END as serviceAccountType, - serviceAccount.name as serviceAccountName, - serviceAccount.objectid as serviceAccountSid, - sqlServer, - sccmDatabases, - server.xpCmdShellEnabled as xpCmdShellEnabled - """, name="sccm_service_account_paths") - - paths = [] - for row in rows: - paths.append({ - 'attack_type': 'service_account', - 'principal_sid': row.get('serviceAccountSid'), - 'principal_name': row.get('serviceAccountName') or '', - 'principal_type': row.get('serviceAccountType') or '', - 'target_name': 'SQL Server Service Account', - 'target_type': 'MSSQL_Server', - 'database_name': ', '.join(row.get('sccmDatabases') or []), - 'server_name': row.get('sqlServer') or '', - 'xp_cmdshell_enabled': row.get('xpCmdShellEnabled', False), - 'path_edges': ['MSSQL_ServiceAccountFor'], - 'path_length': 1, - }) - return paths - - def _format_principal(self, info): - paths = info['paths'] - seen = set() - deduped = [] - - for path in paths: - attack = path.get('attack_type', '') - dedup = (attack, path.get('target_name', ''), tuple(path.get('path_edges', []))) - if dedup in seen: - continue - seen.add(dedup) - deduped.append((attack, path)) - - deduped.sort(key=lambda x: ( - 0 if x[0] == 'privilege_escalation' else 1 if x[0] == 'coercion' else 2, - x[1].get('path_length', 0) - )) - - lines = [] - for attack, path in deduped: - line = self._format_path_line(attack, path) - if line: - lines.append(line) - return lines - - def _format_path_line(self, attack_type, path): - server = (path.get('server_name') or '').split(':')[0] - server_display = server.lower() if server else 'unknown' - xp = path.get('xp_cmdshell_enabled', False) - rce_suffix = "xp_cmdshell ENABLED" if xp else "can enable xp_cmdshell" - - if attack_type == 'privilege_escalation': - chain = self._build_chain(path) - target_type = path.get('target_type', '') - - if target_type == 'SCCM_Site': - impact = "RCE on all managed clients" - else: - impact = f"{rce_suffix} on {server_display}" - - return f"{chain} -> {impact}" if chain else impact - - elif attack_type == 'coercion': - target_login = self._clean_name(path.get('target_name', '') or 'unknown login') - principal_name = self._clean_name(path.get('principal_name', '') or 'principal') - return f"{principal_name} -> CoerceAndRelayToMSSQL -> {target_login} -> {rce_suffix} on {server_display}" - - elif attack_type == 'service_account': - principal_name = self._clean_name(path.get('principal_name', '') or 'principal') - return f"{principal_name} -> MSSQL_ServiceAccountFor -> {server_display} -> {rce_suffix}" - - return "" - - def _build_chain(self, path): - edges = path.get('path_edges', []) - nodes = path.get('path_node_names', []) - - if not edges or not nodes: - return "" - - parts = [self._clean_name(nodes[0])] - for i, edge in enumerate(edges): - next_name = self._clean_name(nodes[i + 1]) if i + 1 < len(nodes) else "?" - parts.append(edge) - parts.append(next_name) - - return " -> ".join(parts) - - def _clean_name(self, name): - if not name: - return "?" - if '\\' in name: - name = name.split('\\')[-1] - if ':' in name: - name = name.split(':')[0] - return name diff --git a/checks/sccm_privilege_escalation.py b/checks/sccm_privilege_escalation.py new file mode 100644 index 0000000..4f0b95d --- /dev/null +++ b/checks/sccm_privilege_escalation.py @@ -0,0 +1,962 @@ +from checks.core import Check, check +from checks.core.high_value import high_value_principal_sets_for_check, is_high_value_principal +from checks.core.mssql_common import ( + MSSQL_ABUSE_EDGES, + host_sid_resolves_to_single_server, + linked_server_target_resolution, + primary_label_expr, + sql_login_holder_parameters, + sql_login_report_holder_expr, + sql_login_report_holder_filter, +) +from checks.core.platform_mixins import MSSQLDomainMixin, SCCMDomainMixin + + +_SCCM_PATH_EDGES = [ + 'MemberOf', *MSSQL_ABUSE_EDGES, + 'SCCM_IsMappedTo', 'SCCM_AllPermissions', 'SCCM_IsAssigned', +] + +_LINKED_PRE_EDGES = [ + 'MSSQL_ExecuteAs', 'MSSQL_Impersonate', + 'MSSQL_Connect', 'MSSQL_ConnectAnyDatabase', +] + +_SERVER_REACH_EDGES = [ + 'MSSQL_Connect', 'MSSQL_ConnectAnyDatabase', +] + +_ASSUME_ROLE_EDGES = [ + 'MSSQL_ExecuteAs', 'MSSQL_Impersonate', +] + +_SERVER_CONTROL_EDGES = [ + 'MSSQL_ControlServer', 'MSSQL_AlterAnyLogin', + 'MSSQL_AlterAnyServerRole', 'MSSQL_GrantAnyPermission', + 'MSSQL_ImpersonateAnyLogin', 'MSSQL_ExecuteAsOwner', +] + +_HIGH_VALUE_SERVER_ROLES = [ + 'SYSADMIN', 'SECURITYADMIN', 'SERVERADMIN', 'SETUPADMIN', +] + +_HIGH_VALUE_DB_ROLES = [ + 'db_owner', 'db_securityadmin', 'db_accessadmin', 'db_ddladmin', +] + +_HIGH_VALUE_DB_PERMS = [ + 'CONTROL', 'ALTER DATABASE', +] + +_SYSADMIN_SUBORDINATES = { + 'SECURITYADMIN', 'SERVERADMIN', 'SETUPADMIN', +} + +_DB_OWNER_SUBORDINATES = { + 'DB_SECURITYADMIN', 'DB_ACCESSADMIN', 'DB_DDLADMIN', +} + +_PRIVILEGED_SCCM_ROLES = [ + 'FULL ADMINISTRATOR', 'INFRASTRUCTURE ADMINISTRATOR', +] + +@check(risk="Critical", category="SCCM Privilege Escalation", entity="user", + data=[], requires=["mssql", "sccm"], display="shared_graph_paths") +class SCCMPrivilegeEscalationCheck(MSSQLDomainMixin, SCCMDomainMixin, Check): + + def execute(self): + high_value_users, high_value_computers, high_value_groups = ( + high_value_principal_sets_for_check( + self, + query_name="sccm_privilege_escalation_high_value_principals", + ) + ) + + sccm_paths = self._fetch_sccm_paths() + if not sccm_paths: + return {} + + by_principal = {} + for path in sccm_paths: + sid = path.get('principal_sid') + if not sid: + continue + + name = (path.get('principal_name') or '').upper() + ptype = path.get('principal_type') or '' + + if is_high_value_principal( + name, + ptype, + high_value_users, + high_value_computers, + high_value_groups, + ): + continue + + by_principal.setdefault(sid, []).append(path) + + results = {} + for sid, paths in by_principal.items(): + lines = self._format_principal(paths) + if lines: + results[sid] = self.finding("\n".join(lines)) + + return results + + def _fetch_sccm_paths(self): + return ( + self._fetch_pure_sccm_paths() + + self._fetch_sql_to_sccm_paths() + + self._fetch_server_control_paths() + + self._fetch_linked_sccm_paths() + + self._fetch_linked_execute_host_sccm_paths() + + self._fetch_coercion_paths() + + self._fetch_service_account_paths() + ) + + def _fetch_pure_sccm_paths(self): + domain_cond = self._domain_condition("principal") + site_cond = self._site_domain_condition("site") + + site_rows = self.query(f""" + MATCH p = (principal)-[:MemberOf*0..6]->(holder) + -[:SCCM_IsMappedTo]->(adminUser:SCCM_AdminUser) + -[:SCCM_AllPermissions]->(site:SCCM_Site) + WHERE (principal:User OR principal:Computer){domain_cond} + AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true) + AND true{site_cond} + RETURN DISTINCT + principal.objectid AS principalSid, + principal.name AS principalName, + CASE WHEN principal:Computer THEN 'Computer' ELSE 'User' END AS principalType, + coalesce(site.siteCode, site.displayName, site.objectid) AS targetName, + 'SCCM_Site' AS targetType, + null AS databaseName, + null AS serverName, + [rel IN relationships(p) | type(rel)] AS pathEdges, + length(p) AS pathLength, + {self._path_node_names_expr('nodes(p)')} AS pathNodeNames, + null AS sccmImpactDb, + null AS sccmImpactSite + """, name="sccm_pure_site_paths") + + role_rows = self.query(f""" + MATCH (site:SCCM_Site) + WHERE true{site_cond} + WITH collect(DISTINCT site.siteCode) AS siteCodes + MATCH p = (principal)-[:MemberOf*0..6]->(holder) + -[:SCCM_IsMappedTo]->(adminUser:SCCM_AdminUser) + -[:SCCM_IsAssigned]->(role:SCCM_SecurityRole) + WHERE (principal:User OR principal:Computer){domain_cond} + AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true) + AND adminUser.sourceSiteCode IN siteCodes + AND toUpper(role.name) IN $_privileged_sccm_roles + RETURN DISTINCT + principal.objectid AS principalSid, + principal.name AS principalName, + CASE WHEN principal:Computer THEN 'Computer' ELSE 'User' END AS principalType, + coalesce(adminUser.name, adminUser.objectid) AS targetName, + 'SCCM_AdminUser' AS targetType, + null AS databaseName, + null AS serverName, + [rel IN relationships(p) | type(rel)] AS pathEdges, + length(p) AS pathLength, + {self._path_node_names_expr('nodes(p)')} AS pathNodeNames, + null AS sccmImpactDb, + null AS sccmImpactSite + """, parameters={ + "_privileged_sccm_roles": _PRIVILEGED_SCCM_ROLES, + }, name="sccm_pure_role_paths") + return self._path_rows(list(site_rows) + list(role_rows)) + + def _fetch_sql_to_sccm_paths(self): + domain_cond = self._domain_condition("principal") + holder_domain_cond = self._domain_condition("holder") + candidate_domain_cond = self._domain_condition("candidatePrincipal") + member_domain_cond = self._domain_condition("memberPrincipal") + site_cond = self._site_domain_condition("site") + impact_site_cond = self._site_domain_condition("impactSite") + + rows = self.query(f""" + MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site) + WHERE true{site_cond} + MATCH (sccmServer:MSSQL_Server {{name: sccmDb.SQLServer}}) + WITH collect(DISTINCT sccmServer) AS sccmServers, + collect(DISTINCT sccmDb) AS sccmDbs + + MATCH (target) + WHERE + (target:MSSQL_ServerRole + AND toUpper(target.name) IN $_high_value_server_roles + AND ANY(srv IN sccmServers WHERE target.SQLServer = srv.name)) + OR (target:MSSQL_DatabaseRole + AND toLower(split(target.name, '@')[0]) IN $_high_value_db_roles + AND EXISTS {{ + MATCH (db:MSSQL_Database)-[:MSSQL_Contains]->(target) + WHERE db IN sccmDbs + }}) + OR (target:MSSQL_DatabaseUser + AND target.explicitPermissions IS NOT NULL + AND ANY(perm IN target.explicitPermissions + WHERE toUpper(perm) IN $_high_value_db_perms) + AND EXISTS {{ + MATCH (db:MSSQL_Database)-[:MSSQL_Contains]->(target) + WHERE db IN sccmDbs + }}) + + MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login) + WHERE holder:User OR holder:Computer OR holder:Group + + WITH holder, startLogin, target, + {sql_login_report_holder_expr()} AS reportHolder + {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)} + MATCH p = shortestPath((startLogin)-[rels*1..6]->(target)) + WHERE ALL(rel IN rels WHERE type(rel) IN $_sccm_path_edges) + + OPTIONAL MATCH (db:MSSQL_Database)-[:MSSQL_Contains]->(target) + OPTIONAL MATCH (impactDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(impactSite:SCCM_Site) + WHERE true{impact_site_cond} + AND ( + (target:MSSQL_ServerRole AND impactDb.SQLServer = target.SQLServer) + OR ((target:MSSQL_DatabaseRole OR target:MSSQL_DatabaseUser) + AND EXISTS {{ MATCH (impactDb)-[:MSSQL_Contains]->(target) }}) + ) + OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder) + WHERE holder:Group AND NOT reportHolder + AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond} + WITH CASE + WHEN reportHolder OR holder:User OR holder:Computer THEN holder + ELSE memberPrincipal + END AS principal, + holder, startLogin, target, db, impactDb, impactSite, p, rels, reportHolder, memberPath + WHERE principal IS NOT NULL + AND (reportHolder OR principal:User OR principal:Computer){domain_cond} + AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true) + + RETURN DISTINCT + CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END AS principalSid, + CASE WHEN reportHolder THEN holder.name ELSE principal.name END AS principalName, + CASE + WHEN reportHolder THEN 'Group' + WHEN principal:Computer THEN 'Computer' + ELSE 'User' + END AS principalType, + target.name AS targetName, + CASE + WHEN target:MSSQL_ServerRole THEN 'MSSQL_ServerRole' + WHEN target:MSSQL_DatabaseRole THEN 'MSSQL_DatabaseRole' + WHEN target:MSSQL_DatabaseUser THEN 'MSSQL_DatabaseUser' + WHEN target:MSSQL_Base THEN 'MSSQL' + WHEN target:SCCM_Base THEN 'SCCM' + ELSE coalesce({primary_label_expr('target')}, 'Unknown') + END AS targetType, + db.name AS databaseName, + target.SQLServer AS serverName, + CASE + WHEN reportHolder THEN ['MSSQL_HasLogin'] + [rel IN rels | type(rel)] + WHEN holder:Group AND memberPath IS NOT NULL + THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin'] + [rel IN rels | type(rel)] + ELSE [rel IN rels | type(rel)] + END AS pathEdges, + CASE + WHEN reportHolder THEN length(p) + 1 + WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + length(p) + 1 + ELSE length(p) + END AS pathLength, + CASE + WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0]] + {self._path_node_names_expr('nodes(p)')} + WHEN holder:Group AND memberPath IS NOT NULL + THEN {self._path_node_names_expr('nodes(memberPath)')} + {self._path_node_names_expr('nodes(p)')} + ELSE {self._path_node_names_expr('nodes(p)')} + END AS pathNodeNames, + impactDb.name AS sccmImpactDb, + impactSite.siteCode AS sccmImpactSite + """, parameters={ + "_sccm_path_edges": _SCCM_PATH_EDGES, + "_high_value_server_roles": _HIGH_VALUE_SERVER_ROLES, + "_high_value_db_roles": _HIGH_VALUE_DB_ROLES, + "_high_value_db_perms": _HIGH_VALUE_DB_PERMS, + **sql_login_holder_parameters(), + }, name="sccm_sql_target_paths") + return self._path_rows(rows) + + def _fetch_server_control_paths(self): + domain_cond = self._domain_condition("principal") + holder_domain_cond = self._domain_condition("holder") + candidate_domain_cond = self._domain_condition("candidatePrincipal") + member_domain_cond = self._domain_condition("memberPrincipal") + site_cond = self._site_domain_condition("site") + + rows = self.query(f""" + MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login) + WHERE holder:User OR holder:Computer OR holder:Group + + WITH holder, startLogin, + {sql_login_report_holder_expr()} AS reportHolder + {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)} + MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site) + WHERE true{site_cond} + MATCH (sccmServer:MSSQL_Server {{name: sccmDb.SQLServer}}) + + MATCH (predecessor)-[controlRel]->(sccmServer) + WHERE type(controlRel) IN $_server_control_edges + + WITH holder, startLogin, sccmServer, sccmDb, site, predecessor, controlRel, reportHolder + MATCH p = shortestPath((startLogin)-[rels*0..5]->(predecessor)) + WHERE ALL(rel IN rels WHERE type(rel) IN $_sccm_path_edges) + + OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder) + WHERE holder:Group AND NOT reportHolder + AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond} + WITH CASE + WHEN reportHolder OR holder:User OR holder:Computer THEN holder + ELSE memberPrincipal + END AS principal, + holder, sccmServer, sccmDb, site, p, rels, controlRel, reportHolder, memberPath + WHERE principal IS NOT NULL + AND (reportHolder OR principal:User OR principal:Computer){domain_cond} + AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true) + + WITH principal, holder, sccmServer, sccmDb, site, p, rels, controlRel, reportHolder, memberPath, + CASE + WHEN reportHolder THEN ['MSSQL_HasLogin'] + [rel IN rels | type(rel)] + [type(controlRel)] + WHEN holder:Group AND memberPath IS NOT NULL + THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin'] + [rel IN rels | type(rel)] + [type(controlRel)] + ELSE [rel IN rels | type(rel)] + [type(controlRel)] + END AS pathEdges, + CASE + WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0]] + {self._path_node_names_expr('nodes(p) + [sccmServer]')} + WHEN holder:Group AND memberPath IS NOT NULL + THEN {self._path_node_names_expr('nodes(memberPath)')} + {self._path_node_names_expr('nodes(p) + [sccmServer]')} + ELSE {self._path_node_names_expr('nodes(p) + [sccmServer]')} + END AS pathNodeNames + + RETURN DISTINCT + CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END AS principalSid, + CASE WHEN reportHolder THEN holder.name ELSE principal.name END AS principalName, + CASE + WHEN reportHolder THEN 'Group' + WHEN principal:Computer THEN 'Computer' + ELSE 'User' + END AS principalType, + sccmServer.name AS targetName, + 'MSSQL_Server' AS targetType, + sccmDb.name AS databaseName, + sccmServer.name AS serverName, + pathEdges, + CASE + WHEN reportHolder THEN length(p) + 2 + WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + length(p) + 2 + ELSE length(p) + 1 + END AS pathLength, + pathNodeNames, + sccmDb.name AS sccmImpactDb, + site.siteCode AS sccmImpactSite + """, parameters={ + "_sccm_path_edges": _SCCM_PATH_EDGES, + "_server_control_edges": _SERVER_CONTROL_EDGES, + **sql_login_holder_parameters(), + }, name="sccm_server_control_paths") + return self._path_rows(rows) + + def _fetch_linked_sccm_paths(self): + domain_cond = self._domain_condition("principal") + holder_domain_cond = self._domain_condition("holder") + candidate_domain_cond = self._domain_condition("candidatePrincipal") + member_domain_cond = self._domain_condition("memberPrincipal") + site_cond = self._site_domain_condition("site") + + rows = self.query(f""" + MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login) + WHERE holder:User OR holder:Computer OR holder:Group + + WITH holder, startLogin, + {sql_login_report_holder_expr()} AS reportHolder + {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)} + MATCH (startLogin)-[:MSSQL_Connect]->(srvA:MSSQL_Server) + MATCH (stub:MSSQL_Base)-[:MSSQL_LinkedAsAdmin]->(srvB_stub:MSSQL_Server) + WHERE toLower(stub.name) ENDS WITH toLower(':' + srvA.sqlServerName) + OR (stub.objectid IS NOT NULL AND srvA.objectid IS NOT NULL + AND stub.objectid CONTAINS ':' AND srvA.objectid CONTAINS ':' + AND split(stub.objectid, ':')[0] = split(srvA.objectid, ':')[0] + AND {host_sid_resolves_to_single_server("split(srvA.objectid, ':')[0]")}) + + MATCH (srvB:MSSQL_Server) + WHERE {linked_server_target_resolution('srvB', 'srvB_stub')} + MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site) + WHERE sccmDb.SQLServer = srvB.name{site_cond} + + MATCH (target) + WHERE + (target:MSSQL_ServerRole + AND toUpper(target.name) IN $_high_value_server_roles + AND EXISTS {{ MATCH (srvB)-[:MSSQL_Contains]->(target) }}) + OR (target:MSSQL_DatabaseRole + AND toLower(split(target.name, '@')[0]) IN $_high_value_db_roles + AND EXISTS {{ MATCH (sccmDb)-[:MSSQL_Contains]->(target) }}) + OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder) + WHERE holder:Group AND NOT reportHolder + AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond} + WITH CASE + WHEN reportHolder OR holder:User OR holder:Computer THEN holder + ELSE memberPrincipal + END AS principal, + holder, startLogin, reportHolder, srvA, srvB, sccmDb, site, target, memberPath + WHERE principal IS NOT NULL + AND (reportHolder OR principal:User OR principal:Computer){domain_cond} + AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true) + + RETURN DISTINCT + CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END AS principalSid, + CASE WHEN reportHolder THEN holder.name ELSE principal.name END AS principalName, + CASE + WHEN reportHolder THEN 'Group' + WHEN principal:Computer THEN 'Computer' + ELSE 'User' + END AS principalType, + target.name AS targetName, + CASE + WHEN target:MSSQL_ServerRole THEN 'MSSQL_ServerRole' + WHEN target:MSSQL_DatabaseRole THEN 'MSSQL_DatabaseRole' + ELSE 'MSSQL' + END AS targetType, + CASE WHEN target:MSSQL_DatabaseRole THEN sccmDb.name ELSE null END AS databaseName, + srvB.name AS serverName, + CASE + WHEN target:MSSQL_DatabaseRole + THEN CASE + WHEN reportHolder THEN ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains', 'MSSQL_Contains'] + WHEN holder:Group AND memberPath IS NOT NULL + THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains', 'MSSQL_Contains'] + ELSE ['MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains', 'MSSQL_Contains'] + END + ELSE CASE + WHEN reportHolder THEN ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains'] + WHEN holder:Group AND memberPath IS NOT NULL + THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin', 'MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains'] + ELSE ['MSSQL_Connect', 'MSSQL_LinkedAsAdmin', 'MSSQL_Contains'] + END + END AS pathEdges, + CASE + WHEN target:MSSQL_DatabaseRole THEN CASE + WHEN reportHolder THEN 5 + WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + 5 + ELSE 4 + END + ELSE CASE + WHEN reportHolder THEN 4 + WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + 4 + ELSE 3 + END + END AS pathLength, + CASE + WHEN target:MSSQL_DatabaseRole + THEN CASE + WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0], startLogin.name, srvA.name, srvB.name, 'MSSQL_Database(' + coalesce(sccmDb.name, 'Unknown') + ')', target.name] + WHEN holder:Group AND memberPath IS NOT NULL + THEN {self._path_node_names_expr('nodes(memberPath)')} + [startLogin.name, srvA.name, srvB.name, 'MSSQL_Database(' + coalesce(sccmDb.name, 'Unknown') + ')', target.name] + ELSE [startLogin.name, srvA.name, srvB.name, 'MSSQL_Database(' + coalesce(sccmDb.name, 'Unknown') + ')', target.name] + END + ELSE CASE + WHEN reportHolder THEN [split(coalesce(holder.name, ''), '@')[0], startLogin.name, srvA.name, srvB.name, target.name] + WHEN holder:Group AND memberPath IS NOT NULL + THEN {self._path_node_names_expr('nodes(memberPath)')} + [startLogin.name, srvA.name, srvB.name, target.name] + ELSE [startLogin.name, srvA.name, srvB.name, target.name] + END + END AS pathNodeNames, + sccmDb.name AS sccmImpactDb, + site.siteCode AS sccmImpactSite + """, parameters={ + "_high_value_server_roles": _HIGH_VALUE_SERVER_ROLES, + "_high_value_db_roles": _HIGH_VALUE_DB_ROLES, + **sql_login_holder_parameters(), + }, name="sccm_linked_server_paths") + return self._path_rows(rows) + + def _fetch_linked_execute_host_sccm_paths(self): + domain_cond = self._domain_condition("principal") + holder_domain_cond = self._domain_condition("holder") + candidate_domain_cond = self._domain_condition("candidatePrincipal") + member_domain_cond = self._domain_condition("memberPrincipal") + site_cond = self._site_domain_condition("site") + + rows = self.query(f""" + MATCH (holder)-[:MSSQL_HasLogin]->(startLogin:MSSQL_Login) + WHERE holder:User OR holder:Computer OR holder:Group + + WITH holder, startLogin, + {sql_login_report_holder_expr()} AS reportHolder + {sql_login_report_holder_filter(holder_domain_cond, candidate_domain_cond)} + + MATCH pre = (startLogin)-[preR*1..5]->(src:MSSQL_Server) + WHERE ALL(rel IN preR WHERE type(rel) IN $_linked_pre_edges) + AND type(last(preR)) IN $_server_reach_edges + AND all(node IN nodes(pre) WHERE single(seen IN nodes(pre) WHERE seen = node)) + + CALL {{ + WITH src + MATCH (src)-[link:MSSQL_LinkedAsAdmin|MSSQL_LinkedTo]->(linked:MSSQL_Server) + RETURN link, linked + UNION + WITH src + MATCH (stub:MSSQL_Base)-[link:MSSQL_LinkedAsAdmin|MSSQL_LinkedTo]->(linkedStub:MSSQL_Server) + WHERE stub.objectid IS NOT NULL + AND src.objectid IS NOT NULL + AND stub.objectid CONTAINS ':' + AND src.objectid CONTAINS ':' + AND split(stub.objectid, ':')[0] = split(src.objectid, ':')[0] + AND {host_sid_resolves_to_single_server("split(src.objectid, ':')[0]")} + MATCH (linked:MSSQL_Server) + WHERE linked = linkedStub + OR {linked_server_target_resolution('linked', 'linkedStub')} + RETURN link, linked + }} + WITH holder, startLogin, reportHolder, pre, preR, src, link, linked + WHERE ( + type(link) = 'MSSQL_LinkedAsAdmin' + OR coalesce(link.remoteIsSysadmin, false) = true + OR coalesce(link.remoteIsSecurityAdmin, false) = true + OR coalesce(link.remoteHasControlServer, false) = true + OR coalesce(link.remoteHasImpersonateAnyLogin, false) = true + ) + AND coalesce(link.rpcOut, true) = true + + MATCH post = (linked)-[:MSSQL_ExecuteOnHost]->(host:Computer) + -[:SCCM_AssignAllPermissions]->(site:SCCM_Site) + WHERE coalesce(host.enabled, true) = true + AND true{site_cond} + + WITH holder, startLogin, reportHolder, pre, preR, src, link, linked, post, host, site, + CASE + WHEN any(rel IN preR WHERE type(rel) IN $_assume_role_edges) THEN 0 + ELSE 1 + END AS assumeRank, + CASE WHEN type(link) = 'MSSQL_LinkedAsAdmin' THEN 0 ELSE 1 END AS linkRank, + CASE + WHEN any(rel IN preR WHERE type(rel) = 'MSSQL_ExecuteAs') THEN 0 + ELSE 1 + END AS executeAsRank + ORDER BY assumeRank, length(pre), linkRank, executeAsRank + WITH holder, startLogin, reportHolder, src, linked, host, site, + collect({{pre: pre, link: link, post: post}})[0] AS selected + WITH holder, startLogin, reportHolder, src, linked, host, site, + selected.pre AS pre, + selected.link AS link, + selected.post AS post + + OPTIONAL MATCH (serviceAccount)-[:MSSQL_ServiceAccountFor]->(linked) + WITH holder, startLogin, reportHolder, src, linked, host, site, pre, link, post, + coalesce(serviceAccount.name, serviceAccount.objectid) AS serviceAccountName + ORDER BY serviceAccountName + WITH holder, startLogin, reportHolder, src, linked, host, site, pre, link, post, + [name IN collect(DISTINCT serviceAccountName) WHERE name IS NOT NULL] AS serviceAccountNames + + OPTIONAL MATCH memberPath = (memberPrincipal)-[:MemberOf*1..6]->(holder) + WHERE holder:Group AND NOT reportHolder + AND (memberPrincipal:User OR memberPrincipal:Computer){member_domain_cond} + WITH CASE + WHEN reportHolder OR holder:User OR holder:Computer THEN holder + ELSE memberPrincipal + END AS principal, + holder, pre, link, linked, post, host, site, serviceAccountNames, + reportHolder, memberPath + WHERE principal IS NOT NULL + AND (reportHolder OR principal:User OR principal:Computer){domain_cond} + AND (NOT principal:Computer OR coalesce(principal.enabled, true) = true) + + WITH principal, holder, link, linked, pre, post, host, site, + CASE + WHEN size(serviceAccountNames) > 0 THEN + 'as ' + reduce( + text = '', + name IN serviceAccountNames | + text + CASE WHEN text = '' THEN '' ELSE ', ' END + name + ) + ELSE null + END AS executeOnHostContext, + reportHolder, memberPath + + WITH principal, holder, link, linked, pre, post, host, site, + executeOnHostContext, reportHolder, memberPath, + [rel IN relationships(pre) | type(rel)] + [type(link)] + + [rel IN relationships(post) | type(rel)] AS coreEdges, + [rel IN relationships(pre) | null] + [null] + + [rel IN relationships(post) | + CASE + WHEN type(rel) = 'MSSQL_ExecuteOnHost' THEN executeOnHostContext + ELSE null + END] AS coreEdgeContexts, + {self._path_node_names_expr('nodes(pre)')} + + [coalesce(linked.name, linked.objectid, 'Unknown')] + + {self._path_node_names_expr('tail(nodes(post))')} AS coreNodes + + RETURN DISTINCT + CASE WHEN reportHolder THEN holder.objectid ELSE principal.objectid END AS principalSid, + CASE WHEN reportHolder THEN holder.name ELSE principal.name END AS principalName, + CASE + WHEN reportHolder THEN 'Group' + WHEN principal:Computer THEN 'Computer' + ELSE 'User' + END AS principalType, + site.siteCode AS targetName, + 'SCCM_Site' AS targetType, + null AS databaseName, + linked.name AS serverName, + CASE + WHEN holder:Group AND memberPath IS NOT NULL + THEN [rel IN relationships(memberPath) | type(rel)] + ['MSSQL_HasLogin'] + coreEdges + ELSE ['MSSQL_HasLogin'] + coreEdges + END AS pathEdges, + CASE + WHEN holder:Group AND memberPath IS NOT NULL + THEN [rel IN relationships(memberPath) | null] + [null] + coreEdgeContexts + ELSE [null] + coreEdgeContexts + END AS pathEdgeContexts, + CASE + WHEN holder:Group AND memberPath IS NOT NULL THEN length(memberPath) + size(coreEdges) + 1 + ELSE size(coreEdges) + 1 + END AS pathLength, + CASE + WHEN holder:Group AND memberPath IS NOT NULL + THEN {self._path_node_names_expr('nodes(memberPath)')} + coreNodes + ELSE [split(coalesce(holder.name, ''), '@')[0]] + coreNodes + END AS pathNodeNames, + null AS sccmImpactDb, + site.siteCode AS sccmImpactSite + """, parameters={ + "_linked_pre_edges": _LINKED_PRE_EDGES, + "_server_reach_edges": _SERVER_REACH_EDGES, + "_assume_role_edges": _ASSUME_ROLE_EDGES, + **sql_login_holder_parameters(), + }, name="sccm_linked_execute_host_paths") + return self._path_rows(rows) + + def _fetch_coercion_paths(self): + source_domain_cond = self._domain_condition("source") + site_cond = self._site_domain_condition("site") + + rows = self.query(f""" + MATCH (source)-[relay]->(login:MSSQL_Login) + WHERE type(relay) IN ['CoerceAndRelayToMSSQL', 'MSSQL_CoerceAndRelayToMSSQL'] + AND ( + ((source:User OR source:Computer OR source:Group){source_domain_cond}) + OR NOT (source:User OR source:Computer OR source:Group) + ) + AND (NOT source:Computer OR coalesce(source.enabled, true) = true) + + WITH source, relay, login, toLower(split(login.SQLServer, ':')[0]) AS loginHost + MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site) + WHERE toLower(split(sccmDb.SQLServer, ':')[0]) = loginHost{site_cond} + + RETURN DISTINCT + CASE + WHEN source:Group THEN 'Group' + WHEN source:Computer THEN 'Computer' + WHEN source:User THEN 'User' + WHEN source:MSSQL_Base THEN 'MSSQL' + WHEN source:SCCM_Base THEN 'SCCM' + ELSE coalesce({primary_label_expr('source')}, 'Unknown') + END AS sourceType, + coalesce(source.name, source.objectid) AS sourceName, + source.objectid AS sourceSid, + login.name AS loginName, + login.SQLServer AS sqlServer, + sccmDb.name AS sccmImpactDb, + site.siteCode AS sccmImpactSite, + type(relay) AS relayType + """, name="sccm_coercion_paths") + + paths = [] + for row in rows: + paths.append({ + 'attack_type': 'coercion', + 'principal_sid': row.get('sourceSid'), + 'principal_name': row.get('sourceName') or '', + 'principal_type': row.get('sourceType') or '', + 'target_name': row.get('loginName') or '', + 'target_type': 'MSSQL_Login', + 'database_name': row.get('sccmImpactDb') or '', + 'server_name': row.get('sqlServer') or '', + 'path_edges': [row.get('relayType') or 'CoerceAndRelayToMSSQL'], + 'path_length': 1, + 'sccm_impact_db': row.get('sccmImpactDb') or '', + 'sccm_impact_site': row.get('sccmImpactSite') or '', + }) + + return self._prefer_ad_coercion_sources(paths) + + def _fetch_service_account_paths(self): + site_cond = self._site_domain_condition("site") + service_account_domain_cond = self._domain_condition("serviceAccount") + + rows = self.query(f""" + MATCH (sccmDb:MSSQL_Database)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site) + WHERE true{site_cond} + + WITH DISTINCT sccmDb.SQLServer AS sqlServer, + collect(DISTINCT sccmDb.name) AS sccmDatabases, + collect(DISTINCT site.siteCode) AS sccmSites + + MATCH (serviceAccount)-[:MSSQL_ServiceAccountFor]->(:MSSQL_Server {{name: sqlServer}}) + WHERE ( + ((serviceAccount:User OR serviceAccount:Computer OR serviceAccount:Group){service_account_domain_cond}) + OR NOT (serviceAccount:User OR serviceAccount:Computer OR serviceAccount:Group) + ) + AND (NOT serviceAccount:Computer OR coalesce(serviceAccount.enabled, true) = true) + + RETURN DISTINCT + CASE + WHEN serviceAccount:Group THEN 'Group' + WHEN serviceAccount:Computer THEN 'Computer' + WHEN serviceAccount:User THEN 'User' + WHEN serviceAccount:MSSQL_Base THEN 'MSSQL' + WHEN serviceAccount:SCCM_Base THEN 'SCCM' + ELSE coalesce({primary_label_expr('serviceAccount')}, 'Unknown') + END AS serviceAccountType, + serviceAccount.name AS serviceAccountName, + serviceAccount.objectid AS serviceAccountSid, + sqlServer, + sccmDatabases, + sccmSites + """, name="sccm_service_account_paths") + + paths = [] + for row in rows: + databases = row.get('sccmDatabases') or [] + sites = row.get('sccmSites') or [] + paths.append({ + 'attack_type': 'service_account', + 'principal_sid': row.get('serviceAccountSid'), + 'principal_name': row.get('serviceAccountName') or '', + 'principal_type': row.get('serviceAccountType') or '', + 'target_name': 'SQL Server Service Account', + 'target_type': 'MSSQL_Server', + 'database_name': ', '.join(databases), + 'server_name': row.get('sqlServer') or '', + 'path_edges': ['MSSQL_ServiceAccountFor'], + 'path_length': 1, + 'sccm_impact_db': databases[0] if databases else '', + 'sccm_impact_site': sites[0] if sites else '', + }) + return paths + + def _path_rows(self, rows): + paths = [] + for row in rows: + paths.append({ + 'attack_type': 'privilege_escalation', + 'principal_sid': row.get('principalSid'), + 'principal_name': row.get('principalName') or '', + 'principal_type': row.get('principalType') or '', + 'target_name': row.get('targetName') or '', + 'target_type': row.get('targetType') or '', + 'database_name': row.get('databaseName') or '', + 'server_name': row.get('serverName') or '', + 'path_edges': row.get('pathEdges') or [], + 'path_edge_contexts': row.get('pathEdgeContexts') or [], + 'path_length': row.get('pathLength') or 0, + 'path_node_names': row.get('pathNodeNames') or [], + 'sccm_impact_db': row.get('sccmImpactDb') or '', + 'sccm_impact_site': row.get('sccmImpactSite') or '', + }) + return paths + + def _prefer_ad_coercion_sources(self, paths): + grouped = {} + for path in paths: + key = ( + self._normalize_host(path.get('server_name') or ''), + path.get('sccm_impact_site') or '', + ) + grouped.setdefault(key, []).append(path) + + kept = [] + for group in grouped.values(): + ad_paths = [ + path for path in group + if path.get('principal_type') in ('User', 'Computer', 'Group') + ] + if not ad_paths: + kept.extend(group) + continue + + ad_names = { + self._clean_name(path.get('principal_name') or '').lower() + for path in ad_paths + } + kept.extend( + path for path in group + if path.get('principal_type') in ('User', 'Computer', 'Group') + or self._clean_name(path.get('principal_name') or '').lower() not in ad_names + ) + return kept + + def _format_principal(self, paths): + seen = set() + deduped = [] + + for path in paths: + dedup = ( + path.get('attack_type', ''), + path.get('target_name', ''), + path.get('server_name', ''), + tuple(path.get('path_edges', [])), + path.get('sccm_impact_site', ''), + ) + if dedup in seen: + continue + seen.add(dedup) + deduped.append(path) + + deduped = self._suppress_subordinate_sql_roles(deduped) + deduped.sort(key=lambda path: ( + 0 if path.get('attack_type') == 'privilege_escalation' else + 1 if path.get('attack_type') == 'coercion' else 2, + path.get('path_length', 0), + )) + + lines = [] + for path in deduped: + line = self._format_path_line(path) + if line: + lines.append(line) + return lines + + def _suppress_subordinate_sql_roles(self, paths): + role_names_by_scope = {} + for path in paths: + if path.get('target_type') not in ('MSSQL_ServerRole', 'MSSQL_DatabaseRole'): + continue + key = ( + path.get('target_type'), + path.get('server_name') or '', + path.get('database_name') or '', + path.get('sccm_impact_site') or '', + ) + role_names_by_scope.setdefault(key, set()).add( + self._role_key(path.get('target_name') or '') + ) + + filtered = [] + for path in paths: + target_type = path.get('target_type') + target_name = self._role_key(path.get('target_name') or '') + key = ( + target_type, + path.get('server_name') or '', + path.get('database_name') or '', + path.get('sccm_impact_site') or '', + ) + scope_roles = role_names_by_scope.get(key, set()) + if target_type == 'MSSQL_ServerRole' and 'SYSADMIN' in scope_roles: + if target_name in _SYSADMIN_SUBORDINATES: + continue + if target_type == 'MSSQL_DatabaseRole' and 'DB_OWNER' in scope_roles: + if target_name in _DB_OWNER_SUBORDINATES: + continue + filtered.append(path) + return filtered + + def _role_key(self, name): + return (name or '').split('@')[0].upper() + + def _format_path_line(self, path): + attack_type = path.get('attack_type', '') + server = (path.get('server_name') or '').split(':')[0] + server_display = server.lower() if server else 'unknown' + + if attack_type == 'privilege_escalation': + chain = self._build_chain(path) + return self._append_sccm_scope(chain, path) + + if attack_type == 'coercion': + target_login = self._clean_name(path.get('target_name', '') or 'unknown login') + principal_name = self._clean_name(path.get('principal_name', '') or 'principal') + edge = (path.get('path_edges') or ['CoerceAndRelayToMSSQL'])[0] + return self._append_sccm_scope( + f"{principal_name} -> {edge} -> {target_login}", + path, + ) + + if attack_type == 'service_account': + principal_name = self._clean_name(path.get('principal_name', '') or 'principal') + return self._append_sccm_scope( + f"{principal_name} -> MSSQL_ServiceAccountFor -> {server_display}", + path, + ) + + return "" + + def _build_chain(self, path): + edges = path.get('path_edges', []) + edge_contexts = path.get('path_edge_contexts', []) + nodes = path.get('path_node_names', []) + + if not edges or not nodes: + return "" + + parts = [self._clean_name(nodes[0])] + for i, edge in enumerate(edges): + next_name = self._clean_name(nodes[i + 1]) if i + 1 < len(nodes) else "?" + context = edge_contexts[i] if i < len(edge_contexts) else None + parts.append(self._format_edge(edge, context)) + parts.append(next_name) + + return " -> ".join(parts) + + def _format_edge(self, edge, context=None): + if context: + return f"{edge} ({context})" + return edge + + def _append_sccm_scope(self, chain, path): + scope = self._sccm_scope_chain(path) + if chain and scope: + return f"{chain} | {scope}" + return chain or scope + + def _sccm_scope_chain(self, path): + target_type = path.get('target_type', '') + if target_type in ('SCCM_Site', 'SCCM_AdminUser'): + return '' + + site = path.get('sccm_impact_site') or '' + if not site: + return '' + + db = path.get('sccm_impact_db') or '' + if db: + return f"MSSQL_Database({db}) -> SCCM_AssignAllPermissions -> SCCM_Site({site})" + return f"SCCM_AssignAllPermissions -> SCCM_Site({site})" + + def _path_node_names_expr(self, nodes_expr): + return f"""[node IN {nodes_expr} | CASE + WHEN node:MSSQL_Database + THEN 'MSSQL_Database(' + coalesce(node.name, node.objectid, 'Unknown') + ')' + WHEN node:SCCM_Site + THEN 'SCCM_Site(' + coalesce(node.siteCode, node.displayName, node.objectid, 'Unknown') + ')' + ELSE coalesce( + node.name, + node.siteCode, + node.displayName, + {primary_label_expr('node')}, + 'Unknown' + ) + END]""" + + def _normalize_host(self, name): + return (name or '').split(':')[0].lower() + + def _clean_name(self, name): + if not name: + return "?" + if '\\' in name: + name = name.split('\\')[-1] + if '@' in name: + base, suffix = name.rsplit('@', 1) + if '.' in suffix: + name = base + if ':' in name: + name = name.split(':')[0] + return name diff --git a/docs/demo.tape b/docs/demo.tape index dd21735..1a41124 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -1,4 +1,4 @@ -Output docs/images/demo.mp4 +Output docs/images/demo-slideshow.mp4 Set FontSize 18 Set Width 900 diff --git a/docs/images/ad-report-slideshow.mp4 b/docs/images/ad-report-slideshow.mp4 new file mode 100644 index 0000000..e53927e Binary files /dev/null and b/docs/images/ad-report-slideshow.mp4 differ diff --git a/docs/images/ad-report-slideshow.webp b/docs/images/ad-report-slideshow.webp new file mode 100644 index 0000000..0877448 Binary files /dev/null and b/docs/images/ad-report-slideshow.webp differ diff --git a/docs/images/adpathfinder-blog-hero-v3.webp b/docs/images/adpathfinder-blog-hero-v3.webp new file mode 100644 index 0000000..dd6088d Binary files /dev/null and b/docs/images/adpathfinder-blog-hero-v3.webp differ diff --git a/docs/images/adpathfinder-opengraph-chain.svg b/docs/images/adpathfinder-opengraph-chain.svg new file mode 100644 index 0000000..2e4e7e1 --- /dev/null +++ b/docs/images/adpathfinder-opengraph-chain.svg @@ -0,0 +1,145 @@ + + ADPathFinder collector-spanning MSSQL to SCCM path + A diagram showing J.REPORTER from SharpHound joined to an MSSQL login, assuming ReportSvc, crossing a linked SQL server, executing on the SCCM SQL host as DA, and reaching SCCM Site TRN. + + + + + + + + + + + + + + + + + + + + + + + + + + + One path, three collectors + A single escalation path assembled from AD identity, SQL privilege, host execution, and SCCM site-control edges. + + + + Collector contributions + + + SharpHound + AD users, computers, SIDs + + + MSSQLHound + SQL login, impersonation, link and host-exec evidence + + + ConfigManBearPig + SCCM site permission edges + + + + + + + J.REPORTER + Domain User + + + j.reporter + MSSQL_Login + + + ReportSvc + Assumed login + + + lab-sql01 + Source SQL + + + sccmdb + Linked SQL + + + + + + + + MSSQL_HasLogin + + MSSQL_ExecuteAs + + MSSQL_Connect + + MSSQL_LinkedAsAdmin + + + + + + + Runs as DA@TRAINING.LOCAL + sccmdb's SQL service account + + + + MSSQL_ExecuteOnHost + + + SCCMDB.TRAINING.LOCAL + Computer from SharpHound + command execution target + + + SCCM_Site(TRN) + High-value SCCM target + + + + SCCM_AssignAllPermissions + SCCM admin rights on the site + + diff --git a/docs/images/client-report-thumb.png b/docs/images/client-report-thumb.png deleted file mode 100644 index 77457f4..0000000 Binary files a/docs/images/client-report-thumb.png and /dev/null differ diff --git a/docs/images/client-report.mp4 b/docs/images/client-report.mp4 deleted file mode 100644 index f8e5b8d..0000000 Binary files a/docs/images/client-report.mp4 and /dev/null differ diff --git a/docs/images/client-report.webp b/docs/images/client-report.webp deleted file mode 100644 index 02e2eb7..0000000 Binary files a/docs/images/client-report.webp and /dev/null differ diff --git a/docs/images/demo-slideshow.mp4 b/docs/images/demo-slideshow.mp4 new file mode 100644 index 0000000..bacb43a Binary files /dev/null and b/docs/images/demo-slideshow.mp4 differ diff --git a/docs/images/demo-slideshow.webp b/docs/images/demo-slideshow.webp new file mode 100644 index 0000000..486a307 Binary files /dev/null and b/docs/images/demo-slideshow.webp differ diff --git a/docs/images/demo-thumb.png b/docs/images/demo-thumb.png deleted file mode 100644 index 644dfa3..0000000 Binary files a/docs/images/demo-thumb.png and /dev/null differ diff --git a/docs/images/demo.mp4 b/docs/images/demo.mp4 deleted file mode 100644 index e6167a8..0000000 Binary files a/docs/images/demo.mp4 and /dev/null differ diff --git a/docs/images/demo.webp b/docs/images/demo.webp deleted file mode 100644 index 83015c6..0000000 Binary files a/docs/images/demo.webp and /dev/null differ diff --git a/docs/images/password-audit-slideshow.mp4 b/docs/images/password-audit-slideshow.mp4 new file mode 100644 index 0000000..9bc6020 Binary files /dev/null and b/docs/images/password-audit-slideshow.mp4 differ diff --git a/docs/images/password-audit-slideshow.webp b/docs/images/password-audit-slideshow.webp new file mode 100644 index 0000000..5eb6f69 Binary files /dev/null and b/docs/images/password-audit-slideshow.webp differ diff --git a/docs/images/password-audit-thumb.png b/docs/images/password-audit-thumb.png deleted file mode 100644 index 21173d1..0000000 Binary files a/docs/images/password-audit-thumb.png and /dev/null differ diff --git a/docs/images/password-audit.mp4 b/docs/images/password-audit.mp4 deleted file mode 100644 index 8edceab..0000000 Binary files a/docs/images/password-audit.mp4 and /dev/null differ diff --git a/docs/images/password-audit.webp b/docs/images/password-audit.webp deleted file mode 100644 index 2b9a2fd..0000000 Binary files a/docs/images/password-audit.webp and /dev/null differ diff --git a/modules/BloodhoundImporter.py b/modules/BloodhoundImporter.py index e3eabce..e9d815f 100644 --- a/modules/BloodhoundImporter.py +++ b/modules/BloodhoundImporter.py @@ -21,6 +21,10 @@ reserved_labels, safe_cypher_identifier, ) +from .mssql_post_import import ( + canonicalize_mssql_linked_server_edges, + dedupe_mssql_servers, +) _NEO4J_PROPERTY_SCALARS = (str, int, float, bool) @@ -71,11 +75,13 @@ def __init__(self, neo4j_conn, bloodhound_username: Optional[str] = None, bloodhound_password: Optional[str] = None, base_url: Optional[str] = None, ingestion_timeout_seconds: Optional[int] = None, - request_timeout_seconds: Optional[int] = None): + request_timeout_seconds: Optional[int] = None, + diagnostics=None): self.connection = neo4j_conn self.user = bloodhound_username self.pwd = bloodhound_password self.base_url = (base_url or "http://localhost:8080").rstrip('/') + self._diagnostics = diagnostics self.jwt = "" self.ingestion_timeout_seconds = self._resolve_ingestion_timeout( ingestion_timeout_seconds @@ -139,7 +145,7 @@ def import_zip(self, zip_paths) -> bool: parts.append(f"{seed_total} seed skipped") print(f"[*] Importing {len(entries)} files ({', '.join(parts)})") - self._upload_json_files(entries) + self._upload_json_files(entries, validate_raw=False) print("[+] Import completed successfully") return True @@ -172,6 +178,13 @@ def _authenticate(self) -> None: def _extract_zip(self, zip_path: Path, extract_dir: Path) -> List[Path]: try: with zipfile.ZipFile(zip_path, 'r') as zip_ref: + extract_root = extract_dir.resolve() + for member in zip_ref.namelist(): + dest = (extract_root / member).resolve() + if not dest.is_relative_to(extract_root): + raise Exception( + f"ZIP entry escapes extract directory: {member}" + ) zip_ref.extractall(extract_dir) except zipfile.BadZipFile: raise Exception("The provided file is not a valid ZIP archive.") @@ -330,14 +343,13 @@ def _filter_companion_properties( data['graph']['nodes'] = out_nodes return data - def _upload_json_files(self, entries: List[JsonEntry]) -> None: + def _upload_json_files(self, entries: List[JsonEntry], *, validate_raw: bool = True) -> None: # SharpHound goes through CE so its native schema and post-processing # remain authoritative. OpenGraph companion/custom collector files are # loaded directly below; repeated CE analysis passes can OOM small # local stacks and custom-kind uploads can stall with empty statuses. - # Raw validation runs before AD companion filtering so malformed - # property/metadata containers fail before upload or graph mutation. - self._validate_opengraph_entries(entries) + if validate_raw: + self._validate_opengraph_entries(entries) headers = { "User-Agent": "bh-automation", "Authorization": f"Bearer {self.jwt}", @@ -381,6 +393,9 @@ def _upload_json_files(self, entries: List[JsonEntry]) -> None: for label in sorted(stub_labels_used): self._merge_orphan_stubs(label) + self._dedupe_mssql_servers() + self._canonicalize_mssql_linked_server_edges() + # Evaluate contracts after orphan-stub merge so future requirement # shapes see the final post-import graph state rather than a temporary # collector-stub layout. @@ -534,16 +549,13 @@ def _validate_opengraph_payload( f"{source_kind!r} is reserved for SharpHound AD nodes" ) + skipped_empty_id: List[int] = [] for index, node in enumerate(nodes): if not isinstance(node, dict): raise ValueError( f"OpenGraph {source_name}: node {index} must be an object" ) context_id = node.get('id', f"index {index}") - if not node.get('id'): - raise ValueError( - f"OpenGraph {source_name}: node {index} missing required id" - ) if 'kinds' not in node or node.get('kinds') == []: labels = ['Base'] else: @@ -564,6 +576,64 @@ def _validate_opengraph_payload( 'node label', f"OpenGraph {source_name}: node {context_id!r} label", ) + if not node.get('id'): + skipped_empty_id.append(index) + + if skipped_empty_id: + dns_to_ids: Dict[str, Set[str]] = {} + skipped_empty_id_set = set(skipped_empty_id) + for i, n in enumerate(nodes): + if i in skipped_empty_id_set: + continue + nid = n.get('id', '') + dns = (n.get('properties') or {}).get('DNSHostName', '') + if nid and dns: + dns_to_ids.setdefault(str(dns).lower(), set()).add(str(nid)) + + ambiguous_dns = { + dns for dns, node_ids in dns_to_ids.items() + if len(node_ids) > 1 + } + dns_to_id = { + dns: next(iter(node_ids)) + for dns, node_ids in dns_to_ids.items() + if dns not in ambiguous_dns + } + + resolved = 0 + ambiguous = 0 + still_empty: List[int] = [] + for i in skipped_empty_id: + dns = (nodes[i].get('properties') or {}).get('DNSHostName', '') + dns_key = str(dns).lower() if dns else '' + if dns_key in ambiguous_dns: + ambiguous += 1 + still_empty.append(i) + continue + matched_id = dns_to_id.get(dns_key) if dns_key else None + if matched_id: + nodes[i]['id'] = matched_id + resolved += 1 + else: + still_empty.append(i) + + if resolved: + self._warn_import( + "opengraph_empty_id_resolved", + f"OpenGraph {source_name}: resolved {resolved} empty-id node(s) via DNSHostName" + ) + if ambiguous: + self._warn_import( + "opengraph_empty_id_ambiguous_dns", + f"OpenGraph {source_name}: skipped {ambiguous} empty-id node(s) with ambiguous DNSHostName" + ) + if still_empty: + skip_set = set(still_empty) + graph['nodes'] = [n for i, n in enumerate(nodes) if i not in skip_set] + self._warn_import( + "opengraph_empty_id_skipped", + f"OpenGraph {source_name}: skipped {len(still_empty)} node(s) with empty id" + ) for index, edge in enumerate(edges): if not isinstance(edge, dict): @@ -684,20 +754,15 @@ def _safe_opengraph_identifier(self, value: str, kind: str, context: str) -> str except ValueError as exc: raise ValueError(f"{context} has unsupported identifier {value!r}") from exc + def _warn_import(self, source: str, message: str) -> None: + print(f"[!] {message}") + if self._diagnostics: + self._diagnostics.record_warning(f"import:{source}", message) + def _stub_label_for_source_kind(self, data: dict) -> str: - # Defensive guard for direct callers that bypass preflight validation. - metadata = data.get('metadata', {}) - if not isinstance(metadata, dict): - raise ValueError("OpenGraph metadata must be an object") - source_kind = metadata.get('source_kind', '') - if 'source_kind' in metadata and not isinstance(source_kind, str): - raise ValueError("OpenGraph metadata.source_kind must be a string") + source_kind = data.get('metadata', {}).get('source_kind', '') if source_kind and source_kind.strip(): label = safe_cypher_identifier(source_kind, 'source_kind') - if label in reserved_labels(): - raise ValueError( - f"Unsupported source_kind reserved for AD nodes: {source_kind!r}" - ) return label return 'OpenGraph_Stub' @@ -993,6 +1058,12 @@ def _endpoint_match_clause( f"Unsupported OpenGraph endpoint match strategy: {endpoint.match_by!r}" ) + def _dedupe_mssql_servers(self) -> int: + return dedupe_mssql_servers(self.connection) + + def _canonicalize_mssql_linked_server_edges(self) -> int: + return canonicalize_mssql_linked_server_edges(self.connection) + def _merge_orphan_stubs(self, label: str) -> None: # don't copy the collector label onto the AD node — account_analysis would mis-classify it as a stub label = safe_cypher_identifier(label, 'orphan stub label') @@ -1021,8 +1092,10 @@ def _merge_orphan_stubs(self, label: str) -> None: WITH stub, old, target MATCH (real) WHERE real.objectid = stub.objectid AND (real:User OR real:Computer OR real:Group) - CREATE (real)-[new:`{rtype}`]->(target) - SET new = properties(old) + AND target <> real + MERGE (real)-[new:`{rtype}`]->(target) + WITH old, new, properties(old) AS old_props, properties(new) AS existing_props + SET new += old_props SET new += existing_props DELETE old """) self.connection.query(f""" @@ -1031,8 +1104,10 @@ def _merge_orphan_stubs(self, label: str) -> None: WITH source, old, stub MATCH (real) WHERE real.objectid = stub.objectid AND (real:User OR real:Computer OR real:Group) - CREATE (source)-[new:`{rtype}`]->(real) - SET new = properties(old) + AND source <> real + MERGE (source)-[new:`{rtype}`]->(real) + WITH old, new, properties(old) AS old_props, properties(new) AS existing_props + SET new += old_props SET new += existing_props DELETE old """) @@ -1163,45 +1238,34 @@ def _collector_identity( _REQUEST_TIMEOUT_ENV = "ADPF_BH_REQUEST_TIMEOUT_SECONDS" def _resolve_ingestion_timeout(self, timeout: Optional[int]) -> int: - raw_timeout = ( - os.environ.get(self._INGESTION_TIMEOUT_ENV) - if timeout is None - else timeout + return self._resolve_int_timeout( + timeout, + self._INGESTION_TIMEOUT_ENV, + self._INGESTION_TIMEOUT_SECONDS, ) - if raw_timeout is None: - raw_timeout = self._INGESTION_TIMEOUT_SECONDS - try: - resolved = int(raw_timeout) - except (TypeError, ValueError) as exc: - raise ValueError( - f"{self._INGESTION_TIMEOUT_ENV} must be a positive integer " - "number of seconds" - ) from exc - if resolved <= 0: - raise ValueError( - f"{self._INGESTION_TIMEOUT_ENV} must be a positive integer " - "number of seconds" - ) - return resolved def _resolve_request_timeout(self, timeout: Optional[int]) -> int: - raw_timeout = ( - os.environ.get(self._REQUEST_TIMEOUT_ENV) - if timeout is None - else timeout + return self._resolve_int_timeout( + timeout, + self._REQUEST_TIMEOUT_ENV, + self._REQUEST_TIMEOUT_SECONDS, ) + + @staticmethod + def _resolve_int_timeout(timeout: Optional[int], env_var: str, default: int) -> int: + raw_timeout = os.environ.get(env_var) if timeout is None else timeout if raw_timeout is None: - raw_timeout = self._REQUEST_TIMEOUT_SECONDS + raw_timeout = default try: resolved = int(raw_timeout) except (TypeError, ValueError) as exc: raise ValueError( - f"{self._REQUEST_TIMEOUT_ENV} must be a positive integer " + f"{env_var} must be a positive integer " "number of seconds" ) from exc if resolved <= 0: raise ValueError( - f"{self._REQUEST_TIMEOUT_ENV} must be a positive integer " + f"{env_var} must be a positive integer " "number of seconds" ) return resolved diff --git a/modules/diagnostics.py b/modules/diagnostics.py index 42d4750..d1b89b7 100644 --- a/modules/diagnostics.py +++ b/modules/diagnostics.py @@ -28,6 +28,7 @@ def __init__(self): self.cross_domain_checks = {} self.mssql_sccm = {} self.report_generation = {} + self.warnings = [] self.errors = [] self.args = {} self.domains = [] @@ -84,6 +85,16 @@ def record_error(self, source, error, domain=None): entry["domain"] = domain self.errors.append(entry) + def record_warning(self, source, warning, domain=None): + entry = { + "source": source, + "warning": str(warning), + "timestamp": datetime.now().isoformat() + } + if domain is not None: + entry["domain"] = domain + self.warnings.append(entry) + def record_escalation_batch(self, domain, batch_num, size, duration_ms, paths_found, success=True, error=None): if domain not in self.escalation_batches: @@ -173,6 +184,7 @@ def to_dict(self): "mssql_sccm": self.mssql_sccm, "check_ordering": self.check_order, "report_generation": self.report_generation, + "warnings": self.warnings, "errors": self.errors } diff --git a/modules/main.py b/modules/main.py index d46277c..4c74418 100644 --- a/modules/main.py +++ b/modules/main.py @@ -71,7 +71,7 @@ def _record_final_inventory(report_dir, domain_name, diagnostics): diagnostics.report_generation[domain_name] = report_files -def populate_neo4j_passwords(conn, neo4j_data, account_analysis, ntds_file_path, config, bloodhound_username=None, bloodhound_password=None, *, bloodhound_url): +def populate_neo4j_passwords(conn, neo4j_data, account_analysis, ntds_file_path, config, bloodhound_username=None, bloodhound_password=None, *, bloodhound_url, diagnostics=None): # Skip if >85% of cracked accounts already have passwords in Neo4j if not hasattr(account_analysis, 'cracked_accounts') or not account_analysis.cracked_accounts: return False @@ -104,7 +104,7 @@ def populate_neo4j_passwords(conn, neo4j_data, account_analysis, ntds_file_path, if not bloodhound_enabled or not bloodhound_username or not bloodhound_password: return False - importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=bloodhound_url) + importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=bloodhound_url, diagnostics=diagnostics) importer.neo4j_data = neo4j_data importer.cracked_accounts = account_analysis.cracked_accounts @@ -366,6 +366,17 @@ def main(): resolved = load_resolved_config(config_file) + diagnostics = None + if args.diagnostics: + from .diagnostics import DiagnosticsCollector, compute_audit_mode + diagnostics = DiagnosticsCollector() + diagnostics.args = { + "ad": args.ad, "pwd": args.pwd, + "ntds": args.ntds is not None, "potfile": args.potfile is not None, + "unsafe_report": args.unsafe_report, + "mode": compute_audit_mode(args.ad, args.pwd), + } + if args.pwd and not args.ntds: print("Error: Password audit requires NTDS data. Please provide it using the --ntds parameter.") sys.exit(1) @@ -417,7 +428,7 @@ def main(): sys.exit(1) bloodhound_enabled = True - importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=resolved.bh_url) + importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=resolved.bh_url, diagnostics=diagnostics) if args.delete: standalone_delete = not (args.import_file or args.ad or args.pwd) @@ -550,17 +561,6 @@ def main(): ntds_hashes = None ntds_file_path = None - diagnostics = None - if args.diagnostics: - from .diagnostics import DiagnosticsCollector, compute_audit_mode - diagnostics = DiagnosticsCollector() - diagnostics.args = { - "ad": args.ad, "pwd": args.pwd, - "ntds": args.ntds is not None, "potfile": args.potfile is not None, - "unsafe_report": args.unsafe_report, - "mode": compute_audit_mode(args.ad, args.pwd), - } - neo4j_data = Neo4jData(conn, excluded_relationships=excluded_relationships, diagnostics=diagnostics) domain_name = neo4j_data.get_domain_name() if domain_name == "Unknown Domain": @@ -612,7 +612,7 @@ def main(): cracked_users_list = list(accountanalysis.cracked_accounts.keys()) print(f"[*] Total number of cracked accounts to mark as owned: {len(cracked_users_list)}") - importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=resolved.bh_url) + importer = BloodhoundImporter(conn, bloodhound_username=bloodhound_username, bloodhound_password=bloodhound_password, base_url=resolved.bh_url, diagnostics=diagnostics) importer.neo4j_data = neo4j_data importer.cracked_accounts = accountanalysis.cracked_accounts importer.mark_as_owned(cracked_users_list, ntds_file_path) @@ -686,7 +686,7 @@ def main(): single_domain_reporting=reporting, single_domain_start_message="Generating reports in directory: {report_dir}" ) - if args.mark_owned and ntds_hashes and ntds_file_path: populate_neo4j_passwords(conn, neo4j_data, accountanalysis, ntds_file_path, config, bloodhound_username, bloodhound_password, bloodhound_url=resolved.bh_url) + if args.mark_owned and ntds_hashes and ntds_file_path: populate_neo4j_passwords(conn, neo4j_data, accountanalysis, ntds_file_path, config, bloodhound_username, bloodhound_password, bloodhound_url=resolved.bh_url, diagnostics=diagnostics) elif main_choice == "2": if ntds_hashes: company_names = reporting.get_company_names() @@ -696,7 +696,7 @@ def main(): single_domain_reporting=reporting, single_domain_completion_message="Password audit reports have been generated in the {report_dir} directory." ) - if args.mark_owned and ntds_hashes and ntds_file_path: populate_neo4j_passwords(conn, neo4j_data, accountanalysis, ntds_file_path, config, bloodhound_username, bloodhound_password, bloodhound_url=resolved.bh_url) + if args.mark_owned and ntds_hashes and ntds_file_path: populate_neo4j_passwords(conn, neo4j_data, accountanalysis, ntds_file_path, config, bloodhound_username, bloodhound_password, bloodhound_url=resolved.bh_url, diagnostics=diagnostics) else: print("Password Audit is not available as NTDS data was not provided.") diff --git a/modules/mssql_post_import.py b/modules/mssql_post_import.py new file mode 100644 index 0000000..a04f1de --- /dev/null +++ b/modules/mssql_post_import.py @@ -0,0 +1,99 @@ +from .opengraph_identifiers import safe_cypher_identifier + + +def dedupe_mssql_servers(connection) -> int: + groups = connection.query(""" + MATCH (s:MSSQL_Server) + WHERE s.name IS NOT NULL + WITH s, toLower(s.name) AS canon + WITH canon, collect(s) AS servers + WHERE size(servers) > 1 + UNWIND servers AS s + OPTIONAL MATCH (s)-[r]-() + WITH canon, s, count(r) AS deg, size(keys(s)) AS prop_count + ORDER BY canon, prop_count DESC, deg DESC + WITH canon, collect(s.objectid) AS ranked + RETURN head(ranked) AS canonical, tail(ranked) AS stale + """) + + merged = 0 + for row in groups or []: + canonical = row['canonical'] + for stale in row['stale']: + connection.query( + "MATCH (s:MSSQL_Server {objectid: $stale}) " + "MATCH (c:MSSQL_Server {objectid: $canonical}) " + "WITH c, properties(s) AS sp, properties(c) AS cp " + "SET c += sp SET c += cp", + parameters={'stale': stale, 'canonical': canonical}, + ) + rel_types = connection.query( + "MATCH (s:MSSQL_Server {objectid: $stale})-[r]-() " + "RETURN DISTINCT type(r) AS rtype", + parameters={'stale': stale}, + ) + for rt in rel_types: + rtype = safe_cypher_identifier(rt['rtype'], 'relationship type') + connection.query( + f"MATCH (s:MSSQL_Server {{objectid: $stale}})-[old:`{rtype}`]->(t) " + f"MATCH (c:MSSQL_Server {{objectid: $canonical}}) " + "WHERE t <> c " + f"MERGE (c)-[new:`{rtype}`]->(t) " + "WITH old, new, properties(old) AS old_props, properties(new) AS existing_props " + "SET new += old_props SET new += existing_props " + "DELETE old", + parameters={'stale': stale, 'canonical': canonical}, + ) + connection.query( + f"MATCH (src)-[old:`{rtype}`]->(s:MSSQL_Server {{objectid: $stale}}) " + f"MATCH (c:MSSQL_Server {{objectid: $canonical}}) " + "WHERE src <> c " + f"MERGE (src)-[new:`{rtype}`]->(c) " + "WITH old, new, properties(old) AS old_props, properties(new) AS existing_props " + "SET new += old_props SET new += existing_props " + "DELETE old", + parameters={'stale': stale, 'canonical': canonical}, + ) + connection.query( + "MATCH (s:MSSQL_Server {objectid: $stale}) DETACH DELETE s", + parameters={'stale': stale}, + ) + merged += 1 + + if merged: + print(f"[*] Merged {merged} duplicate MSSQL_Server node(s)") + return merged + + +def canonicalize_mssql_linked_server_edges(connection) -> int: + # Count canonical edges ensured by MERGE, not newly created edges. + merged = 0 + for rel_type in ("MSSQL_LinkedTo", "MSSQL_LinkedAsAdmin"): + rtype = safe_cypher_identifier(rel_type, "relationship type") + rows = connection.query(f""" + MATCH (stub:MSSQL_Base)-[old:`{rtype}`]->(target:MSSQL_Server) + WHERE NOT stub:MSSQL_Server + AND stub.objectid IS NOT NULL + AND stub.objectid CONTAINS ':' + WITH old, target, split(stub.objectid, ':')[0] AS sourceSid + MATCH (source:MSSQL_Server) + WHERE source.objectid IS NOT NULL + AND source.objectid CONTAINS ':' + AND split(source.objectid, ':')[0] = sourceSid + AND source <> target + AND COUNT {{ + MATCH (sidServer:MSSQL_Server) + WHERE sidServer.objectid CONTAINS ':' + AND split(sidServer.objectid, ':')[0] = sourceSid + }} = 1 + MERGE (source)-[new:`{rtype}`]->(target) + WITH old, new, properties(old) AS old_props, properties(new) AS existing_props + SET new += old_props SET new += existing_props + RETURN count(DISTINCT new) AS merged + """) + if rows: + merged += rows[0].get("merged", 0) or 0 + + if merged: + print(f"[*] Merged {merged} canonical MSSQL linked-server edge(s)") + return merged diff --git a/pyproject.toml b/pyproject.toml index 4a42c6e..a46650a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ test = ["pytest>=7.0", "pytest-cov>=4.0", "mypy>=1.0"] adpathfinder = "modules.main:main" [tool.pytest.ini_options] -addopts = "-m 'not integration'" +addopts = "-m 'not integration and not neo4j'" markers = [ "neo4j: requires a running Neo4j instance (set NEO4J_URI or use the docker one-liner)", "integration: optional end-to-end smoke; not part of the default PR run", diff --git a/sample_data/ConfigManBearPig.zip b/sample_data/ConfigManBearPig.zip index 1e76cda..e54df3d 100644 Binary files a/sample_data/ConfigManBearPig.zip and b/sample_data/ConfigManBearPig.zip differ diff --git a/sample_data/MSSQLHound.zip b/sample_data/MSSQLHound.zip index bc3ad5c..cd9abc1 100644 Binary files a/sample_data/MSSQLHound.zip and b/sample_data/MSSQLHound.zip differ diff --git a/sample_data/SharpHound.zip b/sample_data/SharpHound.zip index f84e7db..cc67b69 100644 Binary files a/sample_data/SharpHound.zip and b/sample_data/SharpHound.zip differ diff --git a/sample_reports/training.local_domain_audit.txt b/sample_reports/training.local_domain_audit.txt index 4e83bf1..fb1f7de 100644 --- a/sample_reports/training.local_domain_audit.txt +++ b/sample_reports/training.local_domain_audit.txt @@ -1,50 +1,43 @@ Risk Profile Statistics: -Critical: Total Users - 51, Total Computers - 10, Default Groups - 4, Total - 65 - Non-admin Users with Escalation Paths: 36 - Admin with Weak Password: 5 - Computer has Weak Password: 5 +Critical: Total Users - 50, Total Computers - 5, Default Groups - 4, Total - 59 + Non-admin Users with Escalation Paths: 37 + SCCM Privilege Escalation: 8 Default Groups with Privilege Escalation Paths: 4 ESC1 — Enrollee Supplies Subject: 2 - SCCM Database Compromise: 2 - AS-REP Roastable with Admin privileges and Weak Password: 1 ESC4 — Template ACL Abuse: 1 ESC6 — CA Allows User-Specified SAN: 1 ESC7 — CA ManageCA Permissions: 1 - Kerberoastable with Admin privileges and Weak Password: 1 SCCM Hierarchy Takeover via AdminService Relay (TAKEOVER-5): 1 SCCM Hierarchy Takeover via HTTP-to-LDAP/LDAPS Relay on Domain Controller (TAKEOVER-8): 1 SCCM Hierarchy Takeover via NTLM Relay (TAKEOVER-1): 1 SCCM Hierarchy Takeover via SMB Relay to SMS Provider (TAKEOVER-6): 1 SCCM Hierarchy Takeover via SMB Relay to Site Database (TAKEOVER-2): 1 - User with Weak Password and Unconstrained Delegation: 1 -High: Total Users - 11, Total Computers - 123, Default Groups - 0, Total - 134 +High: Total Users - 12, Total Computers - 124, Default Groups - 0, Total - 136 Computers with Escalation Paths: 115 - Non-admin AS-REP Roastable with Weak Password: 4 + MSSQL Privilege Escalation: 7 Computers with SMB Signing Disabled and Escalation Paths: 2 Computers with WebClient and Escalation Paths: 2 ESC2 — Any Purpose EKU: 2 + MSSQL Server Vulnerable to NTLM Relay: 2 Computer with Unconstrained Delegation: 1 ESC3 — Enrollment Agent: 1 ESC8-vulnerable Enterprise CAs: 1 ESC9 — No Security Extension: 1 - MSSQL Privilege Escalation: 1 - MSSQL Server Vulnerable to NTLM Relay: 1 - Non-admin Kerberoastable with Weak Password: 1 SCCM Infrastructure SMB Relay (ELEVATE-1): 1 User with Unconstrained Delegation: 1 -Medium: Total Users - 55, Total Computers - 5, Default Groups - 0, Total - 60 - Non-admin with Weak Password: 31 - Account with Shared Password: 21 +Medium: Total Users - 8, Total Computers - 6, Default Groups - 0, Total - 14 + MSSQL Login: 7 Computers with SMB Signing Disabled: 3 - MSSQL Login: 2 Computer with Constrained Delegation: 1 KRBTGT Password Older Than 6 Months: 1 + MSSQL Login Impersonation: 1 SCCM Management Point - Client Policy Retrieval Review: 1 -Low: Total Users - 0, Total Computers - 0, Default Groups - 0, Total - 0 +Low: Total Users - 1, Total Computers - 0, Default Groups - 0, Total - 1 + Tier-0 Session on Non-Tier-0 Host: 1 Info: Total Users - 0, Total Computers - 0, Default Groups - 0, Total - 0 @@ -54,74 +47,97 @@ Critical Risk Profiles: ▶ Domain Users ├─ GenericAll on ESC4 (CertTemplate) + ├─ GenericAll on JENKINS.TRAINING.LOCAL (Computer) - │ └─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 4 steps) - │ └─ JENKINS.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> GenericWrite -> SQLSERVER10.TRAINING.LOCAL (Computer) -> GenericWrite -> DC01.TRAINING.LOCAL (Computer) - └─ GenericAll on TRAINING-DC01-CA (EnterpriseCA) + │ └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps) + │ └─ JENKINS.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer) + + └─ GenericAll on TRAINING-DC01-CA (AIACA) (EnterpriseCA) ▶ Domain Computers └─ WriteOwner on WINDOWS10.TRAINING.LOCAL (Computer) - └─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 4 steps) - └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> GenericWrite -> SQLSERVER10.TRAINING.LOCAL (Computer) -> GenericWrite -> DC01.TRAINING.LOCAL (Computer) + └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps) + └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer) ▶ Authenticated Users ├─ WriteOwner on DEFAULT DOMAIN POLICY (GPO) - ├─ GenericWrite on SQLSERVER10.TRAINING.LOCAL (Computer) - │ └─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 1 steps) - │ └─ SQLSERVER10.TRAINING.LOCAL (Computer) -> GenericWrite -> DC01.TRAINING.LOCAL (Computer) - ├─ WriteDacl on DEFAULT DOMAIN POLICY (GPO) - ├─ GenericWrite on DEFAULT DOMAIN POLICY (GPO) + ├─ GenericAll on ANALYSISUSERS (OU) - ├─ GenericAll on BADSUCSS (OU) + ├─ GenericAll on BADSUCC (OU) + + ├─ GenericAll on BADSUCSS (OU) + + ├─ GenericWrite on DEFAULT DOMAIN POLICY (GPO) + + ├─ WriteDacl on DEFAULT DOMAIN POLICY (GPO) + + ├─ GenericWrite on SQLSERVER10.TRAINING.LOCAL (Computer) + │ └─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 1 steps) + │ └─ SQLSERVER10.TRAINING.LOCAL (Computer) -> WriteDacl -> DC01.TRAINING.LOCAL (Computer) + ├─ GenericAll on ESC4_DISABLED (CertTemplate) + └─ GenericAll on ESC4 (CertTemplate) ▶ Everyone + ├─ CanRDP on DC01.TRAINING.LOCAL (Computer) + │ └─ LEADS TO -> ENTERPRISE DOMAIN CONTROLLERS@TRAINING.LOCAL (Group) (via 1 steps) + │ └─ DC01.TRAINING.LOCAL (Computer) -> MemberOf -> ENTERPRISE DOMAIN CONTROLLERS@TRAINING.LOCAL (Group) + └─ WriteAccountRestrictions on SCCMDB.TRAINING.LOCAL (Computer) └─ LEADS TO -> DC01.TRAINING.LOCAL (Computer) (via 4 steps) - └─ SCCMDB.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> GenericWrite -> SQLSERVER10.TRAINING.LOCAL (Computer) -> GenericWrite -> DC01.TRAINING.LOCAL (Computer) + └─ SCCMDB.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> CanRDP -> DC01.TRAINING.LOCAL (Computer) + + Non-admin Users with Escalation Paths: 37 + Users with Shared Path (25): ADCS, AERIELL.SANDY, ANDREI.ISAHELLA, ANNADIANE.FAE, BOARDROOM, BUFFY.RUTHANNE, CHRISTA.JACQUENETTA, CHRISTAL.EUGENIA, CISCO, COMPUTER01, DCSYNC, DEMETRA.MYRTIA, ELSE.NATALA, J.REPORTER, JANNELLE.LAIRD, JOE.SMITH, JOJO.KATRINA, LEIGH.PENNIE, LOTTI.RENAE, PERLE.SELIE, ROBINA.KIPPAR, SABINE.MYRTIE, TEST, TEST.USERS, VICTORIA.REP - Non-admin Users with Escalation Paths: 36 - Users with Shared Path (30): ADCS, AERIELL.SANDY, ANDREI.ISAHELLA, ANNADIANE.FAE, ANNETTE.LEON, BOARDROOM, BUFFY.RUTHANNE, CAL.EM, CHRISTA.JACQUENETTA, CHRISTAL.EUGENIA, CISCO, COMPUTER01, DCSYNC, DEBBIE.KISSIE, DEMETRA.MYRTIA, DOMAINNAMEASUSER, ELSE.NATALA, JANNELLE.LAIRD, JOE.SMITH, JOJO.KATRINA, LEIGH.PENNIE, LOTTI.RENAE, MARNIA.CHRISSIE, PERLE.SELIE, ROBINA.KIPPAR, SABINE.MYRTIE, TEST, TEST.USERS, UNCONSTRAINED, VICTORIA.REP - Common Escalation Path: MemberOf -> DOMAIN USERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> CoerceAndRelayToSMB -> SCCMDB (Computer) + Common Escalation Path: MemberOf -> DOMAIN USERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> MemberOf -> EVERYONE (Group) -> WriteAccountRestrictions -> SCCMDB (Computer) Users with Shared Path (2): CHERILYNN.ROSELIA, MAURINE.CAMMIE + Common Escalation Path: MemberOf -> FA-MIGORDOBE-DISTLIST1 (Group) -> GenericAll -> IVORY_MCCULLOUGH (User) + Users with Shared Path (2): DEBBIE.KISSIE, DOMAINNAMEASUSER + + Common Escalation Path: MemberOf -> JO-NOWOMANNO-DISTLIST1 (Group) -> GenericAll -> AZRWSECS1000000 (Computer) -> GenericAll -> LAWANDA_FRANKS (User) + Users with Shared Path (1): ANICA.PAULINA - Common Escalation Path: MemberOf -> TA-HOT-ADMINGROUP1 (Group) -> MemberOf -> SE-JAG-DISTLIST1 (Group) -> GenericAll -> DC01 (Computer) + + Common Escalation Path: MemberOf -> TA-HOT-ADMINGROUP1 (Group) -> MemberOf -> SE-JAG-DISTLIST1 (Group) -> GenericAll -> SCCMDB (Computer) + + Users with Shared Path (1): ANNETTE.LEON + + Common Escalation Path: MemberOf -> BE-FER-DISTLIST1 (Group) -> GenericAll -> RU-187960050-ADMINGROUP1 (Group) -> GenericAll -> PA-860-DISTLIST1 (Group) -> GenericAll -> SCCMDB (Computer) + + Users with Shared Path (1): CAL.EM + + Common Escalation Path: MemberOf -> AN-COR-DISTLIST1 (Group) -> MemberOf -> CA-ZID-ADMINGROUP1 (Group) -> GenericAll -> FABIAN_WHITLEY (User) Users with Shared Path (1): JOE.PATHFINDER + Common Escalation Path: GenericAll -> DC01 (Computer) + Users with Shared Path (1): MARNIA.CHRISSIE + + Common Escalation Path: MemberOf -> LE-SAN-ADMINGROUP1 (Group) -> GenericAll -> TSTWAPPS1000001 (Computer) -> GenericAll -> JANELL_ALFORD (User) + Users with Shared Path (1): SQLSVC - Common Escalation Path: GenericAll -> SCCMDB (Computer) - Users with Shared Path (1): WELCOMEUSER1 - Common Escalation Path: GenericWrite -> DC01 (Computer) + Common Escalation Path: GenericAll -> SCCMDB (Computer) + Users with Shared Path (1): UNCONSTRAINED - AS-REP Roastable with Admin privileges and Weak Password: 1 - ▶ JB + Common Escalation Path: MemberOf -> RO-SIVOYCONL-ADMINGROUP1 (Group) -> GenericAll -> CL-PIRATAS12-DISTLIST1 (Group) -> GenericAll -> JANELL_ALFORD (User) - Admin with Weak Password: 5 - ▶ ADMINISTRATOR - ▶ DA - ▶ JB - ▶ KORNEY.DARELLE - ▶ TESTDA + Users with Shared Path (1): WELCOMEUSER1 - Computer has Weak Password: 5 - ▶ COMPUTER01.TRAINING.LOCAL - ▶ LAB-SQL01.TRAINING.LOCAL - ▶ SCCM.TRAINING.LOCAL - ▶ SQLSERVER10.TRAINING.LOCAL - ▶ WINDOWS10.TRAINING.LOCAL + Common Escalation Path: GenericWrite -> DC01 (Computer) ESC1 — Enrollee Supplies Subject: 2 ▶ ESC1 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL) ▶ AUTHENTICATED USERS, DOMAIN USERS + ▶ ESC2 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL) ▶ AUTHENTICATED USERS, DOMAIN USERS @@ -137,23 +153,13 @@ Critical Risk Profiles: ▶ TRAINING-DC01-CA (DC01.TRAINING.LOCAL) ▶ DOMAIN USERS - Kerberoastable with Admin privileges and Weak Password: 1 - ▶ DA - - SCCM Database Compromise: 2 - ▶ LAB-SQL01.TRAINING.LOCAL - ▶ LAB-SQL01$ -> MSSQL_IsMappedTo -> LAB-SQL01$ -> MSSQL_MemberOf -> DB_OWNER -> MSSQL_ControlDB -> CM_TRN -> can enable xp_cmdshell on sccmdb.training.local - ▶ LAB-SQL01$ -> MSSQL_IsMappedTo -> LAB-SQL01$ -> MSSQL_MemberOf -> DB_OWNER -> MSSQL_ControlDB -> CM_TRN -> SCCM_AssignAllPermissions -> SCCM_Site -> RCE on all managed clients - ▶ AUTHENTICATED USERS@TRAINING.LOCAL - ▶ AUTHENTICATED USERS@TRAINING.LOCAL -> CoerceAndRelayToMSSQL -> LAB-SQL01$ -> can enable xp_cmdshell on sccmdb.training.local - SCCM Hierarchy Takeover via AdminService Relay (TAKEOVER-5): 1 ▶ SCCMDB.TRAINING.LOCAL ▶ Coerce LAB-SQL01.TRAINING.LOCAL -> AdminService relay to https://SCCMDB.TRAINING.LOCAL/AdminService (Site TRN) SCCM Hierarchy Takeover via HTTP-to-LDAP/LDAPS Relay on Domain Controller (TAKEOVER-8): 1 ▶ DC01.TRAINING.LOCAL - ▶ Coerce lab-sql01.training.local (Site Server) -> HTTP-to-LDAP/LDAPS NTLM relay to dc01.training.local + ▶ Coerce sccmdb.training.local (SMS Provider), lab-sql01.training.local (Site Server) -> HTTP-to-LDAP/LDAPS NTLM relay to dc01.training.local SCCM Hierarchy Takeover via NTLM Relay (TAKEOVER-1): 1 ▶ SCCMDB.TRAINING.LOCAL @@ -167,97 +173,150 @@ Critical Risk Profiles: ▶ SCCMDB.TRAINING.LOCAL ▶ Coerce lab-sql01.training.local -> SMB relay to sccmdb.training.local (Site TRN) - User with Weak Password and Unconstrained Delegation: 1 + SCCM Privilege Escalation: 8 + ▶ DOMAIN USERS@TRAINING.LOCAL + DOMAIN USERS > MSSQL_HasLogin > Domain Users > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN) + DOMAIN USERS > MSSQL_HasLogin > Domain Users > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN) + + ▶ SQLSVC + sqlsvc > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN) + sqlsvc > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN) + + ▶ JOE.PATHFINDER + joe.pathfinder > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN) + joe.pathfinder > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN) + + ▶ WELCOMEUSER1 + welcomeuser1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN) + welcomeuser1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN) + ▶ UNCONSTRAINED + unconstrained > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN) + unconstrained > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN) + + ▶ DCSYNC + dcsync > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN) + dcsync > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN) + + ▶ J.REPORTER + j.reporter > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin > MSSQL_Database(CM_TRN) > SCCM_Site(TRN) + j.reporter > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) > MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN) + + ▶ AUTHENTICATED USERS@TRAINING.LOCAL + AUTHENTICATED USERS@TRAINING.LOCAL > MSSQL_CoerceAndRelayToMSSQL > LAB-SQL01$ > MSSQL_Database(CM_TRN) > SCCM_Site(TRN) High Risk Profiles: Computers with Escalation Paths: 115 - Computers with Shared Path (89): ADTEST_SOURCEC.TRAINING.LOCAL, ADTEST_TARGETC.TRAINING.LOCAL, ATTACKER-MACHINE.TRAINING.LOCAL, AWSWDBAS1000000.TRAINING.LOCAL, AWSWSECS1000001.TRAINING.LOCAL, AWSWSECS1000002.TRAINING.LOCAL, AWSWVIR1000000.TRAINING.LOCAL, AWSWVIR1000001.TRAINING.LOCAL, AWSWWKS1000000.TRAINING.LOCAL, AZRWDBAS1000000.TRAINING.LOCAL, AZRWLPT1000000.TRAINING.LOCAL, AZRWVIR1000000.TRAINING.LOCAL, AZRWWKS1000000.TRAINING.LOCAL, BDEWCTRX1000000.TRAINING.LOCAL, BDEWLPT1000000.TRAINING.LOCAL, BDEWSECS1000001.TRAINING.LOCAL, BDEWVIR1000000.TRAINING.LOCAL, BDEWVIR1000002.TRAINING.LOCAL, COMPUTER01.TRAINING.LOCAL, ESMWLPT1000000.TRAINING.LOCAL, ESMWLPT1000001.TRAINING.LOCAL, ESMWLPT1000002.TRAINING.LOCAL, ESMWVIR1000000.TRAINING.LOCAL, ESMWVIR1000001.TRAINING.LOCAL, ESMWWEBS1000000.TRAINING.LOCAL, ESMWWKS1000000.TRAINING.LOCAL, ESMWWKS1000001.TRAINING.LOCAL, FINWAPPS1000000.TRAINING.LOCAL, FINWAPPS1000001.TRAINING.LOCAL, FINWAPPS1000002.TRAINING.LOCAL, FINWCTRX1000000.TRAINING.LOCAL, FINWDBAS1000000.TRAINING.LOCAL, FINWLPT1000000.TRAINING.LOCAL, FINWLPT1000001.TRAINING.LOCAL, FINWLPT1000002.TRAINING.LOCAL, FINWVIR1000000.TRAINING.LOCAL, FINWVIR1000001.TRAINING.LOCAL, FINWWKS1000000.TRAINING.LOCAL, FINWWKS1000001.TRAINING.LOCAL, FSRWLPT1000000.TRAINING.LOCAL, FSRWSECS1000000.TRAINING.LOCAL, FSRWVIR1000000.TRAINING.LOCAL, FSRWVIR1000001.TRAINING.LOCAL, GOOWAPPS1000001.TRAINING.LOCAL, GOOWCTRX1000000.TRAINING.LOCAL, GOOWCTRX1000001.TRAINING.LOCAL, GOOWSECS1000000.TRAINING.LOCAL, GOOWWKS1000000.TRAINING.LOCAL, GOOWWKS1000001.TRAINING.LOCAL, HREWAPPS1000000.TRAINING.LOCAL, HREWLPT1000000.TRAINING.LOCAL, HREWLPT1000001.TRAINING.LOCAL, HREWLPT1000002.TRAINING.LOCAL, HREWSECS1000000.TRAINING.LOCAL, ITSWAPPS1000000.TRAINING.LOCAL, ITSWLPT1000000.TRAINING.LOCAL, ITSWLPT1000001.TRAINING.LOCAL, ITSWSECS1000000.TRAINING.LOCAL, ITSWVIR1000000.TRAINING.LOCAL, ITSWVIR1000001.TRAINING.LOCAL, ITSWWEBS1000000.TRAINING.LOCAL, JENKINS.TRAINING.LOCAL, NETSPI-TEST.TRAINING.LOCAL, OGCWAPPS1000000.TRAINING.LOCAL, OGCWAPPS1000001.TRAINING.LOCAL, OGCWDBAS1000000.TRAINING.LOCAL, OGCWLPT1000001.TRAINING.LOCAL, OGCWSECS1000000.TRAINING.LOCAL, OGCWVIR1000000.TRAINING.LOCAL, PRE2K.TRAINING.LOCAL, PRE2K1.TRAINING.LOCAL, SCCM.TRAINING.LOCAL, SECWAPPS1000001.TRAINING.LOCAL, SECWAPPS1000002.TRAINING.LOCAL, SECWLPT1000000.TRAINING.LOCAL, SECWWEBS1000000.TRAINING.LOCAL, SECWWEBS1000002.TRAINING.LOCAL, SECWWKS1000001.TRAINING.LOCAL, SECWWKS1000002.TRAINING.LOCAL, TSTWAPPS1000000.TRAINING.LOCAL, TSTWAPPS1000002.TRAINING.LOCAL, TSTWDBAS1000000.TRAINING.LOCAL, TSTWDBAS1000001.TRAINING.LOCAL, TSTWLPT1000001.TRAINING.LOCAL, TSTWVIR1000000.TRAINING.LOCAL, TSTWVIR1000001.TRAINING.LOCAL, TSTWWEBS1000000.TRAINING.LOCAL, TSTWWKS1000000.TRAINING.LOCAL, WINDOWS10.TRAINING.LOCAL - Common Escalation Path: MemberOf -> DOMAIN COMPUTERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> CoerceAndRelayToSMB -> SCCMDB (Computer) + Computers with Shared Path (88): ADTEST_SOURCEC.TRAINING.LOCAL, ADTEST_TARGETC.TRAINING.LOCAL, ATTACKER-MACHINE.TRAINING.LOCAL, AWSWDBAS1000000.TRAINING.LOCAL, AWSWSECS1000001.TRAINING.LOCAL, AWSWSECS1000002.TRAINING.LOCAL, AWSWVIR1000000.TRAINING.LOCAL, AWSWVIR1000001.TRAINING.LOCAL, AWSWWKS1000000.TRAINING.LOCAL, AZRWDBAS1000000.TRAINING.LOCAL, AZRWLPT1000000.TRAINING.LOCAL, AZRWVIR1000000.TRAINING.LOCAL, AZRWWKS1000000.TRAINING.LOCAL, BDEWCTRX1000000.TRAINING.LOCAL, BDEWLPT1000000.TRAINING.LOCAL, BDEWSECS1000001.TRAINING.LOCAL, BDEWVIR1000000.TRAINING.LOCAL, BDEWVIR1000002.TRAINING.LOCAL, COMPUTER01.TRAINING.LOCAL, ESMWLPT1000000.TRAINING.LOCAL, ESMWLPT1000001.TRAINING.LOCAL, ESMWLPT1000002.TRAINING.LOCAL, ESMWVIR1000000.TRAINING.LOCAL, ESMWVIR1000001.TRAINING.LOCAL, ESMWWEBS1000000.TRAINING.LOCAL, ESMWWKS1000000.TRAINING.LOCAL, ESMWWKS1000001.TRAINING.LOCAL, FINWAPPS1000000.TRAINING.LOCAL, FINWAPPS1000001.TRAINING.LOCAL, FINWAPPS1000002.TRAINING.LOCAL, FINWCTRX1000000.TRAINING.LOCAL, FINWDBAS1000000.TRAINING.LOCAL, FINWLPT1000000.TRAINING.LOCAL, FINWLPT1000001.TRAINING.LOCAL, FINWLPT1000002.TRAINING.LOCAL, FINWVIR1000000.TRAINING.LOCAL, FINWVIR1000001.TRAINING.LOCAL, FINWWKS1000000.TRAINING.LOCAL, FINWWKS1000001.TRAINING.LOCAL, FSRWLPT1000000.TRAINING.LOCAL, FSRWSECS1000000.TRAINING.LOCAL, FSRWVIR1000000.TRAINING.LOCAL, FSRWVIR1000001.TRAINING.LOCAL, GOOWAPPS1000001.TRAINING.LOCAL, GOOWCTRX1000000.TRAINING.LOCAL, GOOWCTRX1000001.TRAINING.LOCAL, GOOWSECS1000000.TRAINING.LOCAL, GOOWWKS1000000.TRAINING.LOCAL, GOOWWKS1000001.TRAINING.LOCAL, HREWAPPS1000000.TRAINING.LOCAL, HREWLPT1000000.TRAINING.LOCAL, HREWLPT1000001.TRAINING.LOCAL, HREWLPT1000002.TRAINING.LOCAL, HREWSECS1000000.TRAINING.LOCAL, ITSWAPPS1000000.TRAINING.LOCAL, ITSWLPT1000000.TRAINING.LOCAL, ITSWLPT1000001.TRAINING.LOCAL, ITSWSECS1000000.TRAINING.LOCAL, ITSWVIR1000000.TRAINING.LOCAL, ITSWVIR1000001.TRAINING.LOCAL, ITSWWEBS1000000.TRAINING.LOCAL, JENKINS.TRAINING.LOCAL, LAB-SQL01.TRAINING.LOCAL, NETSPI-TEST.TRAINING.LOCAL, OGCWAPPS1000000.TRAINING.LOCAL, OGCWAPPS1000001.TRAINING.LOCAL, OGCWDBAS1000000.TRAINING.LOCAL, OGCWLPT1000001.TRAINING.LOCAL, OGCWSECS1000000.TRAINING.LOCAL, OGCWVIR1000000.TRAINING.LOCAL, PRE2K.TRAINING.LOCAL, PRE2K1.TRAINING.LOCAL, SCCM.TRAINING.LOCAL, SECWAPPS1000001.TRAINING.LOCAL, SECWAPPS1000002.TRAINING.LOCAL, SECWLPT1000000.TRAINING.LOCAL, SECWWEBS1000000.TRAINING.LOCAL, SECWWKS1000001.TRAINING.LOCAL, SECWWKS1000002.TRAINING.LOCAL, TSTWAPPS1000000.TRAINING.LOCAL, TSTWAPPS1000002.TRAINING.LOCAL, TSTWDBAS1000001.TRAINING.LOCAL, TSTWLPT1000001.TRAINING.LOCAL, TSTWVIR1000000.TRAINING.LOCAL, TSTWVIR1000001.TRAINING.LOCAL, TSTWWEBS1000000.TRAINING.LOCAL, TSTWWKS1000000.TRAINING.LOCAL, WINDOWS10.TRAINING.LOCAL + + Common Escalation Path: MemberOf -> DOMAIN COMPUTERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> MemberOf -> EVERYONE (Group) -> WriteAccountRestrictions -> SCCMDB (Computer) - Computers with Shared Path (3): AWSWWKS1000001.TRAINING.LOCAL, BDEWWKS1000000.TRAINING.LOCAL, GOOWAPPS1000000.TRAINING.LOCAL - Common Escalation Path: GenericAll -> LAMBERT.LOTTE (User) + Computers with Shared Path (4): AWSWWKS1000001.TRAINING.LOCAL, BDEWWKS1000000.TRAINING.LOCAL, GOOWAPPS1000000.TRAINING.LOCAL, TSTWAPPS1000001.TRAINING.LOCAL + + Common Escalation Path: GenericAll -> JANELL_ALFORD (User) Computers with Shared Path (2): ACPLBGLD.TRAINING.LOCAL, IZFVTJER.TRAINING.LOCAL + Common Escalation Path: AllowedToAct -> SCCMDB (Computer) Computers with Shared Path (2): AWSWSECS1000000.TRAINING.LOCAL, BDEWWKS1000001.TRAINING.LOCAL - Common Escalation Path: GenericAll -> ADRIENNE_BRITT (User) -> GenericAll -> DC01 (Computer) + + Common Escalation Path: GenericAll -> ADRIENNE_BRITT (User) -> GenericAll -> SCCMDB (Computer) Computers with Shared Path (1): AZRWSECS1000000.TRAINING.LOCAL + Common Escalation Path: GenericAll -> LAWANDA_FRANKS (User) Computers with Shared Path (1): BDEWSECS1000000.TRAINING.LOCAL + Common Escalation Path: MemberOf -> GR-9.71275E1-DISTLIST1 (Group) -> GenericAll -> REMOTE MANAGEMENT USERS (Group) -> MemberOf -> ADMINISTRATORS (Group) Computers with Shared Path (1): BDEWVIR1000001.TRAINING.LOCAL + Common Escalation Path: GenericAll -> RICH_CANNON (User) -> GenericAll -> VERNA_BRANCH (User) Computers with Shared Path (1): HREWWEBS1000000.TRAINING.LOCAL + Common Escalation Path: GenericAll -> CAROLE_ALFORD (User) Computers with Shared Path (1): HREWWEBS1000001.TRAINING.LOCAL - Common Escalation Path: GenericAll -> DC01 (Computer) + + Common Escalation Path: GenericAll -> SCCMDB (Computer) Computers with Shared Path (1): HREWWKS1000000.TRAINING.LOCAL + Common Escalation Path: MemberOf -> NE-DAN-DISTLIST1 (Group) -> GenericAll -> LOTTIE_BLANCHARD (User) Computers with Shared Path (1): ITSWLPT1000002.TRAINING.LOCAL + Common Escalation Path: GenericAll -> FREIDA_GROSS (User) Computers with Shared Path (1): KIOSK01.TRAINING.LOCAL - Common Escalation Path: GenericAll -> BRADFORD_BUCKNER (User) - Computers with Shared Path (1): LAB-SQL01.TRAINING.LOCAL - Common Escalation Path: LocalAdminRequired -> SCCMDB (Computer) + Common Escalation Path: GenericAll -> BRADFORD_BUCKNER (User) Computers with Shared Path (1): OGCWLPT1000000.TRAINING.LOCAL - Common Escalation Path: GenericAll -> PASQUALE_SIMS (User) -> MemberOf -> SE-JAG-DISTLIST1 (Group) -> GenericAll -> DC01 (Computer) + + Common Escalation Path: GenericAll -> PASQUALE_SIMS (User) -> MemberOf -> SE-JAG-DISTLIST1 (Group) -> GenericAll -> SCCMDB (Computer) Computers with Shared Path (1): OGCWWKS1000000.TRAINING.LOCAL - Common Escalation Path: MemberOf -> LY-BETITASBO-ADMINGROUP1 (Group) -> GenericAll -> CRISTINA_BERGER (User) + + Common Escalation Path: MemberOf -> LY-BETITASBO-ADMINGROUP1 (Group) -> GenericAll -> SHELBY_ROBBINS (User) Computers with Shared Path (1): SECRET$@TRAINING.LOCAL - Common Escalation Path: MemberOf -> DOMAIN USERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> CoerceAndRelayToSMB -> SCCMDB (Computer) + + Common Escalation Path: MemberOf -> DOMAIN USERS (Group) -> MemberOf -> AUTHENTICATED USERS (Group) -> MemberOf -> EVERYONE (Group) -> WriteAccountRestrictions -> SCCMDB (Computer) Computers with Shared Path (1): SECWAPPS1000000.TRAINING.LOCAL - Common Escalation Path: GenericAll -> CL-PIRATAS12-DISTLIST1 (Group) -> GenericAll -> LAMBERT.LOTTE (User) + + Common Escalation Path: GenericAll -> CL-PIRATAS12-DISTLIST1 (Group) -> GenericAll -> JANELL_ALFORD (User) Computers with Shared Path (1): SECWLPT1000001.TRAINING.LOCAL - Common Escalation Path: GenericAll -> FOREST_SNOW (User) -> MemberOf -> 94-SUK-DISTLIST1 (Group) -> GenericAll -> DC01 (Computer) + + Common Escalation Path: GenericAll -> FOREST_SNOW (User) -> MemberOf -> 94-SUK-DISTLIST1 (Group) -> GenericAll -> SCCMDB (Computer) Computers with Shared Path (1): SECWWEBS1000001.TRAINING.LOCAL + Common Escalation Path: GenericAll -> LOTTIE_BLANCHARD (User) + Computers with Shared Path (1): SECWWEBS1000002.TRAINING.LOCAL + + Common Escalation Path: MemberOf -> RO-SIVOYCONL-ADMINGROUP1 (Group) -> GenericAll -> CL-PIRATAS12-DISTLIST1 (Group) -> GenericAll -> JANELL_ALFORD (User) + Computers with Shared Path (1): SECWWKS1000000.TRAINING.LOCAL - Common Escalation Path: GenericAll -> AWSWWKS1000001 (Computer) -> GenericAll -> LAMBERT.LOTTE (User) + + Common Escalation Path: GenericAll -> AWSWWKS1000001 (Computer) -> GenericAll -> JANELL_ALFORD (User) Computers with Shared Path (1): SQLSERVER10.TRAINING.LOCAL - Common Escalation Path: GenericWrite -> DC01 (Computer) - Computers with Shared Path (1): TSTWAPPS1000001.TRAINING.LOCAL - Common Escalation Path: GenericAll -> JANELL_ALFORD (User) + Common Escalation Path: WriteDacl -> DC01 (Computer) + + Computers with Shared Path (1): TSTWDBAS1000000.TRAINING.LOCAL + + Common Escalation Path: GenericAll -> AN-COR-DISTLIST1 (Group) -> MemberOf -> CA-ZID-ADMINGROUP1 (Group) -> GenericAll -> FABIAN_WHITLEY (User) Computers with Shared Path (1): TSTWLPT1000000.TRAINING.LOCAL - Common Escalation Path: GenericAll -> MOSES_KRAMER (User) + Common Escalation Path: GenericAll -> MOSES_KRAMER (User) Computers with SMB Signing Disabled and Escalation Paths: 2 ▶ LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD) - └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 1 steps) - └─ LAB-SQL01.TRAINING.LOCAL (Computer) -> LocalAdminRequired -> SCCMDB.TRAINING.LOCAL (Computer) + └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps) + └─ LAB-SQL01.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer) + ▶ WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC) - └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 3 steps) - └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> CoerceAndRelayToSMB -> SCCMDB.TRAINING.LOCAL (Computer) + └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps) + └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer) Computers with WebClient and Escalation Paths: 2 ▶ LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD) - └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 1 steps) - └─ LAB-SQL01.TRAINING.LOCAL (Computer) -> LocalAdminRequired -> SCCMDB.TRAINING.LOCAL (Computer) + └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps) + └─ LAB-SQL01.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer) + ▶ WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC) - └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 3 steps) - └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> CoerceAndRelayToSMB -> SCCMDB.TRAINING.LOCAL (Computer) + └─ LEADS TO -> SCCMDB.TRAINING.LOCAL (Computer) (via 4 steps) + └─ WINDOWS10.TRAINING.LOCAL (Computer) -> MemberOf -> DOMAIN COMPUTERS@TRAINING.LOCAL (Group) -> MemberOf -> AUTHENTICATED USERS@TRAINING.LOCAL (Group) -> MemberOf -> EVERYONE@TRAINING.LOCAL (Group) -> WriteAccountRestrictions -> SCCMDB.TRAINING.LOCAL (Computer) Computer with Unconstrained Delegation: 1 ▶ SCCM.TRAINING.LOCAL @@ -265,6 +324,7 @@ High Risk Profiles: ESC2 — Any Purpose EKU: 2 ▶ ESC2 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL) ▶ AUTHENTICATED USERS, DOMAIN USERS + ▶ ESC3 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL) ▶ AUTHENTICATED USERS, DOMAIN USERS @@ -279,119 +339,124 @@ High Risk Profiles: ▶ ESC9 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL) ▶ DOMAIN USERS - MSSQL Privilege Escalation: 1 - ▶ LAB-SQL01.TRAINING.LOCAL - ▶ LAB-SQL01$ -> MSSQL_MemberOf -> sysadmin on sccmdb.training.local - ▶ LAB-SQL01$ -> MSSQL_IsMappedTo -> LAB-SQL01$ -> MSSQL_MemberOf -> db_owner (CM_TRN) on sccmdb.training.local - ▶ LAB-SQL01$ -> MSSQL_MemberOf -> sysadmin -> MSSQL_ControlServer -> SCCMDB.TRAINING.LOCAL -> MSSQL_ExecuteOnHost -> sccmdb.training.local + MSSQL Privilege Escalation: 7 + ▶ J.REPORTER + j.reporter > MSSQL_ExecuteAs > ReportSvc > MSSQL_ExecuteAs > ETLService > MSSQL_ExecuteAs > sa > MSSQL_MemberOf > sysadmin@lab-sql01.training.local + j.reporter > MSSQL_ExecuteAs > ReportSvc > MSSQL_MemberOf > DataAdmin > MSSQL_ChangePassword > DevDeploy@lab-sql01.training.local (ALTER ANY LOGIN) + j.reporter > MSSQL_ExecuteAs > ReportSvc > MSSQL_MemberOf > DataAdmin > MSSQL_ChangePassword > WebAppPool@lab-sql01.training.local (ALTER ANY SERVER ROLE) + j.reporter > MSSQL_ExecuteAs > ReportSvc > MSSQL_MemberOf > DataAdmin > MSSQL_ChangePassword > DevDeploy > MSSQL_IsMappedTo > TestUser2@VulnerableApp (VulnerableApp) + j.reporter > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local - MSSQL Server Vulnerable to NTLM Relay: 1 - ▶ LAB-SQL01.TRAINING.LOCAL - ▶ DOMAIN USERS@TRAINING.LOCAL (all domain users) -> xp_dirtree coercion (DA@TRAINING.LOCAL) -> Relay to SCCMDB.TRAINING.LOCAL (WINDOWS SERVER 2022 STANDARD), WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC) - - Non-admin AS-REP Roastable with Weak Password: 4 - ▶ CHRISTAL.EUGENIA - ▶ LEIGH.PENNIE - ▶ MARNIA.CHRISSIE - ▶ VICTORIA.REP + ▶ JOE.PATHFINDER + joe.pathfinder > MSSQL_ExecuteAs > sqlsvc@lab-sql01.training.local (ALTER ANY SERVER ROLE) + joe.pathfinder > MSSQL_ExecuteAs > sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > DevDeploy@lab-sql01.training.local (ALTER ANY LOGIN) + joe.pathfinder > MSSQL_ExecuteAs > sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > WebAppPool@lab-sql01.training.local (ALTER ANY SERVER ROLE) + joe.pathfinder > MSSQL_ExecuteAs > sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > DevDeploy > MSSQL_IsMappedTo > TestUser2@VulnerableApp (VulnerableApp) + joe.pathfinder > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local - Non-admin Kerberoastable with Weak Password: 1 ▶ SQLSVC + sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > DevDeploy@lab-sql01.training.local (ALTER ANY LOGIN) + sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > WebAppPool@lab-sql01.training.local (ALTER ANY SERVER ROLE) + sqlsvc > MSSQL_AddMember > DataAdmin > MSSQL_ChangePassword > DevDeploy > MSSQL_IsMappedTo > TestUser2@VulnerableApp (VulnerableApp) + sqlsvc > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local + + ▶ DOMAIN USERS@TRAINING.LOCAL + DOMAIN USERS > MSSQL_HasLogin > Domain Users > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local + + ▶ WELCOMEUSER1 + welcomeuser1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local + + ▶ UNCONSTRAINED + unconstrained > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local + + ▶ DCSYNC + dcsync > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin > sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local + + MSSQL Server Vulnerable to NTLM Relay: 2 + ▶ SCCMDB.TRAINING.LOCAL + ▶ sccmdb\ConfigMgr_DViewAccess > MSSQL_Connect > SCCMDB.TRAINING.LOCAL > xp_dirtree > DA@TRAINING.LOCAL > NTLM_Relay > WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC), LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD) + ▶ TRAINING\LAB-SQL01$ > MSSQL_Connect > SCCMDB.TRAINING.LOCAL > xp_dirtree > DA@TRAINING.LOCAL > NTLM_Relay > WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC), LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD) + + ▶ LAB-SQL01.TRAINING.LOCAL + ▶ DOMAIN USERS@TRAINING.LOCAL > MSSQL_Connect > LAB-SQL01.TRAINING.LOCAL > xp_dirtree > DA@TRAINING.LOCAL > NTLM_Relay > SCCMDB.TRAINING.LOCAL (WINDOWS SERVER 2022 STANDARD), WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC) + ▶ LAB-SQL01\WSUS Administrators > MSSQL_Connect > LAB-SQL01.TRAINING.LOCAL > xp_dirtree > DA@TRAINING.LOCAL > NTLM_Relay > SCCMDB.TRAINING.LOCAL (WINDOWS SERVER 2022 STANDARD), WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC) SCCM Infrastructure SMB Relay (ELEVATE-1): 1 ▶ SCCMDB.TRAINING.LOCAL - ▶ Coerce lab-sql01.training.local -> SMB relay to sccmdb.training.local (SMS Component Server, SMS SQL Server, SMS Provider) (Site TRN) + ▶ Coerce lab-sql01.training.local -> SMB relay to sccmdb.training.local (SMS SQL Server, SMS Component Server, SMS Provider, SMS Site System) (Site TRN) User with Unconstrained Delegation: 1 ▶ UNCONSTRAINED Medium Risk Profiles: - Account with Shared Password: 21 - ▶ ADCS - ▶ ADMINISTRATOR - ▶ ANNETTE.LEON - ▶ BUFFY.RUTHANNE - ▶ CAL.EM - ▶ CHRISTAL.EUGENIA - ▶ DA - ▶ DOMAINNAMEASUSER - ▶ JANNELLE.LAIRD - ▶ JB - ▶ JOE.PATHFINDER - ▶ JOE.SMITH - ▶ JOJO.KATRINA - ▶ KORNEY.DARELLE - ▶ LOTTI.RENAE - ▶ MAURINE.CAMMIE - ▶ PERLE.SELIE - ▶ ROBINA.KIPPAR - ▶ SABINE.MYRTIE - ▶ TESTDA - ▶ UNCONSTRAINED - Computer with Constrained Delegation: 1 ▶ KIOSK01.TRAINING.LOCAL ▶ Constrained Delegation: http/JENKINS.training.local, http/JENKINS, HOST/JENKINS.training.local, HOST/JENKINS, cifs/JENKINS.training.local, cifs/JENKINS Computers with SMB Signing Disabled: 3 ▶ LAB-SQL01.TRAINING.LOCAL (WINDOWS SERVER 2019 STANDARD) + ▶ SCCMDB.TRAINING.LOCAL (WINDOWS SERVER 2022 STANDARD) + ▶ WINDOWS10.TRAINING.LOCAL (WINDOWS 10 ENTERPRISE LTSC) KRBTGT Password Older Than 6 Months: 1 ▶ KRBTGT ▶ Last Changed On: 11/29/2023 - MSSQL Login: 2 - ▶ LAB-SQL01.TRAINING.LOCAL - └─ SQL Login on SCCMDB.training.local - └─ Database: CM_TRN - ├─ Roles: db_owner - └─ ATTACK PATH: Full database control + MSSQL Login: 7 + ▶ SQLSVC + └─ SQL Login on lab-sql01.training.local:1433 + └─ No database access + + ▶ JOE.PATHFINDER + └─ SQL Login on lab-sql01.training.local:1433 + └─ Database: VulnerableApp + ├─ Roles: db_owner, public + └─ Permissions: CONNECT + + ▶ WELCOMEUSER1 + └─ SQL Login on lab-sql01.training.local:1433 + └─ No database access + + ▶ UNCONSTRAINED + └─ SQL Login on lab-sql01.training.local:1433 + └─ No database access + + ▶ DCSYNC + └─ SQL Login on lab-sql01.training.local:1433 + └─ No database access + + ▶ J.REPORTER + └─ SQL Login on lab-sql01.training.local:1433 + └─ Database: Reporting + ├─ Roles: db_datareader, public, report_executor + └─ Permissions: CONNECT + ▶ DOMAIN USERS@TRAINING.LOCAL └─ SQL Login on lab-sql01.training.local:1433 └─ Database: VulnerableApp ├─ Roles: public └─ Permissions: CONNECT, DELETE, INSERT, SELECT, UPDATE - Non-admin with Weak Password: 31 - ▶ ADCS - ▶ AERIELL.SANDY - ▶ ANICA.PAULINA - ▶ ANNADIANE.FAE - ▶ ANNETTE.LEON - ▶ BOARDROOM - ▶ BUFFY.RUTHANNE - ▶ CAL.EM - ▶ CHERILYNN.ROSELIA - ▶ CHRISTA.JACQUENETTA - ▶ CHRISTAL.EUGENIA - ▶ CISCO - ▶ COMPUTER01 - ▶ DEBBIE.KISSIE - ▶ DEMETRA.MYRTIA - ▶ DOMAINNAMEASUSER - ▶ JANNELLE.LAIRD - ▶ JOE.PATHFINDER - ▶ JOE.SMITH - ▶ JOJO.KATRINA - ▶ LEIGH.PENNIE - ▶ LOTTI.RENAE - ▶ MARNIA.CHRISSIE - ▶ MAURINE.CAMMIE - ▶ PERLE.SELIE - ▶ ROBINA.KIPPAR - ▶ SABINE.MYRTIE - ▶ SQLSVC - ▶ TEST.USERS - ▶ UNCONSTRAINED - ▶ VICTORIA.REP + MSSQL Login Impersonation: 1 + ▶ LAB-SQL01.TRAINING.LOCAL + ▶ ETLService -> EXECUTE AS sa + ▶ ReportSvc -> EXECUTE AS ETLService -> EXECUTE AS sa + ▶ ReportSvc -> EXECUTE AS TRAINING\Administrator + ▶ TRAINING\j.reporter -> EXECUTE AS ReportSvc -> EXECUTE AS TRAINING\Administrator + ▶ TRAINING\joe.pathfinder -> EXECUTE AS TRAINING\sqlsvc + ▶ TRAINING\unconstrained -> EXECUTE AS TRAINING\dcsync SCCM Management Point - Client Policy Retrieval Review: 1 ▶ LAB-SQL01.TRAINING.LOCAL - ▶ lab-sql01.training.local (Site TRN) - client certificate requirement not observed - review http://lab-sql01.training.local/ccm_system/request + ▶ lab-sql01.training.local (Site TRN) - review http(s)://lab-sql01.training.local/ccm_system/request Low Risk Profiles: -Info Risk Profiles: + Tier-0 Session on Non-Tier-0 Host: 1 + ▶ DA + ▶ Active session on: LAB-SQL01.TRAINING.LOCAL + +Info Risk Profiles: \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index a9c21b0..a781e87 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,11 +1,11 @@ # Tests -Two test surfaces ship in this repo: +Two main test surfaces ship in this repo: -1. **Framework unit tests** at `tests/test_*.py` — mock-based, no Neo4j required. -2. **Per-check fixture tests** at `tests/checks/test_*.py` — load Cypher fixtures into a real Neo4j and run a single check against the resulting graph. Marked `@pytest.mark.neo4j`. +1. **Framework unit tests** — mock-based, no Neo4j required. +2. **Neo4j fixture tests** — load Cypher fixtures into a real Neo4j. They are selected by the `@pytest.mark.neo4j` marker and may live anywhere under `tests/`. -Both suites run on every PR via CI. Framework tests run across the Python 3.9/3.13 matrix; fixture tests run on 3.13 only against a `neo4j:5-community` service container. +Both suites run on every PR via CI. Framework tests run across the Python 3.9/3.13 matrix; Neo4j fixture tests run on 3.13 only against a `neo4j:5-community` service container. ## Running the fast suite (no Docker) @@ -41,7 +41,7 @@ Run the suite: ADPF_TEST_ALLOW_WIPE=1 \ NEO4J_URI=bolt://localhost:17687 \ NEO4J_USER=neo4j NEO4J_PASSWORD=testpassword \ -pytest tests/checks -m "neo4j and not integration" -v +pytest tests/ -m "neo4j and not integration" -v ``` Tear down: @@ -53,7 +53,7 @@ docker rm -f adpf-test-neo4j ## Adding a test for a new check 1. Create `tests/fixtures/.cypher` with a minimal graph that triggers your check. Look at `tests/fixtures/esc3_enrollment_agent.cypher` for the pattern. -2. Create `tests/checks/test_.py` starting with `import pytest` and `pytestmark = pytest.mark.neo4j` at module top, then load the fixture, run the check via `run_check(YourCheck, clean_neo4j)`, and assert the findings. The marker is mandatory — without it the test runs in the fast lane and trips the wipe guard. +2. Create `tests/checks/test_.py` starting with `import pytest` and `pytestmark = pytest.mark.neo4j` at module top, then load the fixture, run the check via `run_check(YourCheck, clean_neo4j)`, and assert the findings. The marker is mandatory — without it the test runs in the fast lane and trips the wipe guard. Non-check Neo4j tests may live elsewhere under `tests/`, but they must use the same marker. 3. Optionally add a negative fixture (`_negative.cypher`) and assert the check stays silent. 4. Run locally as shown above. diff --git a/tests/checks/test_manager_smoke.py b/tests/checks/test_manager_smoke.py index 85c14d7..a74b993 100644 --- a/tests/checks/test_manager_smoke.py +++ b/tests/checks/test_manager_smoke.py @@ -70,11 +70,11 @@ def test_sccm_takeover6_visible_to_manager(clean_neo4j): assert not check_errors, f"Unexpected check errors: {check_errors}" -def test_mssql_sccm_visible_to_manager(clean_neo4j): +def test_sccm_privilege_escalation_visible_to_manager(clean_neo4j): display_content, stats_content, check_errors = _run_manager( - clean_neo4j, fixture="mssql_sccm.cypher", + clean_neo4j, fixture="sccm_privilege_escalation.cypher", ) - category = "SCCM Database Compromise" + category = "SCCM Privilege Escalation" block = _find_block(display_content, category) _assert_block_structure(block, category) _assert_stats_mirror(display_content, stats_content, category) diff --git a/tests/checks/test_mssql_logins.py b/tests/checks/test_mssql_logins.py index e271051..4734902 100644 --- a/tests/checks/test_mssql_logins.py +++ b/tests/checks/test_mssql_logins.py @@ -16,7 +16,9 @@ def test_fires_on_non_admin_user_with_db_control(clean_neo4j): assert isinstance(detail, str) assert "sql01.test.local:1433" in detail assert "AppDB" in detail + assert "ATTACK PATH: Full database control" not in detail assert "S-1-5-21-TEST-513" in findings group_detail = findings["S-1-5-21-TEST-513"] assert isinstance(group_detail, str) assert "sql01.test.local:1433" in group_detail + assert "ATTACK PATH: Full database control" not in group_detail diff --git a/tests/checks/test_mssql_privilege_escalation.py b/tests/checks/test_mssql_privilege_escalation.py index 85b798a..025737f 100644 --- a/tests/checks/test_mssql_privilege_escalation.py +++ b/tests/checks/test_mssql_privilege_escalation.py @@ -19,3 +19,109 @@ def test_fires_on_login_to_sysadmin_path(clean_neo4j): assert isinstance(detail, str) assert "sysadmin" in detail.lower() assert "sql01.test.local" in detail + + +def test_fires_on_linked_server_path_with_case_mismatched_bridge(clean_neo4j): + load_fixture(clean_neo4j, "mssql_privilege_escalation_linked_server.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + user_sid = "S-1-5-21-TEST-3201" + assert user_sid in findings + detail = findings[user_sid] + assert "MSSQL_LinkedAsAdmin" in detail + assert "sysadmin" in detail.lower() + assert "sccmdb.test.local" in detail + + +def test_fires_on_group_owned_linked_server_path(clean_neo4j): + load_fixture(clean_neo4j, "mssql_privilege_escalation_linked_server.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + user_sid = "S-1-5-21-TEST-3202" + assert user_sid in findings + detail = findings[user_sid] + assert "linked-group" in detail + assert "MSSQL_LinkedAsAdmin" in detail + assert "sysadmin" in detail.lower() + + +def test_common_group_owned_linked_server_reports_group_not_members(clean_neo4j): + load_fixture(clean_neo4j, "mssql_privilege_escalation_linked_server.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + assert "S-1-5-21-TEST-513" in findings + assert "S-1-5-21-TEST-3203" not in findings + detail = findings["S-1-5-21-TEST-513"] + assert "DOMAIN USERS" in detail + assert "DOMAIN USERS -> MSSQL_HasLogin -> Domain Users -> MSSQL_Connect" in detail + assert "MSSQL_LinkedAsAdmin" in detail + assert "sysadmin" in detail.lower() + + +def test_fires_on_linked_server_with_sid_based_stub(clean_neo4j): + load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_sid.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + user_sid = "S-1-5-21-TEST-3301" + assert user_sid in findings + detail = findings[user_sid] + assert "MSSQL_LinkedAsAdmin" in detail + assert "sysadmin" in detail.lower() + assert "sccmdb.test.local" in detail + + +def test_fires_when_login_owns_high_value_server_role(clean_neo4j): + load_fixture(clean_neo4j, "mssql_priv_esc_owns_server_role.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + user_sid = "S-1-5-21-TEST-3601" + assert user_sid in findings + detail = findings[user_sid] + assert "MSSQL_Owns" in detail + assert "sysadmin" in detail.lower() + + +def test_does_not_fire_when_stub_name_only_substring_matches_sqlservername(clean_neo4j): + load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_overmatch.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + assert findings == {} + + +def test_fires_on_group_owned_login_through_nested_memberof_depth_6(clean_neo4j): + load_fixture(clean_neo4j, "mssql_priv_esc_group_owned_login.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + user_sid = "S-1-5-21-TEST-3401" + assert user_sid in findings + detail = findings[user_sid] + assert "group-login" in detail + assert "NESTEDUSER -> MemberOf -> G1 -> MemberOf -> G2" in detail + assert "SQL OWNERS -> MSSQL_HasLogin -> group-login" in detail + assert "@TEST.LOCAL" not in detail + assert "sysadmin" in detail.lower() + + +def test_linked_server_target_does_not_overmatch_sibling_host(clean_neo4j): + load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_name_overmatch.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + user_sid = "S-1-5-21-TEST-3701" + assert user_sid in findings + detail = findings[user_sid] + assert "sql01.test.local" in detail + assert "sql01b" not in detail + + +def test_linked_server_empty_target_stub_resolves_to_nothing(clean_neo4j): + load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_empty_stub.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + assert findings == {} + + +def test_linked_server_shared_host_sid_bridges_to_no_instance(clean_neo4j): + load_fixture(clean_neo4j, "mssql_priv_esc_linked_server_shared_host_sid.cypher") + + findings = run_check(MSSQLPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + assert findings == {} diff --git a/tests/checks/test_mssql_sccm.py b/tests/checks/test_mssql_sccm.py deleted file mode 100644 index dd62005..0000000 --- a/tests/checks/test_mssql_sccm.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest - -from checks.mssql_sccm import MSSQLSCCMCheck -from tests.check_harness import load_fixture, run_check - -pytestmark = pytest.mark.neo4j - - -def test_fires_on_cross_schema_path_to_sccm_site(clean_neo4j): - load_fixture(clean_neo4j, "mssql_sccm.cypher") - - sccm_rows = clean_neo4j.query( - "MATCH (n:SCCM_Site) WHERE toUpper(n.sourceForest) = 'TEST.LOCAL' RETURN count(n) AS c" - ) - assert sccm_rows and sccm_rows[0]['c'] > 0 - mssql_rows = clean_neo4j.query( - "MATCH (n:MSSQL_Server) WHERE toUpper(n.name) CONTAINS '.TEST.LOCAL:' RETURN count(n) AS c" - ) - assert mssql_rows and mssql_rows[0]['c'] > 0 - - findings = run_check(MSSQLSCCMCheck, clean_neo4j, domain_filter="TEST.LOCAL") - assert MSSQLSCCMCheck.RISK_LEVEL == "Critical" - assert len(findings) >= 1 - assert "S-1-5-21-TEST-1300" in findings - assert isinstance(findings["S-1-5-21-TEST-1300"], str) - assert "Base" not in findings["S-1-5-21-TEST-1300"] - assert "SCCM_Site" in findings["S-1-5-21-TEST-1300"] diff --git a/tests/checks/test_sccm_privilege_escalation.py b/tests/checks/test_sccm_privilege_escalation.py new file mode 100644 index 0000000..5a650d9 --- /dev/null +++ b/tests/checks/test_sccm_privilege_escalation.py @@ -0,0 +1,178 @@ +import pytest + +from checks.core import DisplayTypes +from checks.sccm_privilege_escalation import SCCMPrivilegeEscalationCheck +from tests.check_harness import load_fixture, run_check + +pytestmark = pytest.mark.neo4j + + +def _run_sccm_check(conn): + load_fixture(conn, "sccm_privilege_escalation.cypher") + return run_check(SCCMPrivilegeEscalationCheck, conn, domain_filter="TEST.LOCAL") + + +def test_fires_on_ad_group_to_sccm_site(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + user_sid = "S-1-5-21-TEST-100" + assert SCCMPrivilegeEscalationCheck.RISK_LEVEL == "Critical" + assert user_sid in findings + detail = findings[user_sid] + assert "SCCM_IsMappedTo" in detail + assert "SCCM_AllPermissions" in detail + assert "P01" in detail + assert "Base" not in detail + + +def test_fires_on_nested_ad_group_to_sccm_site_depth_6(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + user_sid = "S-1-5-21-TEST-101" + assert user_sid in findings + detail = findings[user_sid] + assert "NESTED -> MemberOf -> N1 -> MemberOf -> N2" in detail + assert "@TEST.LOCAL" not in detail + assert "SCCM_AllPermissions" in detail + + +def test_fires_on_priv_esc_to_sccm_server_sysadmin(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + user_sid = "S-1-5-21-TEST-102" + assert user_sid in findings + detail = findings[user_sid] + assert "sysadmin" in detail.lower() + assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail + + +def test_fires_on_priv_esc_to_sccm_database_role(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + user_sid = "S-1-5-21-TEST-103" + assert user_sid in findings + detail = findings[user_sid] + assert "db_owner@CM_P01" in detail + assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail + + +def test_fires_on_linked_server_to_sccm_target(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + user_sid = "S-1-5-21-TEST-104" + assert user_sid in findings + detail = findings[user_sid] + assert "MSSQL_LinkedAsAdmin" in detail + assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail + + +def test_fires_on_linked_execute_host_to_sccm_site(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + user_sid = "S-1-5-21-TEST-109" + assert user_sid in findings + detail = findings[user_sid] + assert "EXECCHAIN -> MSSQL_HasLogin -> execchain -> MSSQL_ExecuteAs -> ReportSvc" in detail + assert "MSSQL_LinkedAsAdmin" in detail + assert "MSSQL_ExecuteOnHost (as SQLSVC@TEST.LOCAL)" in detail + assert detail.count("MSSQL_ExecuteOnHost") == 1 + assert "SCCMSQL.TEST.LOCAL -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail + + +def test_fires_on_group_owned_linked_server_to_sccm_target(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + user_sid = "S-1-5-21-TEST-107" + assert user_sid in findings + detail = findings[user_sid] + assert "GROUPLINKED -> MemberOf -> LINKED SQL USERS -> MSSQL_HasLogin" in detail + assert "linked-group" in detail + assert "GROUPLINKED@TEST.LOCAL" not in detail + assert "LINKED SQL USERS@TEST.LOCAL" not in detail + assert "MSSQL_LinkedAsAdmin" in detail + assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail + + +def test_common_group_owned_linked_server_to_sccm_reports_group_not_members(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + assert SCCMPrivilegeEscalationCheck.DISPLAY_TYPE == DisplayTypes.SHARED_GRAPH_PATHS + assert "S-1-5-21-TEST-513" in findings + assert "S-1-5-21-TEST-108" not in findings + detail = findings["S-1-5-21-TEST-513"] + assert "DOMAIN USERS" in detail + assert "DOMAIN USERS -> MSSQL_HasLogin -> Domain Users -> MSSQL_Connect" in detail + assert "MSSQL_LinkedAsAdmin" in detail + assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail + + +def test_fires_on_alter_any_login_to_sccm_server(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + user_sid = "S-1-5-21-TEST-105" + assert user_sid in findings + detail = findings[user_sid] + assert "MSSQL_AlterAnyLogin" in detail + assert "MSSQL_Database(CM_P01) -> SCCM_AssignAllPermissions -> SCCM_Site(P01)" in detail + + +def test_fires_on_admin_user_with_full_administrator_role(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + + user_sid = "S-1-5-21-TEST-106" + assert user_sid in findings + detail = findings[user_sid] + assert "SCCM_IsAssigned" in detail + assert "Full Administrator" in detail + + +def test_no_finding_for_connect_only_login_on_sccm_server(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + assert "S-1-5-21-TEST-200" not in findings + + +def test_no_finding_for_linked_execute_host_on_disabled_sccm_computer(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + assert "S-1-5-21-TEST-203" not in findings + + +def test_no_finding_for_linked_execute_host_without_remote_admin(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + assert "S-1-5-21-TEST-204" not in findings + + +def test_no_finding_for_non_admin_group_admin_user(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + assert "S-1-5-21-TEST-201" not in findings + + +def test_no_cross_forest_sccm_target_when_domain_filter_set(clean_neo4j): + findings = _run_sccm_check(clean_neo4j) + assert "S-1-5-21-TEST-202" not in findings + + +def test_linked_server_target_does_not_overmatch_sibling_host(clean_neo4j): + load_fixture(clean_neo4j, "sccm_priv_esc_linked_server_name_overmatch.cypher") + + findings = run_check(SCCMPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + user_sid = "S-1-5-21-TEST-310" + assert user_sid in findings + detail = findings[user_sid] + assert "cmsql.test.local" in detail + assert "CM_P10" in detail + assert "cmsql-dr" not in detail + assert "CM_DR" not in detail + + +def test_linked_server_empty_target_stub_resolves_to_nothing(clean_neo4j): + load_fixture(clean_neo4j, "sccm_priv_esc_linked_server_empty_stub.cypher") + + findings = run_check(SCCMPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + assert "S-1-5-21-TEST-311" not in findings + + +def test_linked_server_shared_host_sid_bridges_to_no_instance(clean_neo4j): + load_fixture(clean_neo4j, "sccm_priv_esc_linked_server_shared_host_sid.cypher") + + findings = run_check(SCCMPrivilegeEscalationCheck, clean_neo4j, domain_filter="TEST.LOCAL") + assert "S-1-5-21-TEST-312" not in findings diff --git a/tests/conftest.py b/tests/conftest.py index cdc2ff8..cb40ba9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,29 @@ from modules.neo4j_connection import Neo4jConnection +DEFAULT_NEO4J_TEST_URI = "bolt://localhost:17687" +DEFAULT_NEO4J_TEST_PASSWORD = "testpassword" + + +def _delete_in_batches(conn, cypher, batch_size=500): + while True: + rows = conn.query(cypher, parameters={"batch_size": batch_size}) + deleted = rows[0].get("deleted", 0) if rows else 0 + if not deleted: + break + + +def _wipe_neo4j(conn): + _delete_in_batches( + conn, + "MATCH ()-[r]->() WITH r LIMIT $batch_size DELETE r RETURN count(*) AS deleted", + ) + _delete_in_batches( + conn, + "MATCH (n) WITH n LIMIT $batch_size DELETE n RETURN count(*) AS deleted", + ) + + def _bloodhound_default_port(uri): try: parsed = urlparse(uri) @@ -30,7 +53,7 @@ def neo4j_conn(): "Set the env var only when NEO4J_URI points at a disposable test instance." ) - uri = os.environ.get("NEO4J_URI", "bolt://localhost:17687") + uri = os.environ.get("NEO4J_URI", DEFAULT_NEO4J_TEST_URI) if _bloodhound_default_port(uri): in_ci = os.environ.get("GITHUB_ACTIONS") == "true" @@ -43,7 +66,7 @@ def neo4j_conn(): ) user = os.environ.get("NEO4J_USER", "neo4j") - password = os.environ.get("NEO4J_PASSWORD", "testpassword") + password = os.environ.get("NEO4J_PASSWORD", DEFAULT_NEO4J_TEST_PASSWORD) conn = Neo4jConnection(uri, user, password) assert conn.is_connected(), f"Neo4j not reachable at {uri}" yield conn @@ -52,6 +75,6 @@ def neo4j_conn(): @pytest.fixture def clean_neo4j(neo4j_conn): - neo4j_conn.query("MATCH (n) DETACH DELETE n") + _wipe_neo4j(neo4j_conn) yield neo4j_conn - neo4j_conn.query("MATCH (n) DETACH DELETE n") + _wipe_neo4j(neo4j_conn) diff --git a/tests/fixtures/canonicalize_linked_server.cypher b/tests/fixtures/canonicalize_linked_server.cypher new file mode 100644 index 0000000..68db119 --- /dev/null +++ b/tests/fixtures/canonicalize_linked_server.cypher @@ -0,0 +1,34 @@ +// Stub-owned linked-server edges are copied to their canonical MSSQL_Server. + +CREATE (sourceAdmin:MSSQL_Server:MSSQL_Base:Base { + name: 'src-admin.test.local:1433', + objectid: 'S-1-5-21-TEST-CANON-A:1433' +}); +CREATE (stubAdmin:MSSQL_Base:Base { + name: 'S-1-5-21-TEST-CANON-A:LINKED', + objectid: 'S-1-5-21-TEST-CANON-A:LINKED' +}); +CREATE (targetAdmin:MSSQL_Server:MSSQL_Base:Base { + name: 'linked-admin.test.local:1433', + objectid: 'S-1-5-21-TEST-TGT-A:1433' +}); + +CREATE (sourceLink:MSSQL_Server:MSSQL_Base:Base { + name: 'src-link.test.local:1433', + objectid: 'S-1-5-21-TEST-CANON-B:1433' +}); +CREATE (stubLink:MSSQL_Base:Base { + name: 'S-1-5-21-TEST-CANON-B:LINKED', + objectid: 'S-1-5-21-TEST-CANON-B:LINKED' +}); +CREATE (targetLink:MSSQL_Server:MSSQL_Base:Base { + name: 'linked-to.test.local:1433', + objectid: 'S-1-5-21-TEST-TGT-B:1433' +}); + +MATCH (stubAdmin:MSSQL_Base {objectid: 'S-1-5-21-TEST-CANON-A:LINKED'}), + (targetAdmin:MSSQL_Server {objectid: 'S-1-5-21-TEST-TGT-A:1433'}), + (stubLink:MSSQL_Base {objectid: 'S-1-5-21-TEST-CANON-B:LINKED'}), + (targetLink:MSSQL_Server {objectid: 'S-1-5-21-TEST-TGT-B:1433'}) +CREATE (stubAdmin)-[:MSSQL_LinkedAsAdmin]->(targetAdmin) +CREATE (stubLink)-[:MSSQL_LinkedTo]->(targetLink); diff --git a/tests/fixtures/dedupe_mssql_servers.cypher b/tests/fixtures/dedupe_mssql_servers.cypher new file mode 100644 index 0000000..e77bde1 --- /dev/null +++ b/tests/fixtures/dedupe_mssql_servers.cypher @@ -0,0 +1,44 @@ +CREATE (a1:MSSQL_Server:MSSQL_Base:Base { + objectid: 'sccmdb:1433', + name: 'sccmdb.training.local:1433', + sqlServerName: 'SCCMDB', + version: '15.0.4322.2' +}); +CREATE (a2:MSSQL_Server:MSSQL_Base:Base { + objectid: 'S-1-5-21-1105:1433', + name: 'SCCMDB.training.local:1433', + xpCmdShellEnabled: false +}); +CREATE (a3:MSSQL_Server:MSSQL_Base:Base { + objectid: 'sccmdb.training.local:1433', + name: 'SCCMDB.training.local:1433', + extendedProtection: 'Off' +}); + +CREATE (b1:MSSQL_Server:MSSQL_Base:Base { + objectid: 'sql01\\app1:1444', + name: 'sql01.training.local:1444', + instanceName: 'APP1' +}); +CREATE (b2:MSSQL_Server:MSSQL_Base:Base { + objectid: 'sql01\\app2:1455', + name: 'sql01.training.local:1455', + instanceName: 'APP2' +}); +CREATE (b3:MSSQL_Server:MSSQL_Base:Base { + objectid: 'sql01\\sql01:1433', + name: 'sql01.training.local:1433', + instanceName: 'SQL01' +}); + +CREATE (role:MSSQL_ServerRole:MSSQL_Base:Base { + objectid: 'sysadmin@S-1-5-21-1105:1433', + name: 'sysadmin', + SQLServer: 'SCCMDB.training.local:1433' +}); + +MATCH (canonical:MSSQL_Server {objectid: 'sccmdb:1433'}), + (stale:MSSQL_Server {objectid: 'S-1-5-21-1105:1433'}), + (role:MSSQL_ServerRole {name: 'sysadmin'}) +CREATE (canonical)-[:MSSQL_Contains {traversable: true}]->(role) +CREATE (stale)-[:MSSQL_Contains {traversable: true}]->(role); diff --git a/tests/fixtures/mssql_priv_esc_group_owned_login.cypher b/tests/fixtures/mssql_priv_esc_group_owned_login.cypher new file mode 100644 index 0000000..fed5c76 --- /dev/null +++ b/tests/fixtures/mssql_priv_esc_group_owned_login.cypher @@ -0,0 +1,48 @@ +// Six nested AD groups prove inherited SQL logins are traversed. + +CREATE (u:User { + name: 'NESTEDUSER@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3401', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (g1:Group {name: 'G1@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G1', domain: 'TEST.LOCAL'}); +CREATE (g2:Group {name: 'G2@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G2', domain: 'TEST.LOCAL'}); +CREATE (g3:Group {name: 'G3@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G3', domain: 'TEST.LOCAL'}); +CREATE (g4:Group {name: 'G4@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G4', domain: 'TEST.LOCAL'}); +CREATE (g5:Group {name: 'G5@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G5', domain: 'TEST.LOCAL'}); +CREATE (g6:Group {name: 'SQL OWNERS@TEST.LOCAL', objectid: 'S-1-5-21-TEST-G6', domain: 'TEST.LOCAL'}); + +CREATE (login:MSSQL_Login { + name: 'TEST\\group-login', + SQLServer: 'sql01.test.local:1433' +}); + +CREATE (role:MSSQL_ServerRole { + name: 'sysadmin', + SQLServer: 'sql01.test.local:1433' +}); + +CREATE (server:MSSQL_Server { + name: 'sql01.test.local:1433', + xpCmdShellEnabled: false +}); + +MATCH (u:User {objectid: 'S-1-5-21-TEST-3401'}), + (g1:Group {objectid: 'S-1-5-21-TEST-G1'}), + (g2:Group {objectid: 'S-1-5-21-TEST-G2'}), + (g3:Group {objectid: 'S-1-5-21-TEST-G3'}), + (g4:Group {objectid: 'S-1-5-21-TEST-G4'}), + (g5:Group {objectid: 'S-1-5-21-TEST-G5'}), + (g6:Group {objectid: 'S-1-5-21-TEST-G6'}), + (login:MSSQL_Login {name: 'TEST\\group-login'}), + (role:MSSQL_ServerRole {name: 'sysadmin'}) +CREATE (u)-[:MemberOf]->(g1) +CREATE (g1)-[:MemberOf]->(g2) +CREATE (g2)-[:MemberOf]->(g3) +CREATE (g3)-[:MemberOf]->(g4) +CREATE (g4)-[:MemberOf]->(g5) +CREATE (g5)-[:MemberOf]->(g6) +CREATE (g6)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_MemberOf]->(role); diff --git a/tests/fixtures/mssql_priv_esc_linked_server_empty_stub.cypher b/tests/fixtures/mssql_priv_esc_linked_server_empty_stub.cypher new file mode 100644 index 0000000..f1bc42f --- /dev/null +++ b/tests/fixtures/mssql_priv_esc_linked_server_empty_stub.cypher @@ -0,0 +1,37 @@ +// Empty linked-server stub names must not match every MSSQL_Server. + +CREATE (srvA:MSSQL_Server { + name: 'empty-src.test.local:1433', + sqlServerName: 'empty-src\\SRCINST' +}); + +CREATE (stub:MSSQL_Base {name: 'LinkedServer:EMPTY-SRC\\SRCINST'}); + +CREATE (srvBStub:MSSQL_Server {name: ''}); + +CREATE (unrelated:MSSQL_Server {name: 'unrelated.test.local:1433'}); +CREATE (unrelatedRole:MSSQL_ServerRole {name: 'sysadmin', SQLServer: 'unrelated.test.local:1433'}); + +CREATE (u:User { + name: 'EMPTYSTUB@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3702', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (login:MSSQL_Login { + name: 'TEST\\emptystub', + SQLServer: 'empty-src.test.local:1433' +}); + +MATCH (u:User {objectid: 'S-1-5-21-TEST-3702'}), + (login:MSSQL_Login {name: 'TEST\\emptystub'}), + (srvA:MSSQL_Server {name: 'empty-src.test.local:1433'}), + (stub:MSSQL_Base {name: 'LinkedServer:EMPTY-SRC\\SRCINST'}), + (srvBStub:MSSQL_Server {name: ''}), + (unrelated:MSSQL_Server {name: 'unrelated.test.local:1433'}), + (unrelatedRole:MSSQL_ServerRole {SQLServer: 'unrelated.test.local:1433'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srvA) +CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub) +CREATE (unrelated)-[:MSSQL_Contains]->(unrelatedRole); diff --git a/tests/fixtures/mssql_priv_esc_linked_server_name_overmatch.cypher b/tests/fixtures/mssql_priv_esc_linked_server_name_overmatch.cypher new file mode 100644 index 0000000..0579feb --- /dev/null +++ b/tests/fixtures/mssql_priv_esc_linked_server_name_overmatch.cypher @@ -0,0 +1,43 @@ +// Stub name sql01 must not match the sibling host sql01b. + +CREATE (srvA:MSSQL_Server { + name: 'lab-src.test.local:1433', + sqlServerName: 'lab-src\\SRCINST' +}); + +CREATE (stub:MSSQL_Base {name: 'LinkedServer:LAB-SRC\\SRCINST'}); + +CREATE (srvBStub:MSSQL_Server {name: 'sql01'}); + +CREATE (srvBIntended:MSSQL_Server {name: 'sql01.test.local:1433'}); +CREATE (intendedRole:MSSQL_ServerRole {name: 'sysadmin', SQLServer: 'sql01.test.local:1433'}); + +CREATE (srvBDecoy:MSSQL_Server {name: 'sql01b.test.local:1433'}); +CREATE (decoyRole:MSSQL_ServerRole {name: 'sysadmin', SQLServer: 'sql01b.test.local:1433'}); + +CREATE (u:User { + name: 'NAMEOVERMATCH@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3701', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (login:MSSQL_Login { + name: 'TEST\\nameovermatch', + SQLServer: 'lab-src.test.local:1433' +}); + +MATCH (u:User {objectid: 'S-1-5-21-TEST-3701'}), + (login:MSSQL_Login {name: 'TEST\\nameovermatch'}), + (srvA:MSSQL_Server {name: 'lab-src.test.local:1433'}), + (stub:MSSQL_Base {name: 'LinkedServer:LAB-SRC\\SRCINST'}), + (srvBStub:MSSQL_Server {name: 'sql01'}), + (srvBIntended:MSSQL_Server {name: 'sql01.test.local:1433'}), + (intendedRole:MSSQL_ServerRole {SQLServer: 'sql01.test.local:1433'}), + (srvBDecoy:MSSQL_Server {name: 'sql01b.test.local:1433'}), + (decoyRole:MSSQL_ServerRole {SQLServer: 'sql01b.test.local:1433'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srvA) +CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub) +CREATE (srvBIntended)-[:MSSQL_Contains]->(intendedRole) +CREATE (srvBDecoy)-[:MSSQL_Contains]->(decoyRole); diff --git a/tests/fixtures/mssql_priv_esc_linked_server_overmatch.cypher b/tests/fixtures/mssql_priv_esc_linked_server_overmatch.cypher new file mode 100644 index 0000000..5364c33 --- /dev/null +++ b/tests/fixtures/mssql_priv_esc_linked_server_overmatch.cypher @@ -0,0 +1,43 @@ +// SQL01-BAK must not bridge through a substring match on SQL01. + +CREATE (srvA:MSSQL_Server { + name: 'lab-sql01.test.local:1433', + sqlServerName: 'lab-sql01\\SQL01', + objectid: 'S-1-5-21-TEST-4806:1433' +}); + +CREATE (stub:MSSQL_Base { + name: 'LinkedServer:LAB-SQL01\\SQL01-BAK' +}); + +CREATE (srvB:MSSQL_Server { + name: 'sccmdb.test.local:1433' +}); + +CREATE (role:MSSQL_ServerRole { + name: 'sysadmin', + SQLServer: 'sccmdb.test.local:1433' +}); + +CREATE (u:User { + name: 'OVERMATCH@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3501', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (login:MSSQL_Login { + name: 'TEST\\overmatch', + SQLServer: 'lab-sql01.test.local:1433' +}); + +MATCH (u:User {objectid: 'S-1-5-21-TEST-3501'}), + (login:MSSQL_Login {name: 'TEST\\overmatch'}), + (srvA:MSSQL_Server {name: 'lab-sql01.test.local:1433'}), + (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'}), + (stub:MSSQL_Base {name: 'LinkedServer:LAB-SQL01\\SQL01-BAK'}), + (role:MSSQL_ServerRole {name: 'sysadmin'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srvA) +CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvB) +CREATE (srvB)-[:MSSQL_Contains]->(role); diff --git a/tests/fixtures/mssql_priv_esc_linked_server_shared_host_sid.cypher b/tests/fixtures/mssql_priv_esc_linked_server_shared_host_sid.cypher new file mode 100644 index 0000000..d921671 --- /dev/null +++ b/tests/fixtures/mssql_priv_esc_linked_server_shared_host_sid.cypher @@ -0,0 +1,45 @@ +// Ambiguous host SID prefixes shared by SQL instances must not bridge. + +CREATE (srvA1:MSSQL_Server { + name: 'shared-host.test.local:1433', + sqlServerName: 'shared-host\\INST1', + objectid: 'S-1-5-21-TEST-SHARED:1433' +}); +CREATE (srvA2:MSSQL_Server { + name: 'shared-host.test.local:1434', + sqlServerName: 'shared-host\\INST2', + objectid: 'S-1-5-21-TEST-SHARED:1434' +}); + +CREATE (stub:MSSQL_Base { + name: 'S-1-5-21-TEST-SHARED:INST2', + objectid: 'S-1-5-21-TEST-SHARED:INST2' +}); + +CREATE (srvBStub:MSSQL_Server {name: 'sccmdb.test.local'}); +CREATE (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'}); +CREATE (role:MSSQL_ServerRole {name: 'sysadmin', SQLServer: 'sccmdb.test.local:1433'}); + +CREATE (u:User { + name: 'SHAREDSID@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3703', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (login:MSSQL_Login { + name: 'TEST\\sharedsid', + SQLServer: 'shared-host.test.local:1433' +}); + +MATCH (u:User {objectid: 'S-1-5-21-TEST-3703'}), + (login:MSSQL_Login {name: 'TEST\\sharedsid'}), + (srvA1:MSSQL_Server {objectid: 'S-1-5-21-TEST-SHARED:1433'}), + (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'}), + (srvBStub:MSSQL_Server {name: 'sccmdb.test.local'}), + (stub:MSSQL_Base {objectid: 'S-1-5-21-TEST-SHARED:INST2'}), + (role:MSSQL_ServerRole {SQLServer: 'sccmdb.test.local:1433'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srvA1) +CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub) +CREATE (srvB)-[:MSSQL_Contains]->(role); diff --git a/tests/fixtures/mssql_priv_esc_linked_server_sid.cypher b/tests/fixtures/mssql_priv_esc_linked_server_sid.cypher new file mode 100644 index 0000000..0d51cba --- /dev/null +++ b/tests/fixtures/mssql_priv_esc_linked_server_sid.cypher @@ -0,0 +1,49 @@ +// SID-named stubs fall back to host-SID prefix matching. + +CREATE (srvA:MSSQL_Server { + name: 'lab-sql01.test.local:1433', + sqlServerName: 'lab-sql01\\SQL01', + objectid: 'S-1-5-21-TEST-4806:1433' +}); + +CREATE (stub:MSSQL_Base { + name: 'S-1-5-21-TEST-4806:SQL01', + objectid: 'S-1-5-21-TEST-4806:SQL01' +}); + +CREATE (srvBStub:MSSQL_Server { + name: 'sccmdb.test.local' +}); + +CREATE (srvB:MSSQL_Server { + name: 'sccmdb.test.local:1433' +}); + +CREATE (role:MSSQL_ServerRole { + name: 'sysadmin', + SQLServer: 'sccmdb.test.local:1433' +}); + +CREATE (u:User { + name: 'SIDLINK@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3301', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (login:MSSQL_Login { + name: 'TEST\\sidlink', + SQLServer: 'lab-sql01.test.local:1433' +}); + +MATCH (u:User {objectid: 'S-1-5-21-TEST-3301'}), + (login:MSSQL_Login {name: 'TEST\\sidlink'}), + (srvA:MSSQL_Server {name: 'lab-sql01.test.local:1433'}), + (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'}), + (srvBStub:MSSQL_Server {name: 'sccmdb.test.local'}), + (stub:MSSQL_Base {name: 'S-1-5-21-TEST-4806:SQL01'}), + (role:MSSQL_ServerRole {name: 'sysadmin'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srvA) +CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub) +CREATE (srvB)-[:MSSQL_Contains]->(role); diff --git a/tests/fixtures/mssql_priv_esc_owns_server_role.cypher b/tests/fixtures/mssql_priv_esc_owns_server_role.cypher new file mode 100644 index 0000000..f2b89ff --- /dev/null +++ b/tests/fixtures/mssql_priv_esc_owns_server_role.cypher @@ -0,0 +1,33 @@ +// Owning sysadmin is enough to add yourself to the role. + +CREATE (srv:MSSQL_Server { + name: 'sql01.test.local:1433', + sqlServerName: 'sql01', + objectid: 'S-1-5-21-TEST-4001:1433' +}); + +CREATE (role:MSSQL_ServerRole { + name: 'sysadmin', + SQLServer: 'sql01.test.local:1433' +}); + +CREATE (u:User { + name: 'OWNSROLE@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3601', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (login:MSSQL_Login { + name: 'TEST\\ownsrole', + SQLServer: 'sql01.test.local:1433' +}); + +MATCH (u:User {objectid: 'S-1-5-21-TEST-3601'}), + (login:MSSQL_Login {name: 'TEST\\ownsrole'}), + (srv:MSSQL_Server {name: 'sql01.test.local:1433'}), + (role:MSSQL_ServerRole {name: 'sysadmin'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srv) +CREATE (login)-[:MSSQL_Owns]->(role) +CREATE (srv)-[:MSSQL_Contains]->(role); diff --git a/tests/fixtures/mssql_privilege_escalation_linked_server.cypher b/tests/fixtures/mssql_privilege_escalation_linked_server.cypher new file mode 100644 index 0000000..5c034a6 --- /dev/null +++ b/tests/fixtures/mssql_privilege_escalation_linked_server.cypher @@ -0,0 +1,90 @@ +// Case-mismatched linked-server stubs still bridge to the source server. + +CREATE (srvA:MSSQL_Server { + name: 'lab-sql01.test.local:1433', + sqlServerName: 'lab-sql01\\SQL01' +}); + +CREATE (stub:MSSQL_Base { + name: 'LinkedServer:LAB-SQL01\\SQL01' +}); + +CREATE (srvB:MSSQL_Server { + name: 'sccmdb.test.local:1433' +}); + +CREATE (role:MSSQL_ServerRole { + name: 'sysadmin', + SQLServer: 'sccmdb.test.local:1433' +}); + +CREATE (u:User { + name: 'LOWPRIV@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3201', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (groupUser:User { + name: 'GROUPLINK@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3202', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (broadUser:User { + name: 'BROADLINK@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-3203', + enabled: true, + domain: 'TEST.LOCAL' +}); + +CREATE (linkedGroup:Group { + name: 'LINKED SQL USERS@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-LINKED-GROUP', + domain: 'TEST.LOCAL' +}); + +CREATE (domainUsers:Group { + name: 'DOMAIN USERS@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-513', + domain: 'TEST.LOCAL' +}); + +CREATE (login:MSSQL_Login { + name: 'TEST\\lowpriv', + SQLServer: 'lab-sql01.test.local:1433' +}); + +CREATE (groupLogin:MSSQL_Login { + name: 'TEST\\linked-group', + SQLServer: 'lab-sql01.test.local:1433' +}); + +CREATE (broadLogin:MSSQL_Login { + name: 'TEST\\Domain Users', + SQLServer: 'lab-sql01.test.local:1433' +}); + +MATCH (u:User {objectid: 'S-1-5-21-TEST-3201'}), + (groupUser:User {objectid: 'S-1-5-21-TEST-3202'}), + (broadUser:User {objectid: 'S-1-5-21-TEST-3203'}), + (linkedGroup:Group {objectid: 'S-1-5-21-TEST-LINKED-GROUP'}), + (domainUsers:Group {objectid: 'S-1-5-21-TEST-513'}), + (login:MSSQL_Login {name: 'TEST\\lowpriv'}), + (groupLogin:MSSQL_Login {name: 'TEST\\linked-group'}), + (broadLogin:MSSQL_Login {name: 'TEST\\Domain Users'}), + (srvA:MSSQL_Server {name: 'lab-sql01.test.local:1433'}), + (srvB:MSSQL_Server {name: 'sccmdb.test.local:1433'}), + (stub:MSSQL_Base {name: 'LinkedServer:LAB-SQL01\\SQL01'}), + (role:MSSQL_ServerRole {name: 'sysadmin'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srvA) +CREATE (groupUser)-[:MemberOf]->(linkedGroup) +CREATE (linkedGroup)-[:MSSQL_HasLogin]->(groupLogin) +CREATE (groupLogin)-[:MSSQL_Connect]->(srvA) +CREATE (broadUser)-[:MemberOf]->(domainUsers) +CREATE (domainUsers)-[:MSSQL_HasLogin]->(broadLogin) +CREATE (broadLogin)-[:MSSQL_Connect]->(srvA) +CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvB) +CREATE (srvB)-[:MSSQL_Contains]->(role); diff --git a/tests/fixtures/mssql_sccm.cypher b/tests/fixtures/mssql_sccm.cypher deleted file mode 100644 index 54ae2a3..0000000 --- a/tests/fixtures/mssql_sccm.cypher +++ /dev/null @@ -1,40 +0,0 @@ -// MSSQL_SCCM hybrid positive: a domain user has an MSSQL_Login that controls -// the SCCM site database, which in turn -[:SCCM_AssignAllPermissions]-> the -// SCCM_Site. Cross-schema path with traversable=true on every edge. - -CREATE (site:SCCM_Site:SCCM_Base:Base {siteCode: 'P01', sourceForest: 'TEST.LOCAL'}); - -CREATE (server:MSSQL_Server:MSSQL_Base:Base { - name: 'sql01.test.local:1433', - xpCmdShellEnabled: false, - extendedProtection: false -}); - -CREATE (login:MSSQL_Login:MSSQL_Base:Base { - name: 'TEST\\sccmadmin', - SQLServer: 'sql01.test.local:1433', - type: 'WINDOWS_LOGIN' -}); - -CREATE (sccmDb:MSSQL_Database:MSSQL_Base:Base { - name: 'CM_P01', - SQLServer: 'sql01.test.local:1433', - isTrustworthy: true -}); - -CREATE (u:User:Base { - name: 'sccmadmin@TEST.LOCAL', - objectid: 'S-1-5-21-TEST-1300', - enabled: true, - domain: 'TEST.LOCAL' -}); - -// shortestPath() requires every edge in the chain to carry traversable=true, -// regardless of which schema the edge belongs to (mssql_sccm.py:82). -MATCH (u:User {objectid: 'S-1-5-21-TEST-1300'}), - (login:MSSQL_Login {name: 'TEST\\sccmadmin'}), - (sccmDb:MSSQL_Database {name: 'CM_P01'}), - (site:SCCM_Site {siteCode: 'P01'}) -CREATE (u)-[:MSSQL_HasLogin {traversable: true}]->(login) -CREATE (login)-[:MSSQL_ControlDB {traversable: true}]->(sccmDb) -CREATE (sccmDb)-[:SCCM_AssignAllPermissions {traversable: true}]->(site); diff --git a/tests/fixtures/sccm_priv_esc_linked_server_empty_stub.cypher b/tests/fixtures/sccm_priv_esc_linked_server_empty_stub.cypher new file mode 100644 index 0000000..344cf09 --- /dev/null +++ b/tests/fixtures/sccm_priv_esc_linked_server_empty_stub.cypher @@ -0,0 +1,38 @@ +// Empty SCCM linked-server stub names must not match site database servers. + +CREATE (site:SCCM_Site:SCCM_Base:Base { + siteCode: 'P11', + displayName: 'Primary Site', + objectid: 'P11', + sourceForest: 'TEST.LOCAL' +}); + +CREATE (srvA:MSSQL_Server:MSSQL_Base:Base { + name: 'empty-src.test.local:1433', + sqlServerName: 'empty-src\\SRCINST' +}); +CREATE (stub:MSSQL_Base {name: 'LinkedServer:EMPTY-SRC\\SRCINST'}); +CREATE (srvBStub:MSSQL_Server:MSSQL_Base:Base {name: ''}); + +CREATE (unrelated:MSSQL_Server:MSSQL_Base:Base {name: 'unrelated-cm.test.local:1433'}); +CREATE (unrelatedRole:MSSQL_ServerRole:MSSQL_Base:Base {name: 'sysadmin', SQLServer: 'unrelated-cm.test.local:1433'}); +CREATE (unrelatedDb:MSSQL_Database:MSSQL_Base:Base {name: 'CM_P11', SQLServer: 'unrelated-cm.test.local:1433'}); + +CREATE (u:User:Base {name: 'SCCMEMPTYSTUB@TEST.LOCAL', objectid: 'S-1-5-21-TEST-311', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (login:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sccmemptystub', SQLServer: 'empty-src.test.local:1433'}); + +MATCH (site:SCCM_Site {siteCode: 'P11'}), + (u:User {objectid: 'S-1-5-21-TEST-311'}), + (login:MSSQL_Login {name: 'TEST\\sccmemptystub'}), + (srvA:MSSQL_Server {name: 'empty-src.test.local:1433'}), + (stub:MSSQL_Base {name: 'LinkedServer:EMPTY-SRC\\SRCINST'}), + (srvBStub:MSSQL_Server {name: ''}), + (unrelated:MSSQL_Server {name: 'unrelated-cm.test.local:1433'}), + (unrelatedRole:MSSQL_ServerRole {SQLServer: 'unrelated-cm.test.local:1433'}), + (unrelatedDb:MSSQL_Database {name: 'CM_P11'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srvA) +CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub) +CREATE (unrelated)-[:MSSQL_Contains]->(unrelatedRole) +CREATE (unrelated)-[:MSSQL_Contains]->(unrelatedDb) +CREATE (unrelatedDb)-[:SCCM_AssignAllPermissions]->(site); diff --git a/tests/fixtures/sccm_priv_esc_linked_server_name_overmatch.cypher b/tests/fixtures/sccm_priv_esc_linked_server_name_overmatch.cypher new file mode 100644 index 0000000..5787407 --- /dev/null +++ b/tests/fixtures/sccm_priv_esc_linked_server_name_overmatch.cypher @@ -0,0 +1,48 @@ +// Stub name cmsql must not match the sibling host cmsql-dr. + +CREATE (site:SCCM_Site:SCCM_Base:Base { + siteCode: 'P10', + displayName: 'Primary Site', + objectid: 'P10', + sourceForest: 'TEST.LOCAL' +}); + +CREATE (srvA:MSSQL_Server:MSSQL_Base:Base { + name: 'lab-src.test.local:1433', + sqlServerName: 'lab-src\\SRCINST' +}); +CREATE (stub:MSSQL_Base {name: 'LinkedServer:LAB-SRC\\SRCINST'}); +CREATE (srvBStub:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql'}); + +CREATE (srvBIntended:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql.test.local:1433'}); +CREATE (intendedRole:MSSQL_ServerRole:MSSQL_Base:Base {name: 'sysadmin', SQLServer: 'cmsql.test.local:1433'}); +CREATE (intendedDb:MSSQL_Database:MSSQL_Base:Base {name: 'CM_P10', SQLServer: 'cmsql.test.local:1433'}); + +CREATE (srvBDecoy:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql-dr.test.local:1433'}); +CREATE (decoyRole:MSSQL_ServerRole:MSSQL_Base:Base {name: 'sysadmin', SQLServer: 'cmsql-dr.test.local:1433'}); +CREATE (decoyDb:MSSQL_Database:MSSQL_Base:Base {name: 'CM_DR', SQLServer: 'cmsql-dr.test.local:1433'}); + +CREATE (u:User:Base {name: 'SCCMNAMEOVERMATCH@TEST.LOCAL', objectid: 'S-1-5-21-TEST-310', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (login:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sccmnameovermatch', SQLServer: 'lab-src.test.local:1433'}); + +MATCH (site:SCCM_Site {siteCode: 'P10'}), + (u:User {objectid: 'S-1-5-21-TEST-310'}), + (login:MSSQL_Login {name: 'TEST\\sccmnameovermatch'}), + (srvA:MSSQL_Server {name: 'lab-src.test.local:1433'}), + (stub:MSSQL_Base {name: 'LinkedServer:LAB-SRC\\SRCINST'}), + (srvBStub:MSSQL_Server {name: 'cmsql'}), + (srvBIntended:MSSQL_Server {name: 'cmsql.test.local:1433'}), + (intendedRole:MSSQL_ServerRole {SQLServer: 'cmsql.test.local:1433'}), + (intendedDb:MSSQL_Database {name: 'CM_P10'}), + (srvBDecoy:MSSQL_Server {name: 'cmsql-dr.test.local:1433'}), + (decoyRole:MSSQL_ServerRole {SQLServer: 'cmsql-dr.test.local:1433'}), + (decoyDb:MSSQL_Database {name: 'CM_DR'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srvA) +CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub) +CREATE (srvBIntended)-[:MSSQL_Contains]->(intendedRole) +CREATE (srvBIntended)-[:MSSQL_Contains]->(intendedDb) +CREATE (intendedDb)-[:SCCM_AssignAllPermissions]->(site) +CREATE (srvBDecoy)-[:MSSQL_Contains]->(decoyRole) +CREATE (srvBDecoy)-[:MSSQL_Contains]->(decoyDb) +CREATE (decoyDb)-[:SCCM_AssignAllPermissions]->(site); diff --git a/tests/fixtures/sccm_priv_esc_linked_server_shared_host_sid.cypher b/tests/fixtures/sccm_priv_esc_linked_server_shared_host_sid.cypher new file mode 100644 index 0000000..4bcc810 --- /dev/null +++ b/tests/fixtures/sccm_priv_esc_linked_server_shared_host_sid.cypher @@ -0,0 +1,45 @@ +// SCCM linked paths must not bridge ambiguous shared host SID prefixes. + +CREATE (site:SCCM_Site:SCCM_Base:Base { + siteCode: 'P12', + displayName: 'Primary Site', + objectid: 'P12', + sourceForest: 'TEST.LOCAL' +}); + +CREATE (srvA1:MSSQL_Server:MSSQL_Base:Base { + name: 'shared-host.test.local:1433', + sqlServerName: 'shared-host\\INST1', + objectid: 'S-1-5-21-TEST-SHARED:1433' +}); +CREATE (srvA2:MSSQL_Server:MSSQL_Base:Base { + name: 'shared-host.test.local:1434', + sqlServerName: 'shared-host\\INST2', + objectid: 'S-1-5-21-TEST-SHARED:1434' +}); + +CREATE (stub:MSSQL_Base {name: 'S-1-5-21-TEST-SHARED:INST2', objectid: 'S-1-5-21-TEST-SHARED:INST2'}); + +CREATE (srvBStub:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql.test.local'}); +CREATE (srvB:MSSQL_Server:MSSQL_Base:Base {name: 'cmsql.test.local:1433'}); +CREATE (role:MSSQL_ServerRole:MSSQL_Base:Base {name: 'sysadmin', SQLServer: 'cmsql.test.local:1433'}); +CREATE (sccmDb:MSSQL_Database:MSSQL_Base:Base {name: 'CM_P12', SQLServer: 'cmsql.test.local:1433'}); + +CREATE (u:User:Base {name: 'SCCMSHAREDSID@TEST.LOCAL', objectid: 'S-1-5-21-TEST-312', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (login:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sccmsharedsid', SQLServer: 'shared-host.test.local:1433'}); + +MATCH (site:SCCM_Site {siteCode: 'P12'}), + (u:User {objectid: 'S-1-5-21-TEST-312'}), + (login:MSSQL_Login {name: 'TEST\\sccmsharedsid'}), + (srvA1:MSSQL_Server {objectid: 'S-1-5-21-TEST-SHARED:1433'}), + (stub:MSSQL_Base {objectid: 'S-1-5-21-TEST-SHARED:INST2'}), + (srvBStub:MSSQL_Server {name: 'cmsql.test.local'}), + (srvB:MSSQL_Server {name: 'cmsql.test.local:1433'}), + (role:MSSQL_ServerRole {SQLServer: 'cmsql.test.local:1433'}), + (sccmDb:MSSQL_Database {name: 'CM_P12'}) +CREATE (u)-[:MSSQL_HasLogin]->(login) +CREATE (login)-[:MSSQL_Connect]->(srvA1) +CREATE (stub)-[:MSSQL_LinkedAsAdmin]->(srvBStub) +CREATE (srvB)-[:MSSQL_Contains]->(role) +CREATE (srvB)-[:MSSQL_Contains]->(sccmDb) +CREATE (sccmDb)-[:SCCM_AssignAllPermissions]->(site); diff --git a/tests/fixtures/sccm_privilege_escalation.cypher b/tests/fixtures/sccm_privilege_escalation.cypher new file mode 100644 index 0000000..e8afc00 --- /dev/null +++ b/tests/fixtures/sccm_privilege_escalation.cypher @@ -0,0 +1,336 @@ +// MSSQL edges omit traversable to match MSSQLHound output. + +CREATE (site:SCCM_Site:SCCM_Base:Base { + siteCode: 'P01', + displayName: 'Primary Site', + objectid: 'P01', + sourceForest: 'TEST.LOCAL' +}); + +CREATE (otherSite:SCCM_Site:SCCM_Base:Base { + siteCode: 'X01', + displayName: 'Other Forest Site', + objectid: 'X01', + sourceForest: 'OTHER.LOCAL' +}); + +CREATE (sccmServer:MSSQL_Server:MSSQL_Base:Base { + name: 'sql01.test.local:1433', + xpCmdShellEnabled: false, + objectid: 'S-1-5-21-TEST-SQL01:1433' +}); + +CREATE (sccmDb:MSSQL_Database:MSSQL_Base:Base { + name: 'CM_P01', + SQLServer: 'sql01.test.local:1433' +}); + +CREATE (sysadmin:MSSQL_ServerRole:MSSQL_Base:Base { + name: 'sysadmin', + SQLServer: 'sql01.test.local:1433' +}); + +CREATE (dbOwner:MSSQL_DatabaseRole:MSSQL_Base:Base { + name: 'db_owner@CM_P01', + SQLServer: 'sql01.test.local:1433' +}); + +CREATE (fullRole:SCCM_SecurityRole:SCCM_Base:Base { + name: 'Full Administrator' +}); + +CREATE (adminUser:SCCM_AdminUser:SCCM_Base:Base { + name: 'TEST\\SCCM Admins', + objectid: 'TEST\\SCCM Admins@P01', + sourceSiteCode: 'P01', + isGroup: true +}); + +CREATE (roleOnlyAdmin:SCCM_AdminUser:SCCM_Base:Base { + name: 'TEST\\Role Admin', + objectid: 'TEST\\Role Admin@P01', + sourceSiteCode: 'P01' +}); + +CREATE (lowAdmin:SCCM_AdminUser:SCCM_Base:Base { + name: 'TEST\\Low SCCM Group', + objectid: 'TEST\\Low SCCM Group@P01', + sourceSiteCode: 'P01', + isGroup: true +}); + +CREATE (otherAdmin:SCCM_AdminUser:SCCM_Base:Base { + name: 'OTHER\\SCCM Admins', + objectid: 'OTHER\\SCCM Admins@X01', + sourceSiteCode: 'X01' +}); + +CREATE (sccmGroup:Group:Base { + name: 'SCCM ADMINS@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-SCCM-GROUP', + domain: 'TEST.LOCAL' +}); + +CREATE (linkedSqlGroup:Group:Base { + name: 'LINKED SQL USERS@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-LINKED-SQL-GROUP', + domain: 'TEST.LOCAL' +}); + +CREATE (domainUsers:Group:Base { + name: 'DOMAIN USERS@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-513', + domain: 'TEST.LOCAL' +}); + +CREATE (lowGroup:Group:Base { + name: 'LOW SCCM GROUP@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-LOW-GROUP', + domain: 'TEST.LOCAL' +}); + +CREATE (otherGroup:Group:Base { + name: 'OTHER SCCM GROUP@TEST.LOCAL', + objectid: 'S-1-5-21-TEST-OTHER-GROUP', + domain: 'TEST.LOCAL' +}); + +CREATE (pureUser:User:Base {name: 'PURE@TEST.LOCAL', objectid: 'S-1-5-21-TEST-100', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (nestedUser:User:Base {name: 'NESTED@TEST.LOCAL', objectid: 'S-1-5-21-TEST-101', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (sqlUser:User:Base {name: 'SQLSYS@TEST.LOCAL', objectid: 'S-1-5-21-TEST-102', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (dbUserPrincipal:User:Base {name: 'SQLDB@TEST.LOCAL', objectid: 'S-1-5-21-TEST-103', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (linkedUser:User:Base {name: 'LINKED@TEST.LOCAL', objectid: 'S-1-5-21-TEST-104', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (alterUser:User:Base {name: 'ALTER@TEST.LOCAL', objectid: 'S-1-5-21-TEST-105', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (roleOnlyUser:User:Base {name: 'ROLEONLY@TEST.LOCAL', objectid: 'S-1-5-21-TEST-106', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (groupLinkedUser:User:Base {name: 'GROUPLINKED@TEST.LOCAL', objectid: 'S-1-5-21-TEST-107', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (broadLinkedUser:User:Base {name: 'BROADLINKED@TEST.LOCAL', objectid: 'S-1-5-21-TEST-108', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (execHostUser:User:Base {name: 'EXECCHAIN@TEST.LOCAL', objectid: 'S-1-5-21-TEST-109', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (sqlServiceAccount:User:Base:Tag_Tier_Zero {name: 'SQLSVC@TEST.LOCAL', objectid: 'S-1-5-21-TEST-SQLSVC', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (connectOnlyUser:User:Base {name: 'CONNECTONLY@TEST.LOCAL', objectid: 'S-1-5-21-TEST-200', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (lowSccmUser:User:Base {name: 'LOWSCCM@TEST.LOCAL', objectid: 'S-1-5-21-TEST-201', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (crossForestUser:User:Base {name: 'CROSSFOREST@TEST.LOCAL', objectid: 'S-1-5-21-TEST-202', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (disabledExecUser:User:Base {name: 'DISABLEDEXEC@TEST.LOCAL', objectid: 'S-1-5-21-TEST-203', enabled: true, domain: 'TEST.LOCAL'}); +CREATE (noAdminLinkUser:User:Base {name: 'NOADMINLINK@TEST.LOCAL', objectid: 'S-1-5-21-TEST-204', enabled: true, domain: 'TEST.LOCAL'}); + +CREATE (n1:Group:Base {name: 'N1@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N1', domain: 'TEST.LOCAL'}); +CREATE (n2:Group:Base {name: 'N2@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N2', domain: 'TEST.LOCAL'}); +CREATE (n3:Group:Base {name: 'N3@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N3', domain: 'TEST.LOCAL'}); +CREATE (n4:Group:Base {name: 'N4@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N4', domain: 'TEST.LOCAL'}); +CREATE (n5:Group:Base {name: 'N5@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N5', domain: 'TEST.LOCAL'}); +CREATE (n6:Group:Base {name: 'N6@TEST.LOCAL', objectid: 'S-1-5-21-TEST-N6', domain: 'TEST.LOCAL'}); + +CREATE (sqlLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sqlsys', SQLServer: 'sql01.test.local:1433'}); +CREATE (dbLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\sqldb', SQLServer: 'sql01.test.local:1433'}); +CREATE (dbMappedUser:MSSQL_DatabaseUser:MSSQL_Base:Base {name: 'sqldb@CM_P01', SQLServer: 'sql01.test.local:1433'}); +CREATE (alterLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\alter', SQLServer: 'sql01.test.local:1433'}); +CREATE (connectLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\connectonly', SQLServer: 'sql01.test.local:1433'}); + +CREATE (linkedSource:MSSQL_Server:MSSQL_Base:Base { + name: 'lab-sql01.test.local:1433', + sqlServerName: 'lab-sql01\\SQL01', + objectid: 'S-1-5-21-TEST-LABSQL:1433' +}); +CREATE (linkedStub:MSSQL_Base {name: 'S-1-5-21-TEST-LABSQL:SQL01', objectid: 'S-1-5-21-TEST-LABSQL:SQL01'}); +CREATE (linkedTargetStub:MSSQL_Server:MSSQL_Base:Base {name: 'sql01.test.local'}); +CREATE (linkedLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\linked', SQLServer: 'lab-sql01.test.local:1433'}); +CREATE (linkedGroupLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\linked-group', SQLServer: 'lab-sql01.test.local:1433'}); +CREATE (linkedBroadLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\Domain Users', SQLServer: 'lab-sql01.test.local:1433'}); +CREATE (execLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\execchain', SQLServer: 'lab-sql01.test.local:1433'}); +CREATE (execAssumedLogin:MSSQL_Login:MSSQL_Base:Base {name: 'ReportSvc', SQLServer: 'lab-sql01.test.local:1433'}); + +CREATE (sccmHost:Computer:Base { + name: 'SCCMSQL.TEST.LOCAL', + objectid: 'S-1-5-21-TEST-SCCM-SQL', + enabled: true, + domain: 'TEST.LOCAL', + SCCMSiteSystemRoles: ['SMS SQL Server@P01'] +}); + +CREATE (disabledSource:MSSQL_Server:MSSQL_Base:Base { + name: 'disabled-src.test.local:1433', + sqlServerName: 'disabled-src\\SQL01', + objectid: 'S-1-5-21-TEST-DISABLED-SRC:1433' +}); +CREATE (disabledStub:MSSQL_Base {name: 'S-1-5-21-TEST-DISABLED-SRC:SQL01', objectid: 'S-1-5-21-TEST-DISABLED-SRC:SQL01'}); +CREATE (disabledTargetStub:MSSQL_Server:MSSQL_Base:Base {name: 'disabled-sccm.test.local'}); +CREATE (disabledTarget:MSSQL_Server:MSSQL_Base:Base {name: 'disabled-sccm.test.local:1433'}); +CREATE (disabledLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\disabledexec', SQLServer: 'disabled-src.test.local:1433'}); +CREATE (disabledAssumedLogin:MSSQL_Login:MSSQL_Base:Base {name: 'DisabledExecSvc', SQLServer: 'disabled-src.test.local:1433'}); +CREATE (disabledHost:Computer:Base { + name: 'DISABLED-SCCM.TEST.LOCAL', + objectid: 'S-1-5-21-TEST-DISABLED-HOST', + enabled: false, + domain: 'TEST.LOCAL', + SCCMSiteSystemRoles: ['SMS SQL Server@P01'] +}); + +CREATE (noAdminSource:MSSQL_Server:MSSQL_Base:Base { + name: 'noadmin-src.test.local:1433', + sqlServerName: 'noadmin-src\\SQL01', + objectid: 'S-1-5-21-TEST-NOADMIN-SRC:1433' +}); +CREATE (noAdminStub:MSSQL_Base {name: 'S-1-5-21-TEST-NOADMIN-SRC:SQL01', objectid: 'S-1-5-21-TEST-NOADMIN-SRC:SQL01'}); +CREATE (noAdminTargetStub:MSSQL_Server:MSSQL_Base:Base {name: 'noadmin-sccm.test.local'}); +CREATE (noAdminTarget:MSSQL_Server:MSSQL_Base:Base {name: 'noadmin-sccm.test.local:1433'}); +CREATE (noAdminLogin:MSSQL_Login:MSSQL_Base:Base {name: 'TEST\\noadminlink', SQLServer: 'noadmin-src.test.local:1433'}); +CREATE (noAdminAssumedLogin:MSSQL_Login:MSSQL_Base:Base {name: 'NoAdminExecSvc', SQLServer: 'noadmin-src.test.local:1433'}); +CREATE (noAdminHost:Computer:Base { + name: 'NOADMIN-SCCM.TEST.LOCAL', + objectid: 'S-1-5-21-TEST-NOADMIN-HOST', + enabled: true, + domain: 'TEST.LOCAL', + SCCMSiteSystemRoles: ['SMS SQL Server@P01'] +}); + +MATCH (sccmServer:MSSQL_Server {name: 'sql01.test.local:1433'}), + (sccmDb:MSSQL_Database {name: 'CM_P01'}), + (site:SCCM_Site {siteCode: 'P01'}), + (sysadmin:MSSQL_ServerRole {name: 'sysadmin'}), + (dbOwner:MSSQL_DatabaseRole {name: 'db_owner@CM_P01'}), + (adminUser:SCCM_AdminUser {objectid: 'TEST\\SCCM Admins@P01'}), + (roleOnlyAdmin:SCCM_AdminUser {objectid: 'TEST\\Role Admin@P01'}), + (fullRole:SCCM_SecurityRole {name: 'Full Administrator'}) +CREATE (sccmServer)-[:MSSQL_Contains]->(sccmDb) +CREATE (sccmServer)-[:MSSQL_Contains]->(sysadmin) +CREATE (sysadmin)-[:MSSQL_ControlServer]->(sccmServer) +CREATE (sccmDb)-[:MSSQL_Contains]->(dbOwner) +CREATE (sccmDb)-[:SCCM_AssignAllPermissions]->(site) +CREATE (adminUser)-[:SCCM_AllPermissions]->(site) +CREATE (adminUser)-[:SCCM_IsAssigned]->(fullRole) +CREATE (roleOnlyAdmin)-[:SCCM_IsAssigned]->(fullRole); + +MATCH (pureUser:User {objectid: 'S-1-5-21-TEST-100'}), + (nestedUser:User {objectid: 'S-1-5-21-TEST-101'}), + (sccmGroup:Group {objectid: 'S-1-5-21-TEST-SCCM-GROUP'}), + (adminUser:SCCM_AdminUser {objectid: 'TEST\\SCCM Admins@P01'}), + (n1:Group {objectid: 'S-1-5-21-TEST-N1'}), + (n2:Group {objectid: 'S-1-5-21-TEST-N2'}), + (n3:Group {objectid: 'S-1-5-21-TEST-N3'}), + (n4:Group {objectid: 'S-1-5-21-TEST-N4'}), + (n5:Group {objectid: 'S-1-5-21-TEST-N5'}), + (n6:Group {objectid: 'S-1-5-21-TEST-N6'}) +CREATE (pureUser)-[:MemberOf]->(sccmGroup) +CREATE (sccmGroup)-[:SCCM_IsMappedTo]->(adminUser) +CREATE (nestedUser)-[:MemberOf]->(n1) +CREATE (n1)-[:MemberOf]->(n2) +CREATE (n2)-[:MemberOf]->(n3) +CREATE (n3)-[:MemberOf]->(n4) +CREATE (n4)-[:MemberOf]->(n5) +CREATE (n5)-[:MemberOf]->(n6) +CREATE (n6)-[:SCCM_IsMappedTo]->(adminUser); + +MATCH (sqlUser:User {objectid: 'S-1-5-21-TEST-102'}), + (sqlLogin:MSSQL_Login {name: 'TEST\\sqlsys'}), + (sysadmin:MSSQL_ServerRole {name: 'sysadmin'}), + (dbUserPrincipal:User {objectid: 'S-1-5-21-TEST-103'}), + (dbLogin:MSSQL_Login {name: 'TEST\\sqldb'}), + (dbMappedUser:MSSQL_DatabaseUser {name: 'sqldb@CM_P01'}), + (dbOwner:MSSQL_DatabaseRole {name: 'db_owner@CM_P01'}), + (alterUser:User {objectid: 'S-1-5-21-TEST-105'}), + (alterLogin:MSSQL_Login {name: 'TEST\\alter'}), + (connectOnlyUser:User {objectid: 'S-1-5-21-TEST-200'}), + (connectLogin:MSSQL_Login {name: 'TEST\\connectonly'}), + (sccmServer:MSSQL_Server {name: 'sql01.test.local:1433'}) +CREATE (sqlUser)-[:MSSQL_HasLogin]->(sqlLogin) +CREATE (sqlLogin)-[:MSSQL_MemberOf]->(sysadmin) +CREATE (dbUserPrincipal)-[:MSSQL_HasLogin]->(dbLogin) +CREATE (dbLogin)-[:MSSQL_IsMappedTo]->(dbMappedUser) +CREATE (dbMappedUser)-[:MSSQL_MemberOf]->(dbOwner) +CREATE (alterUser)-[:MSSQL_HasLogin]->(alterLogin) +CREATE (alterLogin)-[:MSSQL_Connect]->(sccmServer) +CREATE (alterLogin)-[:MSSQL_AlterAnyLogin]->(sccmServer) +CREATE (connectOnlyUser)-[:MSSQL_HasLogin]->(connectLogin) +CREATE (connectLogin)-[:MSSQL_Connect]->(sccmServer); + +MATCH (linkedUser:User {objectid: 'S-1-5-21-TEST-104'}), + (groupLinkedUser:User {objectid: 'S-1-5-21-TEST-107'}), + (broadLinkedUser:User {objectid: 'S-1-5-21-TEST-108'}), + (linkedSqlGroup:Group {objectid: 'S-1-5-21-TEST-LINKED-SQL-GROUP'}), + (domainUsers:Group {objectid: 'S-1-5-21-TEST-513'}), + (linkedLogin:MSSQL_Login {name: 'TEST\\linked'}), + (linkedGroupLogin:MSSQL_Login {name: 'TEST\\linked-group'}), + (linkedBroadLogin:MSSQL_Login {name: 'TEST\\Domain Users'}), + (linkedSource:MSSQL_Server {name: 'lab-sql01.test.local:1433'}), + (linkedStub:MSSQL_Base {objectid: 'S-1-5-21-TEST-LABSQL:SQL01'}), + (linkedTargetStub:MSSQL_Server {name: 'sql01.test.local'}) +CREATE (linkedUser)-[:MSSQL_HasLogin]->(linkedLogin) +CREATE (linkedLogin)-[:MSSQL_Connect]->(linkedSource) +CREATE (groupLinkedUser)-[:MemberOf]->(linkedSqlGroup) +CREATE (linkedSqlGroup)-[:MSSQL_HasLogin]->(linkedGroupLogin) +CREATE (linkedGroupLogin)-[:MSSQL_Connect]->(linkedSource) +CREATE (broadLinkedUser)-[:MemberOf]->(domainUsers) +CREATE (domainUsers)-[:MSSQL_HasLogin]->(linkedBroadLogin) +CREATE (linkedBroadLogin)-[:MSSQL_Connect]->(linkedSource) +CREATE (linkedStub)-[:MSSQL_LinkedAsAdmin]->(linkedTargetStub); + +MATCH (execHostUser:User {objectid: 'S-1-5-21-TEST-109'}), + (execLogin:MSSQL_Login {name: 'TEST\\execchain'}), + (execAssumedLogin:MSSQL_Login {name: 'ReportSvc'}), + (linkedSource:MSSQL_Server {name: 'lab-sql01.test.local:1433'}), + (sccmServer:MSSQL_Server {name: 'sql01.test.local:1433'}), + (sqlServiceAccount:User {objectid: 'S-1-5-21-TEST-SQLSVC'}), + (sccmHost:Computer {objectid: 'S-1-5-21-TEST-SCCM-SQL'}), + (site:SCCM_Site {siteCode: 'P01'}) +CREATE (execHostUser)-[:MSSQL_HasLogin]->(execLogin) +CREATE (execLogin)-[:MSSQL_Connect]->(linkedSource) +CREATE (execLogin)-[:MSSQL_ExecuteAs]->(execAssumedLogin) +CREATE (execAssumedLogin)-[:MSSQL_Connect]->(linkedSource) +CREATE (sqlServiceAccount)-[:MSSQL_ServiceAccountFor]->(sccmServer) +CREATE (sccmServer)-[:MSSQL_ExecuteOnHost]->(sccmHost) +CREATE (sccmHost)-[:SCCM_AssignAllPermissions]->(site); + +MATCH (disabledExecUser:User {objectid: 'S-1-5-21-TEST-203'}), + (disabledLogin:MSSQL_Login {name: 'TEST\\disabledexec'}), + (disabledAssumedLogin:MSSQL_Login {name: 'DisabledExecSvc'}), + (disabledSource:MSSQL_Server {name: 'disabled-src.test.local:1433'}), + (disabledStub:MSSQL_Base {objectid: 'S-1-5-21-TEST-DISABLED-SRC:SQL01'}), + (disabledTargetStub:MSSQL_Server {name: 'disabled-sccm.test.local'}), + (disabledTarget:MSSQL_Server {name: 'disabled-sccm.test.local:1433'}), + (disabledHost:Computer {objectid: 'S-1-5-21-TEST-DISABLED-HOST'}), + (site:SCCM_Site {siteCode: 'P01'}) +CREATE (disabledExecUser)-[:MSSQL_HasLogin]->(disabledLogin) +CREATE (disabledLogin)-[:MSSQL_ExecuteAs]->(disabledAssumedLogin) +CREATE (disabledAssumedLogin)-[:MSSQL_Connect]->(disabledSource) +CREATE (disabledStub)-[:MSSQL_LinkedAsAdmin {rpcOut: true}]->(disabledTargetStub) +CREATE (disabledTarget)-[:MSSQL_ExecuteOnHost]->(disabledHost) +CREATE (disabledHost)-[:SCCM_AssignAllPermissions]->(site); + +MATCH (noAdminLinkUser:User {objectid: 'S-1-5-21-TEST-204'}), + (noAdminLogin:MSSQL_Login {name: 'TEST\\noadminlink'}), + (noAdminAssumedLogin:MSSQL_Login {name: 'NoAdminExecSvc'}), + (noAdminSource:MSSQL_Server {name: 'noadmin-src.test.local:1433'}), + (noAdminStub:MSSQL_Base {objectid: 'S-1-5-21-TEST-NOADMIN-SRC:SQL01'}), + (noAdminTargetStub:MSSQL_Server {name: 'noadmin-sccm.test.local'}), + (noAdminTarget:MSSQL_Server {name: 'noadmin-sccm.test.local:1433'}), + (noAdminHost:Computer {objectid: 'S-1-5-21-TEST-NOADMIN-HOST'}), + (site:SCCM_Site {siteCode: 'P01'}) +CREATE (noAdminLinkUser)-[:MSSQL_HasLogin]->(noAdminLogin) +CREATE (noAdminLogin)-[:MSSQL_ExecuteAs]->(noAdminAssumedLogin) +CREATE (noAdminAssumedLogin)-[:MSSQL_Connect]->(noAdminSource) +CREATE (noAdminStub)-[:MSSQL_LinkedTo { + rpcOut: true, + remoteIsSysadmin: false, + remoteIsSecurityAdmin: false, + remoteHasControlServer: false, + remoteHasImpersonateAnyLogin: false +}]->(noAdminTargetStub) +CREATE (noAdminTarget)-[:MSSQL_ExecuteOnHost]->(noAdminHost) +CREATE (noAdminHost)-[:SCCM_AssignAllPermissions]->(site); + +MATCH (roleOnlyUser:User {objectid: 'S-1-5-21-TEST-106'}), + (roleOnlyAdmin:SCCM_AdminUser {objectid: 'TEST\\Role Admin@P01'}) +CREATE (roleOnlyUser)-[:SCCM_IsMappedTo]->(roleOnlyAdmin); + +MATCH (lowSccmUser:User {objectid: 'S-1-5-21-TEST-201'}), + (lowGroup:Group {objectid: 'S-1-5-21-TEST-LOW-GROUP'}), + (lowAdmin:SCCM_AdminUser {objectid: 'TEST\\Low SCCM Group@P01'}) +CREATE (lowSccmUser)-[:MemberOf]->(lowGroup) +CREATE (lowGroup)-[:SCCM_IsMappedTo]->(lowAdmin); + +MATCH (crossForestUser:User {objectid: 'S-1-5-21-TEST-202'}), + (otherGroup:Group {objectid: 'S-1-5-21-TEST-OTHER-GROUP'}), + (otherAdmin:SCCM_AdminUser {objectid: 'OTHER\\SCCM Admins@X01'}), + (otherSite:SCCM_Site {siteCode: 'X01'}) +CREATE (crossForestUser)-[:MemberOf]->(otherGroup) +CREATE (otherGroup)-[:SCCM_IsMappedTo]->(otherAdmin) +CREATE (otherAdmin)-[:SCCM_AllPermissions]->(otherSite); diff --git a/tests/integration/test_pipeline_fingerprint.py b/tests/integration/test_pipeline_fingerprint.py index 2c87b7e..c7f8cf0 100644 --- a/tests/integration/test_pipeline_fingerprint.py +++ b/tests/integration/test_pipeline_fingerprint.py @@ -52,7 +52,7 @@ def _missing_env(): "ESC1 — Enrollee Supplies Subject", "MSSQL Login", "MSSQL Privilege Escalation", - "SCCM Database Compromise", + "SCCM Privilege Escalation", "KRBTGT Password Older Than 6 Months", "Computers with SMB Signing Disabled", ] diff --git a/tests/test_check_inventory.py b/tests/test_check_inventory.py index fc6293c..d47af80 100644 --- a/tests/test_check_inventory.py +++ b/tests/test_check_inventory.py @@ -21,6 +21,19 @@ "tier0_session_exposure_tier0_computer", "cross_domain_escalation_path_via_trust", "cross_domain_computer_escalation_path_via_trust", + "dedupe_mssql_servers", + "canonicalize_linked_server", + "mssql_privilege_escalation_linked_server", + "mssql_priv_esc_group_owned_login", + "mssql_priv_esc_linked_server_sid", + "mssql_priv_esc_linked_server_overmatch", + "mssql_priv_esc_linked_server_name_overmatch", + "mssql_priv_esc_linked_server_empty_stub", + "mssql_priv_esc_linked_server_shared_host_sid", + "mssql_priv_esc_owns_server_role", + "sccm_priv_esc_linked_server_name_overmatch", + "sccm_priv_esc_linked_server_empty_stub", + "sccm_priv_esc_linked_server_shared_host_sid", } diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 1b01386..e6d6742 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -100,6 +100,11 @@ def test_error_converted_to_string(self): dc.record_error("src", Exception(42)) self.assertEqual(dc.errors[0]["error"], "42") + def test_warning_converted_to_string(self): + dc = DiagnosticsCollector() + dc.record_warning("src", Warning("heads up")) + self.assertEqual(dc.warnings[0]["warning"], "heads up") + class TestToDict(unittest.TestCase): def test_all_top_level_keys(self): @@ -110,7 +115,7 @@ def test_all_top_level_keys(self): "checks", "escalation_paths", "escalation_batches", "password_audit", "cross_domain_checks", "mssql_sccm", "check_ordering", "report_generation", - "errors", + "warnings", "errors", } self.assertEqual(set(result.keys()), expected) diff --git a/tests/test_display_spacing.py b/tests/test_display_spacing.py new file mode 100644 index 0000000..4e76b38 --- /dev/null +++ b/tests/test_display_spacing.py @@ -0,0 +1,196 @@ +from checks.core.constants import DataTypes +from checks.core.display.escalation import ( + EscalationPathDisplayHandler, + GroupedEscalationDisplayHandler, +) +from checks.core.display.group_analysis import GroupAnalysisDisplayHandler +from checks.core.display.shared_graph_paths import SharedGraphPathDisplayHandler +from checks.core.display.simple import SimpleDisplayHandler +from checks.cross_domain.injector import _display_grouped_paths, _display_results + + +class FakeSidMapper: + def __init__(self, mapping=None): + self.mapping = mapping or {} + + def get_display_name(self, sid): + return self.mapping.get(sid, sid) + + +class FakeCheck: + ENTITY_TYPE = 'user' + REQUIRED_DATA = [] + + def __init__(self): + self.data = {} + self.neo4j_data = type('Neo4jData', (), {})() + self.account_analysis = None + + +def test_simple_display_inserts_blank_line_between_multiple_items(): + handler = SimpleDisplayHandler( + suppress_terminal_output=True, + check_instance=FakeCheck(), + sid_mapper=FakeSidMapper(), + ) + + content = [] + handler.display( + { + 'ESC1 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)': 'AUTHENTICATED USERS, DOMAIN USERS', + 'ESC2 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)': 'AUTHENTICATED USERS, DOMAIN USERS', + }, + '', + 'ESC1 - Enrollee Supplies Subject', + content, + 2, + '', + ) + + assert content == [ + '\n ESC1 - Enrollee Supplies Subject: 2', + ' ▶ ESC1 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)', + ' ▶ AUTHENTICATED USERS, DOMAIN USERS', + '', + ' ▶ ESC2 (TRAINING-DC01-CA ON DC01.TRAINING.LOCAL)', + ' ▶ AUTHENTICATED USERS, DOMAIN USERS', + ] + + +def test_escalation_path_display_inserts_blank_line_between_entities(): + handler = EscalationPathDisplayHandler( + suppress_terminal_output=True, + check_instance=FakeCheck(), + sid_mapper=FakeSidMapper({'S-1-1': 'HOST1', 'S-1-2': 'HOST2'}), + ) + + content = [] + handler.display({'S-1-1': '', 'S-1-2': ''}, '', 'Escalation Paths', content, 2, '') + + assert content == [ + '\n Escalation Paths: 2', + ' ▶ HOST1', + '', + ' ▶ HOST2', + ] + + +def test_grouped_escalation_display_separates_entities_from_common_path(): + check = FakeCheck() + path = [ + {'name': 'ALICE@TEST.LOCAL', 'labels': ['User']}, + 'MemberOf', + {'name': 'DOMAIN ADMINS@TEST.LOCAL', 'labels': ['Group']}, + ] + check.data = { + DataTypes.FULL_ESCALATION_PATHS: { + 'S-1-1': [{'fullPath': path}], + 'S-1-2': [{'fullPath': path}], + } + } + handler = GroupedEscalationDisplayHandler( + suppress_terminal_output=True, + check_instance=check, + sid_mapper=FakeSidMapper({'S-1-1': 'ALICE', 'S-1-2': 'BOB'}), + ) + + content = [] + handler.display({'S-1-1': '', 'S-1-2': ''}, '', 'Non-admin Users with Escalation Paths', content, 2, '') + + summary_index = content.index(' Users with Shared Path (2): ALICE, BOB') + assert content[summary_index + 1] == '' + assert content[summary_index + 2].startswith(' Common Escalation Path: ') + + +def test_group_analysis_display_separates_multiple_paths_under_same_group(): + class AccountAnalysis: + def _resolve_target_type(self, *_args): + return 'User' + + check = FakeCheck() + check.account_analysis = AccountAnalysis() + handler = GroupAnalysisDisplayHandler( + suppress_terminal_output=True, + check_instance=check, + sid_mapper=FakeSidMapper(), + ) + + content = [] + handler.display( + { + 'DOMAIN USERS': [ + {'rel_type': 'GenericAll', 'target': 'TARGET1', 'targetSID': 'S-1-1'}, + {'rel_type': 'WriteDacl', 'target': 'TARGET2', 'targetSID': 'S-1-2'}, + ] + }, + '', + 'Common Groups with Privilege Escalation Paths', + content, + 1, + '', + ) + + first_path = content.index(' ├─ GenericAll on TARGET1 (User)') + assert content[first_path + 1] == '' + assert content[first_path + 2] == ' └─ WriteDacl on TARGET2 (User)' + + +def test_shared_graph_paths_display_inserts_blank_line_between_entities(): + handler = SharedGraphPathDisplayHandler( + suppress_terminal_output=True, + check_instance=FakeCheck(), + sid_mapper=FakeSidMapper({'S-1-1': 'USER1', 'S-1-2': 'USER2'}), + ) + + content = [] + handler.display( + { + 'S-1-1': 'user1 -> MSSQL_Connect -> sql01', + 'S-1-2': 'user2 -> MSSQL_Connect -> sql01', + }, + '', + 'MSSQL Privilege Escalation', + content, + 2, + '', + ) + + assert content == [ + '\n MSSQL Privilege Escalation: 2', + ' ▶ USER1', + ' user1 > MSSQL_Connect > sql01', + '', + ' ▶ USER2', + ' user2 > MSSQL_Connect > sql01', + ] + + +def test_cross_domain_standard_display_inserts_blank_line_between_items(): + content = [] + _display_results({'ONE': '', 'TWO': ''}, '', 'Cross-Domain', content, 2, '') + + assert content == [ + '\n Cross-Domain: 2', + ' ▶ ONE', + '', + ' ▶ TWO', + ] + + +def test_cross_domain_grouped_display_separates_summary_from_path(): + content = [] + _display_grouped_paths( + {'Users with Shared Path (2): ALICE, BOB': 'Cross-Domain Path: MemberOf -> DOMAIN ADMINS'}, + '', + 'Cross-Domain Escalation Paths', + content, + 2, + '', + ) + + assert content == [ + '\n Cross-Domain Escalation Paths: 2', + ' Users with Shared Path (2): ALICE, BOB', + '', + ' Cross-Domain Path: MemberOf -> DOMAIN ADMINS', + ] diff --git a/tests/test_importer_canonicalize_neo4j.py b/tests/test_importer_canonicalize_neo4j.py new file mode 100644 index 0000000..db8946b --- /dev/null +++ b/tests/test_importer_canonicalize_neo4j.py @@ -0,0 +1,53 @@ +import pytest + +from modules.BloodhoundImporter import BloodhoundImporter +from tests.check_harness import load_fixture + +pytestmark = pytest.mark.neo4j + + +def _linked_edge_count(conn): + rows = conn.query( + "MATCH ()-[r:MSSQL_LinkedAsAdmin|MSSQL_LinkedTo]->() RETURN count(r) AS c" + ) + return rows[0]["c"] + + +def test_canonicalizer_creates_source_to_target_edges(clean_neo4j): + load_fixture(clean_neo4j, "canonicalize_linked_server.cypher") + importer = BloodhoundImporter(None) + importer.connection = clean_neo4j + + merged = importer._canonicalize_mssql_linked_server_edges() + + assert merged == 2 + admin = clean_neo4j.query( + "MATCH (s:MSSQL_Server {objectid: 'S-1-5-21-TEST-CANON-A:1433'})" + "-[:MSSQL_LinkedAsAdmin]->(t:MSSQL_Server {objectid: 'S-1-5-21-TEST-TGT-A:1433'}) " + "RETURN count(*) AS c" + ) + linked_to = clean_neo4j.query( + "MATCH (s:MSSQL_Server {objectid: 'S-1-5-21-TEST-CANON-B:1433'})" + "-[:MSSQL_LinkedTo]->(t:MSSQL_Server {objectid: 'S-1-5-21-TEST-TGT-B:1433'}) " + "RETURN count(*) AS c" + ) + assert admin[0]["c"] == 1 + assert linked_to[0]["c"] == 1 + + +def test_canonicalizer_is_idempotent_on_reimport(clean_neo4j): + load_fixture(clean_neo4j, "canonicalize_linked_server.cypher") + importer = BloodhoundImporter(None) + importer.connection = clean_neo4j + + first = importer._canonicalize_mssql_linked_server_edges() + edges_after_first = _linked_edge_count(clean_neo4j) + + second = importer._canonicalize_mssql_linked_server_edges() + edges_after_second = _linked_edge_count(clean_neo4j) + + # MERGE reports ensured edges on re-import without creating duplicates. + assert first == 2 + assert second == 2 + assert edges_after_first == 4 + assert edges_after_second == 4 diff --git a/tests/test_importer_dedupe_neo4j.py b/tests/test_importer_dedupe_neo4j.py new file mode 100644 index 0000000..01b1109 --- /dev/null +++ b/tests/test_importer_dedupe_neo4j.py @@ -0,0 +1,130 @@ +import pytest + +from modules.BloodhoundImporter import BloodhoundImporter +from tests.check_harness import load_fixture + +pytestmark = pytest.mark.neo4j + + +def test_dedupe_collapses_duplicate_mssql_server_names(clean_neo4j): + load_fixture(clean_neo4j, "dedupe_mssql_servers.cypher") + + importer = BloodhoundImporter(None) + importer.connection = clean_neo4j + + importer._dedupe_mssql_servers() + + sccmdb_nodes = clean_neo4j.query( + "MATCH (s:MSSQL_Server) WHERE toLower(s.name) = 'sccmdb.training.local:1433' " + "RETURN s.objectid AS oid ORDER BY oid" + ) + assert [r['oid'] for r in sccmdb_nodes] == ['sccmdb:1433'] + + sql01_nodes = clean_neo4j.query( + "MATCH (s:MSSQL_Server) WHERE toLower(s.name) STARTS WITH 'sql01.training.local' " + "RETURN s.objectid AS oid ORDER BY s.name" + ) + assert [r['oid'] for r in sql01_nodes] == [ + 'sql01\\sql01:1433', + 'sql01\\app1:1444', + 'sql01\\app2:1455', + ] + + +def test_dedupe_preserves_stale_only_properties_on_canonical(clean_neo4j): + load_fixture(clean_neo4j, "dedupe_mssql_servers.cypher") + importer = BloodhoundImporter(None) + importer.connection = clean_neo4j + + importer._dedupe_mssql_servers() + + rows = clean_neo4j.query( + "MATCH (s:MSSQL_Server {objectid: 'sccmdb:1433'}) " + "RETURN s.version AS version, s.xpCmdShellEnabled AS xp, " + "s.extendedProtection AS epa, s.sqlServerName AS svc" + ) + assert rows[0]['version'] == '15.0.4322.2' + assert rows[0]['xp'] is False + assert rows[0]['epa'] == 'Off' + assert rows[0]['svc'] == 'SCCMDB' + + +def test_dedupe_reroutes_relationships_from_stale_to_canonical(clean_neo4j): + load_fixture(clean_neo4j, "dedupe_mssql_servers.cypher") + importer = BloodhoundImporter(None) + importer.connection = clean_neo4j + + importer._dedupe_mssql_servers() + + rows = clean_neo4j.query( + "MATCH (canonical:MSSQL_Server {objectid: 'sccmdb:1433'})-[:MSSQL_Contains]->(role:MSSQL_ServerRole {name: 'sysadmin'}) " + "RETURN count(role) AS c" + ) + assert rows[0]['c'] == 1 + + +def test_dedupe_preserves_existing_canonical_relationship_properties(clean_neo4j): + clean_neo4j.query(""" + CREATE (canonical:MSSQL_Server:MSSQL_Base:Base { + objectid: 'canonical:1433', + name: 'sql.training.local:1433', + sqlServerName: 'SQL', + version: '15.0' + }) + CREATE (stale:MSSQL_Server:MSSQL_Base:Base { + objectid: 'stale:1433', + name: 'SQL.TRAINING.LOCAL:1433' + }) + CREATE (role:MSSQL_ServerRole:MSSQL_Base:Base { + objectid: 'sysadmin@sql', + name: 'sysadmin' + }) + CREATE (canonical)-[:MSSQL_Contains { + canonicalOnly: 'yes', + shared: 'canonical' + }]->(role) + CREATE (stale)-[:MSSQL_Contains { + staleOnly: 'yes', + shared: 'stale' + }]->(role) + """) + importer = BloodhoundImporter(None) + importer.connection = clean_neo4j + + importer._dedupe_mssql_servers() + + rows = clean_neo4j.query( + "MATCH (:MSSQL_Server {objectid: 'canonical:1433'})-[r:MSSQL_Contains]->(:MSSQL_ServerRole {name: 'sysadmin'}) " + "RETURN count(r) AS c, r.canonicalOnly AS canonicalOnly, r.staleOnly AS staleOnly, r.shared AS shared" + ) + assert rows[0]['c'] == 1 + assert rows[0]['canonicalOnly'] == 'yes' + assert rows[0]['staleOnly'] == 'yes' + assert rows[0]['shared'] == 'canonical' + + +def test_dedupe_does_not_rewrite_stale_canonical_edges_as_self_loops(clean_neo4j): + clean_neo4j.query(""" + CREATE (canonical:MSSQL_Server:MSSQL_Base:Base { + objectid: 'canonical:1433', + name: 'sql.training.local:1433', + sqlServerName: 'SQL', + version: '15.0' + }) + CREATE (stale:MSSQL_Server:MSSQL_Base:Base { + objectid: 'stale:1433', + name: 'SQL.TRAINING.LOCAL:1433' + }) + CREATE (canonical)-[:MSSQL_LinkedAsAdmin]->(stale) + CREATE (stale)-[:MSSQL_LinkedAsAdmin]->(canonical) + """) + importer = BloodhoundImporter(None) + importer.connection = clean_neo4j + + importer._dedupe_mssql_servers() + + rows = clean_neo4j.query( + "MATCH (canonical:MSSQL_Server {objectid: 'canonical:1433'})-[r]->(canonical) " + "RETURN count(r) AS c" + ) + assert rows[0]['c'] == 0 diff --git a/tests/test_importer_opengraph.py b/tests/test_importer_opengraph.py index 1a5d4da..e87c990 100644 --- a/tests/test_importer_opengraph.py +++ b/tests/test_importer_opengraph.py @@ -10,6 +10,7 @@ _normalise_neo4j_properties, ) from modules.collectors.mssqlhound import _mssql_identity +from modules.diagnostics import DiagnosticsCollector from modules.opengraph_contracts import ( OpenGraphRequirement, OpenGraphRequirementWarning, @@ -886,6 +887,9 @@ def test_import_warns_once_for_dropped_properties(capsys): def test_upload_warns_when_mssql_servers_lack_host_edges(capsys): conn = RecordingConnection( responses=[ + [], + [], + [], [], [{"missing": 1, "examples": ["sql01.example.local"]}], ] @@ -936,6 +940,9 @@ def test_print_opengraph_requirement_warning_formats_more_suffix_without_used_by def test_upload_does_not_warn_when_mssql_host_mapping_exists(capsys): conn = RecordingConnection( responses=[ + [], + [], + [], [], [{"missing": 0, "examples": []}], ] @@ -994,6 +1001,9 @@ def fake_prime_from_checks(): ) conn = RecordingConnection( responses=[ + [], + [], + [], [], [{"missing": 1, "examples": ["build-release"]}], ] @@ -1563,31 +1573,32 @@ def test_deduplicate_by_identity_suppresses_same_instance_views(capsys): assert "OpenGraph identity dedupe: skipped 1 duplicate" in capsys.readouterr().out -def test_deduplicate_by_identity_matches_normal_mssqlhound_files(capsys): +def test_deduplicate_by_identity_keeps_distinct_normal_mssqlhound_files(capsys): importer = BloodhoundImporter(None) zip_path = Path("sample_data/MSSQLHound.zip") entries = [] + expected_identities = { + "mssql-lab-sql01.training.local_SQL01.json": "lab-sql01.training.local|instance|sql01", + "mssql-sccmdb.training.local.json": "sccmdb.training.local|port|1433", + } + with zipfile.ZipFile(zip_path) as archive: - for name in ( - "mssql-lab-sql01.training.local.json", - "mssql-lab-sql01.training.local_SQL01.json", - ): + missing = set(expected_identities) - set(archive.namelist()) + assert not missing + + for name, expected_identity in expected_identities.items(): data = json.loads(archive.read(name).decode("utf-8-sig")) manifest = importer._select_collector_manifest(data) entries.append((Path(name), "opengraph", data)) assert manifest is not None assert manifest.source_kind == "MSSQL_Base" - assert importer._collector_identity(manifest, data) == ( - "lab-sql01.training.local|instance|sql01" - ) + assert importer._collector_identity(manifest, data) == expected_identity deduped = importer._deduplicate_by_identity(entries) - assert [entry[0].name for entry in deduped] == [ - "mssql-lab-sql01.training.local_SQL01.json" - ] - assert "OpenGraph identity dedupe: skipped 1 duplicate" in capsys.readouterr().out + assert [entry[0].name for entry in deduped] == list(expected_identities) + assert "OpenGraph identity dedupe" not in capsys.readouterr().out @pytest.mark.parametrize( @@ -1672,3 +1683,296 @@ def test_merge_orphan_stubs_refuses_broad_labels_before_query(label): importer._merge_orphan_stubs(label) assert conn.queries == [] + + +def test_opengraph_skips_empty_id_nodes_with_warning(capsys): + conn = RecordingConnection() + importer = BloodhoundImporter(conn) + data = _opengraph( + "Mixed_Base", + [ + {"id": "good-1", "kinds": ["Base"], "properties": {"name": "good"}}, + {"id": "", "kinds": ["Computer", "Base"], "properties": {}}, + {"id": "good-2", "kinds": ["Base"], "properties": {"name": "alsogood"}}, + ], + ) + + importer._validate_opengraph_payloads([("computers.json", data, "opengraph")]) + + output = capsys.readouterr().out + assert "computers.json: skipped 1 node(s) with empty id" in output + assert [n.get("id") for n in data["graph"]["nodes"]] == ["good-1", "good-2"] + + +def test_opengraph_skip_empty_id_does_not_repeat_warning_on_second_validation(capsys): + conn = RecordingConnection() + importer = BloodhoundImporter(conn) + data = _opengraph( + "Mixed_Base", + [ + {"id": "good-1", "kinds": ["Base"], "properties": {"name": "good"}}, + {"id": "", "kinds": ["Computer", "Base"], "properties": {}}, + ], + ) + + importer._validate_opengraph_payloads([("computers.json", data, "opengraph")]) + capsys.readouterr() # drain + importer._validate_opengraph_payloads([("computers.json", data, "opengraph")]) + second = capsys.readouterr().out + assert "skipped" not in second + + +def test_opengraph_resolves_empty_id_via_dnshostname(capsys): + conn = RecordingConnection() + importer = BloodhoundImporter(conn) + data = _opengraph( + "Mixed_Base", + [ + { + "id": "S-1-5-21-999-1110", + "kinds": ["Computer", "Base"], + "properties": {"name": "SQL01.LAB.LOCAL", "DNSHostName": "sql01.lab.local"}, + }, + { + "id": "", + "kinds": ["Computer", "Base"], + "properties": {"DNSHostName": "SQL01.LAB.LOCAL", "SCCMSiteSystemRoles": "MP,DP"}, + }, + { + "id": "S-1-5-21-999-1111", + "kinds": ["Computer", "Base"], + "properties": {"name": "DC01.LAB.LOCAL"}, + }, + ], + ) + + importer._validate_opengraph_payloads([("computers.json", data, "opengraph")]) + + output = capsys.readouterr().out + assert "resolved 1 empty-id node(s) via DNSHostName" in output + assert "skipped" not in output + ids = [n.get("id") for n in data["graph"]["nodes"]] + assert ids == ["S-1-5-21-999-1110", "S-1-5-21-999-1110", "S-1-5-21-999-1111"] + + +def test_opengraph_empty_id_ambiguous_dns_is_skipped(capsys): + conn = RecordingConnection() + importer = BloodhoundImporter(conn) + data = _opengraph( + "Mixed_Base", + [ + { + "id": "S-1-5-21-999-1110", + "kinds": ["Computer", "Base"], + "properties": {"name": "SQL01A.LAB.LOCAL", "DNSHostName": "sql01.lab.local"}, + }, + { + "id": "S-1-5-21-999-2220", + "kinds": ["Computer", "Base"], + "properties": {"name": "SQL01B.LAB.LOCAL", "DNSHostName": "SQL01.LAB.LOCAL"}, + }, + { + "id": "", + "kinds": ["Computer", "Base"], + "properties": {"DNSHostName": "sql01.lab.local"}, + }, + ], + ) + + importer._validate_opengraph_payloads([("computers.json", data, "opengraph")]) + + output = capsys.readouterr().out + assert "ambiguous DNSHostName" in output + assert "resolved" not in output + assert [n.get("id") for n in data["graph"]["nodes"]] == [ + "S-1-5-21-999-1110", + "S-1-5-21-999-2220", + ] + + +def test_opengraph_empty_id_no_dns_match_still_skipped(capsys): + conn = RecordingConnection() + importer = BloodhoundImporter(conn) + data = _opengraph( + "Mixed_Base", + [ + { + "id": "S-1-5-21-999-1110", + "kinds": ["Computer", "Base"], + "properties": {"name": "SQL01.LAB.LOCAL", "DNSHostName": "sql01.lab.local"}, + }, + { + "id": "", + "kinds": ["Computer", "Base"], + "properties": {"DNSHostName": "UNKNOWN.LAB.LOCAL"}, + }, + ], + ) + + importer._validate_opengraph_payloads([("computers.json", data, "opengraph")]) + + output = capsys.readouterr().out + assert "skipped 1 node(s) with empty id" in output + assert "resolved" not in output + assert [n.get("id") for n in data["graph"]["nodes"]] == ["S-1-5-21-999-1110"] + + +def test_opengraph_empty_id_warning_recorded_in_diagnostics(capsys): + diagnostics = DiagnosticsCollector() + conn = RecordingConnection() + importer = BloodhoundImporter(conn, diagnostics=diagnostics) + data = _opengraph( + "Mixed_Base", + [ + {"id": "good-1", "kinds": ["Base"], "properties": {"name": "good"}}, + {"id": "", "kinds": ["Computer", "Base"], "properties": {}}, + ], + ) + + importer._validate_opengraph_payloads([("computers.json", data, "opengraph")]) + + capsys.readouterr() + assert len(diagnostics.warnings) == 1 + warning = diagnostics.warnings[0] + assert warning["source"] == "import:opengraph_empty_id_skipped" + assert "skipped 1 node(s) with empty id" in warning["warning"] + + +def test_opengraph_empty_id_node_with_malformed_kinds_rejected_before_resolution(): + conn = RecordingConnection() + importer = BloodhoundImporter(conn) + data = _opengraph( + "Mixed_Base", + [ + { + "id": "S-1-5-21-999-1110", + "kinds": ["Computer", "Base"], + "properties": {"name": "SQL01.LAB.LOCAL", "DNSHostName": "sql01.lab.local"}, + }, + { + "id": "", + "kinds": "Bad Label", + "properties": {"DNSHostName": "sql01.lab.local"}, + }, + ], + ) + + with pytest.raises(ValueError, match="kinds must be a list"): + importer._validate_opengraph_payloads([("computers.json", data, "opengraph")]) + + +def test_dedupe_mssql_servers_merges_duplicates(capsys): + conn = RecordingConnection( + responses=[ + [ + { + "canonical": "sccmdb:1433", + "stale": ["S-1-5-21-1105:1433", "SCCMDB.training.local:1433"], + } + ], + [], + [{"rtype": "MSSQL_LinkedAsAdmin"}], + [], + [], + [], + [], + [{"rtype": "MSSQL_Contains"}], + [], + [], + [], + ] + ) + importer = BloodhoundImporter(conn) + + importer._dedupe_mssql_servers() + + output = capsys.readouterr().out + assert "Merged 2 duplicate MSSQL_Server" in output + queries = [q[0] for q in conn.queries] + assert any("toLower(s.name)" in q for q in queries) + assert any("SET c += sp SET c += cp" in q for q in queries) + assert any("DETACH DELETE" in q for q in queries) + + +def test_dedupe_mssql_servers_no_op_when_unique(capsys): + conn = RecordingConnection(responses=[[]]) + importer = BloodhoundImporter(conn) + + importer._dedupe_mssql_servers() + + output = capsys.readouterr().out + assert "Merged" not in output + assert len(conn.queries) == 1 + + +def test_dedupe_mssql_servers_property_merge_lets_canonical_win_on_conflict(): + conn = RecordingConnection( + responses=[ + [{"canonical": "real:1433", "stale": ["stub:1433"]}], + [], + [], + [], + ] + ) + importer = BloodhoundImporter(conn) + + importer._dedupe_mssql_servers() + + node_merge_query = next(q for q, _ in conn.queries if "SET c +=" in q) + assert "SET c += sp SET c += cp" in node_merge_query + assert node_merge_query.index("SET c += sp") < node_merge_query.index("SET c += cp") + + +def test_dedupe_mssql_servers_relationship_merge_preserves_existing_properties_and_skips_self_loops(): + conn = RecordingConnection( + responses=[ + [{"canonical": "real:1433", "stale": ["stub:1433"]}], + [], + [{"rtype": "MSSQL_Contains"}], + [], + [], + [], + ] + ) + importer = BloodhoundImporter(conn) + + importer._dedupe_mssql_servers() + + rel_merge_queries = [q for q, _ in conn.queries if "MERGE" in q and "-[new:`MSSQL_Contains`]" in q] + assert len(rel_merge_queries) == 2 + assert "WHERE t <> c" in rel_merge_queries[0] + assert "WHERE src <> c" in rel_merge_queries[1] + for query in rel_merge_queries: + assert "SET new += old_props SET new += existing_props" in query + assert query.index("SET new += old_props") < query.index("SET new += existing_props") + + +def test_dedupe_mssql_servers_groups_by_full_name_not_short_hostname(): + conn = RecordingConnection(responses=[[]]) + importer = BloodhoundImporter(conn) + + importer._dedupe_mssql_servers() + + groups_query = conn.queries[0][0] + assert "toLower(s.name)" in groups_query + assert "split(s.name" not in groups_query + + +def test_canonicalize_mssql_linked_server_edges_uses_sid_prefix(capsys): + conn = RecordingConnection(responses=[[{"merged": 1}], [{"merged": 2}]]) + importer = BloodhoundImporter(conn) + + merged = importer._canonicalize_mssql_linked_server_edges() + + output = capsys.readouterr().out + assert merged == 3 + assert "Merged 3 canonical MSSQL linked-server edge" in output + queries = [q for q, _ in conn.queries] + assert any("MSSQL_LinkedTo" in q for q in queries) + assert any("MSSQL_LinkedAsAdmin" in q for q in queries) + assert all("split(stub.objectid, ':')[0] AS sourceSid" in q for q in queries) + assert all("split(source.objectid, ':')[0] = sourceSid" in q for q in queries) + assert all("MERGE (source)-[new:" in q for q in queries) + assert all("SET new += old_props SET new += existing_props" in q for q in queries) + assert all("NOT stub:MSSQL_Server" in q for q in queries) + assert all("count(DISTINCT new)" in q for q in queries) diff --git a/tests/test_importer_zip_slip.py b/tests/test_importer_zip_slip.py new file mode 100644 index 0000000..f410f79 --- /dev/null +++ b/tests/test_importer_zip_slip.py @@ -0,0 +1,62 @@ +import json +import zipfile +from pathlib import Path + +import pytest + +from modules.BloodhoundImporter import BloodhoundImporter + + +def _write_zip(zip_path: Path, entries: dict) -> Path: + with zipfile.ZipFile(zip_path, 'w') as zip_ref: + for name, content in entries.items(): + zip_ref.writestr(name, content) + return zip_path + + +def test_extract_zip_rejects_parent_directory_traversal(tmp_path): + zip_path = _write_zip(tmp_path / "evil.zip", { + "../escapee.json": json.dumps({"k": "v"}), + }) + extract_dir = tmp_path / "extract" + extract_dir.mkdir() + + importer = BloodhoundImporter(None) + + with pytest.raises(Exception, match="escapes extract directory"): + importer._extract_zip(zip_path, extract_dir) + + assert not (tmp_path / "escapee.json").exists() + + +def test_extract_zip_rejects_absolute_path_member(tmp_path): + zip_path = tmp_path / "evil.zip" + with zipfile.ZipFile(zip_path, 'w') as zip_ref: + info = zipfile.ZipInfo(filename="/tmp/adpf_zipslip_canary.json") + zip_ref.writestr(info, json.dumps({"k": "v"})) + extract_dir = tmp_path / "extract" + extract_dir.mkdir() + + importer = BloodhoundImporter(None) + + with pytest.raises(Exception, match="escapes extract directory"): + importer._extract_zip(zip_path, extract_dir) + + assert not Path("/tmp/adpf_zipslip_canary.json").exists() + + +def test_extract_zip_accepts_well_formed_archive(tmp_path): + zip_path = _write_zip(tmp_path / "good.zip", { + "users.json": json.dumps({"users": []}), + "nested/groups.json": json.dumps({"groups": []}), + }) + extract_dir = tmp_path / "extract" + extract_dir.mkdir() + + importer = BloodhoundImporter(None) + + json_files = importer._extract_zip(zip_path, extract_dir) + + assert sorted(p.name for p in json_files) == ["groups.json", "users.json"] + assert (extract_dir / "users.json").exists() + assert (extract_dir / "nested" / "groups.json").exists() diff --git a/tests/test_manager_hardening.py b/tests/test_manager_hardening.py index b8d1f4a..176bf80 100644 --- a/tests/test_manager_hardening.py +++ b/tests/test_manager_hardening.py @@ -182,6 +182,11 @@ def test_check_detail_and_platform_summary_per_domain(self): @check(risk="High", category="MSSQL Test", data=[]) class MSSQLTestCheck(Check): def execute(self): + diagnostics.record_query( + name=f"mssql_test_{self.neo4j_data._domain_filter}", + duration_ms=25, + result_count=1, + ) return {self.neo4j_data._domain_filter: "finding"} diagnostics = DiagnosticsCollector() @@ -199,8 +204,45 @@ def execute(self): ) self.assertEqual(diagnostics.mssql_sccm["training.local"]["mssql"]["checks_run"], 1) self.assertEqual(diagnostics.mssql_sccm["training.local"]["mssql"]["total_findings"], 1) + self.assertEqual(diagnostics.mssql_sccm["training.local"]["mssql"]["queries_run"], 1) + self.assertEqual(diagnostics.mssql_sccm["training.local"]["mssql"]["query_duration_ms"], 25) + self.assertEqual( + diagnostics.mssql_sccm["training.local"]["mssql"]["slowest_check"]["name"], + "MSSQLTestCheck", + ) self.assertEqual(diagnostics.mssql_sccm["secret.training.local"]["mssql"]["checks_run"], 1) self.assertEqual(diagnostics.mssql_sccm["secret.training.local"]["mssql"]["total_findings"], 1) + self.assertEqual(diagnostics.mssql_sccm["secret.training.local"]["mssql"]["queries_run"], 1) + + def test_platform_summary_groups_sccm_takeover_query_names(self): + diagnostics = DiagnosticsCollector() + diagnostics.record_check( + name="SCCMTestCheck", + risk_level="Critical", + category="SCCM Test", + entity_type="computer", + duration_ms=50, + entities_input=0, + entities_after_filter=0, + filtered_breakdown={}, + findings_count=1, + domain="training.local", + ) + diagnostics.record_query( + name="takeover1_edge_paths", + duration_ms=42, + result_count=1, + ) + manager = VulnerabilityFrameworkManager( + FakeDomainNeo4jData("training.local"), + diagnostics=diagnostics, + ) + manager._diagnostic_query_start_index = 0 + manager._record_platform_summary() + sccm_summary = diagnostics.mssql_sccm["training.local"]["sccm"] + + self.assertEqual(sccm_summary["queries_run"], 1) + self.assertEqual(sccm_summary["slowest_query"]["name"], "takeover1_edge_paths") def test_record_diagnostics_domains_keeps_all_discovered_domains(self): diagnostics = DiagnosticsCollector() diff --git a/tests/test_neo4j_fixture_config.py b/tests/test_neo4j_fixture_config.py new file mode 100644 index 0000000..6732b57 --- /dev/null +++ b/tests/test_neo4j_fixture_config.py @@ -0,0 +1,25 @@ +import importlib.util +from pathlib import Path + + +def _load_test_conftest(): + path = Path(__file__).with_name("conftest.py") + spec = importlib.util.spec_from_file_location("adpf_test_conftest", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +conftest = _load_test_conftest() + + +def test_neo4j_fixture_defaults_to_disposable_test_container(): + assert conftest.DEFAULT_NEO4J_TEST_URI == "bolt://localhost:17687" + assert conftest.DEFAULT_NEO4J_TEST_PASSWORD == "testpassword" + + +def test_bloodhound_default_port_guard_detects_common_local_uris(): + assert conftest._bloodhound_default_port("neo4j://localhost:7687") + assert conftest._bloodhound_default_port("bolt://127.0.0.1") + assert not conftest._bloodhound_default_port(conftest.DEFAULT_NEO4J_TEST_URI) + assert not conftest._bloodhound_default_port("bolt://localhost:17687") diff --git a/tests/test_platform_privilege_filtering.py b/tests/test_platform_privilege_filtering.py new file mode 100644 index 0000000..7dfe11a --- /dev/null +++ b/tests/test_platform_privilege_filtering.py @@ -0,0 +1,311 @@ +from checks.core.dependencies import CheckDependencies +from checks.mssql_ntlm_relay import MSSQLNTLMRelayCheck +from checks.mssql_privilege_escalation import MSSQLPrivilegeEscalationCheck +from checks.sccm_privilege_escalation import SCCMPrivilegeEscalationCheck + + +class FakeNeo4jData: + high_value_users_cache = ["PKIADMIN@TRAINING.LOCAL"] + high_value_computers_cache = [] + + def __init__(self): + self.conn = self + + def get_admin_users_and_computers(self): + return ["DA@TRAINING.LOCAL"], [] + + def query(self, _cypher, parameters=None, name=None): + return [] + + +def _deps(): + return CheckDependencies(FakeNeo4jData()) + + +def test_mssql_privilege_escalation_skips_high_value_start_principals(): + class StubMSSQLCheck(MSSQLPrivilegeEscalationCheck): + def _fetch_escalation_paths(self): + path = { + 'login_name': 'TRAINING\\lowpriv', + 'target_name': 'sysadmin', + 'target_type': 'MSSQL_ServerRole', + 'target_server': 'sql01.training.local:1433', + 'path_nodes': ['TRAINING\\lowpriv', 'sysadmin'], + 'path_edges': ['MSSQL_MemberOf'], + } + return { + 'S-1-5-21-TRAINING-500': { + 'name': 'DA@TRAINING.LOCAL', + 'principal_type': 'User', + 'paths': [path], + }, + 'S-1-5-21-TRAINING-1101': { + 'name': 'LOWPRIV@TRAINING.LOCAL', + 'principal_type': 'User', + 'paths': [path], + }, + } + + findings = StubMSSQLCheck(_deps()).execute() + + assert 'S-1-5-21-TRAINING-500' not in findings + assert 'S-1-5-21-TRAINING-1101' in findings + + +def test_sccm_privilege_escalation_skips_high_value_service_accounts(): + class StubSCCMCheck(SCCMPrivilegeEscalationCheck): + def _fetch_sccm_paths(self): + base_path = { + 'attack_type': 'service_account', + 'target_name': 'SQL Server Service Account', + 'target_type': 'MSSQL_Server', + 'database_name': 'CM_TRN', + 'server_name': 'sccmdb.training.local:1433', + 'path_edges': ['MSSQL_ServiceAccountFor'], + 'path_length': 1, + 'sccm_impact_db': 'CM_TRN', + 'sccm_impact_site': 'TRN', + } + return [ + { + **base_path, + 'principal_sid': 'S-1-5-21-TRAINING-500', + 'principal_name': 'DA@TRAINING.LOCAL', + 'principal_type': 'User', + }, + { + **base_path, + 'principal_sid': 'S-1-5-21-TRAINING-1101', + 'principal_name': 'LOWPRIV@TRAINING.LOCAL', + 'principal_type': 'User', + }, + ] + + findings = StubSCCMCheck(_deps()).execute() + + assert 'S-1-5-21-TRAINING-500' not in findings + assert 'S-1-5-21-TRAINING-1101' in findings + + +def test_mssql_privilege_escalation_skips_graph_tagged_high_value_principals(): + class StubMSSQLCheck(MSSQLPrivilegeEscalationCheck): + def query(self, _cypher, parameters=None, name=None): + if name == "mssql_privilege_escalation_high_value_principals": + return [{"name": "TIER0USER@TRAINING.LOCAL", "type": "User"}] + return [] + + def _fetch_escalation_paths(self): + path = { + 'login_name': 'TRAINING\\tier0user', + 'target_name': 'sysadmin', + 'target_type': 'MSSQL_ServerRole', + 'target_server': 'sql01.training.local:1433', + 'path_nodes': ['TRAINING\\tier0user', 'sysadmin'], + 'path_edges': ['MSSQL_MemberOf'], + } + return { + 'S-1-5-21-TRAINING-700': { + 'name': 'TIER0USER@TRAINING.LOCAL', + 'principal_type': 'User', + 'paths': [path], + }, + 'S-1-5-21-TRAINING-1101': { + 'name': 'LOWPRIV@TRAINING.LOCAL', + 'principal_type': 'User', + 'paths': [path], + }, + } + + findings = StubMSSQLCheck(_deps()).execute() + + assert 'S-1-5-21-TRAINING-700' not in findings + assert 'S-1-5-21-TRAINING-1101' in findings + + +def test_sccm_privilege_escalation_skips_graph_tagged_high_value_principals(): + class StubSCCMCheck(SCCMPrivilegeEscalationCheck): + def query(self, _cypher, parameters=None, name=None): + if name == "sccm_privilege_escalation_high_value_principals": + return [{"name": "TIER0USER@TRAINING.LOCAL", "type": "User"}] + return [] + + def _fetch_sccm_paths(self): + base_path = { + 'attack_type': 'service_account', + 'target_name': 'SQL Server Service Account', + 'target_type': 'MSSQL_Server', + 'database_name': 'CM_TRN', + 'server_name': 'sccmdb.training.local:1433', + 'path_edges': ['MSSQL_ServiceAccountFor'], + 'path_length': 1, + 'sccm_impact_db': 'CM_TRN', + 'sccm_impact_site': 'TRN', + } + return [ + { + **base_path, + 'principal_sid': 'S-1-5-21-TRAINING-700', + 'principal_name': 'TIER0USER@TRAINING.LOCAL', + 'principal_type': 'User', + }, + { + **base_path, + 'principal_sid': 'S-1-5-21-TRAINING-1101', + 'principal_name': 'LOWPRIV@TRAINING.LOCAL', + 'principal_type': 'User', + }, + ] + + findings = StubSCCMCheck(_deps()).execute() + + assert 'S-1-5-21-TRAINING-700' not in findings + assert 'S-1-5-21-TRAINING-1101' in findings + + +def test_sccm_privilege_escalation_formats_raw_sql_to_site_scope(): + check = SCCMPrivilegeEscalationCheck(_deps()) + line = check._format_path_line({ + 'attack_type': 'privilege_escalation', + 'target_type': 'MSSQL_ServerRole', + 'server_name': 'sccmdb.training.local:1433', + 'path_edges': ['MSSQL_HasLogin', 'MSSQL_MemberOf'], + 'path_node_names': ['LOWPRIV@TRAINING.LOCAL', 'TRAINING\\lowpriv', 'sysadmin'], + 'sccm_impact_db': 'CM_TRN', + 'sccm_impact_site': 'TRN', + }) + + assert line == ( + "LOWPRIV -> MSSQL_HasLogin -> lowpriv -> MSSQL_MemberOf " + "-> sysadmin | MSSQL_Database(CM_TRN) -> SCCM_AssignAllPermissions -> SCCM_Site(TRN)" + ) + assert "can enable xp_cmdshell" not in line + + +def test_sccm_privilege_escalation_enriches_execute_on_host_context(): + check = SCCMPrivilegeEscalationCheck(_deps()) + line = check._format_path_line({ + 'attack_type': 'privilege_escalation', + 'target_type': 'SCCM_Site', + 'path_edges': ['MSSQL_ExecuteOnHost', 'SCCM_AssignAllPermissions'], + 'path_edge_contexts': ['as SQLSVC@TRAINING.LOCAL', None], + 'path_node_names': [ + 'sccmdb.training.local:1433', + 'SCCMDB.TRAINING.LOCAL', + 'SCCM_Site(TRN)', + ], + 'sccm_impact_site': 'TRN', + }) + + assert line == ( + "sccmdb.training.local -> MSSQL_ExecuteOnHost (as SQLSVC@TRAINING.LOCAL) " + "-> SCCMDB.TRAINING.LOCAL -> SCCM_AssignAllPermissions -> SCCM_Site(TRN)" + ) + + +def test_sccm_service_account_query_scopes_ad_accounts_to_domain(): + check = SCCMPrivilegeEscalationCheck.__new__(SCCMPrivilegeEscalationCheck) + check._domain_filter = "training.local" + captured = {} + + def capture_query(cypher, parameters=None, name=None): + captured["cypher"] = cypher + return [] + + check.query = capture_query + + assert check._fetch_service_account_paths() == [] + assert 'serviceAccount.domain IN ["TRAINING.LOCAL", ["TRAINING.LOCAL"]]' in captured["cypher"] + assert "OR NOT (serviceAccount:User OR serviceAccount:Computer OR serviceAccount:Group)" in captured["cypher"] + + +def test_sccm_coercion_prefers_matching_ad_source_only(): + check = SCCMPrivilegeEscalationCheck.__new__(SCCMPrivilegeEscalationCheck) + paths = [ + { + 'principal_type': 'User', + 'principal_name': 'DA@TRAINING.LOCAL', + 'server_name': 'sql01.training.local:1433', + 'sccm_impact_site': 'TRN', + }, + { + 'principal_type': 'MSSQL', + 'principal_name': 'TRAINING\\DA', + 'server_name': 'sql01.training.local', + 'sccm_impact_site': 'TRN', + }, + { + 'principal_type': 'MSSQL', + 'principal_name': 'mssql-stub', + 'server_name': 'sql01.training.local', + 'sccm_impact_site': 'TRN', + }, + ] + + kept = check._prefer_ad_coercion_sources(paths) + + assert [path['principal_name'] for path in kept] == ['DA@TRAINING.LOCAL', 'mssql-stub'] + + +def test_sccm_format_principal_keeps_distinct_service_account_servers(): + check = SCCMPrivilegeEscalationCheck.__new__(SCCMPrivilegeEscalationCheck) + base = { + 'attack_type': 'service_account', + 'principal_name': 'SQLSVC@TRAINING.LOCAL', + 'principal_type': 'User', + 'target_type': 'MSSQL_Server', + 'path_edges': ['MSSQL_ServiceAccountFor'], + 'path_length': 1, + 'sccm_impact_db': 'CM_TRN', + 'sccm_impact_site': 'TRN', + } + + lines = check._format_principal([ + {**base, 'server_name': 'sql01.training.local:1433'}, + {**base, 'server_name': 'sql02.training.local:1433'}, + ]) + + assert len(lines) == 2 + assert any("sql01.training.local" in line for line in lines) + assert any("sql02.training.local" in line for line in lines) + + +def test_mssql_ntlm_relay_dedupes_equivalent_principal_formats(): + check = MSSQLNTLMRelayCheck.__new__(MSSQLNTLMRelayCheck) + check._domain_filter = "training.local" + + principals = check._dedupe([ + "TRAINING\\DA", + "DA@TRAINING.LOCAL", + "OTHER\\DA", + ]) + + assert principals == ["DA@TRAINING.LOCAL", "OTHER\\DA"] + + +def test_mssql_ntlm_relay_does_not_collide_across_domains_sharing_netbios_prefix(): + check = MSSQLNTLMRelayCheck.__new__(MSSQLNTLMRelayCheck) + check._domain_filter = "corp.local" + + principals = check._dedupe([ + "DA@CORP.LOCAL", + "DA@CORP.EXAMPLE.COM", + ]) + + assert principals == ["DA@CORP.LOCAL", "DA@CORP.EXAMPLE.COM"] + + +def test_mssql_ntlm_relay_caps_principal_lines_per_server(): + check = MSSQLNTLMRelayCheck.__new__(MSSQLNTLMRelayCheck) + + principals = [f"USER{i}@TRAINING.LOCAL" for i in range(12)] + desc = check._format_relay_path( + principals, + "SQL01.TRAINING.LOCAL", + "SQLSVC@TRAINING.LOCAL", + ["WEB01 (Windows Server)"], + ) + + lines = desc.split("\n") + assert sum(1 for line in lines if "xp_dirtree" in line) == 5 + assert lines[-1] == "(+7 more principals)" + assert "USER5@TRAINING.LOCAL" not in desc diff --git a/tests/test_report_label_filtering.py b/tests/test_report_label_filtering.py index d2b67b2..b0fb507 100644 --- a/tests/test_report_label_filtering.py +++ b/tests/test_report_label_filtering.py @@ -1,7 +1,7 @@ from modules.neo4j_data import Neo4jData from checks.core.dependencies import CheckDependencies from checks.mssql_privilege_escalation import MSSQLPrivilegeEscalationCheck -from checks.mssql_sccm import MSSQLSCCMCheck +from checks.sccm_privilege_escalation import SCCMPrivilegeEscalationCheck class RecordingQueryConnection: @@ -57,10 +57,10 @@ def test_sid_object_lookup_suppresses_plugin_only_type_in_ad_report_context(): assert "metadata_labels" in parameters -def test_mssql_sccm_check_canonicalises_broad_platform_labels(): - check, conn = _build_check(MSSQLSCCMCheck) +def test_sccm_privilege_check_canonicalises_broad_platform_labels(): + check, conn = _build_check(SCCMPrivilegeEscalationCheck) - check._fetch_priv_esc_paths() + check._fetch_sql_to_sccm_paths() check._fetch_coercion_paths() check._fetch_service_account_paths() diff --git a/tests/test_shared_graph_paths_display.py b/tests/test_shared_graph_paths_display.py new file mode 100644 index 0000000..af017eb --- /dev/null +++ b/tests/test_shared_graph_paths_display.py @@ -0,0 +1,80 @@ +from checks.core.display.shared_graph_paths import SharedGraphPathDisplayHandler + + +class FakeSidMapper: + def __init__(self, mapping): + self.mapping = mapping + + def get_display_name(self, sid): + return self.mapping.get(sid, sid) + + +class FakeCheck: + ENTITY_TYPE = 'user' + + +def _run(results): + handler = SharedGraphPathDisplayHandler( + suppress_terminal_output=True, + check_instance=FakeCheck(), + sid_mapper=FakeSidMapper({ + 'S-1-1': 'USER1', + 'S-1-2': 'USER2', + }), + ) + content = [] + handler.display(results, '', 'SCCM Privilege Escalation', content, len(results), '') + return content + + +def test_graph_path_display_shows_only_node_chains(): + suffix = ( + "MSSQL_Connect -> lab-sql01.training.local -> MSSQL_LinkedAsAdmin " + "-> sccmdb.training.local -> MSSQL_Contains -> sysadmin@sccmdb.training.local" + ) + out = _run({ + 'S-1-1': f"user1 -> {suffix}", + 'S-1-2': f"user2 -> {suffix}", + }) + + assert " ▶ USER1" in out + assert " ▶ USER2" in out + assert ( + " user1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin " + "> sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local" + ) in out + assert ( + " user2 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin " + "> sccmdb.training.local > MSSQL_Contains > sysadmin@sccmdb.training.local" + ) in out + assert not any("Shared Graph Path" in line for line in out) + assert not any("Path:" in line for line in out) + + +def test_shared_graph_path_splits_sccm_scope_from_path_template(): + path = ( + "user1 -> MSSQL_Connect -> lab-sql01.training.local -> MSSQL_LinkedAsAdmin " + "-> sccmdb.training.local -> MSSQL_Contains -> MSSQL_Database(CM_TRN) " + "-> MSSQL_Contains -> db_owner@CM_TRN" + ) + scope = "MSSQL_Database(CM_TRN) -> SCCM_AssignAllPermissions -> SCCM_Site(TRN)" + + out = _run({'S-1-1': f"{path} | {scope}"}) + + assert ( + " user1 > MSSQL_Connect > lab-sql01.training.local > MSSQL_LinkedAsAdmin " + "> sccmdb.training.local > MSSQL_Contains > MSSQL_Database(CM_TRN) " + "> MSSQL_Contains > db_owner@CM_TRN > SCCM_Site(TRN)" + ) in out + assert not any("SCCM Scope:" in line for line in out) + + +def test_scope_node_with_platform_prefix_is_not_dropped(): + out = _run({ + 'S-1-1': ( + "user1 -> MSSQL_Connect -> sql01 | " + "SCCM_AssignAllPermissions -> MSSQL_login_stub" + ) + }) + + assert " user1 > MSSQL_Connect > sql01 > MSSQL_login_stub" in out diff --git a/tests/test_suppress_subordinate_sql_roles.py b/tests/test_suppress_subordinate_sql_roles.py new file mode 100644 index 0000000..6251481 --- /dev/null +++ b/tests/test_suppress_subordinate_sql_roles.py @@ -0,0 +1,111 @@ +from checks.core.dependencies import CheckDependencies +from checks.sccm_privilege_escalation import SCCMPrivilegeEscalationCheck + + +class FakeNeo4jData: + high_value_users_cache = [] + high_value_computers_cache = [] + + def __init__(self): + self.conn = self + + def get_admin_users_and_computers(self): + return [], [] + + def query(self, _cypher, parameters=None, name=None): + return [] + + +def _check(): + return SCCMPrivilegeEscalationCheck(CheckDependencies(FakeNeo4jData())) + + +def _server_role(name, server='sql01.training.local:1433', site=''): + return { + 'target_type': 'MSSQL_ServerRole', + 'target_name': name, + 'server_name': server, + 'database_name': '', + 'sccm_impact_site': site, + } + + +def _db_role(name, server='sql01.training.local:1433', database='master'): + return { + 'target_type': 'MSSQL_DatabaseRole', + 'target_name': name, + 'server_name': server, + 'database_name': database, + 'sccm_impact_site': '', + } + + +def test_sysadmin_suppresses_subordinate_server_role_in_same_scope(): + paths = [_server_role('sysadmin'), _server_role('securityadmin')] + + filtered = _check()._suppress_subordinate_sql_roles(paths) + + assert [p['target_name'] for p in filtered] == ['sysadmin'] + + +def test_sysadmin_absent_keeps_subordinate_server_role(): + paths = [_server_role('securityadmin'), _server_role('serveradmin')] + + filtered = _check()._suppress_subordinate_sql_roles(paths) + + assert [p['target_name'] for p in filtered] == ['securityadmin', 'serveradmin'] + + +def test_db_owner_suppresses_subordinate_database_role_in_same_scope(): + paths = [_db_role('db_owner'), _db_role('db_securityadmin')] + + filtered = _check()._suppress_subordinate_sql_roles(paths) + + assert [p['target_name'] for p in filtered] == ['db_owner'] + + +def test_sysadmin_on_one_server_does_not_suppress_subordinate_on_another(): + paths = [ + _server_role('sysadmin', server='sql01.training.local:1433'), + _server_role('securityadmin', server='sql02.training.local:1433'), + ] + + filtered = _check()._suppress_subordinate_sql_roles(paths) + + assert [(p['target_name'], p['server_name']) for p in filtered] == [ + ('sysadmin', 'sql01.training.local:1433'), + ('securityadmin', 'sql02.training.local:1433'), + ] + + +def test_non_subordinate_role_is_kept_even_when_sysadmin_present(): + paths = [_server_role('sysadmin'), _server_role('processadmin')] + + filtered = _check()._suppress_subordinate_sql_roles(paths) + + assert [p['target_name'] for p in filtered] == ['sysadmin', 'processadmin'] + + +def test_role_key_strips_scope_suffix_and_normalises_case(): + paths = [ + _server_role('SYSADMIN@SQL01'), + _server_role('SecurityAdmin@SQL01'), + ] + + filtered = _check()._suppress_subordinate_sql_roles(paths) + + assert [p['target_name'] for p in filtered] == ['SYSADMIN@SQL01'] + + +def test_sccm_impact_site_isolates_suppression_scope(): + paths = [ + _server_role('sysadmin', site='PRI'), + _server_role('securityadmin', site='SEC'), + ] + + filtered = _check()._suppress_subordinate_sql_roles(paths) + + assert [(p['target_name'], p['sccm_impact_site']) for p in filtered] == [ + ('sysadmin', 'PRI'), + ('securityadmin', 'SEC'), + ]