Skip to content
Closed
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
28 changes: 28 additions & 0 deletions CITATION.cff
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
cff-version: 1.2.0
title: GhostHound
message: >-
If you use GhostHound in research, an engagement writeup, a conference talk, or another tool,
please credit it using the metadata below.
type: software
authors:
- given-names: JVBotelho
alias: JVBotelho
website: "https://glitchedcat.com"
abstract: >-
A BloodHound OpenGraph extension that enumerates Active Directory tombstones in
CN=Deleted Objects over LDAP, determines which principals hold the Reanimate-Tombstones
right, and emits an OpenGraph JSON payload so tombstone reanimation appears as a
first-class attack path in BloodHound CE.
keywords:
- active-directory
- bloodhound
- opengraph
- tombstone
- attack-path
- red-team
license:
- MIT
- Apache-2.0
repository-code: "https://github.com/JVBotelho/ghosthound"
url: "https://glitchedcat.com"
version: 0.1.2
127 changes: 127 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Contributing to GhostHound

Thank you for contributing to GhostHound. Before making a change, review the architecture
decision records in [`docs/adr/`](docs/adr/) for the design context and constraints behind the
workspace.

## Reporting a vulnerability

**Do not open a public issue for a security vulnerability**, including one in the binary parsers
(`ad-secdesc`, `ad-tombstone`) or the LDAP client. Those parsers consume untrusted bytes from a
domain controller, so a parsing defect may be exploitable. Follow the private disclosure process in
[`SECURITY.md`](SECURITY.md) instead.

Ordinary bugs, crashes on your own lab data, and feature requests belong in public issues as usual.

## Before you start

- For anything beyond a typo, comment on the issue before you begin so work isn't duplicated. If no
issue exists, open one first and describe the approach you have in mind.
- Every pull request needs a review from a code owner (see [`.github/CODEOWNERS`](.github/CODEOWNERS)).
- Any change to behaviour needs a test that fails without it. Prefer tests that run without a live
domain controller; see the existing unit tests for how parsing and CLI logic are covered offline.
- Keep pull requests focused on one concern. If a change turns out to need an unrelated fix, say so
in the pull request rather than folding it in silently.

## Toolchain and local checks

The project uses the stable Rust toolchain and requires the `rustfmt` and `clippy` components. Every
crate is on `edition = "2024"`, so **Rust 1.85 or newer** is required; older stable toolchains fail
with an edition error rather than a clear message. If you manage Rust with `rustup`, install the
toolchain and components with:

```bash
rustup toolchain install stable --component rustfmt clippy
```

From the repository root, build and test the default workspace members with:

```bash
cargo build
cargo test --all-targets --all-features
```

CI enforces formatting and treats every Clippy warning as an error. Run the same checks before
submitting a change:

```bash
cargo fmt -- --check
cargo clippy --all-targets --all-features -- -D warnings
```

Run `cargo fmt` to apply formatting fixes.

### Platforms

The tool is used on both Linux and Windows, and `crates/ghosthound/src/output.rs` has
platform-specific path handling behind `#[cfg(unix)]` and `#[cfg(windows)]`. A local
`cargo test` therefore runs a different subset of tests depending on your operating system —
if you touch path or output handling, note which platform you tested on in the pull request.

## Dependencies and supply chain

The supply-chain posture is deliberate and enforced in CI; see
[`docs/adr/0005-supply-chain-openssf-posture.md`](docs/adr/0005-supply-chain-openssf-posture.md).
Two rules catch contributors by surprise:

- **New dependencies are scrutinised.** `cargo-deny` bans copyleft licences in the published crates
and fails on known advisories, and the OSV Scanner runs against `Cargo.lock`. Justify any new
dependency in the pull request, and check it locally before pushing:

```bash
cargo install cargo-deny --locked
cargo deny check
```

- **GitHub Actions are pinned by commit SHA**, not by tag. If you edit a workflow, keep that style
and leave the human-readable version in a trailing comment, as the existing entries do.

## Contribution licensing

Contributions are accepted under the same terms as the project: dual-licensed
[MIT](LICENSE-MIT) OR [Apache-2.0](LICENSE-APACHE). By submitting a pull request you agree that your
contribution may be distributed under both licences.

## Default workspace members and the oracle

The workspace's `default-members` deliberately exclude `ad-secdesc-oracle`. As a result, commands
such as `cargo build` and `cargo test --all-targets --all-features` run the four published crates
(three libraries plus the `ghosthound` binary) but skip the oracle crate.

`ad-secdesc-oracle` is an unpublished differential-test harness that depends on a GPL-3.0
implementation. It is kept separate so GPL code never enters the dependency graph of the
permissively licensed, published crates. See
[`docs/adr/0003-security-descriptor-parsing-strategy.md`](docs/adr/0003-security-descriptor-parsing-strategy.md)
for the full rationale.

When a change affects security-descriptor parsing or the oracle harness, run its tests explicitly:

```bash
cargo test -p ad-secdesc-oracle
```

## Fuzzing

The fuzz targets require the nightly Rust toolchain and `cargo-fuzz`:

```bash
rustup toolchain install nightly
cargo install cargo-fuzz --locked
```

Run the `ad-secdesc` target from its crate directory:

```bash
cd crates/ad-secdesc
cargo +nightly fuzz run fuzz_target_1
```

Run the `ad-tombstone` target from its crate directory:

```bash
cd crates/ad-tombstone
cargo +nightly fuzz run fuzz_target_1
```

Stop a local fuzzing campaign with `Ctrl-C`. For a CI-style 60-second smoke test, append
`-- -max_total_time=60` to either command.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,31 @@ you are explicitly authorized to test.
- Network reachability to a Domain Controller on LDAP (389) or LDAPS (636).
- BloodHound CE v8+ if you want to import the resulting graph (collection and JSON output work
standalone without it).
- To build from source: Rust 1.85 or newer (every crate is on `edition = "2024"`). See
[`CONTRIBUTING.md`](CONTRIBUTING.md) for the full toolchain and local check setup.

## Usage

```bash
ghosthound -d ghost.local --dc-ip 10.0.0.10 -u alice -o tombstones.json
```

`--output`/`-o` accepts either an explicit file path or an existing directory. When given a
directory, GhostHound writes the default `ghosthound_output.json` inside it:

```bash
# Write to an explicit path
ghosthound -d ghost.local --dc-ip 10.0.0.10 -u alice -o /tmp/tombstones.json

# Write /tmp/ghosthound_output.json
ghosthound -d ghost.local --dc-ip 10.0.0.10 -u alice -o /tmp
```

Parent directories are not created automatically. GhostHound validates the destination before
connecting to LDAP and reports a contextual error if the parent is missing or is not a directory.
On Unix, a newly-created or reused output file is restricted to mode `0600`; on Windows, its access
is controlled by filesystem ACLs.

Password resolution order: `--password` (avoid — visible via `ps`/`/proc/<pid>/cmdline` and shell
history), then the `LDAP_PASSWORD` environment variable, then an interactive prompt if neither is
set.
Expand Down Expand Up @@ -106,6 +124,21 @@ This project prioritizes high-confidence security from day 1 (`docs/adr/0005-sup
- `cargo fuzz` for panic safety in parsing raw binary descriptors.
- No `unsafe` in foundational libraries.

## Credits

Built by **JVBotelho** — [glitchedcat.com](https://glitchedcat.com).

GhostHound is MIT OR Apache-2.0 licensed, so you are free to use, modify, and embed it without
asking. If it turns up something useful on an engagement or in research, a link back is appreciated
but never required. `CITATION.cff` has ready-made citation metadata (GitHub renders it as the
"Cite this repository" button).

## Contributing

See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the toolchain, the local checks CI enforces, and the
dependency rules. Security vulnerabilities go through the private process in
[`SECURITY.md`](SECURITY.md), not public issues.

## Design Rationale

The `docs/adr/` directory records the decisions behind this design (language choice, auth scope,
Expand Down
2 changes: 2 additions & 0 deletions crates/ad-secdesc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ name = "ad-secdesc"
version = "0.1.2"
edition = "2024"
description = "Binary parser for Active Directory ntSecurityDescriptor and ACEs"
authors = ["JVBotelho"]
repository = "https://github.com/JVBotelho/ghosthound"
homepage = "https://glitchedcat.com"

[dependencies]
byteorder = "1.5.0"
Expand Down
2 changes: 2 additions & 0 deletions crates/ad-tombstone/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ name = "ad-tombstone"
version = "0.1.2"
edition = "2024"
description = "Active Directory Tombstone and Recycle Bin enumerator"
authors = ["JVBotelho"]
repository = "https://github.com/JVBotelho/ghosthound"
homepage = "https://glitchedcat.com"

[dependencies]
ad-secdesc = { version = "0.1.2", path = "../ad-secdesc" }
Expand Down
2 changes: 2 additions & 0 deletions crates/bloodhound-opengraph/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ name = "bloodhound-opengraph"
version = "0.1.2"
edition = "2024"
description = "BloodHound OpenGraph JSON builder and schema structures"
authors = ["JVBotelho"]
repository = "https://github.com/JVBotelho/ghosthound"
homepage = "https://glitchedcat.com"

[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
Expand Down
5 changes: 5 additions & 0 deletions crates/ghosthound/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ name = "ghosthound"
version = "0.1.2"
edition = "2024"
description = "GhostHound CLI: Orchestrates AD Tombstone discovery and OpenGraph generation"
authors = ["JVBotelho"]
repository = "https://github.com/JVBotelho/ghosthound"
homepage = "https://glitchedcat.com"
readme = "../../README.md"

[dependencies]
Expand All @@ -21,3 +23,6 @@ rpassword = "7.4"
serde_json = "1.0.150"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] }
zeroize = "1.8"

[dev-dependencies]
tempfile = "3.27.0"
52 changes: 34 additions & 18 deletions crates/ghosthound/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

#![forbid(unsafe_code)]

mod output;

use ad_tombstone::{
check_reanimate_rights, check_recycle_bin_enabled, fetch_tombstones, resolve_object_sid,
with_timeout,
Expand All @@ -14,20 +16,20 @@ use clap::Parser;
use ldap3::{LdapConnAsync, LdapConnSettings};
use serde_json::json;
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use zeroize::Zeroizing;

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;

#[derive(Parser)]
#[command(
version,
about = "GhostHound CLI - Discover AD Tombstones and Reanimation Paths"
)]
struct Args {
/// Suppress the startup banner (useful when wrapping GhostHound in a script)
#[arg(long, default_value_t = false)]
no_banner: bool,

/// Domain name (e.g. ghost.local)
#[arg(short, long)]
domain: String,
Expand Down Expand Up @@ -65,19 +67,37 @@ struct Args {
#[arg(long, default_value_t = 30)]
timeout_secs: u64,

/// Output JSON file name
#[arg(short, long, default_value = "ghosthound_output.json")]
output: String,
/// Output JSON file path or existing directory
#[arg(short, long, default_value = output::DEFAULT_OUTPUT_FILENAME)]
output: PathBuf,
}

/// Prints the startup banner on stderr, keeping stdout free for output a caller may want to capture
/// or pipe.
fn print_banner() {
eprintln!(
"GhostHound v{} - AD tombstone reanimation paths for BloodHound",
env!("CARGO_PKG_VERSION")
);
eprintln!("by JVBotelho - https://glitchedcat.com");
eprintln!();
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();

if !args.no_banner {
print_banner();
}

if args.ntlm {
return Err("NTLM authentication is currently disabled due to upstream dependencies (sspi-rs) failing strict security checks on the latest compiler toolchain. Please use Simple Bind.".into());
}

let output_path = output::resolve_output_path(&args.output)?;
println!("[*] Output destination: {}", output_path.display());

let password = Zeroizing::new(match args.password {
Some(p) => p,
None => rpassword::prompt_password("Password: ")?,
Expand Down Expand Up @@ -267,16 +287,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

let graph_data = builder.build("GhostHound");
let json_output = serde_json::to_string_pretty(&graph_data)?;
// The output documents privileged principals and attack paths, so restrict it to the owner
// rather than relying on the process umask (typically 644, world-readable) on Unix.
let mut open_options = File::options();
open_options.write(true).create(true).truncate(true);
#[cfg(unix)]
open_options.mode(0o600);
let mut file = open_options.open(&args.output)?;
file.write_all(json_output.as_bytes())?;

println!("[+] Successfully wrote graph data to {}", args.output);
output::write_output(&output_path, json_output.as_bytes())?;

println!(
"[+] Successfully wrote graph data to {}",
output_path.display()
);

ldap.unbind().await?;
Ok(())
Expand Down
Loading