diff --git a/README.md b/README.md index df42a8a..9ebeced 100644 --- a/README.md +++ b/README.md @@ -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 +`-`, 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 @@ -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 diff --git a/crates/ad-tombstone/README.md b/crates/ad-tombstone/README.md index 8b10971..c4c6d5f 100644 --- a/crates/ad-tombstone/README.md +++ b/crates/ad-tombstone/README.md @@ -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. @@ -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. diff --git a/crates/ad-tombstone/model.json b/crates/ad-tombstone/model.json index 8e4519d..4589060 100644 --- a/crates/ad-tombstone/model.json +++ b/crates/ad-tombstone/model.json @@ -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 }, { diff --git a/crates/ad-tombstone/queries/01-all-tombstones.json b/crates/ad-tombstone/queries/01-all-tombstones.json new file mode 100644 index 0000000..6d928f9 --- /dev/null +++ b/crates/ad-tombstone/queries/01-all-tombstones.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/02-find-all-recoverable-tombstones-deleted-state.json b/crates/ad-tombstone/queries/02-find-all-recoverable-tombstones-deleted-state.json new file mode 100644 index 0000000..7006a11 --- /dev/null +++ b/crates/ad-tombstone/queries/02-find-all-recoverable-tombstones-deleted-state.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/03-tombstones-deleted-from-a-sensitive-location.json b/crates/ad-tombstone/queries/03-tombstones-deleted-from-a-sensitive-location.json new file mode 100644 index 0000000..bd774fb --- /dev/null +++ b/crates/ad-tombstone/queries/03-tombstones-deleted-from-a-sensitive-location.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/04-who-can-reanimate-which-tombstone.json b/crates/ad-tombstone/queries/04-who-can-reanimate-which-tombstone.json new file mode 100644 index 0000000..b6d8d8e --- /dev/null +++ b/crates/ad-tombstone/queries/04-who-can-reanimate-which-tombstone.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/05-reanimation-paths-that-need-an-acl-rewrite-first.json b/crates/ad-tombstone/queries/05-reanimation-paths-that-need-an-acl-rewrite-first.json new file mode 100644 index 0000000..4dc2c64 --- /dev/null +++ b/crates/ad-tombstone/queries/05-reanimation-paths-that-need-an-acl-rewrite-first.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/06-reanimation-paths-already-formally-granted.json b/crates/ad-tombstone/queries/06-reanimation-paths-already-formally-granted.json new file mode 100644 index 0000000..0e09962 --- /dev/null +++ b/crates/ad-tombstone/queries/06-reanimation-paths-already-formally-granted.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/07-reanimation-capability-held-by-non-tier-zero-principals.json b/crates/ad-tombstone/queries/07-reanimation-capability-held-by-non-tier-zero-principals.json new file mode 100644 index 0000000..862db86 --- /dev/null +++ b/crates/ad-tombstone/queries/07-reanimation-capability-held-by-non-tier-zero-principals.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/08-reanimation-paths-for-a-specific-principal-edit-the-name.json b/crates/ad-tombstone/queries/08-reanimation-paths-for-a-specific-principal-edit-the-name.json new file mode 100644 index 0000000..ebe6a94 --- /dev/null +++ b/crates/ad-tombstone/queries/08-reanimation-paths-for-a-specific-principal-edit-the-name.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/09-path-to-domain-admins-via-reanimation.json b/crates/ad-tombstone/queries/09-path-to-domain-admins-via-reanimation.json new file mode 100644 index 0000000..1bd607f --- /dev/null +++ b/crates/ad-tombstone/queries/09-path-to-domain-admins-via-reanimation.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/10-reanimation-into-any-tier-zero-group.json b/crates/ad-tombstone/queries/10-reanimation-into-any-tier-zero-group.json new file mode 100644 index 0000000..20611ba --- /dev/null +++ b/crates/ad-tombstone/queries/10-reanimation-into-any-tier-zero-group.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/11-shortest-path-into-tier-zero-that-goes-through-a-tombstone.json b/crates/ad-tombstone/queries/11-shortest-path-into-tier-zero-that-goes-through-a-tombstone.json new file mode 100644 index 0000000..2528f73 --- /dev/null +++ b/crates/ad-tombstone/queries/11-shortest-path-into-tier-zero-that-goes-through-a-tombstone.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/12-tombstones-with-no-reanimation-path-found.json b/crates/ad-tombstone/queries/12-tombstones-with-no-reanimation-path-found.json new file mode 100644 index 0000000..da9f122 --- /dev/null +++ b/crates/ad-tombstone/queries/12-tombstones-with-no-reanimation-path-found.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/13-tombstones-whose-security-descriptor-was-unreadable.json b/crates/ad-tombstone/queries/13-tombstones-whose-security-descriptor-was-unreadable.json new file mode 100644 index 0000000..07183f3 --- /dev/null +++ b/crates/ad-tombstone/queries/13-tombstones-whose-security-descriptor-was-unreadable.json @@ -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" +} diff --git a/crates/ad-tombstone/queries/14-unbridged-ghosthound-placeholder-nodes.json b/crates/ad-tombstone/queries/14-unbridged-ghosthound-placeholder-nodes.json new file mode 100644 index 0000000..ea1e172 --- /dev/null +++ b/crates/ad-tombstone/queries/14-unbridged-ghosthound-placeholder-nodes.json @@ -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" +} diff --git a/crates/ad-tombstone/src/lib.rs b/crates/ad-tombstone/src/lib.rs index 0efd70d..0d896a7 100644 --- a/crates/ad-tombstone/src/lib.rs +++ b/crates/ad-tombstone/src/lib.rs @@ -2,9 +2,17 @@ //! //! This is the domain-logic layer behind //! [GhostHound](https://github.com/JVBotelho/ghosthound)'s tombstone-reanimation attack-path -//! analysis: it enumerates tombstones, models their AD Recycle Bin state, and determines who -//! holds the Reanimate-Tombstones right. It's a plain library with no CLI or OpenGraph output of -//! its own -- see the `ghosthound` crate for that. +//! analysis: it enumerates tombstones, models their AD Recycle Bin state, and determines who can +//! reanimate them. It's a plain library with no CLI or OpenGraph output of its own -- see the +//! `ghosthound` crate for that. +//! +//! Reanimation control comes from two places, and both are collected: +//! +//! - the **Reanimate-Tombstones** control access right, held domain-wide and read off the domain +//! naming-context root -- [`check_reanimate_rights`]; +//! - **ownership or `WRITE_DAC`/`WRITE_OWNER` on an individual tombstone**, which lets a principal +//! rewrite that object's DACL and grant itself the right -- [`TombstoneObject::owner_sid`] and +//! [`TombstoneObject::reanimation_paths`], computed by [`analyze_reanimation_control`]. //! //! Typical flow, given an authenticated [`ldap3::Ldap`] handle and a domain naming context: //! [`check_recycle_bin_enabled`], then [`fetch_tombstones`] and [`check_reanimate_rights`]. See @@ -12,9 +20,10 @@ #![forbid(unsafe_code)] -use ad_secdesc::SecurityDescriptor; +use ad_secdesc::{Ace, SecurityDescriptor}; use ldap3::{Ldap, SearchEntry, SearchOptions, adapters::PagedResults, controls::RawControl}; use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; use std::time::Duration; use thiserror::Error; @@ -25,6 +34,56 @@ use uuid::Uuid; /// rather than picking an arbitrary smaller number. const LDAP_PAGE_SIZE: i32 = 1000; +/// `ADS_RIGHT_DS_CONTROL_ACCESS` -- the right to exercise a control access (extended) right, such +/// as Reanimate-Tombstones. +const RIGHT_DS_CONTROL_ACCESS: u32 = 0x0000_0100; + +/// `ADS_RIGHT_GENERIC_ALL` -- implies every other right, including all control access rights. +const RIGHT_GENERIC_ALL: u32 = 0x1000_0000; + +/// `WRITE_DAC` -- the right to rewrite the object's DACL, and therefore to grant oneself any +/// right on it (including Reanimate-Tombstones). +const RIGHT_WRITE_DAC: u32 = 0x0004_0000; + +/// `WRITE_OWNER` -- the right to take ownership of the object, which in turn confers `WRITE_DAC`. +const RIGHT_WRITE_OWNER: u32 = 0x0008_0000; + +/// `LDAP_SERVER_SD_FLAGS_OID` -- restricts which parts of `nTSecurityDescriptor` the DC returns. +/// +/// This control is **mandatory, not an optimization**, for any bind that isn't +/// `SeSecurityPrivilege`-holding. Without it AD tries to return the *entire* descriptor including +/// the SACL; reading a SACL requires that privilege, and rather than returning the readable parts, +/// the DC **omits `nTSecurityDescriptor` from the response entirely**. The attribute silently +/// disappears and every DACL/owner-based finding with it -- which is exactly what happened +/// enumerating as a plain user before this was added. +const SD_FLAGS_CONTROL_OID: &str = "1.2.840.113556.1.4.801"; + +/// BER-encoded control value for [`SD_FLAGS_CONTROL_OID`]: `SEQUENCE { INTEGER 0x07 }`, i.e. +/// `OWNER (0x1) | GROUP (0x2) | DACL (0x4)` -- everything this crate reads, and nothing that needs +/// a privilege a normal enumerating account won't have. `SACL (0x8)` is deliberately excluded. +const SD_FLAGS_OWNER_GROUP_DACL: [u8; 5] = [0x30, 0x03, 0x02, 0x01, 0x07]; + +/// The `LDAP_SERVER_SD_FLAGS_OID` control asking for owner + group + DACL only. +/// +/// Sent non-critical: on a DC that somehow doesn't implement the control, the search still returns +/// (degrading to the old "descriptor omitted" behavior, which callers already report) instead of +/// failing outright. +fn sd_flags_control() -> RawControl { + RawControl { + ctype: SD_FLAGS_CONTROL_OID.to_string(), + crit: false, + val: Some(SD_FLAGS_OWNER_GROUP_DACL.to_vec()), + } +} + +/// The Reanimate-Tombstones control access right's `rightsGuid`. +/// +/// Parsed at each use rather than stored as a `const`, matching how this crate already reads +/// GUIDs; the literal is fixed, so `expect` here is unreachable. +fn reanimate_tombstones_guid() -> Uuid { + Uuid::parse_str("45ec5156-db7e-47bb-b53f-dbeb2d03c40f").expect("static GUID literal is valid") +} + /// Errors returned while enumerating tombstones or reanimation rights over LDAP. #[derive(Error, Debug)] pub enum TombstoneError { @@ -98,6 +157,22 @@ pub struct TombstoneObject { /// the node's display name -- without it, a tombstone shows up in BloodHound as a bare /// SID/GUID string. pub sam_account_name: Option, + /// The `OwnerSid` of the tombstone's own `nTSecurityDescriptor`, if the descriptor was + /// readable and parseable. `None` when the bound principal lacks `READ_CONTROL` on the + /// tombstone (AD then omits the attribute entirely rather than erroring). + /// + /// The owner of an object can always rewrite its DACL, so this is a reanimation path in its + /// own right -- see `reanimation_paths`. + pub owner_sid: Option, + /// Every principal that can reanimate *this specific tombstone*, from its own + /// `nTSecurityDescriptor` -- via ownership, `WRITE_DAC`/`WRITE_OWNER`, or an + /// (often inherited) Reanimate-Tombstones ACE. Computed by + /// [`analyze_reanimation_control`]; empty when the descriptor wasn't readable. + /// + /// Distinct from [`check_reanimate_rights`], which reads the *domain NC root* descriptor and + /// so applies to every tombstone at once. Callers emit `CanReanimate` edges from the union of + /// the two. + pub reanimation_paths: Vec, } impl TombstoneObject { @@ -175,6 +250,20 @@ impl TombstoneObject { }) .map(|sid| sid.to_string()); + // A tombstone's own nTSecurityDescriptor carries both halves of the ownership/DACL + // reanimation path: the OwnerSid, and any WRITE_DAC/WRITE_OWNER or (usually inherited from + // CN=Deleted Objects) Reanimate-Tombstones ACE. A missing attribute means the bound + // principal has no READ_CONTROL on this object; an unparseable one means a malformed blob. + // Neither is fatal to enumerating the rest of the tombstone, so both degrade to "no + // ownership/ACL data" rather than failing the whole object. + let (owner_sid, reanimation_paths) = raw_attr(entry, "nTSecurityDescriptor") + .and_then(|bytes| SecurityDescriptor::parse(&bytes).ok()) + .map(|sd| { + let owner = sd.owner.as_ref().map(|s| s.to_string()); + (owner, analyze_reanimation_control(&sd)) + }) + .unwrap_or_default(); + Ok(Self { object_guid, object_sid, @@ -187,6 +276,8 @@ impl TombstoneObject { lastknownparent, member_of, sam_account_name, + owner_sid, + reanimation_paths, }) } } @@ -324,10 +415,224 @@ fn is_allow_ace(ace_type: u8) -> bool { /// Whether an ACE actually applies to the object it's read from, as opposed to only propagating /// to children (INHERIT_ONLY_ACE, 0x08). The Reanimate-Tombstones right is evaluated at the /// domain NC root itself, so an inherit-only ACE there doesn't grant anything on that object. +/// The same applies to a tombstone's own DACL: an ACE inherited *from* `CN=Deleted Objects` shows +/// up there with INHERITED_ACE (0x10) set but not INHERIT_ONLY_ACE, so it still counts. fn applies_to_self(ace_flags: u8) -> bool { ace_flags & 0x08 == 0 } +/// How a principal ends up able to reanimate a given tombstone. +/// +/// Variant order is significant: it's the precedence used by [`ReanimationPath::primary`] and by +/// `Ord`, running from "the right is already formally granted" to "the principal must rewrite the +/// object's security descriptor first". The two are operationally different -- the second needs an +/// extra ACL-rewrite step that is loud and auditable -- so the distinction is preserved on the +/// emitted edge rather than flattened away. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReanimateMechanism { + /// The principal holds the Reanimate-Tombstones control access right (or an unscoped + /// control-access grant, which implies it). + ReanimateRight, + /// The principal is the object's owner (`OwnerSid`), and an owner can always rewrite the + /// object's DACL regardless of what the DACL itself says. + Owner, + /// The principal holds `WRITE_DAC` on the object and can grant itself the right. + WriteDac, + /// The principal holds `WRITE_OWNER` on the object, can take ownership, and thereby obtain + /// `WRITE_DAC`. + WriteOwner, +} + +impl ReanimateMechanism { + /// The stable string used on emitted graph edges (`reanimate_right`, `owner`, `write_dac`, + /// `write_owner`) -- what Cypher queries match on, so it must not change casually. + pub fn as_str(&self) -> &'static str { + match self { + ReanimateMechanism::ReanimateRight => "reanimate_right", + ReanimateMechanism::Owner => "owner", + ReanimateMechanism::WriteDac => "write_dac", + ReanimateMechanism::WriteOwner => "write_owner", + } + } +} + +impl std::fmt::Display for ReanimateMechanism { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One principal's ability to reanimate a tombstone, together with every mechanism that grants it. +/// +/// One entry per SID, never one per qualifying ACE: a principal that is both the owner and holds +/// `WRITE_DAC` appears once with both mechanisms recorded, so callers emit a single edge (same +/// de-duplication rule [`check_reanimate_rights`] already applies to its SID list). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReanimationPath { + /// The principal's SID, as an `S-1-5-...` string. + pub sid: String, + /// Every mechanism granting this principal control, in [`ReanimateMechanism`] precedence + /// order, deduplicated. Never empty. + pub mechanisms: Vec, +} + +impl ReanimationPath { + /// The strongest (lowest-precedence-value) mechanism -- what to report as the edge's single + /// `source` property when a query wants one value rather than the full list. + pub fn primary(&self) -> ReanimateMechanism { + // `mechanisms` is built from a BTreeSet and is documented non-empty; fall back to the + // weakest mechanism rather than panicking if a hand-constructed value breaks that. + self.mechanisms + .first() + .copied() + .unwrap_or(ReanimateMechanism::WriteOwner) + } +} + +/// Whether this ACE grants the Reanimate-Tombstones right **as read at the domain NC root**. +/// +/// Either an unscoped grant (no `object_type`, which per AD semantics covers every control access +/// right the mask allows) or one scoped to exactly the Reanimate-Tombstones GUID. `GenericAll` +/// counts too, since it implies all control access rights. +/// +/// This is the NC-root rule only, and must not be applied to an individual object's descriptor -- +/// see `grants_reanimate_right_on_object` for why the two differ. +/// +/// Does **not** check `ace_type`/`ace_flags` -- callers must gate on `is_allow_ace` and +/// `applies_to_self` first. +fn grants_reanimate_right_at_nc_root(ace: &Ace) -> bool { + let grants_control_access = + ace.access_mask & (RIGHT_DS_CONTROL_ACCESS | RIGHT_GENERIC_ALL) != 0; + let is_reanimate_right = + ace.object_type == Some(reanimate_tombstones_guid()) || ace.object_type.is_none(); + grants_control_access && is_reanimate_right +} + +/// Whether this ACE, read from an **individual object's** descriptor, grants the +/// Reanimate-Tombstones right -- which requires the ACE to name that right's GUID explicitly. +/// +/// Deliberately stricter than `grants_reanimate_right_at_nc_root`. Reanimate-Tombstones is a +/// control access right validated at the domain naming-context root (ADR-0001), so an *unscoped* +/// control-access grant -- or `GenericAll` -- on a tombstone does **not** confer it: it confers +/// broad write access to that one object, which is a `write_dac`/`write_owner`-class finding +/// requiring an ACL rewrite first, not a formally-held right. Reporting such an ACE as +/// `reanimate_right` overstates it, which is the whole distinction the mechanism labels exist to +/// draw. +/// +/// A genuine object-level grant does occur -- typically an ACE inherited from `CN=Deleted Objects` +/// naming the GUID -- and that is exactly what this matches. +/// +/// Does **not** check `ace_type`/`ace_flags` -- callers must gate on `is_allow_ace` and +/// `applies_to_self` first. +fn grants_reanimate_right_on_object(ace: &Ace) -> bool { + let grants_control_access = + ace.access_mask & (RIGHT_DS_CONTROL_ACCESS | RIGHT_GENERIC_ALL) != 0; + grants_control_access && ace.object_type == Some(reanimate_tombstones_guid()) +} + +/// Whether this ACE grants `WRITE_DAC` or `WRITE_OWNER` on the object, i.e. lets the trustee +/// rewrite the security descriptor and grant itself the Reanimate-Tombstones right. +/// +/// `GenericAll` counts for both: the generic-to-specific mapping expands it to every standard +/// right, `WRITE_DAC` and `WRITE_OWNER` included. On an individual object that is precisely what a +/// `GenericAll` ACE buys -- the ability to rewrite the descriptor -- not the formal extended right +/// (see `grants_reanimate_right_on_object`). +/// +/// `object_type` is deliberately ignored: it only narrows the AD-specific rights (control-access, +/// read/write-property, create/delete-child), never the standard rights `WRITE_DAC`/`WRITE_OWNER`, +/// which always apply to the object as a whole. `GENERIC_WRITE` is *not* included -- for a directory +/// object it maps to write-property/self, which does not include `WRITE_DAC`. +/// +/// Does **not** check `ace_type`/`ace_flags` -- callers must gate on `is_allow_ace` and +/// `applies_to_self` first. +fn grants_secdesc_write(ace: &Ace) -> (bool, bool) { + ( + ace.access_mask & (RIGHT_WRITE_DAC | RIGHT_GENERIC_ALL) != 0, + ace.access_mask & (RIGHT_WRITE_OWNER | RIGHT_GENERIC_ALL) != 0, + ) +} + +/// Analyzes one object's security descriptor for every principal that can reanimate it, by any of +/// the mechanisms in [`ReanimateMechanism`]. +/// +/// This is the whole of the reanimation-rights logic and is deliberately pure (no LDAP): it takes +/// a parsed [`SecurityDescriptor`] -- e.g. a tombstone's own `nTSecurityDescriptor`, as +/// [`TombstoneObject::from_entry`] passes it -- and returns one [`ReanimationPath`] per principal, +/// sorted by SID. +/// +/// Reads *both* halves of the descriptor, which is the point: the DACL alone misses the +/// `OwnerSid`, and an owner can rewrite the DACL at will even with no ACE naming it. Deny/audit +/// ACEs (`is_allow_ace`) and inherit-only ACEs (`applies_to_self`) are excluded, since both can +/// carry the same access mask and object-type GUID as a real grant. +pub fn analyze_reanimation_control(sd: &SecurityDescriptor) -> Vec { + let mut by_sid: BTreeMap> = BTreeMap::new(); + + if let Some(owner) = &sd.owner { + by_sid + .entry(owner.to_string()) + .or_default() + .insert(ReanimateMechanism::Owner); + } + + if let Some(dacl) = &sd.dacl { + for ace in &dacl.aces { + if !is_allow_ace(ace.ace_type) || !applies_to_self(ace.ace_flags) { + continue; + } + + let mut mechanisms = Vec::new(); + if grants_reanimate_right_on_object(ace) { + mechanisms.push(ReanimateMechanism::ReanimateRight); + } + let (write_dac, write_owner) = grants_secdesc_write(ace); + if write_dac { + mechanisms.push(ReanimateMechanism::WriteDac); + } + if write_owner { + mechanisms.push(ReanimateMechanism::WriteOwner); + } + if mechanisms.is_empty() { + continue; + } + + by_sid + .entry(ace.sid.to_string()) + .or_default() + .extend(mechanisms); + } + } + + by_sid + .into_iter() + .map(|(sid, mechanisms)| ReanimationPath { + sid, + mechanisms: mechanisms.into_iter().collect(), + }) + .collect() +} + +/// Pulls an attribute's raw bytes out of a search entry, checking `bin_attrs` first and falling +/// back to `attrs`. +/// +/// `ldap3` routes a value into `attrs` instead of `bin_attrs` whenever it happens to be valid +/// UTF-8, which a binary `nTSecurityDescriptor` blob occasionally is; reading only `bin_attrs` +/// would silently drop the descriptor for those objects. +fn raw_attr(entry: &SearchEntry, name: &str) -> Option> { + entry + .bin_attrs + .get(name) + .and_then(|v| v.first()) + .cloned() + .or_else(|| { + entry + .attrs + .get(name) + .and_then(|v| v.first()) + .map(|s| s.as_bytes().to_vec()) + }) +} + /// Returns the SIDs (as `S-1-5-...` strings, deduplicated) of every principal holding the /// Reanimate-Tombstones right on the domain. /// @@ -344,17 +649,22 @@ pub async fn check_reanimate_rights( // CN=Deleted Objects): the Reanimate-Tombstones control access right is evaluated at the // NC root, so that DACL is the one that matters (see docs/adr/0001). The SHOW_DELETED // control is harmless but unnecessary here since domain_nc is a live, non-deleted object; - // it's included only for consistency with the other searches in this crate. - let ctrl = RawControl { - ctype: "1.2.840.113556.1.4.417".to_string(), - crit: true, - val: None, - }; + // it's included only for consistency with the other searches in this crate. SD_FLAGS, by + // contrast, is required: without it the DC returns no descriptor at all to a bind without + // SeSecurityPrivilege (see `sd_flags_control`). + let ctrls = vec![ + RawControl { + ctype: "1.2.840.113556.1.4.417".to_string(), + crit: true, + val: None, + }, + sd_flags_control(), + ]; let opts = SearchOptions::new().timelimit(timeout_secs as i32); let (rs, _) = with_timeout( timeout_secs, - ldap.with_controls(ctrl).with_search_options(opts).search( + ldap.with_controls(ctrls).with_search_options(opts).search( domain_nc, ldap3::Scope::Base, "(objectClass=*)", @@ -365,32 +675,24 @@ pub async fn check_reanimate_rights( .success()?; let mut principals = Vec::new(); - let reanimate_guid = Uuid::parse_str("45ec5156-db7e-47bb-b53f-dbeb2d03c40f").unwrap(); for entry in rs { let search_entry = SearchEntry::construct(entry); - if let Some(sec_desc_bytes) = search_entry - .bin_attrs - .get("nTSecurityDescriptor") - .and_then(|v| v.first()) - && let Ok(sd) = SecurityDescriptor::parse(sec_desc_bytes) - && let Some(dacl) = sd.dacl + if let Some(sec_desc_bytes) = raw_attr(&search_entry, "nTSecurityDescriptor") + && let Ok(sd) = SecurityDescriptor::parse(&sec_desc_bytes) + && let Some(dacl) = &sd.dacl { - for ace in dacl.aces { - // Grants a control access right (ExtendedRight 0x100, or GenericAll 0x10000000) - // AND that right is either unscoped (non-object ACE, which implicitly grants - // all control access rights per AD semantics) or scoped to exactly the - // Reanimate-Tombstones GUID -- but only if the ACE is an actual grant (not a - // deny/audit ACE reusing the same mask/GUID) that applies to this object itself - // (not inherit-only). - let grants_control_access = - (ace.access_mask & 0x00000100 != 0) || (ace.access_mask & 0x10000000 != 0); - let is_reanimate_right = - ace.object_type == Some(reanimate_guid) || ace.object_type.is_none(); - if grants_control_access - && is_reanimate_right - && is_allow_ace(ace.ace_type) + for ace in &dacl.aces { + // Only ACEs that actually grant the right: an actual grant (not a deny/audit ACE + // reusing the same mask/GUID) that applies to this object itself (not + // inherit-only). Ownership of the domain NC root is deliberately *not* counted + // here -- this function answers "who holds the right domain-wide", and an + // ACL-rewrite path on the NC root is a different (far broader) finding than + // tombstone reanimation. Per-object ownership is handled by + // `analyze_reanimation_control`. + if is_allow_ace(ace.ace_type) && applies_to_self(ace.ace_flags) + && grants_reanimate_right_at_nc_root(ace) { principals.push(ace.sid.to_string()); } @@ -409,10 +711,11 @@ pub async fn check_reanimate_rights( /// Enumerates every tombstone under `CN=Deleted Objects,`. /// -/// Uses both the `SHOW_DELETED` control (to see the tombstones at all) and -/// `SHOW_DEACTIVATED_LINK` (to see their preserved `memberOf` values, if any -- see -/// [`TombstoneObject::member_of`]). Pass the same `recycle_bin_enabled` value obtained from -/// [`check_recycle_bin_enabled`] earlier in the run. +/// Uses the `SHOW_DELETED` control (to see the tombstones at all), `SHOW_DEACTIVATED_LINK` (to see +/// their preserved `memberOf` values, if any -- see [`TombstoneObject::member_of`]), and `SD_FLAGS` +/// (without which the DC returns no `nTSecurityDescriptor` at all to a bind lacking +/// `SeSecurityPrivilege`, taking every owner/DACL reanimation path with it). Pass the same +/// `recycle_bin_enabled` value obtained from [`check_recycle_bin_enabled`] earlier in the run. /// /// Paged with the Simple Paged Results control (page size [`LDAP_PAGE_SIZE`]) rather than a /// single unpaged search: AD's default `MaxPageSize` policy caps an unpaged search at 1000 @@ -442,6 +745,9 @@ pub async fn fetch_tombstones( crit: true, val: None, }, + // Required for nTSecurityDescriptor to come back at all on a non-SeSecurityPrivilege + // bind -- see `sd_flags_control`. + sd_flags_control(), ]; let opts = SearchOptions::new().timelimit(timeout_secs as i32); @@ -462,6 +768,12 @@ pub async fn fetch_tombstones( "lastKnownParent", "memberOf", "sAMAccountName", + // Read per-tombstone so ownership and WRITE_DAC/WRITE_OWNER reanimation paths are + // visible at all -- the domain-NC-root descriptor read by + // `check_reanimate_rights` says nothing about who controls an individual + // tombstone. Requires READ_CONTROL on the object *and* the SD_FLAGS control above; + // silently absent otherwise. + "nTSecurityDescriptor", ], ) .await?; @@ -488,6 +800,432 @@ mod tests { use ldap3::SearchEntry; use std::collections::HashMap; + const ACE_ALLOWED: u8 = 0x00; + const ACE_ALLOWED_OBJECT: u8 = 0x05; + const ACE_DENIED: u8 = 0x01; + const ACE_INHERIT_ONLY: u8 = 0x08; + const RIGHT_DS_READ_PROP: u32 = 0x0000_0010; + + /// `S-1-5-21-1-2-3-` in on-the-wire form. + fn sid_bytes(rid: u32) -> Vec { + // revision 1, 5 sub-authorities, identifier authority NT_AUTHORITY (5), big-endian. + let mut out = vec![0x01, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05]; + for sub in [21u32, 1, 2, 3, rid] { + out.extend_from_slice(&sub.to_le_bytes()); + } + out + } + + fn sid_str(rid: u32) -> String { + format!("S-1-5-21-1-2-3-{}", rid) + } + + /// A non-object ACE (no object-type GUID), i.e. one whose access mask applies unscoped. + fn plain_ace(ace_type: u8, ace_flags: u8, mask: u32, rid: u32) -> Vec { + let sid = sid_bytes(rid); + let mut out = vec![ace_type, ace_flags]; + out.extend_from_slice(&((8 + sid.len()) as u16).to_le_bytes()); + out.extend_from_slice(&mask.to_le_bytes()); + out.extend_from_slice(&sid); + out + } + + /// An object ACE carrying an `ACE_OBJECT_TYPE_PRESENT` GUID -- how a control access right like + /// Reanimate-Tombstones is actually granted. + fn object_ace(ace_type: u8, ace_flags: u8, mask: u32, object_type: Uuid, rid: u32) -> Vec { + let sid = sid_bytes(rid); + let mut out = vec![ace_type, ace_flags]; + out.extend_from_slice(&((12 + 16 + sid.len()) as u16).to_le_bytes()); + out.extend_from_slice(&mask.to_le_bytes()); + out.extend_from_slice(&0x0000_0001u32.to_le_bytes()); // ACE_OBJECT_TYPE_PRESENT + out.extend_from_slice(&object_type.to_bytes_le()); + out.extend_from_slice(&sid); + out + } + + /// A self-relative security descriptor with the given owner (if any) and DACL. + fn security_descriptor(owner_rid: Option, aces: &[Vec]) -> Vec { + security_descriptor_with_control(owner_rid, aces, 0x8004) + } + + /// As [`security_descriptor`], but with an explicit control word -- lets a test build a blob + /// that happens to be valid UTF-8 (see `test_from_entry_reads_descriptor_from_attrs`). + fn security_descriptor_with_control( + owner_rid: Option, + aces: &[Vec], + control: u16, + ) -> Vec { + const HEADER_LEN: u32 = 20; + let owner = owner_rid.map(sid_bytes).unwrap_or_default(); + let owner_offset = if owner.is_empty() { 0 } else { HEADER_LEN }; + let dacl_offset = HEADER_LEN + owner.len() as u32; + let acl_size = (8 + aces.iter().map(Vec::len).sum::()) as u16; + + let mut out = vec![0x01, 0x00]; + out.extend_from_slice(&control.to_le_bytes()); + out.extend_from_slice(&owner_offset.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); // group: absent + out.extend_from_slice(&0u32.to_le_bytes()); // SACL: absent + out.extend_from_slice(&dacl_offset.to_le_bytes()); + out.extend_from_slice(&owner); + out.push(0x04); // ACL_REVISION_DS + out.push(0x00); + out.extend_from_slice(&acl_size.to_le_bytes()); + out.extend_from_slice(&(aces.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + for ace in aces { + out.extend_from_slice(ace); + } + out + } + + fn analyze(bytes: &[u8]) -> Vec { + let sd = SecurityDescriptor::parse(bytes).expect("test descriptor should parse"); + analyze_reanimation_control(&sd) + } + + fn mechanisms_for(paths: &[ReanimationPath], rid: u32) -> Option> { + paths + .iter() + .find(|p| p.sid == sid_str(rid)) + .map(|p| p.mechanisms.clone()) + } + + /// Path A, unchanged: a control-access ACE scoped to the Reanimate-Tombstones GUID. + #[test] + fn test_analyze_reanimate_right_only() { + let sd = security_descriptor( + Some(500), + &[object_ace( + ACE_ALLOWED_OBJECT, + 0x00, + RIGHT_DS_CONTROL_ACCESS, + reanimate_tombstones_guid(), + 1104, + )], + ); + let paths = analyze(&sd); + + assert_eq!( + mechanisms_for(&paths, 1104), + Some(vec![ReanimateMechanism::ReanimateRight]) + ); + // The owner is a separate principal here, and is reported separately. + assert_eq!( + mechanisms_for(&paths, 500), + Some(vec![ReanimateMechanism::Owner]) + ); + } + + /// A control-access ACE scoped to some *other* extended right doesn't grant reanimation. + #[test] + fn test_analyze_ignores_unrelated_extended_right() { + let other_right = Uuid::parse_str("00299570-246d-11d0-a768-00aa006e0529").unwrap(); // User-Force-Change-Password + let sd = security_descriptor( + Some(500), + &[object_ace( + ACE_ALLOWED_OBJECT, + 0x00, + RIGHT_DS_CONTROL_ACCESS, + other_right, + 1104, + )], + ); + assert_eq!(mechanisms_for(&analyze(&sd), 1104), None); + } + + /// Path B via ACE: `WRITE_DAC` alone, no extended right anywhere. + #[test] + fn test_analyze_write_dac_only() { + let sd = security_descriptor( + Some(500), + &[plain_ace(ACE_ALLOWED, 0x00, RIGHT_WRITE_DAC, 1104)], + ); + assert_eq!( + mechanisms_for(&analyze(&sd), 1104), + Some(vec![ReanimateMechanism::WriteDac]) + ); + } + + /// Path B via ACE: `WRITE_OWNER` alone. + #[test] + fn test_analyze_write_owner_only() { + let sd = security_descriptor( + Some(500), + &[plain_ace(ACE_ALLOWED, 0x00, RIGHT_WRITE_OWNER, 1104)], + ); + assert_eq!( + mechanisms_for(&analyze(&sd), 1104), + Some(vec![ReanimateMechanism::WriteOwner]) + ); + } + + /// Path B via ownership: the trustee holds no qualifying ACE at all -- only `OwnerSid` names + /// it. This is the case a DACL-only walk misses entirely. + #[test] + fn test_analyze_owner_only_no_matching_ace() { + let sd = security_descriptor( + Some(1104), + // A read-property ACE for the same principal: present in the DACL, but grants nothing + // that qualifies. + &[plain_ace(ACE_ALLOWED, 0x00, RIGHT_DS_READ_PROP, 1104)], + ); + let paths = analyze(&sd); + + assert_eq!(paths.len(), 1); + assert_eq!( + mechanisms_for(&paths, 1104), + Some(vec![ReanimateMechanism::Owner]) + ); + } + + /// An empty DACL still yields the owner. + #[test] + fn test_analyze_owner_with_empty_dacl() { + let sd = security_descriptor(Some(1104), &[]); + assert_eq!( + mechanisms_for(&analyze(&sd), 1104), + Some(vec![ReanimateMechanism::Owner]) + ); + } + + /// A principal qualifying several ways gets one entry, not one per mechanism/ACE, with the + /// mechanisms recorded in precedence order. + #[test] + fn test_analyze_dedups_multiple_mechanisms_per_sid() { + let sd = security_descriptor( + Some(1104), + &[ + object_ace( + ACE_ALLOWED_OBJECT, + 0x00, + RIGHT_DS_CONTROL_ACCESS, + reanimate_tombstones_guid(), + 1104, + ), + plain_ace(ACE_ALLOWED, 0x00, RIGHT_WRITE_DAC | RIGHT_WRITE_OWNER, 1104), + // A second, redundant WRITE_DAC grant to the same principal. + plain_ace(ACE_ALLOWED, 0x00, RIGHT_WRITE_DAC, 1104), + ], + ); + let paths = analyze(&sd); + + assert_eq!(paths.len(), 1, "expected one path per SID, got {:?}", paths); + assert_eq!( + paths[0].mechanisms, + vec![ + ReanimateMechanism::ReanimateRight, + ReanimateMechanism::Owner, + ReanimateMechanism::WriteDac, + ReanimateMechanism::WriteOwner, + ] + ); + assert_eq!(paths[0].primary(), ReanimateMechanism::ReanimateRight); + } + + /// `GenericAll` on a *tombstone* buys the ability to rewrite that object's descriptor, not the + /// Reanimate-Tombstones right itself (which is validated at the domain NC root) -- so it is + /// reported as write_dac/write_owner, the mechanisms that actually require an ACL rewrite first. + #[test] + fn test_analyze_generic_all_on_object_is_secdesc_write_not_extended_right() { + let sd = security_descriptor( + Some(500), + &[plain_ace(ACE_ALLOWED, 0x00, RIGHT_GENERIC_ALL, 1104)], + ); + assert_eq!( + mechanisms_for(&analyze(&sd), 1104), + Some(vec![ + ReanimateMechanism::WriteDac, + ReanimateMechanism::WriteOwner + ]) + ); + } + + /// Same rule for an *unscoped* control-access ACE (no object_type): at object level it names no + /// extended right, so it must not be reported as reanimate_right. It also grants no + /// descriptor-write bit, so it qualifies for nothing at all. + #[test] + fn test_analyze_unscoped_control_access_on_object_is_not_reanimate_right() { + let sd = security_descriptor( + Some(500), + &[plain_ace(ACE_ALLOWED, 0x00, RIGHT_DS_CONTROL_ACCESS, 1104)], + ); + assert_eq!(mechanisms_for(&analyze(&sd), 1104), None); + } + + /// The domain-NC-root rule is deliberately looser -- an unscoped control-access grant there does + /// cover every extended right, Reanimate-Tombstones included. This is what + /// `check_reanimate_rights` uses, and it must not be tightened along with the object-level rule. + #[test] + fn test_nc_root_rule_accepts_unscoped_control_access() { + let unscoped = Ace { + ace_type: ACE_ALLOWED, + ace_flags: 0x00, + access_mask: RIGHT_DS_CONTROL_ACCESS, + object_type: None, + inherited_object_type: None, + sid: ad_secdesc::Sid::parse(&mut std::io::Cursor::new(sid_bytes(1104).as_slice())) + .unwrap(), + }; + assert!(grants_reanimate_right_at_nc_root(&unscoped)); + // ...and the object-level rule rejects the very same ACE. + assert!(!grants_reanimate_right_on_object(&unscoped)); + } + + /// Deny and inherit-only ACEs carry the same masks as real grants and must not count. + #[test] + fn test_analyze_skips_deny_and_inherit_only_aces() { + let sd = security_descriptor( + Some(500), + &[ + plain_ace(ACE_DENIED, 0x00, RIGHT_WRITE_DAC, 1104), + plain_ace(ACE_ALLOWED, ACE_INHERIT_ONLY, RIGHT_WRITE_OWNER, 1105), + ], + ); + let paths = analyze(&sd); + + assert_eq!(mechanisms_for(&paths, 1104), None); + assert_eq!(mechanisms_for(&paths, 1105), None); + assert_eq!(paths.len(), 1); // the owner only + } + + #[test] + fn test_mechanism_edge_property_strings() { + assert_eq!( + ReanimateMechanism::ReanimateRight.as_str(), + "reanimate_right" + ); + assert_eq!(ReanimateMechanism::Owner.as_str(), "owner"); + assert_eq!(ReanimateMechanism::WriteDac.as_str(), "write_dac"); + assert_eq!(ReanimateMechanism::WriteOwner.as_str(), "write_owner"); + } + + #[test] + fn test_tombstone_from_entry_parses_owner_and_paths() { + let mut attrs = HashMap::new(); + attrs.insert("isDeleted".to_string(), vec!["TRUE".to_string()]); + + let mut bin_attrs = HashMap::new(); + bin_attrs.insert("objectGUID".to_string(), vec![vec![0; 16]]); + bin_attrs.insert( + "nTSecurityDescriptor".to_string(), + vec![security_descriptor( + Some(1104), + &[plain_ace(ACE_ALLOWED, 0x00, RIGHT_WRITE_DAC, 1105)], + )], + ); + + let entry = SearchEntry { + dn: "CN=cert_admin,CN=Deleted Objects,DC=ghost,DC=local".to_string(), + attrs, + bin_attrs, + }; + + let tombstone = TombstoneObject::from_entry(&entry, true).unwrap(); + assert_eq!(tombstone.owner_sid, Some(sid_str(1104))); + assert_eq!( + mechanisms_for(&tombstone.reanimation_paths, 1104), + Some(vec![ReanimateMechanism::Owner]) + ); + assert_eq!( + mechanisms_for(&tombstone.reanimation_paths, 1105), + Some(vec![ReanimateMechanism::WriteDac]) + ); + } + + /// No `READ_CONTROL` on the tombstone means AD omits the attribute; that degrades to "no + /// ownership data" instead of failing the object. + #[test] + fn test_tombstone_from_entry_without_descriptor() { + let mut attrs = HashMap::new(); + attrs.insert("isDeleted".to_string(), vec!["TRUE".to_string()]); + + let mut bin_attrs = HashMap::new(); + bin_attrs.insert("objectGUID".to_string(), vec![vec![0; 16]]); + + let entry = SearchEntry { + dn: "CN=opaque,CN=Deleted Objects,DC=ghost,DC=local".to_string(), + attrs, + bin_attrs, + }; + + let tombstone = TombstoneObject::from_entry(&entry, true).unwrap(); + assert_eq!(tombstone.owner_sid, None); + assert!(tombstone.reanimation_paths.is_empty()); + } + + /// A malformed descriptor is also non-fatal. + #[test] + fn test_tombstone_from_entry_with_malformed_descriptor() { + let mut attrs = HashMap::new(); + attrs.insert("isDeleted".to_string(), vec!["TRUE".to_string()]); + + let mut bin_attrs = HashMap::new(); + bin_attrs.insert("objectGUID".to_string(), vec![vec![0; 16]]); + bin_attrs.insert( + "nTSecurityDescriptor".to_string(), + vec![vec![0x01, 0x00, 0x04, 0x80]], // truncated header + ); + + let entry = SearchEntry { + dn: "CN=broken,CN=Deleted Objects,DC=ghost,DC=local".to_string(), + attrs, + bin_attrs, + }; + + let tombstone = TombstoneObject::from_entry(&entry, true).unwrap(); + assert_eq!(tombstone.owner_sid, None); + assert!(tombstone.reanimation_paths.is_empty()); + } + + /// `ldap3` puts a value in `attrs` rather than `bin_attrs` whenever it happens to be valid + /// UTF-8, which some descriptor blobs are; `raw_attr` must find it either way. + #[test] + fn test_from_entry_reads_descriptor_from_attrs() { + // control 0x0004 (SE_DACL_PRESENT, no SE_SELF_RELATIVE bit) and a WRITE_DAC mask keep + // every byte of this blob inside valid UTF-8. + let sd_bytes = security_descriptor_with_control( + Some(1104), + &[plain_ace(ACE_ALLOWED, 0x00, RIGHT_WRITE_DAC, 1105)], + 0x0004, + ); + let sd_string = + String::from_utf8(sd_bytes).expect("test blob is intentionally valid UTF-8"); + + let mut attrs = HashMap::new(); + attrs.insert("isDeleted".to_string(), vec!["TRUE".to_string()]); + attrs.insert("nTSecurityDescriptor".to_string(), vec![sd_string]); + + let mut bin_attrs = HashMap::new(); + bin_attrs.insert("objectGUID".to_string(), vec![vec![0; 16]]); + + let entry = SearchEntry { + dn: "CN=utf8sd,CN=Deleted Objects,DC=ghost,DC=local".to_string(), + attrs, + bin_attrs, + }; + + let tombstone = TombstoneObject::from_entry(&entry, true).unwrap(); + assert_eq!(tombstone.owner_sid, Some(sid_str(1104))); + assert_eq!( + mechanisms_for(&tombstone.reanimation_paths, 1105), + Some(vec![ReanimateMechanism::WriteDac]) + ); + } + + /// The SD_FLAGS control value is hand-encoded BER; a wrong byte here silently costs every + /// owner/DACL finding, since the DC then omits `nTSecurityDescriptor` rather than erroring. + #[test] + fn test_sd_flags_control_encoding() { + let ctrl = sd_flags_control(); + assert_eq!(ctrl.ctype, "1.2.840.113556.1.4.801"); + assert!(!ctrl.crit, "must degrade, not fail, on an unsupporting DC"); + // SEQUENCE (0x30), length 3, INTEGER (0x02), length 1, OWNER|GROUP|DACL (0x07). + assert_eq!(ctrl.val, Some(vec![0x30, 0x03, 0x02, 0x01, 0x07])); + // SACL (0x08) must stay unset: reading it needs SeSecurityPrivilege. + assert_eq!(SD_FLAGS_OWNER_GROUP_DACL[4] & 0x08, 0); + } + #[test] fn test_is_allow_ace() { assert!(is_allow_ace(0x00)); // ACCESS_ALLOWED_ACE_TYPE diff --git a/crates/ghosthound/src/main.rs b/crates/ghosthound/src/main.rs index 177d5b8..1401516 100644 --- a/crates/ghosthound/src/main.rs +++ b/crates/ghosthound/src/main.rs @@ -6,14 +6,14 @@ #![forbid(unsafe_code)] use ad_tombstone::{ - check_reanimate_rights, check_recycle_bin_enabled, fetch_tombstones, resolve_object_sid, - with_timeout, + ReanimateMechanism, ReanimationPath, check_reanimate_rights, check_recycle_bin_enabled, + fetch_tombstones, resolve_object_sid, with_timeout, }; use bloodhound_opengraph::{Edge, Node, OpenGraphBuilder}; use clap::Parser; use ldap3::{LdapConnAsync, LdapConnSettings}; use serde_json::json; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fs::File; use std::io::Write; use std::time::Duration; @@ -70,6 +70,86 @@ struct Args { output: String, } +/// Rewrites a well-known SID into the domain-scoped form BloodHound itself uses as `objectid`. +/// +/// SharpHound/RustHound-CE store non-domain principals (BUILTIN groups, `NT AUTHORITY\SYSTEM`, +/// Authenticated Users, ...) as `-` -- e.g. +/// `TOMBWATCHER.HTB-S-1-5-32-544` -- because the same well-known SID means a different principal in +/// every domain. Emitting the bare `S-1-5-32-544` produces a placeholder whose `objectid` matches no +/// real node, so `bridge_shadow_nodes.cypher` can never pair it and those edges stay stranded +/// (observed in the lab: the SYSTEM/Administrators/Account Operators shadows were the only +/// unbridged ones). +/// +/// A real domain SID (`S-1-5-21--`) is already globally unique and is passed through +/// untouched. Anything else gets the domain prefix; for an exotic non-AD SID that BloodHound has no +/// node for either way (`S-1-5-80-*` service SIDs, say), the result is still an unbridged +/// placeholder -- no worse than the bare form, and never a false pairing. +fn graph_principal_id(sid: &str, domain: &str) -> String { + if sid.starts_with("S-1-5-21-") { + sid.to_string() + } else { + format!("{}-{}", domain.to_uppercase(), sid) + } +} + +/// Builds the `GhostHound_CanReanimate` edges pointing at one tombstone. +/// +/// Both reanimation paths land here: `domain_rights` is the set of SIDs holding the +/// Reanimate-Tombstones right domain-wide (read off the domain NC root, so it applies to every +/// tombstone), and `object_paths` is control over this specific object's security descriptor -- +/// ownership, `WRITE_DAC`, `WRITE_OWNER`, or an inherited Reanimate-Tombstones ACE. +/// +/// The same principal can qualify both ways, so mechanisms are merged per SID into a single edge +/// carrying all of them rather than one edge per mechanism -- the same one-edge-per-principal rule +/// `check_reanimate_rights` already applies to its own SID list. +fn reanimation_edges( + target_id: &str, + domain: &str, + domain_rights: &[String], + object_paths: &[ReanimationPath], +) -> Vec { + let mut mechanisms_by_sid: BTreeMap<&str, BTreeSet> = BTreeMap::new(); + for sid in domain_rights { + mechanisms_by_sid + .entry(sid.as_str()) + .or_default() + .insert(ReanimateMechanism::ReanimateRight); + } + for path in object_paths { + mechanisms_by_sid + .entry(path.sid.as_str()) + .or_default() + .extend(path.mechanisms.iter().copied()); + } + + mechanisms_by_sid + .into_iter() + .filter_map(|(sid, mechanisms)| { + // Non-empty by construction (every entry is created with at least one mechanism). + let primary = *mechanisms.iter().next()?; + // Well-known SIDs (BUILTIN groups, SYSTEM, ...) must be domain-scoped to match the + // objectid BloodHound already stores for them -- see `graph_principal_id`. + let start = + bloodhound_opengraph::EdgeEndpoint::new(graph_principal_id(sid, domain), "id"); + let end = bloodhound_opengraph::EdgeEndpoint::new(target_id.to_string(), "id"); + // Kind must match model.json's relationship_kinds[].name exactly, same reasoning as + // the node kinds. + let mut edge = Edge::new(start, end, "GhostHound_CanReanimate"); + // `source` is the single strongest mechanism (reanimate_right > owner > write_dac > + // write_owner) so a Cypher query can filter on one scalar value; `sources` lists every + // mechanism for the cases where that matters. The distinction is operational: + // reanimate_right is already granted, while the others require first rewriting the + // tombstone's DACL -- a loud, auditable extra step. + edge.add_property("source", json!(primary.as_str())); + edge.add_property( + "sources", + json!(mechanisms.iter().map(|m| m.as_str()).collect::>()), + ); + Some(edge) + }) + .collect() +} + #[tokio::main] async fn main() -> Result<(), Box> { let args = Args::parse(); @@ -157,10 +237,30 @@ async fn main() -> Result<(), Box> { println!("[*] Checking for reanimation rights on the domain naming context root..."); let reanimate_rights = check_reanimate_rights(&mut ldap, &domain_nc, args.timeout_secs).await?; println!( - "[+] Found {} SIDs with reanimation rights.", + "[+] Found {} SIDs with the Reanimate-Tombstones right domain-wide.", reanimate_rights.len() ); + // Ownership/WRITE_DAC/WRITE_OWNER on an individual tombstone is a reanimation path too (the + // principal rewrites that object's DACL to grant itself the right), and it lives in the + // tombstone's own nTSecurityDescriptor rather than the domain NC root's. A tombstone whose + // descriptor wasn't readable (no READ_CONTROL for the bound principal) is reported rather than + // silently treated as "nobody controls this". + let per_object_controllers: usize = tombstones.iter().map(|t| t.reanimation_paths.len()).sum(); + println!( + "[+] Found {} owner/WRITE_DAC/WRITE_OWNER reanimation paths on individual tombstones.", + per_object_controllers + ); + let opaque_tombstones = tombstones.iter().filter(|t| t.owner_sid.is_none()).count(); + if opaque_tombstones > 0 { + eprintln!( + "[!] {} of {} tombstones had no readable nTSecurityDescriptor (READ_CONTROL denied or \ + malformed); their ownership/DACL reanimation paths are not represented in the output.", + opaque_tombstones, + tombstones.len() + ); + } + println!("[*] Resolving preserved group memberships to SIDs..."); let mut group_dn_to_sid: HashMap = HashMap::new(); for t in &tombstones { @@ -236,15 +336,22 @@ async fn main() -> Result<(), Box> { if let Some(parent) = &t.lastknownparent { node.add_property("lastknownparent", json!(parent)); } + // The object's own owner, from its nTSecurityDescriptor. Surfaced on the node (not only + // as an edge) because "who owns this tombstone" is a fact about the object that an analyst + // reads directly, and it's what makes the corresponding owner-sourced edge explainable. + if let Some(owner) = &t.owner_sid { + node.add_property("ownersid", json!(graph_principal_id(owner, &args.domain))); + } builder.add_node(node); - // Edges: Principals with the reanimation right can reanimate this tombstone. Kind - // must match model.json's relationship_kinds[].name exactly, same reasoning as above. - for sid in &reanimate_rights { - let start = bloodhound_opengraph::EdgeEndpoint::new(sid.clone(), "id"); - let end = bloodhound_opengraph::EdgeEndpoint::new(target_id.clone(), "id"); - builder.add_edge(Edge::new(start, end, "GhostHound_CanReanimate")); + for edge in reanimation_edges( + &target_id, + &args.domain, + &reanimate_rights, + &t.reanimation_paths, + ) { + builder.add_edge(edge); } // Edges: groups this tombstone was a member of, still walkable while in the @@ -259,7 +366,10 @@ async fn main() -> Result<(), Box> { for dn in &t.member_of { if let Some(group_sid) = group_dn_to_sid.get(dn) { let start = bloodhound_opengraph::EdgeEndpoint::new(target_id.clone(), "id"); - let end = bloodhound_opengraph::EdgeEndpoint::new(group_sid.clone(), "id"); + let end = bloodhound_opengraph::EdgeEndpoint::new( + graph_principal_id(group_sid, &args.domain), + "id", + ); builder.add_edge(Edge::new(start, end, "GhostHound_WasMemberOf")); } } @@ -281,3 +391,160 @@ async fn main() -> Result<(), Box> { ldap.unbind().await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn path(sid: &str, mechanisms: &[ReanimateMechanism]) -> ReanimationPath { + ReanimationPath { + sid: sid.to_string(), + mechanisms: mechanisms.to_vec(), + } + } + + fn source_of(edge: &Edge) -> &str { + edge.properties["source"].as_str().unwrap() + } + + fn sources_of(edge: &Edge) -> Vec<&str> { + edge.properties["sources"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect() + } + + /// Path A alone: the domain-wide right, applied to every tombstone. + #[test] + fn test_edges_from_domain_right_only() { + let edges = reanimation_edges( + "S-1-5-21-1-2-3-1109", + "ghost.local", + &["S-1-5-32-544".to_string()], + &[], + ); + + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].kind, "GhostHound_CanReanimate"); + // Well-known SID, domain-scoped to match BloodHound's own objectid for it. + assert_eq!(edges[0].start.value, "GHOST.LOCAL-S-1-5-32-544"); + assert_eq!(edges[0].end.value, "S-1-5-21-1-2-3-1109"); + assert_eq!(source_of(&edges[0]), "reanimate_right"); + assert_eq!(sources_of(&edges[0]), vec!["reanimate_right"]); + } + + /// Path B alone -- the case that previously produced no edge at all: a principal with only + /// ownership plus WRITE_DAC/WRITE_OWNER on the tombstone, and no formal extended right + /// anywhere. + #[test] + fn test_edges_from_object_control_only() { + let edges = reanimation_edges( + "S-1-5-21-1-2-3-1109", + "ghost.local", + &[], + &[path( + "S-1-5-21-1-2-3-1104", + &[ + ReanimateMechanism::Owner, + ReanimateMechanism::WriteDac, + ReanimateMechanism::WriteOwner, + ], + )], + ); + + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].start.value, "S-1-5-21-1-2-3-1104"); + assert_eq!(source_of(&edges[0]), "owner"); + assert_eq!( + sources_of(&edges[0]), + vec!["owner", "write_dac", "write_owner"] + ); + } + + #[test] + fn test_edge_source_for_write_dac_only() { + let edges = reanimation_edges( + "S-1-5-21-1-2-3-1109", + "ghost.local", + &[], + &[path("S-1-5-21-1-2-3-1104", &[ReanimateMechanism::WriteDac])], + ); + + assert_eq!(source_of(&edges[0]), "write_dac"); + assert_eq!(sources_of(&edges[0]), vec!["write_dac"]); + } + + /// A principal qualifying via both paths gets one edge recording both, not two edges. + #[test] + fn test_edges_dedup_across_both_paths() { + let edges = reanimation_edges( + "S-1-5-21-1-2-3-1109", + "ghost.local", + &["S-1-5-21-1-2-3-1104".to_string()], + &[path("S-1-5-21-1-2-3-1104", &[ReanimateMechanism::Owner])], + ); + + assert_eq!( + edges.len(), + 1, + "expected one edge per principal: {:?}", + edges + ); + // The formal grant wins as the scalar `source`: it needs no ACL rewrite first. + assert_eq!(source_of(&edges[0]), "reanimate_right"); + assert_eq!(sources_of(&edges[0]), vec!["reanimate_right", "owner"]); + } + + /// Distinct principals still get one edge each, in a deterministic order. + #[test] + fn test_edges_for_distinct_principals() { + let edges = reanimation_edges( + "S-1-5-21-1-2-3-1109", + "ghost.local", + &["S-1-5-32-544".to_string()], + &[path("S-1-5-21-1-2-3-1104", &[ReanimateMechanism::Owner])], + ); + + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].start.value, "S-1-5-21-1-2-3-1104"); + assert_eq!(edges[1].start.value, "GHOST.LOCAL-S-1-5-32-544"); + } + + /// A domain principal's SID is globally unique and must pass through untouched -- prefixing it + /// would break the match against the real node. + #[test] + fn test_graph_principal_id_passes_through_domain_sids() { + assert_eq!( + graph_principal_id( + "S-1-5-21-1392491010-1358638721-2126982587-1106", + "tombwatcher.htb" + ), + "S-1-5-21-1392491010-1358638721-2126982587-1106" + ); + } + + /// Well-known SIDs mean a different principal per domain, so BloodHound stores them scoped -- + /// e.g. TOMBWATCHER.HTB-S-1-5-32-544. Emitting the bare SID leaves the shadow node unbridgeable. + #[test] + fn test_graph_principal_id_scopes_well_known_sids() { + for sid in [ + "S-1-5-32-544", + "S-1-5-32-548", + "S-1-5-18", + "S-1-5-11", + "S-1-1-0", + ] { + assert_eq!( + graph_principal_id(sid, "tombwatcher.htb"), + format!("TOMBWATCHER.HTB-{}", sid) + ); + } + } + + #[test] + fn test_no_edges_when_nothing_qualifies() { + assert!(reanimation_edges("S-1-5-21-1-2-3-1109", "ghost.local", &[], &[]).is_empty()); + } +} diff --git a/docs/adr/0007-ownership-and-dacl-reanimation-paths.md b/docs/adr/0007-ownership-and-dacl-reanimation-paths.md new file mode 100644 index 0000000..0ec2cc6 --- /dev/null +++ b/docs/adr/0007-ownership-and-dacl-reanimation-paths.md @@ -0,0 +1,119 @@ +# ADR-0007: `CanReanimate` Covers Ownership and DACL-Write Paths, Tagged by Mechanism + +**Status:** Accepted +**Date:** 2026-07-30 + +## Context + +Until now, GhostHound emitted `GhostHound_CanReanimate` only for principals holding the formal +**Reanimate-Tombstones** control access right (extended right GUID +`45ec5156-db7e-47bb-b53f-dbeb2d03c40f`), read off the domain naming-context root per ADR-0004. + +That misses a second, equally usable path. A principal who **owns** a tombstoned object, or holds +**`WRITE_DAC`** (0x00040000) or **`WRITE_OWNER`** (0x00080000) on it, can rewrite that object's +`nTSecurityDescriptor` to grant itself the Reanimate-Tombstones right — or manipulate the object out +of `CN=Deleted Objects` directly — without already holding the extended right. An owner can do this +regardless of what the DACL says, which is exactly why parsing only the ACE list is not sufficient. + +Confirmed in a lab domain (`tombwatcher.htb`): `bloodyAD get writable` reports a plain user with +`OWNER: WRITE` / `DACL: WRITE` on several tombstoned `cert_admin` objects, while GhostHound's ingest +JSON for those tombstones contained no edges at all for that user, and no owner/ACL data on the +nodes. The information was being discarded at collection time — `fetch_tombstones` never requested +`nTSecurityDescriptor` for the tombstones themselves, only for the NC root. + +## Decision + +**Collect each tombstone's own descriptor, with the `SD_FLAGS` control.** `fetch_tombstones` +requests `nTSecurityDescriptor` alongside the existing attributes — but requesting the attribute is +not sufficient on its own. Absent `LDAP_SERVER_SD_FLAGS_OID` (`1.2.840.113556.1.4.801`), AD attempts +to return the entire descriptor including the SACL; reading a SACL requires `SeSecurityPrivilege`, +and instead of returning the readable parts the DC **omits the attribute from the response +entirely**. Verified against `tombwatcher.htb`: as the plain user `john`, all 3 tombstones *and* the +domain NC root came back with no descriptor at all until the control was added, which reads +identically to "no ACL data exists" — the same class of silent data loss this ADR exists to fix. The +control is sent with flags `OWNER|GROUP|DACL` (0x07, SACL excluded since nothing here needs it) and +non-critical, so an unsupporting DC degrades to the old behavior instead of failing the search. It's +sent on the domain-NC-root read in `check_reanimate_rights` for the same reason. + +`TombstoneObject::from_entry` parses both halves of the descriptor: the +`OwnerSid` (new `owner_sid` field) and the DACL. A missing attribute (no `READ_CONTROL` for the +bound principal) or an unparseable blob degrades to "no ownership/ACL data" for that object rather +than failing the run; the CLI reports the count on stderr so an analyst isn't left reading absence +of data as absence of control. + +**One analysis function, no LDAP.** `analyze_reanimation_control(&SecurityDescriptor)` is the whole +of the rights logic and is pure, so every mechanism is unit-testable against hand-built descriptor +blobs. It keeps the existing correctness gates — deny/audit ACE types (`is_allow_ace`) and +inherit-only ACEs (`applies_to_self`) are excluded, since both can carry the same access mask and +object-type GUID as a real grant. + +**Four mechanisms, in precedence order** (`ReanimateMechanism`): `reanimate_right`, `owner`, +`write_dac`, `write_owner`. + +- **The `reanimate_right` test is stricter on an individual object than at the NC root.** At the root, + an unscoped control-access ACE (or `GenericAll`) covers every extended right, Reanimate-Tombstones + included — `grants_reanimate_right_at_nc_root`. On a tombstone it does not: the right is *validated* + at the naming-context root, so broad rights on the object confer the ability to rewrite its + descriptor, not the right itself. Object-level `reanimate_right` therefore requires an ACE naming + the GUID explicitly (`grants_reanimate_right_on_object`), which is what an ACE inherited from + `CN=Deleted Objects` looks like. `GenericAll` on a tombstone maps to `write_dac` + `write_owner` + instead, per the generic-to-specific mapping. Labeling it `reanimate_right` would report an + ACL-rewrite path as a formally-held right — the exact overstatement these labels exist to prevent. + Caught in the lab: john's `GenericAll` on the `cert_admin` tombstones first surfaced as + `source: "reanimate_right"`, when `write_dac` is the truthful answer. +- `GENERIC_WRITE` (0x40000000) is not treated as a descriptor-write grant: for a directory object it + maps to write-property/self, which does not include `WRITE_DAC`. +- `object_type` is ignored when testing `WRITE_DAC`/`WRITE_OWNER`. An object ACE's GUID narrows only + the AD-specific rights (control-access, read/write-property, create/delete-child); the standard + rights always apply to the object as a whole. +- Ownership of the **domain NC root** is deliberately *not* treated as a reanimation path by + `check_reanimate_rights`. That function answers "who holds the right domain-wide", and an + ACL-rewrite path on the NC root is a far broader finding than tombstone reanimation — it belongs + to a different tool, not smuggled in as a `CanReanimate` edge. + +**One edge per principal per tombstone, tagged with its mechanisms.** The domain-wide right and the +per-object descriptor can both name the same principal, so the CLI merges mechanisms per SID and +emits a single edge carrying: + +- `source` — the strongest mechanism, as one scalar string, so a Cypher query can filter without + list handling; +- `sources` — every mechanism that qualified. + +This matches the de-duplication rule `check_reanimate_rights` already applied to its own SID list +(one edge per principal, not one per qualifying ACE). The tombstone node also carries `ownersid`, +because "who owns this tombstone" is a fact an analyst reads directly off the object and is what +makes an `owner`-sourced edge explainable. + +## Consequences + +- The mechanism distinction is operational, and analysts must be able to see it: `reanimate_right` + is usable as-is, while the other three require first rewriting the tombstone's DACL or ownership — + a loud, auditable extra step. Flattening all four into one undifferentiated edge would overstate + the immediacy of the ownership paths, the same failure mode ADR-0004 corrected for + `Deleted`-vs-`Recycled`. +- `source`/`sources` are edge property *values*, not schema: BloodHound's OpenGraph extension + definition declares node and relationship *kinds* only, so `model.json` needs no new field for + them — its `GhostHound_CanReanimate` description documents them instead. The edge kind name and + `is_traversable: true` are unchanged, so existing queries keep working; `bridge_shadow_nodes.cypher` + and `privilege_zones.cypher` are untouched. +- The starter query pack moved from a single `queries.json` to one file per query under + `crates/ad-tombstone/queries/`, and gained queries for the new mechanisms (ACL-rewrite-only paths, + non-Tier-Zero holders, per-principal lookup) plus collection-health checks (unreadable descriptors, + unbridged placeholders). The old file was in BloodHound *Legacy*'s `customqueries.json` schema + (`{"queries": [{"queryList": [...]}]}`), which BloodHound CE cannot import at all: CE's + `POST /api/v2/saved-queries/import` unmarshals each file into a single + `TransferableSavedQuery{query, name, description}` and rejects arrays or wrapper objects. Several of + those queries also returned scalar properties rather than nodes/paths, which CE's Cypher view + renders as an empty result — every query in the pack now returns nodes or paths. +- `fetch_tombstones` now requests one extra (potentially large) attribute per tombstone. No + additional round-trips: it rides along on the existing paged search. +- Emitted principal IDs are domain-scoped for well-known SIDs (`-`, e.g. + `TOMBWATCHER.HTB-S-1-5-32-544`), matching SharpHound/RustHound-CE's own `objectid` convention. + Without it, `bridge_shadow_nodes.cypher` has no shared `objectid` to match on and those shadows stay + stranded — observed in the lab, where SYSTEM/Administrators/Account Operators were the only + unbridged nodes. Domain SIDs (`S-1-5-21-*`) are already unique and pass through untouched, so the + bridge script itself needs no loosening (no fuzzy suffix matching, no cross-domain ambiguity). +- Tombstone descriptors are frequently owned by `Domain Admins`/`Administrators`, so expect + `owner`-sourced edges from those principals on most tombstones. They are accurate — a Domain Admin + really can reanimate — and no filtering is applied, since suppressing "obvious" high-privilege + principals is a presentation choice for the consumer, not something the collector should decide.