From 71538227f9c537e2f6af946b35c67b7249d18fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Botelho=20Gon=C3=A7alves?= Date: Sun, 9 Aug 2026 12:06:41 +0100 Subject: [PATCH 1/2] fix(ghosthound): accept directories for output --- Cargo.lock | 1 + README.md | 16 ++ crates/ghosthound/Cargo.toml | 3 + crates/ghosthound/src/main.rs | 32 ++-- crates/ghosthound/src/output.rs | 321 ++++++++++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 crates/ghosthound/src/output.rs 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..f247f5e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,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. diff --git a/crates/ghosthound/Cargo.toml b/crates/ghosthound/Cargo.toml index 2931914..d7b1d9c 100644 --- a/crates/ghosthound/Cargo.toml +++ b/crates/ghosthound/Cargo.toml @@ -21,3 +21,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..d10c239 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,14 +16,10 @@ 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, @@ -65,14 +63,16 @@ 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, } #[tokio::main] async fn main() -> Result<(), Box> { let args = Args::parse(); + let output_path = output::resolve_output_path(&args.output)?; + println!("[*] Output destination: {}", output_path.display()); 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()); @@ -267,16 +267,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); + } +} From 8755221a68243c8670734385a3e4f6f7b6b65e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Botelho=20Gon=C3=A7alves?= Date: Sun, 9 Aug 2026 15:23:37 +0100 Subject: [PATCH 2/2] refactor(ghosthound): defer output path resolution Move output path resolution and display after the NTLM check. This prevents unnecessary work and output if NTLM authentication is disabled, as the program would exit early. --- crates/ghosthound/src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ghosthound/src/main.rs b/crates/ghosthound/src/main.rs index d10c239..59b646a 100644 --- a/crates/ghosthound/src/main.rs +++ b/crates/ghosthound/src/main.rs @@ -71,13 +71,14 @@ struct Args { #[tokio::main] async fn main() -> Result<(), Box> { let args = Args::parse(); - let output_path = output::resolve_output_path(&args.output)?; - println!("[*] Output destination: {}", output_path.display()); 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: ")?,