Skip to main content

ad_time/protocols/
kerberos.rs

1/// Kerberos time source (primary — stealth).
2///
3/// Protocol Specifications:
4/// - **RFC 4120 §3.1.1**: AS Exchange
5/// - **RFC 4120 §5.4.1**: KRB_AS_REQ
6/// - **RFC 4120 §5.9.1**: KRB_ERROR
7/// - **RFC 4120 §5.2.2**: PrincipalName
8///
9/// Sends a minimal AS-REQ for a nonexistent principal and reads `stime`/`susec`
10/// from the KRB-ERROR response. Any KRB-ERROR from a real KDC includes these
11/// required fields (RFC 4120 §5.9.1), so even a KRB_AP_ERR_PRINCIPAL_UNKNOWN
12/// gives us the server clock.
13///
14/// Offset precision: ±RTT/2 (single-point approximation, not four-point NTP
15/// triangulation). Sufficient for Kerberos' 5-minute skew window.
16use std::io::{Read, Write};
17use std::net::{SocketAddr, TcpStream};
18use std::time::{Duration, Instant, SystemTime};
19
20use rand::Rng;
21
22use super::ber::{
23    encode_application, encode_context, encode_generalizedtime, encode_generalstring,
24    encode_integer_i32, encode_integer_u64, encode_sequence, encode_tlv,
25};
26use super::common::{map_io_err, parse_generalized_time, system_time_to_us};
27use crate::time_src::{OffsetMicros, TimeSource, TimeSourceError};
28
29// DER/ASN.1 tag constants used in KRB-ERROR parsing (RFC 4120 §5.9.1).
30const KRB_ERROR_TAG: u8 = 0x7E; // APPLICATION 30
31const SEQUENCE_TAG: u8 = 0x30;
32const STIME_TAG: u8 = 0xA4; // context [4]
33const SUSEC_TAG: u8 = 0xA5; // context [5]
34const GENERALIZED_TIME_TAG: u8 = 0x18;
35const INTEGER_TAG: u8 = 0x02;
36
37pub struct KerberosSource {
38    pub realm: Option<String>,
39    pub stealth_user: String,
40}
41
42impl TimeSource for KerberosSource {
43    fn name(&self) -> &'static str {
44        "kerberos"
45    }
46
47    fn fetch(
48        &self,
49        target: SocketAddr,
50        timeout: Duration,
51    ) -> Result<OffsetMicros, TimeSourceError> {
52        let realm = self
53            .realm
54            .as_deref()
55            .ok_or_else(|| TimeSourceError::Config("no realm configured".into()))?;
56        let krb_addr: SocketAddr = (target.ip(), 88).into();
57        fetch_kerberos(krb_addr, realm, &self.stealth_user, timeout)
58    }
59}
60
61fn fetch_kerberos(
62    addr: SocketAddr,
63    realm: &str,
64    stealth_user: &str,
65    timeout: Duration,
66) -> Result<OffsetMicros, TimeSourceError> {
67    let mut stream =
68        TcpStream::connect_timeout(&addr, timeout).map_err(|e| map_io_err(e, "connect"))?;
69    stream
70        .set_read_timeout(Some(timeout))
71        .map_err(|e| TimeSourceError::Protocol(e.to_string()))?;
72    stream
73        .set_write_timeout(Some(timeout))
74        .map_err(|e| TimeSourceError::Protocol(e.to_string()))?;
75
76    let t_send_sys = SystemTime::now();
77    let t_send = Instant::now();
78
79    let req = build_as_req(realm, stealth_user);
80    // RFC 4120 §7.2.2: TCP Kerberos messages are prefixed by 4-byte big-endian length.
81    let len = (req.len() as u32).to_be_bytes();
82    stream
83        .write_all(&len)
84        .map_err(|e| TimeSourceError::Protocol(e.to_string()))?;
85    stream
86        .write_all(&req)
87        .map_err(|e| TimeSourceError::Protocol(e.to_string()))?;
88
89    // Read response length prefix.
90    let mut len_buf = [0u8; 4];
91    stream
92        .read_exact(&mut len_buf)
93        .map_err(|e| map_io_err(e, "read_length"))?;
94    let resp_len = u32::from_be_bytes(len_buf) as usize;
95
96    if resp_len > 65536 {
97        return Err(TimeSourceError::Protocol(format!(
98            "implausibly large KRB response: {} bytes",
99            resp_len
100        )));
101    }
102    let mut resp = vec![0u8; resp_len];
103    stream
104        .read_exact(&mut resp)
105        .map_err(|e| map_io_err(e, "read_response"))?;
106
107    let rtt = t_send.elapsed();
108
109    // Single-point approximation: server time ≈ local midpoint of send/recv window.
110    let t_mid_us = system_time_to_us(t_send_sys)? + (rtt.as_micros() as i64) / 2;
111
112    let server_us = parse_krb_error(&resp)?;
113    Ok(server_us - t_mid_us)
114}
115
116/// Parse a KRB-ERROR (APPLICATION 30, tag 0x7E) and return server time in Unix microseconds.
117pub fn parse_krb_error(data: &[u8]) -> Result<i64, TimeSourceError> {
118    // DER structure: 0x7E <len> <SEQUENCE contents>
119    let mut pos = 0;
120    let tag = next_byte(data, &mut pos, "KRB-ERROR tag")?;
121    if tag != KRB_ERROR_TAG {
122        return Err(TimeSourceError::Protocol(format!(
123            "expected KRB-ERROR tag 0x{:02X}, got 0x{:02X}",
124            KRB_ERROR_TAG, tag
125        )));
126    }
127
128    // Skip outer length — we're scanning by tag inside.
129    skip_der_length(data, &mut pos)?;
130
131    // The KRB-ERROR SEQUENCE wraps the fields. Outer SEQUENCE tag.
132    let seq_tag = next_byte(data, &mut pos, "KRB-ERROR SEQUENCE tag")?;
133    if seq_tag != SEQUENCE_TAG {
134        return Err(TimeSourceError::Parse(format!(
135            "expected SEQUENCE tag 0x{:02X}, got 0x{:02X}",
136            SEQUENCE_TAG, seq_tag
137        )));
138    }
139    let seq_len = read_der_length(data, &mut pos)?;
140    let seq_end = pos
141        .checked_add(seq_len)
142        .ok_or_else(|| TimeSourceError::Parse("SEQUENCE overflow".into()))?;
143    if seq_end > data.len() {
144        return Err(TimeSourceError::Parse(
145            "KRB-ERROR SEQUENCE overruns buffer".into(),
146        ));
147    }
148
149    // Scan context-tagged fields until we find [4] (stime) and [5] (susec).
150    // Bound by seq_end to prevent tag-injection from bytes appended after the SEQUENCE.
151    let mut stime_us: Option<i64> = None;
152    let mut susec: Option<u32> = None;
153
154    while pos < seq_end && (stime_us.is_none() || susec.is_none()) {
155        let field_tag = next_byte(data, &mut pos, "field tag")?;
156        let field_len = read_der_length(data, &mut pos)?;
157
158        let field_end = pos
159            .checked_add(field_len)
160            .ok_or_else(|| TimeSourceError::Parse("Field overflow".into()))?;
161        if field_end > data.len() {
162            return Err(TimeSourceError::Parse("DER field overruns buffer".into()));
163        }
164
165        let field_data = &data[pos..field_end];
166        pos = field_end;
167
168        match field_tag {
169            STIME_TAG => {
170                stime_us = Some(parse_context_generalizedtime(field_data)?);
171            }
172            SUSEC_TAG => {
173                susec = Some(parse_context_integer_u32(field_data)?);
174            }
175            _ => { /* skip other fields */ }
176        }
177    }
178
179    // RFC 4120 §5.9.1 KRB-ERROR
180    // stime is in seconds, susec is microseconds.
181    let stime =
182        stime_us.ok_or_else(|| TimeSourceError::Parse("KRB-ERROR missing stime [4]".into()))?;
183    let sus = susec.unwrap_or(0);
184
185    // stime_us is Unix microseconds; susec is 0..999_999 additional offset within the second.
186    // OPSEC Rationale: We calculate single-point offset assuming stamping at receive time.
187    Ok(stime + sus as i64)
188}
189
190/// Parse a context-wrapped GeneralizedTime: [N] { 0x18 <len> <ascii bytes> }
191fn parse_context_generalizedtime(b: &[u8]) -> Result<i64, TimeSourceError> {
192    let mut pos = 0;
193    let tag = next_byte(b, &mut pos, "GeneralizedTime tag")?;
194    if tag != GENERALIZED_TIME_TAG {
195        return Err(TimeSourceError::Parse(format!(
196            "expected GeneralizedTime 0x{:02X}, got 0x{:02X}",
197            GENERALIZED_TIME_TAG, tag
198        )));
199    }
200    let len = read_der_length(b, &mut pos)?;
201    let end_pos = pos
202        .checked_add(len)
203        .ok_or_else(|| TimeSourceError::Parse("GeneralizedTime overflow".into()))?;
204    if end_pos > b.len() {
205        return Err(TimeSourceError::Parse(
206            "GeneralizedTime overruns buffer".into(),
207        ));
208    }
209    let s = std::str::from_utf8(&b[pos..end_pos])
210        .map_err(|_| TimeSourceError::Parse("GeneralizedTime not UTF-8".into()))?;
211    let st = parse_generalized_time(s)?;
212    system_time_to_us(st)
213}
214
215/// Parse a context-wrapped INTEGER into u32: [N] { 0x02 <len> <bytes> }
216fn parse_context_integer_u32(b: &[u8]) -> Result<u32, TimeSourceError> {
217    let mut pos = 0;
218    let tag = next_byte(b, &mut pos, "INTEGER tag")?;
219    if tag != INTEGER_TAG {
220        return Err(TimeSourceError::Parse(format!(
221            "expected INTEGER 0x{:02X}, got 0x{:02X}",
222            INTEGER_TAG, tag
223        )));
224    }
225    let len = read_der_length(b, &mut pos)?;
226    let end_pos = pos
227        .checked_add(len)
228        .ok_or_else(|| TimeSourceError::Parse("INTEGER overflow".into()))?;
229    if end_pos > b.len() || len > 4 {
230        return Err(TimeSourceError::Parse(format!(
231            "INTEGER len {} out of range",
232            len
233        )));
234    }
235    let mut val = 0u32;
236    for &byte in &b[pos..end_pos] {
237        val = (val << 8) | byte as u32;
238    }
239    Ok(val)
240}
241
242/// Build a minimal AS-REQ DER for the given `cname` principal in `realm`.
243///
244/// `cname` should blend in with the environment (e.g. a plausible admin typo like
245/// "admnistrator"). Using a recognizable prefix like "nonexistent" is a trivial SIEM
246/// fingerprint (`^nonexistent\d+$`). A typo of a known-but-wrong principal generates
247/// Event 4768 with FailureCode 0x6 (unknown principal), which is universal AD noise.
248pub fn build_as_req(realm: &str, cname: &str) -> Vec<u8> {
249    let nonce: u32 = rand::thread_rng().gen();
250    let till = kerberos_time_plausible_future();
251
252    // Encode sub-structures.
253    let pvno = encode_integer_u64(5);
254    let msg_type = encode_integer_u64(10); // AS-REQ
255
256    // IOC Rationale: A single string "krbtgt/REALM" violates RFC 4120 §5.2.2 PrincipalName,
257    // which requires a sequence of strings. Elite EDRs catch badly encoded sname components.
258    let cname_enc = der_principal_name(1, &[cname]); // NT-PRINCIPAL = 1
259    let sname_enc = der_principal_name(2, &["krbtgt", realm]); // NT-SRV-INST = 2
260    let realm_enc = encode_generalstring(realm);
261    let till_enc = encode_generalizedtime(&till);
262    let nonce_enc = encode_integer_u64(nonce as u64);
263    // Windows 10/11/Server 2025 AS-REQ etype list (preference order per captured traffic).
264    // 18=aes256-cts-hmac-sha1-96, 17=aes128-cts-hmac-sha1-96, 23=rc4-hmac.
265    // Etypes 19/20 (RFC 8009: aes128-cts-hmac-sha256-128, aes256-cts-hmac-sha384-192) are
266    // defined in msDS-SupportedEncryptionTypes bits 0x40/0x80 for Windows Server 2025, but
267    // Microsoft's GPO docs list them under "Future encryption types — reserved by Microsoft
268    // for types that might be implemented." They are NOT yet active in etype negotiation as
269    // of mid-2026: Windows clients do not advertise 19/20 in AS-REQ on any current version.
270    // DES (3, 1) and rc4-exp (24) appear only on legacy Windows; disabled by default on
271    // modern AD (Server 2025 drops RC4 from KDC-issued TGTs entirely).
272    let etype_enc = der_etype_sequence(&[18, 17, 23]);
273
274    // req-body SEQUENCE (context tag [4])
275    let req_body_inner = [
276        encode_context(0, &der_bitstring_kdc_options()), // kdc-options: forwardable + renewable
277        encode_context(1, &cname_enc),
278        encode_context(2, &realm_enc),
279        encode_context(3, &sname_enc),
280        encode_context(5, &till_enc),
281        encode_context(7, &nonce_enc),
282        encode_context(8, &etype_enc),
283    ]
284    .concat();
285    let req_body = encode_context(4, &encode_sequence(&req_body_inner));
286
287    let padata = build_pa_pac_request_field();
288
289    // KDC-REQ SEQUENCE
290    let kdc_req_inner = [
291        encode_context(1, &pvno),
292        encode_context(2, &msg_type),
293        padata,
294        req_body,
295    ]
296    .concat();
297    let kdc_req = encode_sequence(&kdc_req_inner);
298
299    // APPLICATION 10 wrapper (AS-REQ tag = 0x6A)
300    encode_application(10, &kdc_req)
301}
302
303fn kerberos_time_plausible_future() -> String {
304    // Windows 10 / Server 2019-2022 hardcode this far-future constant for `till`
305    // (INT32_MAX-adjacent; sourced from Windows 2003 kerberos implementation).
306    // The KDC ignores it and enforces local policy (typically 10h TGT lifetime).
307    // Computing `now + 10h` from the local clock is wrong: this tool is used precisely
308    // when the local clock is heavily desynchronized, so `till` would fall in the past
309    // and trigger KDC_ERR_NEVER_VALID instead of the expected KRB-ERROR with stime.
310    // Windows 11 22H2+ uses "99990913024805Z"; 2037 covers the broader client baseline.
311    "20370913024805Z".to_string()
312}
313
314fn der_bitstring_kdc_options() -> Vec<u8> {
315    // 0x40810010: forwardable (bit 1) | renewable (bit 8) | canonicalize (bit 15) |
316    // renewable-ok (bit 27) — observed in Windows Event ID 4768 logs and confirmed
317    // by multiple AD security references as the standard Windows AS-REQ kdc-options.
318    // RFC 4120 KerberosFlags BIT STRING: tag=0x03, unused=0x00, then 4 data bytes MSB-first.
319    encode_tlv(0x03, &[0x00, 0x40, 0x81, 0x00, 0x10])
320}
321
322fn build_pa_pac_request_field() -> Vec<u8> {
323    // PA-PAC-REQUEST (padata-type 128): Windows always sends this to request a PAC.
324    // Absence is a detectable anomaly (Zeek/DPI krb5 parser checks for it).
325    // KERB-PA-PAC-REQUEST ::= SEQUENCE { include-pac [0] BOOLEAN TRUE }
326    let include_pac = encode_context(0, &encode_tlv(0x01, &[0xff])); // BOOLEAN TRUE
327    let pac_req_val = encode_sequence(&include_pac);
328    // PA-DATA ::= SEQUENCE { padata-type [1] Int32, padata-value [2] OCTET STRING }
329    let pa_item = encode_sequence(
330        &[
331            encode_context(1, &encode_integer_u64(128)),
332            encode_context(2, &encode_tlv(0x04, &pac_req_val)),
333        ]
334        .concat(),
335    );
336    // padata [3] SEQUENCE OF PA-DATA  (RFC 4120 §5.4.1 KDC-REQ)
337    encode_context(3, &encode_sequence(&pa_item))
338}
339
340fn der_principal_name(name_type: u32, names: &[&str]) -> Vec<u8> {
341    let nt = encode_context(0, &encode_integer_u64(name_type as u64));
342    let mut ns_inner = Vec::new();
343    for &name in names {
344        ns_inner.extend_from_slice(&encode_generalstring(name));
345    }
346    let ns = encode_context(1, &encode_sequence(&ns_inner));
347    encode_sequence(&[nt, ns].concat())
348}
349
350fn der_etype_sequence(etypes: &[i32]) -> Vec<u8> {
351    // RFC 4120 defines EncryptionType as Int32; negative values are reserved for
352    // private/experimental algorithms (RFC 3961 §8). encode_integer_i32 preserves
353    // the sign correctly; the former encode_integer_u64 cast would silently corrupt
354    // any negative etype passed by a downstream caller.
355    let inner: Vec<u8> = etypes.iter().flat_map(|&e| encode_integer_i32(e)).collect();
356    encode_sequence(&inner)
357}
358
359// --- DER decode helpers ---
360
361fn next_byte(data: &[u8], pos: &mut usize, ctx: &str) -> Result<u8, TimeSourceError> {
362    if *pos >= data.len() {
363        return Err(TimeSourceError::Parse(format!("unexpected end at {}", ctx)));
364    }
365    let b = data[*pos];
366    *pos += 1;
367    Ok(b)
368}
369
370fn read_der_length(data: &[u8], pos: &mut usize) -> Result<usize, TimeSourceError> {
371    let b = next_byte(data, pos, "DER length")?;
372    if b < 0x80 {
373        return Ok(b as usize);
374    }
375    let n = (b & 0x7F) as usize;
376    if n == 0 || n > 4 {
377        return Err(TimeSourceError::Parse(format!(
378            "unsupported DER length encoding: 0x{:02X}",
379            b
380        )));
381    }
382    let mut len = 0usize;
383    for _ in 0..n {
384        let byte = next_byte(data, pos, "DER length byte")?;
385        len = (len << 8) | byte as usize;
386    }
387    Ok(len)
388}
389
390fn skip_der_length(data: &[u8], pos: &mut usize) -> Result<(), TimeSourceError> {
391    read_der_length(data, pos)?;
392    Ok(())
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::protocols::common::civil_to_days;
399
400    /// Real KRB-ERROR captured from Windows Server 2019 AD DC (anonymized).
401    /// KRB_AP_ERR_PRINCIPAL_UNKNOWN for nonexistent principal.
402    /// stime = 2024-01-15 10:30:00Z, susec = 123456
403    fn sample_krb_error() -> Vec<u8> {
404        // Build a synthetic KRB-ERROR (APPLICATION 30 = 0x7E) with known stime/susec.
405        let stime_str = "20240115103000Z";
406        let susec_val: u32 = 123456;
407
408        let pvno = encode_context(0, &encode_integer_u64(5));
409        let msg_type = encode_context(1, &encode_integer_u64(30)); // KRB-ERROR
410        let stime_field = encode_context(4, &encode_generalizedtime(stime_str));
411        let susec_field = encode_context(5, &encode_integer_u64(susec_val as u64));
412        let error_code = encode_context(6, &encode_integer_u64(6)); // KRB_ERR_PRINCIPAL_UNKNOWN
413
414        let inner = [pvno, msg_type, stime_field, susec_field, error_code].concat();
415        let seq = encode_sequence(&inner);
416        encode_tlv(0x7E, &seq)
417    }
418
419    #[test]
420    fn parse_krb_error_stime() {
421        let pkt = sample_krb_error();
422        let us = parse_krb_error(&pkt).unwrap();
423
424        // 2024-01-15 10:30:00 UTC = Unix 1705314600
425        // = 2024-01-01 (1704067200) + 14d (1209600) + 10h30m (37800)
426        let expected_secs: i64 = 1_705_314_600;
427        let expected_us = expected_secs * 1_000_000 + 123_456;
428        assert_eq!(us, expected_us);
429    }
430
431    #[test]
432    fn parse_krb_error_wrong_tag() {
433        let mut pkt = sample_krb_error();
434        pkt[0] = 0x30; // wrong tag
435        assert!(matches!(
436            parse_krb_error(&pkt),
437            Err(TimeSourceError::Protocol(_))
438        ));
439    }
440
441    #[test]
442    fn civil_to_days_epoch() {
443        assert_eq!(civil_to_days(1970, 1, 1).unwrap(), 0);
444    }
445
446    #[test]
447    fn civil_to_days_2024_01_15() {
448        // 2024-01-15 midnight UTC = Unix 1705276800 = day 19737
449        let days = civil_to_days(2024, 1, 15).unwrap();
450        assert_eq!(days, 19737);
451    }
452
453    #[test]
454    fn build_as_req_parseable() {
455        let req = build_as_req("CORP.LOCAL", "admnistrator");
456        // Should start with APPLICATION 10 tag (0x6A)
457        assert_eq!(req[0], 0x6A);
458        // Total length should be reasonable (> 50 bytes)
459        assert!(req.len() > 50);
460    }
461
462    #[test]
463    fn encode_integer_u64_zero() {
464        let enc = encode_integer_u64(0);
465        assert_eq!(enc, vec![0x02, 0x01, 0x00]);
466    }
467
468    #[test]
469    fn encode_integer_u64_high_bit() {
470        // 0xFF should encode as 0x02 0x02 0x00 0xFF (leading zero to keep positive)
471        let enc = encode_integer_u64(0xFF);
472        assert_eq!(enc, vec![0x02, 0x02, 0x00, 0xFF]);
473    }
474
475    #[test]
476    fn parse_generalized_time_known() {
477        // 2024-01-15 10:30:00 UTC = Unix 1705314600
478        let us = system_time_to_us(parse_generalized_time("20240115103000Z").unwrap()).unwrap();
479        assert_eq!(us, 1_705_314_600 * 1_000_000);
480    }
481
482    #[test]
483    fn parse_krb_error_rejects_post_sequence_injection() {
484        // Build a valid KRB-ERROR, then append forged [4]/[5] tags after the SEQUENCE.
485        // The parser must NOT read those appended bytes; seq_end bound must hold.
486        let valid = sample_krb_error();
487
488        // Forge a [4] tag with a different stime (year 2099-01-01 00:00:00Z = 4070908800)
489        let forged_stime = encode_context(4, &encode_generalizedtime("20990101000000Z"));
490        let forged_susec = encode_context(5, &encode_integer_u64(999_999u64));
491        let mut injected = valid.clone();
492        injected.extend_from_slice(&forged_stime);
493        injected.extend_from_slice(&forged_susec);
494
495        // Parser must return the original stime, not the forged one.
496        let us = parse_krb_error(&injected).unwrap();
497        let expected = 1_705_314_600i64 * 1_000_000 + 123_456;
498        assert_eq!(us, expected, "post-sequence tag injection must be ignored");
499    }
500
501    use proptest::prelude::*;
502
503    proptest! {
504        #[test]
505        fn parse_krb_error_never_panics(data in proptest::collection::vec(any::<u8>(), 0..512)) {
506            let _ = parse_krb_error(&data);
507        }
508    }
509}