Source: SpecterOps/BloodHound @ 65074fd

How It Works

BloodHound CE is the graph-based attack path analysis platform for Active Directory and Azure environments. It ingests data collected by analyzing-sharphound (AD) and AzureHound (Azure), stores it in a graph database, and exposes pathfinding and analysis capabilities through a REST API served to a React SPA frontend.

The system is a monolithic Go binary (cmd/api/) that serves a REST API and embedded React static assets. It maintains two database backends: a relational PostgreSQL instance for users, roles, permissions, audit logs, and ingest metadata (~40 tables), and a graph database — either Neo4j or PostgreSQL-via-DAWGS — for the AD/Azure entity graph. The graph abstraction layer, DAWGS, is SpecterOps’ own library providing a database-agnostic graph.Database interface with typed query builders, batch operations, and pathfinding algorithms.

A background DataPipe daemon orchestrates the ingest-analyze lifecycle on a configurable tick: it purges stale data, processes queued ingestion files, then runs post-analysis operations (ADCS ESC edges, NTLM relay edges, Tier Zero tagging, GPO application). The analysis engine computes synthetic edges that SharpHound doesn’t collect directly — things like DCSync, SyncLAPSPassword, ADCSESC1 through ADCSESC13, and CoerceAndRelayNTLMToSMB — by reasoning over the ingested graph topology.

Architecture

SharpHound/AzureHound JSON/ZIP
         │
         ▼
   POST /api/v2/file-ingest
         │
         ▼
┌─────────────────────────────────────────────┐
│              Go API Server (monolith)         │
│                                               │
│  ┌──────────┐  ┌────────────┐  ┌───────────┐ │
│  │ FileIngest│→ │  Graphify  │→ │   DAWGS   │ │
│  │  Service  │  │  Service   │  │ Graph DB  │ │
│  └──────────┘  └────────────┘  └───────────┘ │
│                                      │        │
│  ┌──────────────┐  ┌───────────┐     │        │
│  │  Analysis     │← │ DataPipe  │─────┘        │
│  │  Engine       │  │  Daemon   │              │
│  └──────────────┘  └───────────┘              │
│                                      │        │
│  ┌──────────────┐  ┌───────────┐     │        │
│  │  Pathfinding  │→ │  DAWGS   │     │        │
│  │  / Cypher API │  │ Queries  │     │        │
│  └──────────────┘  └───────────┘     │        │
│                                      │        │
│  ┌──────────────┐  ┌───────────┐     │        │
│  │  Auth / RBAC  │  │PostgreSQL│     │        │
│  │  (JWT/OIDC/   │  │ (users,  │     │        │
│  │   SAML)       │  │ roles)   │     │        │
│  └──────────────┘  └───────────┘     │        │
└─────────────────────────────────────────────┘
         │ REST API (gorilla/mux)
         ▼
   React SPA (Vite + MUI + Sigma.js)

Frontend stack: React 18, TypeScript, Redux Toolkit + Redux Saga, MUI v5, Sigma.js v2 + Graphology for graph rendering with ForceAtlas2 layout. The frontend is a Yarn workspace monorepo including bh-shared-ui, doodle-ui (Storybook component library), and js-client-library.

The Spine

The primary request path — file upload through graph analysis to visualization — follows this flow:

  1. Upload: POST /api/v2/file-ingest → validates MIME type, creates a file_upload_job in PostgreSQL, stores the file on disk
  2. Queue: An ingest task is created in the ingest_tasks table
  3. DataPipe picks it up: The daemon dequeues and calls Graphify.IngestFile()
  4. Graphify dispatches by meta.Type tag in the JSON stream — separate handlers for each AD entity type (User, Computer, Group, GPO, OU, Container, Domain, certificate infrastructure) and Azure types
  5. Conversion: Typed converters (convertors.go, azure_convertors.go) map raw JSON properties to DAWGS graph node/relationship types
  6. Batch write: Nodes are upserted by object_id with lastseen timestamps using DAWGS BatchUpdater (threshold: 500 nodes per batch). Deduplication uses a ChangeManager interface
  7. Post-analysis runs: RunAnalysisOperations() creates synthetic edges — ADCS ESC chains, NTLM relay opportunities, DCSync paths, LAPS sync, GPO application — using reader/writer worker patterns within graph transactions
  8. Frontend queries: Pathfinding and entity queries hit GET /api/v2/pathfinding/shortest-path or GET /api/v2/ad/entity/{object_id}, which use DAWGS FetchAllShortestPaths() with filtered relationship sets

Key Patterns

DAWGS Graph Abstraction

The entire graph layer goes through SpecterOps’ DAWGS library rather than raw Cypher or SQL. This provides:

// Type-safe query building
criteria := []graph.Criteria{
    query.Equals(query.StartID(), startNode.ID),
    query.KindIn(pathfindingRelationships...),
}
return tx.Relationships().Filter(query.And(criteria...)).FetchAllShortestPaths(...)

This abstracts over Neo4j vs. PostgreSQL backends — the same Go code generates Cypher for Neo4j or recursive SQL for PostgreSQL. The library includes Roaring bitmap optimizations, traversal algorithms (BFS/DFS), and a graph cache layer.

Cuelang-Generated Schema

Graph schema definitions live in packages/cue/bh/ad/, packages/cue/bh/azure/, and packages/cue/bh/common/ as CUE files. A code-generation step produces Go constants in packages/go/graphschema/ — 16 AD node kinds, 70+ relationship kinds, and parallel Azure types. This ensures compile-time safety between schema definition and application code.

Cypher Complexity Limiting

User-submitted Cypher queries (via POST /api/v2/query/cypher) are parsed through DAWGS’ cypher/frontend parser, walked via cypher/models/walk, and scored by cypher/analyzer. Queries exceeding a “fitness” threshold (default: -7) get reduced timeouts or rejection. Mutation queries require GraphDBMutate permission and are audit-logged.

Post-Processed Synthetic Edges

Many relationship types in the pathfinding graph are not collected by SharpHound at all — they’re computed post-ingestion:

Edge TypeSource DataComputation
ADCSESC1–ADCSESC13Cert templates + CAs + enrollment agentsESC chain analysis per template
DCSyncDomain + high-privilege groupsTier Zero group membership
SyncLAPSPasswordGPO-linked computers + LAPS-enabled GPOsGPO-to-computer LAPS mapping
CoerceAndRelayNTLMToSMBComputer objects with specific attributesPetitpotam-to-SMB relay chain
ProtectAdminGroupsSDProp + AdminSDHolder configurationACL inference

Each is implemented as a separate analysis file in packages/go/analysis/ad/.

Non-Obvious Details

Dual graph backend is a first-class design decision, not a migration artifact. The dawgs/drivers/pg package provides a full PostgreSQL graph driver. This means BloodHound CE can run entirely on PostgreSQL without Neo4j — a significant operational simplification over BloodHound 4.x (Neo4j-only). The relational and graph data can live in the same PostgreSQL instance.

ETAC filtering happens at the API response layer, not in the graph query. The Environment Targeted Access Control system restricts users to specific AD domains or Azure tenants by filtering nodes/edges after the graph query executes. This means expensive queries still run to completion even if most results will be discarded — a correctness-over-performance trade-off.

JSON stream ingestion uses tagged streams, not plain JSON arrays. SharpHound output uses a tagged JSON stream format with meta blocks marking entity types. CreateIngestDecoder(reader, "data", 1) seeks to specific tags. ZIP files contain multiple independent streams that are processed sequentially.

The Database interface is explicitly deprecated — cmd/api/src/database/db.go has ~180 lines of method signatures with a comment directing new code to create narrow per-service interfaces. The MockDatabase generated by go.uber.org/mock must stay in sync with this interface per the repo’s AGENTS.md.

OpenGraph extension allows runtime schema changes. Users can define custom node kinds and relationship types through the API, stored in PostgreSQL (schema_extensions, schema_node_kinds tables) and synced to the graph. This enables mapping non-AD/Azure environments (cloud IAM, network topologies) onto the same pathfinding engine.

Assessment

BloodHound CE is a substantial engineering effort — ~142K lines of Go backend, ~153 frontend source files, with excellent project structure and clear architectural boundaries. The DAWGS abstraction layer is a key differentiator, enabling database portability and type-safe graph operations.

Strengths: The separation between data collection (SharpHound), graph storage (DAWGS), and visualization (React) is clean. The post-processing analysis engine that computes synthetic edges from raw collected data is well-designed — it means SharpHound stays a simple collector while complex reasoning lives server-side. The auth model (JWT + OIDC + SAML + TOTP with RBAC) makes it viable for enterprise deployment. The Cypher complexity limiter using AST analysis (not string matching) is a smart defensive measure. LikeC4 architecture documentation is well-maintained.

Concerns: ETAC filtering at the response layer will waste graph compute on multi-tenant deployments. The deprecated Database interface is still widely used despite the stated direction toward narrow interfaces. The monolithic Go binary bundles a lot — API server, graph queries, analysis engine, background daemons — which limits horizontal scaling of individual components.

Notable: BloodHound CE’s shift from Neo4j-only to a portable graph backend via DAWGS is architecturally significant. It eliminates the operational burden of running Neo4j alongside PostgreSQL, and the DAWGS query builder ensures the same application code works across both backends. The OpenGraph extension mechanism positions BloodHound as a general-purpose attack path analysis tool, not just an AD/Azure platform.

  • analyzing-sharphound — BloodHound’s AD data collector; produces the JSON files this backend ingests
  • analyzing-scubagear — AD offensive security tool with complementary enumeration capabilities
  • analyzing-netext — Network extension mapping; similar graph-based pathfinding approach for network topologies