Skip to main content

ad_time/protocols/
cldap.rs

1//! CLDAP (UDP/389) rootDSE time extraction.
2//!
3//! Protocol Specifications:
4//! - **RFC 4511 §4.5**: Search Operation
5//! - **RFC 4512 §5.1**: rootDSE
6//! - **MS-ADTS §3.1.1.3.2.1**: root DSE (currentTime attribute)
7//!
8//! Windows domain controllers reply to connectionless LDAP (CLDAP) queries on UDP 389.
9//! This is typically used for "DC Locator Pings" by Windows workstations. We mimic
10//! this legitimate background noise to stealthily request the `currentTime` attribute
11//! from the `rootDSE`.
12//!
13//! OPSEC:
14//! - UDP/389 is practically invisible to typical EDRs and is rarely DPI'd or rate-limited.
15//! - The query pattern (rootDSE `objectClass=*` base search) matches the baseline of
16//!   `ldapsearch`, PowerShell AD cmdlets, and monitoring agents — not DC Locator Pings,
17//!   which use a different filter and attribute set.
18//! - The attribute list is diluted with common admin attrs so `currentTime` does not appear
19//!   as a surgical probe.
20//! - `messageID` and `timeLimit` are randomized to break static NDR signatures.
21
22use std::net::{SocketAddr, UdpSocket};
23use std::time::{Duration, Instant, SystemTime};
24
25use rand::seq::SliceRandom;
26use rand::Rng;
27
28use super::ber::{encode_integer_i32, encode_tlv};
29use super::common::{map_io_err, parse_generalized_time, system_time_to_us};
30use super::socket_opts::set_windows_ttl_udp;
31use crate::time_src::{OffsetMicros, TimeSource, TimeSourceError};
32
33pub struct CldapSource;
34
35impl TimeSource for CldapSource {
36    fn name(&self) -> &'static str {
37        "cldap"
38    }
39
40    fn fetch(
41        &self,
42        target: SocketAddr,
43        timeout: Duration,
44    ) -> Result<OffsetMicros, TimeSourceError> {
45        let addr: SocketAddr = (target.ip(), 389).into();
46        fetch_cldap(addr, timeout)
47    }
48}
49
50fn fetch_cldap(addr: SocketAddr, timeout: Duration) -> Result<OffsetMicros, TimeSourceError> {
51    let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| map_io_err(e, "bind"))?;
52    set_windows_ttl_udp(&socket).map_err(|e| TimeSourceError::Protocol(e.to_string()))?;
53    socket
54        .set_read_timeout(Some(timeout))
55        .map_err(|e| map_io_err(e, "set_read_timeout"))?;
56    socket
57        .set_write_timeout(Some(timeout))
58        .map_err(|e| map_io_err(e, "set_write_timeout"))?;
59
60    // OPSEC: Randomize message ID (1..1000)
61    let msg_id = rand::rng().random_range(1..=1000);
62
63    let req = build_cldap_search_request(msg_id);
64
65    let t_send = Instant::now();
66    let t_send_sys = SystemTime::now();
67
68    socket
69        .send_to(&req, addr)
70        .map_err(|e| map_io_err(e, "send_to"))?;
71
72    // Enforce an overall deadline across the receive loop. Without it, an on-path
73    // attacker or a UDP flood with spoofed source IPs could keep the loop spinning
74    // indefinitely (each non-matching packet returns within the per-call timeout).
75    let deadline = Instant::now() + timeout;
76    let mut buf = [0u8; 4096];
77    let len = loop {
78        let remaining = deadline
79            .checked_duration_since(Instant::now())
80            .filter(|d| !d.is_zero())
81            .ok_or(TimeSourceError::Timeout)?;
82        socket
83            .set_read_timeout(Some(remaining))
84            .map_err(|e| map_io_err(e, "set_read_timeout"))?;
85
86        let (len, src) = socket
87            .recv_from(&mut buf)
88            .map_err(|e| map_io_err(e, "recv_from"))?;
89        if src.ip() == addr.ip() {
90            break len;
91        }
92    };
93
94    let rtt = t_send.elapsed();
95    let resp = &buf[..len];
96
97    let server_time = parse_cldap_search_response(resp, msg_id)?;
98
99    let t_mid_us = system_time_to_us(t_send_sys)? + (rtt.as_micros() as i64) / 2;
100    let server_us = system_time_to_us(server_time)?;
101
102    Ok(server_us - t_mid_us)
103}
104
105fn build_cldap_search_request(msg_id: i32) -> Vec<u8> {
106    // timeLimit = 0: no client-imposed limit. Standard per RFC 4511 §4.5.1 and
107    // the observed behavior of ldapsearch, PowerShell AD cmdlets, and monitoring tools.
108    // A randomized 10-30 range has no documented baseline and is self-generated noise.
109    let time_limit_enc = encode_integer_i32(0);
110
111    let base_object = encode_tlv(0x04, b""); // LDAPDN ""
112    let scope = encode_tlv(0x0a, &[0]); // ENUMERATED 0 (baseObject)
113    let deref = encode_tlv(0x0a, &[0]); // ENUMERATED 0 (neverDerefAliases)
114    let size_limit = encode_integer_i32(1); // INTEGER 1
115    let types_only = encode_tlv(0x01, &[0x00]); // BOOLEAN FALSE
116
117    // Filter: (objectClass=*)
118    // RFC 4511 4.5.1: present is context-specific, primitive, tag 7
119    let filter = encode_tlv(0x87, b"objectClass");
120
121    // Attributes to request
122    let mut attrs = vec![
123        "schemaNamingContext",
124        "namingContexts",
125        "currentTime",
126        "dnsHostName",
127        "supportedLDAPVersion",
128    ];
129    attrs.shuffle(&mut rand::rng());
130    let mut attrs_seq = Vec::new();
131    for a in attrs {
132        attrs_seq.extend_from_slice(&encode_tlv(0x04, a.as_bytes()));
133    }
134    let attributes = encode_tlv(0x30, &attrs_seq); // SEQUENCE OF LDAPString
135
136    let mut search_req_seq = Vec::new();
137    search_req_seq.extend_from_slice(&base_object);
138    search_req_seq.extend_from_slice(&scope);
139    search_req_seq.extend_from_slice(&deref);
140    search_req_seq.extend_from_slice(&size_limit);
141    search_req_seq.extend_from_slice(&time_limit_enc);
142    search_req_seq.extend_from_slice(&types_only);
143    search_req_seq.extend_from_slice(&filter);
144    search_req_seq.extend_from_slice(&attributes);
145
146    let protocol_op = encode_tlv(0x63, &search_req_seq); // [APPLICATION 3] (searchRequest)
147
148    let mut ldap_msg_seq = Vec::new();
149    ldap_msg_seq.extend_from_slice(&encode_integer_i32(msg_id));
150    ldap_msg_seq.extend_from_slice(&protocol_op);
151
152    encode_tlv(0x30, &ldap_msg_seq) // SEQUENCE (LDAPMessage)
153}
154
155/// Simple BER decoder struct for scanning LDAP responses.
156struct BerReader<'a> {
157    buf: &'a [u8],
158    pos: usize,
159}
160
161impl<'a> BerReader<'a> {
162    fn new(buf: &'a [u8]) -> Self {
163        Self { buf, pos: 0 }
164    }
165
166    fn read_tlv(&mut self) -> Result<(u8, &'a [u8]), TimeSourceError> {
167        if self.pos >= self.buf.len() {
168            return Err(TimeSourceError::Parse("Unexpected EOF in BER".into()));
169        }
170        let tag = self.buf[self.pos];
171        self.pos += 1;
172
173        if self.pos >= self.buf.len() {
174            return Err(TimeSourceError::Parse(
175                "Unexpected EOF reading BER length".into(),
176            ));
177        }
178        let mut len = self.buf[self.pos] as usize;
179        self.pos += 1;
180
181        if len & 0x80 != 0 {
182            let len_bytes = len & 0x7F;
183            let end_bytes = self
184                .pos
185                .checked_add(len_bytes)
186                .ok_or_else(|| TimeSourceError::Parse("BER length overflow".into()))?;
187            if len_bytes == 0 || end_bytes > self.buf.len() {
188                return Err(TimeSourceError::Parse(
189                    "Invalid BER long form length".into(),
190                ));
191            }
192            let mut actual_len = 0;
193            for i in 0..len_bytes {
194                actual_len = (actual_len << 8) | (self.buf[self.pos + i] as usize);
195            }
196            self.pos += len_bytes;
197            len = actual_len;
198        }
199
200        let end_pos = self
201            .pos
202            .checked_add(len)
203            .ok_or_else(|| TimeSourceError::Parse("BER value length overflow".into()))?;
204        if end_pos > self.buf.len() {
205            return Err(TimeSourceError::Parse(
206                "BER value length exceeds buffer".into(),
207            ));
208        }
209
210        let val = &self.buf[self.pos..end_pos];
211        self.pos = end_pos;
212
213        Ok((tag, val))
214    }
215
216    fn has_more(&self) -> bool {
217        self.pos < self.buf.len()
218    }
219}
220
221fn parse_cldap_search_response(
222    resp: &[u8],
223    expected_msg_id: i32,
224) -> Result<SystemTime, TimeSourceError> {
225    let mut msg_reader = BerReader::new(resp);
226    let (tag, msg_val) = msg_reader.read_tlv()?;
227    if tag != 0x30 {
228        return Err(TimeSourceError::Parse(
229            "Expected LDAPMessage SEQUENCE".into(),
230        ));
231    }
232
233    let mut inner = BerReader::new(msg_val);
234
235    // 1. messageID
236    let (id_tag, id_val) = inner.read_tlv()?;
237    if id_tag != 0x02 {
238        return Err(TimeSourceError::Parse("Expected messageID INTEGER".into()));
239    }
240    if id_val.len() > 4 {
241        return Err(TimeSourceError::Parse("messageID too long".into()));
242    }
243    let mut msg_id = 0;
244    for &b in id_val {
245        msg_id = (msg_id << 8) | (b as i32);
246    }
247    if msg_id != expected_msg_id {
248        return Err(TimeSourceError::Protocol("Message ID mismatch".into()));
249    }
250
251    // 2. protocolOp (SearchResultEntry [APPLICATION 4])
252    let (op_tag, op_val) = inner.read_tlv()?;
253    if op_tag != 0x64 {
254        // SearchResEntry
255        return Err(TimeSourceError::Protocol(format!(
256            "Expected SearchResEntry (0x64), got 0x{:02X}",
257            op_tag
258        )));
259    }
260
261    let mut entry = BerReader::new(op_val);
262    let (_dn_tag, _dn_val) = entry.read_tlv()?; // objectName LDAPDN
263
264    let (attr_tag, attr_val) = entry.read_tlv()?; // attributes PartialAttributeList (SEQUENCE)
265    if attr_tag != 0x30 {
266        return Err(TimeSourceError::Parse(
267            "Expected attributes SEQUENCE".into(),
268        ));
269    }
270
271    let mut attrs = BerReader::new(attr_val);
272    while attrs.has_more() {
273        let (seq_tag, seq_val) = attrs.read_tlv()?;
274        if seq_tag != 0x30 {
275            continue;
276        }
277
278        let mut attr = BerReader::new(seq_val);
279        let (type_tag, type_val) = attr.read_tlv()?;
280        if type_tag != 0x04 {
281            continue;
282        } // OCTET STRING
283
284        if type_val == b"currentTime" {
285            let (set_tag, set_val) = attr.read_tlv()?;
286            if set_tag != 0x31 {
287                // SET OF
288                return Err(TimeSourceError::Parse(
289                    "Expected SET OF for attribute values".into(),
290                ));
291            }
292
293            let mut vals = BerReader::new(set_val);
294            let (v_tag, v_val) = vals.read_tlv()?;
295            if v_tag != 0x04 {
296                return Err(TimeSourceError::Parse(
297                    "Expected OCTET STRING for currentTime".into(),
298                ));
299            }
300
301            let time_str = std::str::from_utf8(v_val)
302                .map_err(|_| TimeSourceError::Parse("currentTime is not valid UTF-8".into()))?;
303
304            return parse_generalized_time(time_str);
305        }
306    }
307
308    Err(TimeSourceError::Parse(
309        "currentTime attribute not found in CLDAP response".into(),
310    ))
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use std::time::UNIX_EPOCH;
317
318    #[test]
319    fn parse_generalized_time_works() {
320        // Active Directory often returns ".0Z" fractional seconds
321        let t1 = parse_generalized_time("20240115000000.0Z").unwrap();
322        let t2 = parse_generalized_time("20240115000000Z").unwrap();
323        assert_eq!(t1, t2);
324
325        let d = t1.duration_since(UNIX_EPOCH).unwrap().as_secs();
326        // 2024-01-15 00:00:00 UTC = 1705276800
327        assert_eq!(d, 1_705_276_800);
328    }
329
330    #[test]
331    fn build_cldap_search_request_structure() {
332        let req = build_cldap_search_request(123);
333        // Should be a SEQUENCE
334        assert_eq!(req[0], 0x30);
335    }
336
337    use proptest::prelude::*;
338
339    proptest! {
340        #[test]
341        fn parse_cldap_search_response_never_panics(
342            data in proptest::collection::vec(any::<u8>(), 0..512),
343        ) {
344            let _ = parse_cldap_search_response(&data, 1);
345        }
346    }
347}
348
349#[cfg(feature = "fuzzing")]
350pub fn fuzz_parse_cldap_response(
351    resp: &[u8],
352    msg_id: i32,
353) -> Result<std::time::SystemTime, crate::time_src::TimeSourceError> {
354    parse_cldap_search_response(resp, msg_id)
355}