Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
timeout-minutes: 20
services:
neo4j:
image: neo4j:5-community
image: neo4j:4.4
env:
NEO4J_AUTH: neo4j/testpassword
NEO4J_PLUGINS: '[]'
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ ADPathfinder is an attack mapping tool for pentesters and red teamers. It analys

<p align="center">
<a href="docs/images/demo-slideshow.mp4">
<img src="docs/images/demo-slideshow.webp" alt="ADPathfinder demo video" width="760">
<img src="docs/images/demo-slideshow.webp" alt="ADPathfinder CLI demo showing grouped risk findings and attack paths" width="1000">
</a>
</p>

Expand Down Expand Up @@ -132,15 +132,15 @@ adpathfinder --ad --pwd Contoso,ContosoIT --ntds ntds.txt -p hashcat.potfile --d

<p align="center">
<a href="docs/images/ad-report-slideshow.mp4">
<img src="docs/images/ad-report-slideshow.webp" alt="AD HTML report demo video" width="760">
<img src="docs/images/ad-report-slideshow.webp" alt="ADPathFinder AD report walkthrough showing overview, mitigation, and confirmation steps" width="1000">
</a>
</p>

## Password Audit HTML Report

<p align="center">
<a href="docs/images/password-audit-slideshow.mp4">
<img src="docs/images/password-audit-slideshow.webp" alt="Password audit HTML report demo video" width="760">
<img src="docs/images/password-audit-slideshow.webp" alt="ADPathFinder password audit report walkthrough showing cracked categories, policy compliance, password lengths, and roastable accounts" width="1000">
</a>
</p>

Expand Down
67 changes: 10 additions & 57 deletions checks/bad_successor.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,19 @@
from checks.core import Check, check


@check(risk="High", category="BadSuccessor - Default Groups with OU Privileges",
@check(risk="High", category="BadSuccessor - Privileged OU/Container Control",
entity="user", data=["bad_successor_ou_privileges"])
class BadSuccessorCheck(Check):

CRITICAL_GROUPS_BY_SID = {
'S-1-5-11': 'AUTHENTICATED USERS',
'-513': 'DOMAIN USERS',
'-515': 'DOMAIN COMPUTERS',
'S-1-1-0': 'EVERYONE',
'S-1-5-32-545': 'USERS',
}

DANGEROUS_PERMISSIONS = {
'GenericAll', 'WriteOwner', 'WriteDacl', 'Owns',
'GenericWrite', 'WriteOwnerRaw', 'OwnsRaw',
'WriteGPLink', 'GPLink', 'CreateChild',
}

def execute(self):
return self.check_critical_groups_ou_privileges()

def check_critical_groups_ou_privileges(self):
results = {}

try:
ou_privileges = self.get_bad_successor_ou_privileges()

for record in ou_privileges:
if not record:
continue

entity_sid = record.get('entity_sid', '')
rel_type = record.get('relationship_type', '')
target_name = record.get('target_name', record.get('ou_name', ''))

for sid_pattern, group_display in self.CRITICAL_GROUPS_BY_SID.items():
if entity_sid and self._matches_sid_pattern(entity_sid, sid_pattern):
if rel_type in self.DANGEROUS_PERMISSIONS:
description = f"{group_display} HAS {rel_type.upper()} ON OU: {target_name}"
results[description] = self.finding("", inline=True)
break # Only match one group per entity

except Exception:
pass

for record in self.get_bad_successor_ou_privileges():
if not record:
continue
entity_name = record.get('entity_name') or record.get('entity_id') or 'Unknown'
rel_type = record.get('relationship_type') or ''
target_type = record.get('target_type', 'OU')
target_name = record.get('target_name') or record.get('target_id') or 'Unknown'
description = f"{entity_name} HAS {rel_type.upper()} ON {target_type.upper()}: {target_name}"
results[description] = self.finding("", inline=True)
return results

def _matches_sid_pattern(self, entity_sid, pattern):
if not entity_sid or not pattern:
return False

if pattern.startswith('S-1-'):
return entity_sid.endswith(pattern)
elif pattern.startswith('-'):
if not entity_sid.endswith(pattern):
return False
prefix_pos = len(entity_sid) - len(pattern)
if prefix_pos <= 0:
return False
preceding_char = entity_sid[prefix_pos - 1]
return preceding_char.isdigit() or preceding_char == '-'
else:
return pattern in entity_sid
10 changes: 5 additions & 5 deletions checks/core/mssql_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ def linked_server_target_resolution(real_var, stub_var):
)


def host_sid_resolves_to_single_server(sid_expr):
# Avoid bridging ambiguous host SID prefixes shared by sibling instances.
def host_sid_resolves_to_single_server(sid_expr, source_var):
return (
f"COUNT {{ MATCH (sidServer:MSSQL_Server) "
f"WHERE sidServer.objectid CONTAINS ':' "
f"AND split(sidServer.objectid, ':')[0] = {sid_expr} }} = 1"
f"NOT EXISTS {{ MATCH (sidDup:MSSQL_Server) "
f"WHERE sidDup <> {source_var} "
f"AND sidDup.objectid CONTAINS ':' "
f"AND split(sidDup.objectid, ':')[0] = {sid_expr} }}"
)
2 changes: 1 addition & 1 deletion checks/mssql_privilege_escalation.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ def _fetch_linked_server_paths(self):
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]")})
AND {host_sid_resolves_to_single_server("split(srvA.objectid, ':')[0]", "srvA")})

MATCH (srvB:MSSQL_Server)-[:MSSQL_Contains]->(target:MSSQL_ServerRole)
WHERE {linked_server_target_resolution('srvB', 'srvB_stub')}
Expand Down
2 changes: 2 additions & 0 deletions checks/sccm_elevate1.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def execute(self):
rows = self.query(f"""
MATCH (g)-[:CoerceAndRelayToSMB]->(target:Computer)
WHERE target.SMBSigningRequired = false
AND coalesce(target.enabled, true) = true
AND target.SCCMSiteSystemRoles IS NOT NULL {cdf}

UNWIND target.SCCMSiteSystemRoles as role
Expand All @@ -30,6 +31,7 @@ def execute(self):

OPTIONAL MATCH (ss:Computer)
WHERE ss.SCCMSiteSystemRoles IS NOT NULL
AND coalesce(ss.enabled, true) = true
AND any(r IN ss.SCCMSiteSystemRoles WHERE r = 'SMS Site Server@' + site_code)
AND ss.objectid <> target.objectid

Expand Down
4 changes: 2 additions & 2 deletions checks/sccm_privilege_escalation.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ def _fetch_linked_sccm_paths(self):
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]")})
AND {host_sid_resolves_to_single_server("split(srvA.objectid, ':')[0]", "srvA")})

MATCH (srvB:MSSQL_Server)
WHERE {linked_server_target_resolution('srvB', 'srvB_stub')}
Expand Down Expand Up @@ -506,7 +506,7 @@ def _fetch_linked_execute_host_sccm_paths(self):
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]")}
AND {host_sid_resolves_to_single_server("split(src.objectid, ':')[0]", "src")}
MATCH (linked:MSSQL_Server)
WHERE linked = linkedStub
OR {linked_server_target_resolution('linked', 'linkedStub')}
Expand Down
9 changes: 8 additions & 1 deletion checks/sccm_takeover1.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,24 @@ class SCCMTakeover1Check(SCCMDomainMixin, Check):
)

def execute(self):
source_domain_cond = self._domain_condition("g")
sdf = self._site_domain_condition()
rows = self.query(f"""
MATCH (g)-[relay:CoerceAndRelayToMSSQL]->(login)
-[:MSSQL_MemberOf|MSSQL_IsMappedTo|MSSQL_ControlServer
|MSSQL_ControlDB|MSSQL_Contains
|SCCM_AssignAllPermissions*1..6]->(site:SCCM_Site)
WHERE true {sdf}
WHERE (
((g:User OR g:Computer OR g:Group){source_domain_cond})
OR NOT (g:User OR g:Computer OR g:Group)
)
AND (NOT g:Computer OR coalesce(g.enabled, true) = true)
AND true{sdf}

OPTIONAL MATCH (login)-[:MSSQL_MemberOf]->(:MSSQL_ServerRole)
-[:MSSQL_ControlServer]->(server:MSSQL_Server)
OPTIONAL MATCH (target:Computer)-[:MSSQL_HostFor]->(server)
WHERE coalesce(target.enabled, true) = true

RETURN DISTINCT
site.siteCode as site_code,
Expand Down
4 changes: 3 additions & 1 deletion checks/sccm_takeover2.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ def execute(self):
MATCH (g)-[:CoerceAndRelayToSMB]->(target:Computer)
-[:MSSQL_HostFor]->(:MSSQL_Server)-[:MSSQL_Contains]->(db:MSSQL_Database)
-[:SCCM_AssignAllPermissions]->(site:SCCM_Site)
WHERE target.SMBSigningRequired = false {cdf}
WHERE target.SMBSigningRequired = false
AND coalesce(target.enabled, true) = true {cdf}

OPTIONAL MATCH (ss:Computer)
WHERE ss.SCCMSiteSystemRoles IS NOT NULL
AND coalesce(ss.enabled, true) = true
AND any(role IN ss.SCCMSiteSystemRoles WHERE role = 'SMS Site Server@' + site.siteCode)
AND ss.objectid <> target.objectid

Expand Down
2 changes: 2 additions & 0 deletions checks/sccm_takeover4.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ def _fetch_paths(self):

OPTIONAL MATCH (cas_comp:Computer)
WHERE cas_comp.SCCMSiteSystemRoles IS NOT NULL
AND coalesce(cas_comp.enabled, true) = true
AND any(role IN cas_comp.SCCMSiteSystemRoles
WHERE role = 'SMS Site Server@' + parent_site.siteCode)
WITH parent_site, child_site,
collect(DISTINCT {self.host_name_expr("cas_comp")}) as cas_servers

MATCH (child_comp:Computer)
WHERE child_comp.SCCMSiteSystemRoles IS NOT NULL
AND coalesce(child_comp.enabled, true) = true
AND any(role IN child_comp.SCCMSiteSystemRoles
WHERE role = 'SMS Site Server@' + child_site.siteCode)
AND child_comp.SMBSigningRequired = false
Expand Down
2 changes: 2 additions & 0 deletions checks/sccm_takeover5.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def _fetch_paths(self):

MATCH (ss_comp:Computer)
WHERE ss_comp.SCCMSiteSystemRoles IS NOT NULL
AND coalesce(ss_comp.enabled, true) = true
AND any(role IN ss_comp.SCCMSiteSystemRoles
WHERE role = 'SMS Site Server@' + site.siteCode)
WITH site,
Expand All @@ -49,6 +50,7 @@ def _fetch_paths(self):

MATCH (sp_comp:Computer)
WHERE sp_comp.SCCMSiteSystemRoles IS NOT NULL
AND coalesce(sp_comp.enabled, true) = true
AND any(role IN sp_comp.SCCMSiteSystemRoles
WHERE role = 'SMS Provider@' + site.siteCode)
WITH site, site_server_sids, site_servers,
Expand Down
4 changes: 3 additions & 1 deletion checks/sccm_takeover6.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ def execute(self):

rows = self.query(f"""
MATCH (g)-[:CoerceAndRelayToSMB]->(target:Computer)-[:SCCM_AssignAllPermissions]->(site:SCCM_Site)
WHERE target.SMBSigningRequired = false {cdf}
WHERE target.SMBSigningRequired = false
AND coalesce(target.enabled, true) = true {cdf}
AND any(role IN target.SCCMSiteSystemRoles WHERE role = 'SMS Provider@' + site.siteCode)

OPTIONAL MATCH (ss:Computer)
WHERE ss.SCCMSiteSystemRoles IS NOT NULL
AND coalesce(ss.enabled, true) = true
AND any(role IN ss.SCCMSiteSystemRoles WHERE role = 'SMS Site Server@' + site.siteCode)
AND ss.objectid <> target.objectid

Expand Down
3 changes: 2 additions & 1 deletion checks/sccm_takeover7.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ def _fetch_paths(self):

rows = self.query(f"""
MATCH (role_comp:Computer)
WHERE role_comp.SCCMSiteSystemRoles IS NOT NULL{comp_filter}
WHERE role_comp.SCCMSiteSystemRoles IS NOT NULL
AND coalesce(role_comp.enabled, true) = true{comp_filter}
UNWIND role_comp.SCCMSiteSystemRoles as role
WITH role_comp,
trim(split(role, '@')[0]) as role_name,
Expand Down
2 changes: 2 additions & 0 deletions checks/sccm_takeover8.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def execute(self):
WHERE
((dc.ldapavailable = true AND dc.ldapsigning = false)
OR (dc.ldapsavailable = true AND dc.ldapsepa = false))
AND coalesce(dc.enabled, true) = true
{cdf}
WITH dc,
(dc.ldapavailable = true AND dc.ldapsigning = false) AS ldap_relay,
Expand All @@ -51,6 +52,7 @@ def execute(self):
WHERE coalesce(src.webclientrunning, src.WebClientRunning, false) = true
AND src.domain = dc.domain
AND src.SCCMSiteSystemRoles IS NOT NULL
AND coalesce(src.enabled, true) = true
AND (
any(role IN src.SCCMSiteSystemRoles WHERE role STARTS WITH 'SMS Site Server@')
OR any(role IN src.SCCMSiteSystemRoles WHERE role STARTS WITH 'SMS Provider@')
Expand Down
41 changes: 19 additions & 22 deletions checks/tier0_session_exposure.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,23 @@ def execute(self):
if not self.neo4j_data:
return {}

try:
rows = self.neo4j_data.conn.query("""
MATCH (c:Computer)-[:HasSession]->(u:User)
WHERE u.system_tags CONTAINS 'admin_tier_0'
AND (c.system_tags IS NULL OR NOT c.system_tags CONTAINS 'admin_tier_0')
AND u.enabled = true
AND c.enabled = true
RETURN u.objectid AS sid,
collect(DISTINCT c.name) AS hosts
""", name="tier0_session_exposure")
rows = self.neo4j_data.conn.query("""
MATCH (c:Computer)-[:HasSession]->(u:User)
WHERE u.system_tags CONTAINS 'admin_tier_0'
AND (c.system_tags IS NULL OR NOT c.system_tags CONTAINS 'admin_tier_0')
AND u.enabled = true
AND c.enabled = true
RETURN u.objectid AS sid,
collect(DISTINCT c.name) AS hosts
""", name="tier0_session_exposure")

findings = {}
for row in rows:
sid = row.get('sid')
hosts = sorted(row.get('hosts') or [])
if sid and hosts:
findings[sid] = self.finding(
f"Active session on: {', '.join(hosts)}",
details={"hosts": hosts},
)
return findings
except Exception:
return {}
findings = {}
for row in rows:
sid = row.get('sid')
hosts = sorted(row.get('hosts') or [])
if sid and hosts:
findings[sid] = self.finding(
f"Active session on: {', '.join(hosts)}",
details={"hosts": hosts},
)
return findings
Binary file modified docs/images/ad-report-slideshow.mp4
Binary file not shown.
Binary file modified docs/images/ad-report-slideshow.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/demo-slideshow.mp4
Binary file not shown.
Binary file modified docs/images/demo-slideshow.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/password-audit-slideshow.mp4
Binary file not shown.
Binary file modified docs/images/password-audit-slideshow.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 10 additions & 5 deletions modules/mssql_post_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,21 @@ def canonicalize_mssql_linked_server_edges(connection) -> int:
AND stub.objectid IS NOT NULL
AND stub.objectid CONTAINS ':'
WITH old, target, split(stub.objectid, ':')[0] AS sourceSid
CALL {{
WITH sourceSid
MATCH (sidServer:MSSQL_Server)
WHERE sidServer.objectid IS NOT NULL
AND sidServer.objectid CONTAINS ':'
AND split(sidServer.objectid, ':')[0] = sourceSid
RETURN count(sidServer) AS sidCount
}}
WITH old, target, sourceSid, sidCount
WHERE sidCount = 1
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
Expand Down
Loading