BloodHound Deep Dive Part 3: Ingestion, Postgres, Neo4j, and the Go API
Part 2 ended with a SharpHound run producing zipped JSON. This post covers what happens to that JSON once it reaches BloodHound Community Edition, and where this series’ verification approach changes from reading documentation to running a real graph database.
Two databases, one job split cleanly
BHCE’s docker-compose.yml, covered in Part 1, wires up two databases for a reason: they store fundamentally different kinds of data. Postgres (app-db, pinned to Postgres 18 in the official compose file) holds everything about the BloodHound application itself: user accounts and roles for the multi-user web UI, saved Cypher queries, custom asset group definitions, and metadata about every file that’s been uploaded. None of that is graph data. It’s ordinary relational application state, and Postgres is the right tool for it.
Neo4j (graph-db, pinned to neo4j:4.4.42) holds the graph itself: every node SharpHound found and every edge connecting them. When the web UI shows a path from a compromised user to Domain Admins, that path is the result of a Cypher query run against Neo4j, not a lookup against Postgres. The bloodhound container, a single Go binary, is the only thing that talks to either database. Nothing else in the stack has direct database access, which is also why bhe_enable_cypher_mutations defaulting to false matters: it’s the API server, not a database permission, that decides whether an ad-hoc query from the UI is allowed to write anything.
What an upload actually does
A SharpHound zip, uploaded through the web UI or the API, gets unpacked and parsed by the Go binary into a batch of node-creation and edge-creation Cypher operations against Neo4j. Object properties collected by ObjectProps, hasspn and dontreqpreauth among them, become properties on the corresponding User node. Group memberships become MemberOf edges. Abusable ACEs collected by the ACL method become edges typed by the specific right they represent: GenericAll, GenericWrite, WriteOwner, WriteDacl, AllExtendedRights, ForceChangePassword, Owns, and several more, plus session and local-admin data from Session and LocalGroup becoming HasSession and AdminTo edges. Domain replication rights collected against the DC become the edges behind BloodHound’s DCSync detection. None of these are BloodHound inventions: they’re Windows AD security primitives, GenericAll and ForceChangePassword are real access rights any AD administrator would recognize from dsacls output, and BloodHound’s contribution is turning thousands of individually unremarkable ACEs into a graph that’s actually queryable.
Verifying the graph layer directly
The full BHCE application, Postgres plus Neo4j plus the Go API container plus the web UI, needs Docker. This sandbox has neither Docker nor root, so that stack wasn’t stood up for this series. What’s genuinely possible without either: Neo4j itself is a plain JVM application, distributed as a tarball, that runs fine as an unprivileged user on a non-standard port.
A Neo4j 4.4.30 Community server (chosen over the pinned 4.4.42 because it’s the newest 4.4.x release confirmed compatible with the sandbox’s OpenJDK 11 runtime) was downloaded, extracted, and started directly:
$ tar xzf neo4j-community-4.4.30-unix.tar.gz
$ neo4j-community-4.4.30/bin/neo4j-admin set-initial-password 'TestPass123!'
$ neo4j-community-4.4.30/bin/neo4j console
...
2026-07-29 19:23:25.381+0000 INFO Bolt enabled on localhost:7687.
2026-07-29 19:23:28.134+0000 INFO Remote interface available at http://localhost:7474/
2026-07-29 19:23:28.140+0000 INFO Started.
From there, cypher-shell loaded a graph built by hand to match CONTOSO.LOCAL exactly as documented in Part 1: three Computer nodes (DC01, FS01, WKS01), four User nodes (j.reyes, svc-backup, svc-legacy-scan, a.oyelaran) with hasspn and dontreqpreauth properties set to match what SharpHound’s ObjectProps collection would actually report, three Group nodes (Domain Admins, Backup Operators, and the lab-added IT-HELPDESK), and the real edge types BHCE’s ingestion pipeline would have written: MemberOf, AdminTo, HasSession, GenericAll, and ForceChangePassword.
$ cypher-shell -u neo4j -p 'TestPass123!' --format plain < contoso.cypher
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’s a genuine query against a genuine running graph database, not a transcription. It matters for the rest of this series because every Cypher query and every result shown in Parts 4 through 6 was run against this same instance, seeded once and queried repeatedly, exactly the way an analyst would work against a real BHCE deployment’s Neo4j backend, just without Postgres, the Go API, or the web UI sitting in front of it.
What this doesn’t verify
This approach confirms the graph engine, the part that actually answers “is there a path here,” behaves the way BloodHound’s documentation says it does. It does not confirm anything about the Go API’s upload parsing, the web UI’s rendering, Postgres-backed multi-user access control, or the ingestion service’s handling of a malformed or oversized SharpHound zip. Those are real gaps, flagged here rather than glossed over, the same way this site’s FRR and hashcat coverage flagged what couldn’t be verified without root or a GPU.
Next: Part 4, where that seeded graph gets queried for real, including one query that comes back empty and why that empty result is itself useful information.