Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,36 @@ for the rationale:
reanimation-rights analysis.
- `ghosthound`: the CLI orchestrating the above.

## Detected Paths

A `GhostHound_CanReanimate` edge is emitted for two distinct mechanisms, recorded on the edge's
`source` property (the single strongest one) and `sources` property (all of them) — see
`docs/adr/0007-ownership-and-dacl-reanimation-paths.md`:

| `source` | Meaning |
| --- | --- |
| `reanimate_right` | The principal formally holds the Reanimate-Tombstones control access right (GUID `45ec5156-db7e-47bb-b53f-dbeb2d03c40f`): either domain-wide, from the naming-context root's DACL (where an unscoped control-access/`GenericAll` grant also implies it), or from an ACE on the tombstone naming that GUID explicitly — typically inherited from `CN=Deleted Objects`. |
| `owner` | The principal is the tombstone's owner (`OwnerSid` of its own `nTSecurityDescriptor`). An owner can rewrite the object's DACL regardless of what that DACL says. |
| `write_dac` | The principal holds `WRITE_DAC` on the tombstone and can grant itself the right. `GenericAll` on the tombstone lands here (plus `write_owner`), *not* under `reanimate_right`: Reanimate-Tombstones is validated at the naming-context root, so broad rights on the object itself buy the ACL rewrite, not the right. |
| `write_owner` | The principal holds `WRITE_OWNER` on the tombstone, can take ownership, and thereby obtain `WRITE_DAC`. |

The distinction is operational, not cosmetic: `reanimate_right` is ready to use as-is, while the
other three need a DACL/owner rewrite on the tombstone first — an extra step that leaves an
auditable trace. Filter on `source` (or `'write_dac' IN e.sources`) when that matters. Each
principal gets one edge per tombstone no matter how many mechanisms qualify it; the tombstone node
also carries its owner as an `ownersid` property.

Reading a tombstone's own descriptor needs `READ_CONTROL` on that object, and the `SD_FLAGS` LDAP
control (`1.2.840.113556.1.4.801`, requesting `OWNER|GROUP|DACL` only) — without it the DC would try
to hand back the SACL too, which needs `SeSecurityPrivilege`, and drops `nTSecurityDescriptor` from
the response entirely instead. When a descriptor still isn't readable, GhostHound says so on stderr
rather than reporting the tombstone as uncontrolled.

Well-known principals (BUILTIN groups, `SYSTEM`, Authenticated Users) are emitted domain-scoped as
`<DOMAIN FQDN>-<SID>`, matching how SharpHound/RustHound-CE store their `objectid` — otherwise their
placeholder nodes share no `objectid` with any real node and `bridge_shadow_nodes.cypher` can't pair
them.

## Importing into BloodHound

1. In BloodHound CE's OpenGraph Management page, upload `crates/ad-tombstone/model.json` once to
Expand All @@ -86,9 +116,19 @@ for the rationale:
rather than the real ones BloodHound already has — an OpenGraph ingest limitation, not a bug in
this data; see `docs/adr/0006-opengraph-cross-source-node-identity.md`. This script bridges
them so paths are actually traversable. Safe to re-run after every import.
4. Import the starter queries in `crates/ad-tombstone/queries.json` and, optionally, run
`crates/ad-tombstone/privilege_zones.cypher` once to tag tombstones under Tier Zero OUs as
high-value — this also needs `cypher-shell` rather than the search bar, for the same reason
4. Import the starter queries in `crates/ad-tombstone/queries/`. BloodHound CE's saved-query import
takes **one query per JSON file** (`{name, description, query}`), or a ZIP of such files — so zip
the directory and upload that in one go:
```bash
(cd crates/ad-tombstone/queries && zip -X ../ghosthound-queries.zip *.json)
```
Then, in the Cypher search panel, use the import control (it accepts `application/json` and
`application/zip`). Individual `.json` files can also be imported one at a time. Note this is
*not* BloodHound Legacy's single-file `customqueries.json` format — CE's
`POST /api/v2/saved-queries/import` unmarshals each file into one query and rejects an array or a
`{"queries": [...]}` wrapper.
5. Optionally run `crates/ad-tombstone/privilege_zones.cypher` once to tag tombstones under Tier Zero
OUs as high-value — this also needs `cypher-shell` rather than the search bar, for the same reason
as step 3 (its `SET` is an updating clause too).

Once bridged, the reanimation path renders as a normal traversable path — a tombstone that was
Expand Down
35 changes: 34 additions & 1 deletion crates/ad-tombstone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ the `ghosthound` crate for that.
- **Reanimate-Tombstones is evaluated at the domain naming-context root**, not on
`CN=Deleted Objects` itself or on the tombstone — a detail that's easy to get wrong and produces
a DACL read against the wrong object.
- **The formal right isn't the only way in.** A principal that *owns* a tombstone, or holds
`WRITE_DAC`/`WRITE_OWNER` on it, can rewrite that object's DACL and grant itself the right — so
each tombstone's own `nTSecurityDescriptor` is read too, and `OwnerSid` is parsed alongside the
DACL rather than only walking the ACEs (`analyze_reanimation_control`, `ReanimateMechanism`).
- **Reading `nTSecurityDescriptor` requires the `SD_FLAGS` control** (`1.2.840.113556.1.4.801`,
`OWNER|GROUP|DACL`). Ask for the attribute without it and AD tries to include the SACL, which
needs `SeSecurityPrivilege` — so the DC drops the attribute from the response entirely rather
than returning the readable parts. It looks exactly like "no ACL data exists", on every object.
- **Every LDAP round-trip has a client-side timeout** (`with_timeout`), because
`ldap3::SearchOptions::timelimit` is a server-side-only hint that does not protect against a
wrong DC IP, a firewalled port, or a dead link.
Expand All @@ -54,12 +62,37 @@ for t in &tombstones {
}

let reanimators = check_reanimate_rights(&mut ldap, domain_nc, TIMEOUT_SECS).await?;
println!("{} principals can reanimate tombstones here", reanimators.len());
println!("{} principals hold the Reanimate-Tombstones right domain-wide", reanimators.len());

// Per-tombstone control, from each object's own nTSecurityDescriptor: ownership,
// WRITE_DAC/WRITE_OWNER, or an inherited Reanimate-Tombstones ACE. Requires READ_CONTROL on the
// object; empty (and `owner_sid` is None) when the descriptor wasn't readable.
for t in &tombstones {
for path in &t.reanimation_paths {
let how: Vec<_> = path.mechanisms.iter().map(|m| m.as_str()).collect();
println!("{} can reanimate {} via {}", path.sid, t.dn, how.join(", "));
}
}
```

See `ghosthound`'s `main.rs` for the full orchestration, including turning `member_of`'s group DNs
back into SIDs via `resolve_object_sid` and assembling everything into an OpenGraph payload.

## Starter Queries

`queries/` holds the BloodHound CE saved-query pack (one query per file, as CE's importer requires —
see the root README's import steps). Beyond enumerating tombstones, it covers the reanimation
mechanisms this crate distinguishes:

- **Reanimation Paths That Need an ACL Rewrite First** — `NOT 'reanimate_right' IN r.sources`, i.e.
owner/`WRITE_DAC`/`WRITE_OWNER` holders who must rewrite the descriptor before restoring.
- **Reanimation Paths Already Formally Granted** — the inverse; usable as-is.
- **Reanimation Capability Held by Non-Tier-Zero Principals** — filters out the principals expected
to have it, leaving the actual escalations.
- **Reanimation Paths for a Specific Principal** — edit the name to whoever you're operating as.
- **Tombstones Whose Security Descriptor Was Unreadable** / **Unbridged Placeholder Nodes** —
collection-health checks, so an empty result is distinguishable from a blind spot.

## License

MIT OR Apache-2.0.
2 changes: 1 addition & 1 deletion crates/ad-tombstone/model.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"relationship_kinds": [
{
"name": "GhostHound_CanReanimate",
"description": "The source principal holds the Reanimate-Tombstones control access right (or an unscoped control-access grant, which implies it) on the domain naming context root, and can therefore restore the target tombstone via LDAP_SERVER_SHOW_DELETED_OID. See docs/adr/0001 and docs/adr/0004.",
"description": "The source principal can restore the target tombstone via LDAP_SERVER_SHOW_DELETED_OID, by either of two mechanisms, recorded on the edge's `source` property (single strongest mechanism) and `sources` property (all of them). `reanimate_right`: the principal already holds the Reanimate-Tombstones control access right (or an unscoped control-access grant, which implies it), read off the domain naming context root. `owner`/`write_dac`/`write_owner`: the principal owns the tombstone, or holds WRITE_DAC/WRITE_OWNER on its own nTSecurityDescriptor, and can therefore rewrite that DACL to grant itself the right first -- an extra, auditable step, so filter on `source` when that distinction matters. See docs/adr/0001, docs/adr/0004 and docs/adr/0007.",
"is_traversable": true
},
{
Expand Down
5 changes: 5 additions & 0 deletions crates/ad-tombstone/queries/01-all-tombstones.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "All Tombstones",
"description": "Every deleted object GhostHound enumerated from CN=Deleted Objects. Click a node for its Recycle Bin state (is_recycled, group_membership_recoverable), its pre-deletion location (lastknownparent), and its owner (ownersid).",
"query": "MATCH (t) WHERE t:GhostHound_TombstoneUser OR t:GhostHound_TombstoneComputer OR t:GhostHound_TombstoneGroup RETURN t"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Find All Recoverable Tombstones (Deleted State)",
"description": "Finds all AD tombstones that are in the full-fidelity 'Deleted' state (Recycle Bin) and can be recovered with their previous group memberships.",
"query": "MATCH (n) WHERE n.group_membership_recoverable = true AND (n:GhostHound_TombstoneUser OR n:GhostHound_TombstoneComputer OR n:GhostHound_TombstoneGroup) RETURN n"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Tombstones Deleted From a Sensitive Location",
"description": "Tombstones whose lastknownparent was a sensitive container (Domain Controllers, Tier 0, ADCS), or that privilege_zones.cypher already tagged highvalue. Reanimating one of these restores an identity that used to live in a privileged part of the directory.",
"query": "MATCH (t) WHERE (t:GhostHound_TombstoneUser OR t:GhostHound_TombstoneComputer OR t:GhostHound_TombstoneGroup) AND (t.highvalue = true OR t.lastknownparent CONTAINS 'OU=Domain Controllers' OR t.lastknownparent CONTAINS 'OU=Tier 0' OR t.lastknownparent CONTAINS 'OU=ADCS') RETURN t"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Who Can Reanimate Which Tombstone",
"description": "Every reanimation capability GhostHound found, as a graph. Click an edge to read `source` (the strongest mechanism: reanimate_right, owner, write_dac or write_owner) and `sources` (all of them). The reanimator endpoint is a GhostHound placeholder node until bridge_shadow_nodes.cypher is run -- see docs/adr/0006.",
"query": "MATCH p=()-[:GhostHound_CanReanimate]->(t) WHERE t:GhostHound_TombstoneUser OR t:GhostHound_TombstoneComputer OR t:GhostHound_TombstoneGroup RETURN p"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Reanimation Paths That Need an ACL Rewrite First",
"description": "Principals that can reanimate a tombstone only by first rewriting its security descriptor -- they own it, or hold WRITE_DAC/WRITE_OWNER on it, but do not hold the Reanimate-Tombstones right. Operationally distinct from a formal grant: the ACL/owner rewrite is an extra, auditable step. This is the class of path ADR-0007 added.",
"query": "MATCH p=()-[r:GhostHound_CanReanimate]->(t) WHERE NOT 'reanimate_right' IN r.sources RETURN p"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Reanimation Paths Already Formally Granted",
"description": "Principals holding the Reanimate-Tombstones control access right (domain-wide from the naming-context root, or via an ACE on the tombstone naming that GUID explicitly). No ACL rewrite needed -- usable as-is.",
"query": "MATCH p=()-[r:GhostHound_CanReanimate]->(t) WHERE 'reanimate_right' IN r.sources RETURN p"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Reanimation Capability Held by Non-Tier-Zero Principals",
"description": "The interesting subset: reanimation capability held by principals that are NOT already Tier Zero. A Domain Admin being able to reanimate a tombstone is expected; a regular user being able to is an escalation path. Requires bridge_shadow_nodes.cypher -- the bridge is what resolves GhostHound's placeholders to the real AD principals, which is where the Tier Zero tag lives.",
"query": "MATCH p=(real)<-[:GhostHound_SameAs]-(shadow)-[:GhostHound_CanReanimate]->(t) WHERE NOT real:Tag_Tier_Zero RETURN p"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Reanimation Paths for a Specific Principal (edit the name)",
"description": "Everything one principal can reanimate, starting from its real AD node and crossing the GhostHound bridge. Edit 'JOHN@' to the principal you're operating as -- BloodHound names are uppercase SAMACCOUNTNAME@DOMAIN.TLD. Requires bridge_shadow_nodes.cypher.",
"query": "MATCH p=(real)<-[:GhostHound_SameAs]-(shadow)-[:GhostHound_CanReanimate]->(t) WHERE real.name STARTS WITH 'JOHN@' RETURN p"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Path to Domain Admins via Reanimation",
"description": "Finds the full attack path from a principal with the Reanimate-Tombstones right, through the tombstone, into the real Domain Admins node: GhostHound_CanReanimate (reanimator -> tombstone), GhostHound_WasMemberOf (tombstone -> placeholder), and the GhostHound_SameAs bridge (placeholder -> the real Group node). Requires bridge_shadow_nodes.cypher to have been run against Neo4j after import (see docs/adr/0006) -- without it, GhostHound_WasMemberOf's target is an unbridged placeholder and this query returns nothing.",
"query": "MATCH p=(reanimator)-[:GhostHound_CanReanimate]->(n:GhostHound_TombstoneUser)-[:GhostHound_WasMemberOf]->(shadow)-[:GhostHound_SameAs]->(g:Group) WHERE g.objectid ENDS WITH '-512' RETURN p"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Reanimation Into Any Tier Zero Group",
"description": "Generalizes the Domain Admins query to every Tier Zero principal: tombstones whose preserved memberOf leads into one, so restoring the tombstone hands that membership back. Only meaningful while group_membership_recoverable = true on the tombstone (the AD Recycle Bin 'Deleted' state, ADR-0004). Requires bridge_shadow_nodes.cypher.",
"query": "MATCH p=(t)-[:GhostHound_WasMemberOf]->(shadow)-[:GhostHound_SameAs]->(g:Tag_Tier_Zero) RETURN p"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Shortest Path Into Tier Zero That Goes Through a Tombstone",
"description": "Pathfinding across the whole graph, constrained to routes that actually traverse a reanimation edge -- escalations that exist only because of a tombstone. Mixes GhostHound edges with BloodHound's own AD edges, which is the point of registering the kinds as traversable (ADR-0004). Requires bridge_shadow_nodes.cypher; can be slow on a large graph.",
"query": "MATCH p=shortestPath((u:User)-[*1..8]->(g:Tag_Tier_Zero)) WHERE NOT u:Tag_Tier_Zero AND ANY(r IN relationships(p) WHERE type(r) = 'GhostHound_CanReanimate') RETURN p"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Tombstones With No Reanimation Path Found",
"description": "Tombstones nobody was found able to reanimate. Read this as a possible collection gap before a clean bill of health: if ownersid is also null, the object's nTSecurityDescriptor was unreadable and its real owner/DACL paths are simply unknown.",
"query": "MATCH (t) WHERE (t:GhostHound_TombstoneUser OR t:GhostHound_TombstoneComputer OR t:GhostHound_TombstoneGroup) AND NOT ()-[:GhostHound_CanReanimate]->(t) RETURN t"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Tombstones Whose Security Descriptor Was Unreadable",
"description": "Tombstones whose nTSecurityDescriptor came back empty, so no owner- or DACL-based reanimation path could be derived. Usually means no READ_CONTROL for the collecting account; re-run GhostHound as a more privileged principal to close the gap. The collector also reports this count on stderr during the run.",
"query": "MATCH (t) WHERE (t:GhostHound_TombstoneUser OR t:GhostHound_TombstoneComputer OR t:GhostHound_TombstoneGroup) AND t.ownersid IS NULL RETURN t"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Unbridged GhostHound Placeholder Nodes",
"description": "Placeholder nodes that GhostHound edges point at but which aren't linked to a real AD node yet, so any path through them dead-ends. Expected immediately after an import: run bridge_shadow_nodes.cypher via cypher-shell and re-check. Anything still listed afterwards is a principal BloodHound has no node for at all -- a SID from a trusted domain, or an orphaned SID left in the DACL.",
"query": "MATCH (s:GhostHound) WHERE NOT s:GhostHound_TombstoneUser AND NOT s:GhostHound_TombstoneComputer AND NOT s:GhostHound_TombstoneGroup AND NOT (s)-[:GhostHound_SameAs]->() RETURN s"
}
Loading