diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..ec5bfb0 --- /dev/null +++ b/CITATION.cff @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ad7531e --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 344f10d..203f591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -544,6 +544,7 @@ dependencies = [ "native-tls", "rpassword", "serde_json", + "tempfile", "tokio", "zeroize", ] diff --git a/README.md b/README.md index df42a8a..8042700 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ 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 @@ -33,6 +35,22 @@ you are explicitly authorized to test. 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//cmdline` and shell history), then the `LDAP_PASSWORD` environment variable, then an interactive prompt if neither is set. @@ -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, diff --git a/crates/ad-secdesc/Cargo.toml b/crates/ad-secdesc/Cargo.toml index 85dc7ac..33f1fc6 100644 --- a/crates/ad-secdesc/Cargo.toml +++ b/crates/ad-secdesc/Cargo.toml @@ -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" diff --git a/crates/ad-tombstone/Cargo.toml b/crates/ad-tombstone/Cargo.toml index 5e58495..b12c1d2 100644 --- a/crates/ad-tombstone/Cargo.toml +++ b/crates/ad-tombstone/Cargo.toml @@ -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" } diff --git a/crates/bloodhound-opengraph/Cargo.toml b/crates/bloodhound-opengraph/Cargo.toml index ca061c3..0e584ec 100644 --- a/crates/bloodhound-opengraph/Cargo.toml +++ b/crates/bloodhound-opengraph/Cargo.toml @@ -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"] } diff --git a/crates/ghosthound/Cargo.toml b/crates/ghosthound/Cargo.toml index 2931914..5723ef5 100644 --- a/crates/ghosthound/Cargo.toml +++ b/crates/ghosthound/Cargo.toml @@ -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] @@ -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" diff --git a/crates/ghosthound/src/main.rs b/crates/ghosthound/src/main.rs index 177d5b8..27d0e01 100644 --- a/crates/ghosthound/src/main.rs +++ b/crates/ghosthound/src/main.rs @@ -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, @@ -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, @@ -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> { 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: ")?, @@ -267,16 +287,12 @@ async fn main() -> Result<(), Box> { 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(()) diff --git a/crates/ghosthound/src/output.rs b/crates/ghosthound/src/output.rs new file mode 100644 index 0000000..88e4fe2 --- /dev/null +++ b/crates/ghosthound/src/output.rs @@ -0,0 +1,321 @@ +use std::error::Error; +use std::ffi::OsStr; +use std::fmt; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; + +pub(crate) const DEFAULT_OUTPUT_FILENAME: &str = "ghosthound_output.json"; + +/// An output-path or output-write failure with enough context to be useful from the CLI. +/// +/// `main` returns a boxed error, whose termination implementation formats errors with `Debug`. +/// Keeping `Debug` identical to `Display` avoids exposing an opaque raw `io::Error` structure to +/// the user while retaining the original error as a source. +pub(crate) struct OutputError { + message: String, + source: Option, +} + +impl OutputError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + source: None, + } + } + + fn with_source(message: impl Into, source: std::io::Error) -> Self { + Self { + message: message.into(), + source: Some(source), + } + } +} + +impl fmt::Display for OutputError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message)?; + if let Some(source) = &self.source { + write!(formatter, ": {source}")?; + } + Ok(()) + } +} + +impl fmt::Debug for OutputError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl Error for OutputError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source + .as_ref() + .map(|source| source as &(dyn Error + 'static)) + } +} + +/// Resolves a user-supplied output argument and validates its directory structure. +/// +/// An existing directory receives [`DEFAULT_OUTPUT_FILENAME`]; every other path is treated as an +/// explicit file path. A trailing separator is retained as a directory hint so a missing `dir/` +/// is rejected instead of being reinterpreted as a file named `dir`. Directory checks use +/// [`Path::is_dir`] and [`fs::metadata`], which deliberately follow symlinks. This function does +/// not create or truncate anything, so it is safe to call before LDAP collection begins. +pub(crate) fn resolve_output_path(requested: &Path) -> Result { + if requested.as_os_str().is_empty() { + return Err(OutputError::new("output path must not be empty")); + } + + if requested.is_dir() { + return Ok(requested.join(DEFAULT_OUTPUT_FILENAME)); + } + + if has_trailing_separator(requested.as_os_str()) { + return Err(OutputError::new(format!( + "output directory '{}' does not exist or is not a directory", + requested.display() + ))); + } + + let parent = requested + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + + match fs::metadata(parent) { + Ok(metadata) if metadata.is_dir() => Ok(requested.to_path_buf()), + Ok(_) => Err(OutputError::new(format!( + "output parent path '{}' is not a directory", + parent.display() + ))), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + Err(OutputError::with_source( + format!( + "output parent directory '{}' does not exist", + parent.display() + ), + source, + )) + } + Err(source) => Err(OutputError::with_source( + format!( + "failed to inspect output parent directory '{}'", + parent.display() + ), + source, + )), + } +} + +/// Writes the completed JSON payload to a previously resolved output path. +/// +/// On Unix, permissions are set to `0600` before an existing file is truncated so data never +/// passes through a world-readable state. Windows retains the ACL behavior of the host filesystem. +pub(crate) fn write_output(path: &Path, contents: &[u8]) -> Result<(), OutputError> { + let mut open_options = File::options(); + open_options.write(true).create(true); + #[cfg(unix)] + open_options.mode(0o600); + + let mut file = open_options.open(path).map_err(|source| { + OutputError::with_source( + format!("failed to open output file '{}'", path.display()), + source, + ) + })?; + + #[cfg(unix)] + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|source| { + OutputError::with_source( + format!( + "failed to restrict output file '{}' to mode 0600", + path.display() + ), + source, + ) + })?; + + file.set_len(0).map_err(|source| { + OutputError::with_source( + format!("failed to truncate output file '{}'", path.display()), + source, + ) + })?; + file.write_all(contents).map_err(|source| { + OutputError::with_source( + format!("failed to write output file '{}'", path.display()), + source, + ) + })?; + + Ok(()) +} + +#[cfg(unix)] +fn has_trailing_separator(value: &OsStr) -> bool { + value.as_bytes().last() == Some(&b'/') +} + +#[cfg(windows)] +fn has_trailing_separator(value: &OsStr) -> bool { + matches!(value.encode_wide().last(), Some(value) if value == u16::from(b'/') || value == u16::from(b'\\')) +} + +#[cfg(not(any(unix, windows)))] +fn has_trailing_separator(value: &OsStr) -> bool { + value + .to_string_lossy() + .chars() + .last() + .is_some_and(std::path::is_separator) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + fn with_trailing_separator(path: &Path) -> PathBuf { + let mut value = OsString::from(path.as_os_str()); + value.push(std::path::MAIN_SEPARATOR_STR); + PathBuf::from(value) + } + + #[test] + fn keeps_an_explicit_file_path_unchanged() { + let temp = tempfile::tempdir().unwrap(); + let requested = temp.path().join("report.json"); + + assert_eq!(resolve_output_path(&requested).unwrap(), requested); + } + + #[test] + fn keeps_a_bare_filename_relative_to_the_current_directory() { + let requested = Path::new("report.json"); + + assert_eq!( + resolve_output_path(requested).unwrap(), + PathBuf::from("report.json") + ); + } + + #[test] + fn appends_the_default_filename_to_an_existing_directory() { + let temp = tempfile::tempdir().unwrap(); + + assert_eq!( + resolve_output_path(temp.path()).unwrap(), + temp.path().join(DEFAULT_OUTPUT_FILENAME) + ); + } + + #[test] + fn accepts_an_existing_directory_with_a_trailing_separator() { + let temp = tempfile::tempdir().unwrap(); + let requested = with_trailing_separator(temp.path()); + + assert_eq!( + resolve_output_path(&requested).unwrap(), + temp.path().join(DEFAULT_OUTPUT_FILENAME) + ); + } + + #[test] + fn rejects_a_missing_directory_indicated_by_a_trailing_separator() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing"); + let requested = with_trailing_separator(&missing); + + let error = resolve_output_path(&requested).unwrap_err(); + + assert!(error.to_string().contains("output directory")); + assert!(error.to_string().contains("does not exist")); + assert!(!missing.exists()); + } + + #[test] + fn rejects_a_file_path_whose_parent_does_not_exist() { + let temp = tempfile::tempdir().unwrap(); + let requested = temp.path().join("missing").join("report.json"); + + let error = resolve_output_path(&requested).unwrap_err(); + + assert!(error.to_string().contains("output parent directory")); + assert!(error.to_string().contains("does not exist")); + } + + #[test] + fn rejects_a_parent_path_that_is_a_file() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("not-a-directory"); + fs::write(&parent, b"existing file").unwrap(); + + let error = resolve_output_path(&parent.join("report.json")).unwrap_err(); + + assert!(error.to_string().contains("is not a directory")); + } + + #[cfg(unix)] + #[test] + fn follows_a_symlink_to_an_existing_directory() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target"); + let link = temp.path().join("link"); + fs::create_dir(&target).unwrap(); + symlink(&target, &link).unwrap(); + + assert_eq!( + resolve_output_path(&link).unwrap(), + link.join(DEFAULT_OUTPUT_FILENAME) + ); + } + + #[test] + fn writes_and_truncates_the_resolved_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("report.json"); + fs::write(&path, b"a longer previous report").unwrap(); + + write_output(&path, b"{}\n").unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"{}\n"); + } + + #[test] + fn creates_a_new_output_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("report.json"); + + write_output(&path, b"{}\n").unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"{}\n"); + } + + #[cfg(unix)] + #[test] + fn restricts_an_existing_file_to_owner_only_before_reuse() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("report.json"); + fs::write(&path, b"old report").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + write_output(&path, b"new report").unwrap(); + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +}