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