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