Files

403 lines
16 KiB
Python

#!/usr/bin/env python3
"""
BloodHound Data Analyzer
Parses BloodHound JSON collection data to identify high-risk attack paths,
Kerberoastable accounts, ACL misconfigurations, and delegation abuse
opportunities without requiring the BloodHound GUI.
"""
import json
import os
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class ADUser:
name: str
enabled: bool = True
has_spn: bool = False
dont_req_preauth: bool = False
admin_count: bool = False
password_last_set: str = ""
last_logon: str = ""
description: str = ""
sid: str = ""
@dataclass
class ADComputer:
name: str
os: str = ""
enabled: bool = True
unconstrained_delegation: bool = False
allowed_to_delegate: list = field(default_factory=list)
local_admins: list = field(default_factory=list)
has_sessions: list = field(default_factory=list)
@dataclass
class ADGroup:
name: str
members: list = field(default_factory=list)
high_value: bool = False
sid: str = ""
@dataclass
class Finding:
severity: str # critical, high, medium, low
category: str
title: str
description: str
affected_objects: list = field(default_factory=list)
attack_path: str = ""
remediation: str = ""
mitre_technique: str = ""
class BloodHoundAnalyzer:
"""Analyze BloodHound collection data for attack paths."""
def __init__(self):
self.users: dict[str, ADUser] = {}
self.computers: dict[str, ADComputer] = {}
self.groups: dict[str, ADGroup] = {}
self.acl_edges: list[dict] = []
self.findings: list[Finding] = []
self.domain_admins: set = set()
def load_bloodhound_data(self, data_dir: str) -> None:
"""Load BloodHound JSON files from collection directory."""
for filename in os.listdir(data_dir):
filepath = os.path.join(data_dir, filename)
if not filename.endswith(".json"):
continue
with open(filepath) as f:
try:
data = json.load(f)
except json.JSONDecodeError:
print(f"[-] Failed to parse {filename}")
continue
if "users" in filename.lower() or (isinstance(data, dict) and data.get("meta", {}).get("type") == "users"):
self._parse_users(data)
elif "computers" in filename.lower() or (isinstance(data, dict) and data.get("meta", {}).get("type") == "computers"):
self._parse_computers(data)
elif "groups" in filename.lower() or (isinstance(data, dict) and data.get("meta", {}).get("type") == "groups"):
self._parse_groups(data)
print(f"[+] Loaded: {len(self.users)} users, {len(self.computers)} computers, {len(self.groups)} groups")
def _parse_users(self, data: dict) -> None:
"""Parse user data from BloodHound JSON."""
items = data.get("data", data) if isinstance(data, dict) else data
if isinstance(items, dict):
items = items.get("data", [])
for user_data in items:
props = user_data.get("Properties", user_data.get("properties", {}))
name = props.get("name", user_data.get("name", "unknown"))
user = ADUser(
name=name,
enabled=props.get("enabled", True),
has_spn=props.get("hasspn", False),
dont_req_preauth=props.get("dontreqpreauth", False),
admin_count=props.get("admincount", False),
password_last_set=str(props.get("pwdlastset", "")),
last_logon=str(props.get("lastlogon", "")),
description=props.get("description", ""),
sid=props.get("objectid", props.get("objectsid", "")),
)
self.users[name.upper()] = user
def _parse_computers(self, data: dict) -> None:
"""Parse computer data from BloodHound JSON."""
items = data.get("data", data) if isinstance(data, dict) else data
if isinstance(items, dict):
items = items.get("data", [])
for comp_data in items:
props = comp_data.get("Properties", comp_data.get("properties", {}))
name = props.get("name", comp_data.get("name", "unknown"))
computer = ADComputer(
name=name,
os=props.get("operatingsystem", ""),
enabled=props.get("enabled", True),
unconstrained_delegation=props.get("unconstraineddelegation", False),
allowed_to_delegate=props.get("allowedtodelegate", []) or [],
)
self.computers[name.upper()] = computer
def _parse_groups(self, data: dict) -> None:
"""Parse group data from BloodHound JSON."""
items = data.get("data", data) if isinstance(data, dict) else data
if isinstance(items, dict):
items = items.get("data", [])
for group_data in items:
props = group_data.get("Properties", group_data.get("properties", {}))
name = props.get("name", group_data.get("name", "unknown"))
members_raw = group_data.get("Members", group_data.get("members", []))
members = [m.get("MemberId", m.get("ObjectIdentifier", "")) for m in members_raw] if members_raw else []
group = ADGroup(
name=name,
members=members,
high_value=props.get("highvalue", False),
sid=props.get("objectid", ""),
)
self.groups[name.upper()] = group
if "DOMAIN ADMINS" in name.upper():
self.domain_admins = set(members)
def find_kerberoastable_accounts(self) -> list[Finding]:
"""Identify Kerberoastable service accounts."""
kerberoastable = [u for u in self.users.values() if u.has_spn and u.enabled]
findings = []
if kerberoastable:
privileged_kerberoastable = [
u for u in kerberoastable if u.admin_count
]
findings.append(Finding(
severity="critical" if privileged_kerberoastable else "high",
category="Kerberoasting",
title=f"Found {len(kerberoastable)} Kerberoastable Accounts",
description=(
f"{len(kerberoastable)} enabled user accounts have Service Principal Names (SPNs) "
f"set, making them vulnerable to Kerberoasting (T1558.003). "
f"{len(privileged_kerberoastable)} of these are privileged accounts."
),
affected_objects=[u.name for u in kerberoastable],
attack_path="GetUserSPNs.py -> Request TGS -> Crack offline with hashcat -m 13100",
remediation=(
"1. Use Group Managed Service Accounts (gMSA) where possible\n"
"2. Set 25+ character passwords on service accounts\n"
"3. Enable AES encryption only (disable RC4)\n"
"4. Monitor Event ID 4769 for anomalous TGS requests"
),
mitre_technique="T1558.003",
))
return findings
def find_asrep_roastable_accounts(self) -> list[Finding]:
"""Identify AS-REP Roastable accounts."""
asrep = [u for u in self.users.values() if u.dont_req_preauth and u.enabled]
findings = []
if asrep:
findings.append(Finding(
severity="high",
category="AS-REP Roasting",
title=f"Found {len(asrep)} AS-REP Roastable Accounts",
description=(
f"{len(asrep)} accounts have 'Do not require Kerberos pre-authentication' "
f"enabled, allowing offline password cracking (T1558.004)."
),
affected_objects=[u.name for u in asrep],
attack_path="GetNPUsers.py -> Request AS-REP -> Crack with hashcat -m 18200",
remediation=(
"1. Enable Kerberos pre-authentication for all accounts\n"
"2. Use strong passwords (25+ characters) on affected accounts\n"
"3. Monitor Event ID 4768 with pre-auth type 0"
),
mitre_technique="T1558.004",
))
return findings
def find_unconstrained_delegation(self) -> list[Finding]:
"""Identify computers with unconstrained delegation."""
unconstrained = [
c for c in self.computers.values()
if c.unconstrained_delegation and c.enabled
and "DOMAIN CONTROLLER" not in c.name.upper()
]
findings = []
if unconstrained:
findings.append(Finding(
severity="critical",
category="Delegation Abuse",
title=f"Found {len(unconstrained)} Non-DC Computers with Unconstrained Delegation",
description=(
f"{len(unconstrained)} computers (excluding DCs) have unconstrained delegation "
f"enabled. An attacker with admin access to these systems can capture TGTs "
f"from any user that authenticates to them, including Domain Admins."
),
affected_objects=[c.name for c in unconstrained],
attack_path=(
"Compromise unconstrained host -> Coerce DC auth (PetitPotam/PrinterBug) -> "
"Capture DC TGT with Rubeus monitor -> DCSync"
),
remediation=(
"1. Remove unconstrained delegation from non-DC computers\n"
"2. Migrate to constrained delegation or RBCD\n"
"3. Add sensitive accounts to 'Protected Users' group\n"
"4. Enable 'Account is sensitive and cannot be delegated'"
),
mitre_technique="T1558.001",
))
return findings
def find_constrained_delegation(self) -> list[Finding]:
"""Identify constrained delegation abuse opportunities."""
constrained = [
c for c in self.computers.values()
if c.allowed_to_delegate and c.enabled
]
findings = []
if constrained:
findings.append(Finding(
severity="high",
category="Delegation Abuse",
title=f"Found {len(constrained)} Computers with Constrained Delegation",
description=(
f"{len(constrained)} computers have constrained delegation configured. "
f"If protocol transition is enabled (TrustedToAuthForDelegation), an attacker "
f"can abuse S4U2Self and S4U2Proxy to impersonate any user to the target service."
),
affected_objects=[
f"{c.name} -> {', '.join(c.allowed_to_delegate)}" for c in constrained
],
remediation=(
"1. Review all constrained delegation configurations\n"
"2. Disable protocol transition where not needed\n"
"3. Use RBCD instead of traditional constrained delegation\n"
"4. Add sensitive accounts to 'Protected Users' group"
),
mitre_technique="T1550.003",
))
return findings
def run_full_analysis(self) -> list[Finding]:
"""Run all analysis checks and return findings."""
self.findings = []
self.findings.extend(self.find_kerberoastable_accounts())
self.findings.extend(self.find_asrep_roastable_accounts())
self.findings.extend(self.find_unconstrained_delegation())
self.findings.extend(self.find_constrained_delegation())
# Sort by severity
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
self.findings.sort(key=lambda f: severity_order.get(f.severity, 99))
return self.findings
def generate_report(self) -> str:
"""Generate analysis report."""
lines = []
lines.append("=" * 70)
lines.append("BLOODHOUND ACTIVE DIRECTORY ANALYSIS REPORT")
lines.append("=" * 70)
lines.append(f"\nDomain Statistics:")
lines.append(f" Users: {len(self.users)}")
lines.append(f" Computers: {len(self.computers)}")
lines.append(f" Groups: {len(self.groups)}")
lines.append(f" Findings: {len(self.findings)}")
# Summary by severity
sev_counts = defaultdict(int)
for f in self.findings:
sev_counts[f.severity] += 1
lines.append(f"\n Critical: {sev_counts['critical']}")
lines.append(f" High: {sev_counts['high']}")
lines.append(f" Medium: {sev_counts['medium']}")
lines.append(f" Low: {sev_counts['low']}")
lines.append("\n" + "=" * 70)
lines.append("DETAILED FINDINGS")
lines.append("=" * 70)
for i, finding in enumerate(self.findings, 1):
lines.append(f"\n--- Finding #{i}: [{finding.severity.upper()}] {finding.title} ---")
lines.append(f"Category: {finding.category}")
lines.append(f"MITRE ATT&CK: {finding.mitre_technique}")
lines.append(f"\nDescription:\n {finding.description}")
lines.append(f"\nAttack Path:\n {finding.attack_path}")
lines.append(f"\nAffected Objects ({len(finding.affected_objects)}):")
for obj in finding.affected_objects[:10]:
lines.append(f" - {obj}")
if len(finding.affected_objects) > 10:
lines.append(f" ... and {len(finding.affected_objects) - 10} more")
lines.append(f"\nRemediation:\n {finding.remediation}")
return "\n".join(lines)
def main():
"""Demonstrate BloodHound data analysis."""
analyzer = BloodHoundAnalyzer()
# Create sample data for demonstration
sample_users = {
"meta": {"type": "users"},
"data": [
{"Properties": {"name": "SVC_SQL@CORP.LOCAL", "enabled": True, "hasspn": True,
"dontreqpreauth": False, "admincount": True, "description": "SQL Service Account"}},
{"Properties": {"name": "SVC_WEB@CORP.LOCAL", "enabled": True, "hasspn": True,
"dontreqpreauth": False, "admincount": False, "description": "Web Service"}},
{"Properties": {"name": "SVC_BACKUP@CORP.LOCAL", "enabled": True, "hasspn": True,
"dontreqpreauth": False, "admincount": True, "description": "Backup Service"}},
{"Properties": {"name": "J.SMITH@CORP.LOCAL", "enabled": True, "hasspn": False,
"dontreqpreauth": True, "admincount": False}},
{"Properties": {"name": "ADMIN@CORP.LOCAL", "enabled": True, "hasspn": False,
"dontreqpreauth": False, "admincount": True}},
],
}
sample_computers = {
"meta": {"type": "computers"},
"data": [
{"Properties": {"name": "DC01.CORP.LOCAL", "enabled": True,
"unconstraineddelegation": True, "operatingsystem": "Windows Server 2022"}},
{"Properties": {"name": "WEB01.CORP.LOCAL", "enabled": True,
"unconstraineddelegation": True, "operatingsystem": "Windows Server 2019"}},
{"Properties": {"name": "SQL01.CORP.LOCAL", "enabled": True,
"unconstraineddelegation": False, "operatingsystem": "Windows Server 2019",
"allowedtodelegate": ["MSSQLSvc/DB01.CORP.LOCAL:1433"]}},
],
}
sample_groups = {
"meta": {"type": "groups"},
"data": [
{"Properties": {"name": "DOMAIN ADMINS@CORP.LOCAL", "highvalue": True},
"Members": [{"MemberId": "S-1-5-21-xxx-500"}]},
{"Properties": {"name": "BACKUP OPERATORS@CORP.LOCAL", "highvalue": True},
"Members": []},
],
}
# Write sample data
sample_dir = "./bloodhound_sample"
os.makedirs(sample_dir, exist_ok=True)
for name, data in [("users.json", sample_users), ("computers.json", sample_computers), ("groups.json", sample_groups)]:
with open(os.path.join(sample_dir, name), "w") as f:
json.dump(data, f)
# Load and analyze
analyzer.load_bloodhound_data(sample_dir)
analyzer.run_full_analysis()
print(analyzer.generate_report())
# Cleanup
import shutil
shutil.rmtree(sample_dir)
if __name__ == "__main__":
main()