#!/usr/bin/env python3 # For authorized penetration testing and educational environments only. # Usage against targets without prior mutual consent is illegal. # It is the end user's responsibility to obey all applicable local, state and federal laws. """BloodHound Attack Path Analysis Agent - Queries Neo4j for AD attack paths to Domain Admin.""" import json import logging import argparse from datetime import datetime from neo4j import GraphDatabase logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger(__name__) def connect_neo4j(uri, username, password): """Connect to Neo4j database containing BloodHound data.""" driver = GraphDatabase.driver(uri, auth=(username, password)) driver.verify_connectivity() logger.info("Connected to Neo4j at %s", uri) return driver def find_domain_admins(driver): """Find all members of the Domain Admins group.""" query = ( "MATCH (u:User)-[:MemberOf*1..]->(g:Group) " "WHERE g.name STARTS WITH 'DOMAIN ADMINS' " "RETURN u.name AS user, u.enabled AS enabled, u.lastlogon AS lastlogon" ) with driver.session() as session: results = [dict(record) for record in session.run(query)] logger.info("Found %d Domain Admin members", len(results)) return results def find_shortest_paths_to_da(driver, start_user=None): """Find shortest attack paths from owned users to Domain Admin.""" if start_user: query = ( "MATCH p=shortestPath((u:User {name: $user})-[*1..]->(g:Group)) " "WHERE g.name STARTS WITH 'DOMAIN ADMINS' " "RETURN p, length(p) AS hops" ) params = {"user": start_user} else: query = ( "MATCH p=shortestPath((u:User {owned: true})-[*1..]->(g:Group)) " "WHERE g.name STARTS WITH 'DOMAIN ADMINS' " "RETURN u.name AS start, length(p) AS hops " "ORDER BY hops ASC LIMIT 20" ) params = {} with driver.session() as session: results = [dict(record) for record in session.run(query, params)] logger.info("Found %d attack paths to DA", len(results)) return results def find_kerberoastable_users(driver): """Find users with SPNs set (Kerberoastable) that have paths to high-value targets.""" query = ( "MATCH (u:User) WHERE u.hasspn = true AND u.enabled = true " "RETURN u.name AS user, u.serviceprincipalnames AS spns, " "u.admincount AS admincount, u.pwdlastset AS pwdlastset" ) with driver.session() as session: results = [dict(record) for record in session.run(query)] logger.info("Found %d Kerberoastable users", len(results)) return results def find_asrep_roastable(driver): """Find users with Kerberos pre-auth disabled (AS-REP Roastable).""" query = ( "MATCH (u:User) WHERE u.dontreqpreauth = true AND u.enabled = true " "RETURN u.name AS user, u.enabled AS enabled" ) with driver.session() as session: results = [dict(record) for record in session.run(query)] logger.info("Found %d AS-REP Roastable users", len(results)) return results def find_unconstrained_delegation(driver): """Find computers with unconstrained delegation enabled.""" query = ( "MATCH (c:Computer) WHERE c.unconstraineddelegation = true " "RETURN c.name AS computer, c.operatingsystem AS os, c.enabled AS enabled" ) with driver.session() as session: results = [dict(record) for record in session.run(query)] logger.info("Found %d unconstrained delegation computers", len(results)) return results def find_local_admin_paths(driver, target_computer): """Find users with local admin rights on a target computer.""" query = ( "MATCH p=(u:User)-[:AdminTo|MemberOf*1..]->(c:Computer {name: $computer}) " "RETURN u.name AS user, length(p) AS hops " "ORDER BY hops ASC LIMIT 50" ) with driver.session() as session: results = [dict(record) for record in session.run(query, {"computer": target_computer})] logger.info("Found %d users with admin access to %s", len(results), target_computer) return results def find_gpo_attack_paths(driver): """Find GPO-based attack paths that could lead to privilege escalation.""" query = ( "MATCH (g:GPO)-[:GpLink]->(ou:OU)-[:Contains*1..]->(c:Computer) " "MATCH (u:User)-[:GenericAll|GenericWrite|WriteOwner|WriteDacl]->(g) " "WHERE u.enabled = true " "RETURN u.name AS user, g.name AS gpo, c.name AS affected_computer " "LIMIT 50" ) with driver.session() as session: results = [dict(record) for record in session.run(query)] logger.info("Found %d GPO attack paths", len(results)) return results def assess_ad_risk(da_members, paths, kerberoastable, asrep, unconstrained, gpo_paths): """Calculate overall AD security risk score.""" score = 0 if len(paths) > 0: score += 30 if len(kerberoastable) > 5: score += 20 if len(asrep) > 0: score += 15 if len(unconstrained) > 1: score += 15 if len(gpo_paths) > 0: score += 20 risk = "Critical" if score >= 60 else "High" if score >= 40 else "Medium" if score >= 20 else "Low" return {"score": score, "risk_level": risk} def generate_report(da_members, paths, kerberoastable, asrep, unconstrained, gpo_paths, risk): """Generate BloodHound analysis report.""" report = { "timestamp": datetime.utcnow().isoformat(), "domain_admins": da_members, "attack_paths_to_da": paths[:20], "kerberoastable_users": kerberoastable, "asrep_roastable": asrep, "unconstrained_delegation": unconstrained, "gpo_attack_paths": gpo_paths[:20], "risk_assessment": risk, } print(f"BLOODHOUND REPORT: Risk={risk['risk_level']} Score={risk['score']}") return report def main(): parser = argparse.ArgumentParser(description="BloodHound Attack Path Analysis Agent") parser.add_argument("--neo4j-uri", default="bolt://localhost:7687") parser.add_argument("--neo4j-user", default="neo4j") parser.add_argument("--neo4j-password", required=True) parser.add_argument("--start-user", help="Specific user to find paths from") parser.add_argument("--output", default="bloodhound_report.json") args = parser.parse_args() driver = connect_neo4j(args.neo4j_uri, args.neo4j_user, args.neo4j_password) da_members = find_domain_admins(driver) paths = find_shortest_paths_to_da(driver, args.start_user) kerberoastable = find_kerberoastable_users(driver) asrep = find_asrep_roastable(driver) unconstrained = find_unconstrained_delegation(driver) gpo_paths = find_gpo_attack_paths(driver) risk = assess_ad_risk(da_members, paths, kerberoastable, asrep, unconstrained, gpo_paths) report = generate_report(da_members, paths, kerberoastable, asrep, unconstrained, gpo_paths, risk) driver.close() with open(args.output, "w") as f: json.dump(report, f, indent=2, default=str) logger.info("Report saved to %s", args.output) if __name__ == "__main__": main()