BloodHound Deep Dive Part 4: Cypher and the Built-In Attack Path Queries

Part 3 got a real Neo4j instance running with CONTOSO.LOCAL’s principals loaded as nodes and edges. This post runs the queries BloodHound’s UI ships as one-click buttons, written out as the Cypher they actually are, against that same graph.

Cypher in three lines

Cypher describes graph patterns with ASCII art. (u:User) is a node labeled User. [:MemberOf] is a relationship of type MemberOf. (u:User)-[:MemberOf]->(g:Group) reads as “match a User node connected to a Group node by an outgoing MemberOf edge,” which is precisely the shape of an Active Directory group membership. Chain relationship types with | to match any of several edge types in one hop, and add a range like *1..4 to match a variable-length path of one to four hops. That’s most of what the queries below actually use.

Confirming the graph loaded correctly

Before anything path-related, a sanity check: what does group membership actually look like in the seeded graph?

neo4j> MATCH (n:Group)<-[:MemberOf]-(u:User) RETURN n.name AS group, collect(u.name) AS members ORDER BY group;
group, members
"BACKUP OPERATORS@CONTOSO.LOCAL", ["SVC-BACKUP@CONTOSO.LOCAL"]
"DOMAIN ADMINS@CONTOSO.LOCAL", ["A.OYELARAN@CONTOSO.LOCAL"]
"IT-HELPDESK@CONTOSO.LOCAL", ["J.REYES@CONTOSO.LOCAL"]

That matches Impacket Part 2’s samrdump.py output exactly, which is the point: BloodHound isn’t discovering different facts than Impacket’s enumeration tools, it’s presenting the same facts as a graph instead of a scrolling terminal transcript.

The GenericAll edge

The lab addition from Part 1, IT-HELPDESK holding GenericAll over Backup Operators, shows up as a direct edge:

neo4j> MATCH (n)-[r:GenericAll]->(m) RETURN n.name, type(r), m.name;
n.name, type(r), m.name
"IT-HELPDESK@CONTOSO.LOCAL", "GenericAll", "BACKUP OPERATORS@CONTOSO.LOCAL"

GenericAll on a group object is one of the more consequential edges in BloodHound’s schema. It’s not read access, it’s full control: anyone holding it can add themselves, or anyone else, as a member of the target group through an ordinary LDAP write, no exploit involved. j.reyes, a member of IT-HELPDESK, inherits that control transitively.

The query that comes back empty

BloodHound’s flagship built-in query is “Shortest Paths to Domain Admins,” which in Cypher is a shortestPath() call across every edge type BloodHound considers meaningful:

neo4j> MATCH p = shortestPath((n {name:'J.REYES@CONTOSO.LOCAL'})-[*1..6]->(m:Group {name:'DOMAIN ADMINS@CONTOSO.LOCAL'})) RETURN p;

Against this graph, that query returns nothing. No rows, no path. That’s a real result, not an error, and it’s worth sitting with rather than treating as a dead end. j.reyes reaches IT-HELPDESK, which reaches Backup Operators via GenericAll, but nothing in this graph connects Backup Operators onward to Domain Admins. Backup Operators carries a well-known real-world privilege, the ability to abuse SeBackupPrivilege to read the DC’s NTDS.dit outside of normal AD replication, but that’s an operating-system privilege, not an Active Directory access-control edge, and it isn’t part of BloodHound’s default edge schema the way DCSync rights (GetChanges plus GetChangesAll, granted directly on the domain object) are. BloodHound’s graph is only as complete as the relationships SharpHound’s collection methods actually model. A real path can exist that the tool simply doesn’t draw, and knowing which category of privilege that is, an AD ACE the graph will show versus an OS-level right it won’t, is exactly the judgment a canned query can’t supply on its own.

The path that isn’t empty

Backup Operators does connect somewhere in this graph, just not to Domain Admins. The lab also has Backup Operators holding ForceChangePassword over svc-legacy-scan, set up specifically for Part 6:

neo4j> MATCH p=(helpdesk:Group {name:'IT-HELPDESK@CONTOSO.LOCAL'})-[:GenericAll]->(bo:Group)-[:ForceChangePassword]->(target:User) RETURN helpdesk.name, bo.name, target.name;
helpdesk.name, bo.name, target.name
"IT-HELPDESK@CONTOSO.LOCAL", "BACKUP OPERATORS@CONTOSO.LOCAL", "SVC-LEGACY-SCAN@CONTOSO.LOCAL"

Two hops, both real AD ACEs, both edges BloodHound’s schema models directly: j.reyes, through IT-HELPDESK’s GenericAll over Backup Operators, through Backup OperatorsForceChangePassword over svc-legacy-scan, ends at a credential reset with no password required. ForceChangePassword means exactly what it says: the holder can set a new password for the target account without knowing, or needing, the current one.

What the graph answers and what it doesn’t

Running these queries makes the boundary concrete. BloodHound will reliably tell an analyst that an edge exists and what kind it is. It will not tell them that GenericAll on a group means “add a member,” or that ForceChangePassword means “reset without the old password,” or, as the empty shortest-path query above demonstrated, that a real privilege can exist entirely outside its edge schema. That translation from graph edge to concrete action is where Part 6 of this series is headed, once Part 5 covers the other half of BloodHound’s value: not ACL edges at all, but object properties that flag which accounts are worth attacking with the tools this site already covers.

Next: Part 5, where the same graph’s hasspn and dontreqpreauth properties point at exactly the two accounts Impacket Deep Dive Parts 3 and 4 targeted, before either of those posts ever ran a single enumeration command.