Skip to main content

rusthound_ce/transport/
ldap.rs

1//! LDAP authentication and collection.
2//!
3//! [`ldap_auth`] connects and authenticates, returning a ready `Ldap` session.
4//! The full workflow is `api::run_collection`; collection itself is
5//! `collect_from_ldap_into`.
6
7use crate::args::Options;
8use crate::banner::progress_bar;
9use crate::storage::Storage;
10use crate::utils::format::domain_to_dc;
11
12use colored::Colorize;
13use indicatif::ProgressBar;
14use ldap3::adapters::{Adapter, EntriesOnly};
15use ldap3::exop::WhoAmI;
16use ldap3::{adapters::PagedResults, controls::RawControl, LdapConnAsync, LdapConnSettings};
17use ldap3::{Scope, SearchEntry};
18use log::{info, debug, error, trace};
19use std::io::{self, Write, stdin};
20use std::collections::HashMap;
21use std::error::Error;
22
23/// Connect to the DC and authenticate, returning a ready `ldap3::Ldap` session.
24/// The method is picked from `options`: certificate (`pfx` or `crt`/`key`),
25/// NTLM pass-the-hash (`hashes`), Kerberos (`kerberos`), else simple bind.
26/// Certificate auth defaults to StartTLS on 389, or LDAPS 636 with `--ldaps`.
27/// The caller owns the session (never unbound here).
28pub async fn ldap_auth(options: &Options) -> Result<ldap3::Ldap, Box<dyn Error>> {
29    let use_cert = options.pfx.is_some() || options.crt.is_some();
30
31    // Certificate transport: StartTLS by default, LDAPS with --ldaps.
32    let starttls = use_cert && !options.ldaps;
33    let effective_ldaps = if use_cert { !starttls } else { options.ldaps };
34    let effective_port = if use_cert {
35        options.port.or(Some(if starttls { 389 } else { 636 }))
36    } else {
37        options.port
38    };
39
40    let ldap_args = ldap_constructor(
41        effective_ldaps,
42        options.ip.as_deref(),
43        effective_port,
44        &options.domain,
45        options.ldapfqdn.as_deref(),
46        options.username.as_deref(),
47        options.password.as_deref(),
48        options.hashes.as_deref(),
49        options.kerberos,
50        use_cert,
51    )?;
52
53    let mut consettings = LdapConnSettings::new()
54        .set_conn_timeout(std::time::Duration::from_secs(10))
55        .set_no_tls_verify(true);
56    if use_cert {
57        let config = crate::transport::cert::build_client_config(
58            options.pfx.as_deref(),
59            options.pfx_pass.as_deref(),
60            options.crt.as_deref(),
61            options.key.as_deref(),
62        )?;
63        consettings = consettings.set_config(config);
64        if starttls {
65            consettings = consettings.set_starttls(true);
66        }
67    }
68
69    let (conn, mut ldap) = LdapConnAsync::with_settings(consettings, &ldap_args.s_url).await?;
70    ldap3::drive!(conn);
71
72    let domain = &options.domain;
73
74    if use_cert {
75        // Pass-the-Certificate: SASL EXTERNAL over StartTLS, or implicit
76        // Schannel mapping over LDAPS. Confirm the mapped identity with whoami.
77        if starttls {
78            debug!("Certificate authentication (StartTLS + SASL EXTERNAL)");
79            ldap.sasl_external_bind()
80                .await
81                .and_then(|r| r.success())
82                .map_err(|e| format!("certificate SASL EXTERNAL bind failed (try --ldaps): {e}"))?;
83        } else {
84            debug!("Certificate authentication (LDAPS, implicit Schannel mapping)");
85        }
86        let who = ldap
87            .extended(WhoAmI)
88            .await
89            .map(|r| r.success())
90            .map_err(|e| format!("certificate whoami request failed: {e}"))?
91            .map_err(|e| format!("certificate whoami failed: {e}"))?
92            .0
93            .val
94            .as_ref()
95            .map(|v| String::from_utf8_lossy(v).to_string())
96            .unwrap_or_default();
97        if who.is_empty() {
98            return Err(format!(
99                "certificate not mapped by {} (empty whoami); check the cert SID and DC enforcement (KB5014754)",
100                domain.to_uppercase()
101            )
102            .into());
103        }
104        info!(
105            "Connected to {} Active Directory via certificate as {}!",
106            domain.to_uppercase().bold().green(),
107            who.bold().green()
108        );
109    } else if let Some(ref ntlm_password) = ldap_args.s_ntlm_password {
110        debug!("NTLM pass-the-hash (sasl_ntlm_bind)");
111        ldap.sasl_ntlm_bind(&ldap_args.s_username, ntlm_password)
112            .await?
113            .success()
114            .map_err(|e| format!("NTLM authentication to {} failed: {e}", domain.to_uppercase()))?;
115        info!("Connected to {} Active Directory via NTLM!", domain.to_uppercase().bold().green());
116    } else if !options.kerberos {
117        debug!("Simple bind (username:password)");
118        ldap.simple_bind(&ldap_args.s_username, &ldap_args.s_password)
119            .await?
120            .success()
121            .map_err(|e| format!("authentication to {} failed: {e}", domain.to_uppercase()))?;
122        info!("Connected to {} Active Directory!", domain.to_uppercase().bold().green());
123    } else {
124        debug!("Kerberos (sasl_gssapi_bind)");
125        let fqdn = options
126            .ldapfqdn
127            .as_deref()
128            .filter(|f| !f.is_empty())
129            .ok_or("Kerberos requires the Domain Controller FQDN (set options.ldapfqdn, e.g. DC01.DOMAIN.LOCAL)")?;
130        #[cfg(not(feature = "nogssapi"))]
131        {
132            gssapi_connection(&mut ldap, fqdn, domain).await?;
133        }
134        #[cfg(feature = "nogssapi")]
135        {
136            let _ = fqdn;
137            return Err("Kerberos/GSSAPI is not available in this build (nogssapi feature)".into());
138        }
139    }
140
141    Ok(ldap)
142}
143
144/// Collect every namingContext into `storage` and return the object count.
145/// Each context is walked with the SD-flags and show-deleted controls and
146/// streamed into `storage`. The caller owns the session.
147pub(crate) async fn collect_from_ldap_into<S: Storage<LdapSearchEntry>>(
148    ldap: &mut ldap3::Ldap,
149    ldapfilter: &str,
150    storage: &mut S,
151) -> Result<usize, Box<dyn Error>> {
152    let mut total = 0usize;
153
154    let res = get_all_naming_contexts(ldap).await?;
155    trace!("naming_contexts: {:?}", &res);
156
157    if !res.iter().any(|s| s.contains("Configuration")) {
158        return Err("no Configuration namingContext found (is the target a Domain Controller?)".into());
159    }
160
161    for cn in &res {
162        // Control 1: LDAP_SERVER_SD_FLAGS_OID to get nTSecurityDescriptor.
163        let sd_flags = RawControl {
164            ctype: String::from("1.2.840.113556.1.4.801"),
165            crit: true,
166            val: Some(vec![48, 3, 2, 1, 5]), // SEQUENCE { INTEGER 5 }
167        };
168        // Control 2: LDAP_SERVER_SHOW_DELETED_OID.
169        let show_deleted = RawControl {
170            ctype: String::from("1.2.840.113556.1.4.417"),
171            crit: false,
172            val: None,
173        };
174        ldap.with_controls(vec![sd_flags, show_deleted]);
175
176        info!("Ldap filter : {}", ldapfilter.bold().green());
177
178        let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
179            Box::new(EntriesOnly::new()),
180            Box::new(PagedResults::new(999)),
181        ];
182
183        let mut search = ldap
184            .streaming_search_with(
185                adapters,
186                cn,
187                Scope::Subtree,
188                ldapfilter,
189                vec!["*", "nTSecurityDescriptor", "msDS-User-Account-Control-Computed"],
190            )
191            .await?;
192
193        // Ranged-retrieval side channel (#76): a cloned `Ldap` handle multiplexes
194        // the follow-up queries over the same connection while the main stream is
195        // consumed. The controls above were already taken by the search
196        let mut ranged_ldap = ldap.clone();
197
198        let pb = ProgressBar::new(1);
199        let mut count = 0;
200        while let Some(entry) = search.next().await? {
201            let mut entry = SearchEntry::construct(entry);
202            complete_ranged_attributes(&mut ranged_ldap, &mut entry).await?;
203            total += 1;
204            count += 1;
205            progress_bar(
206                pb.to_owned(),
207                "LDAP objects retrieved".to_string(),
208                count,
209                "#".to_string(),
210            );
211            storage.add(entry.into())?;
212        }
213        pb.finish_and_clear();
214
215        match search.finish().await.success() {
216            Ok(_) => info!("All data collected for NamingContext {}", &cn.bold()),
217            Err(err) => error!("No data collected on {}! Reason: {err}", &cn.bold().red()),
218        }
219    }
220
221    storage.flush()?;
222    Ok(total)
223}
224
225/// Complete attributes truncated by AD ranged retrieval, then re-key the values
226/// under the plain attribute name so parsers (e.g. the `"member"` arm in
227/// `objects::group`) see them unchanged.
228///
229/// Past `MaxValRange` (default 1500) AD renames an attribute to
230/// `attr;range=0-1499` and drops the plain key, why groups with >1499 members
231/// looked empty (#76). Each chunk is fetched with base-scoped
232/// `attr;range=<next>-*` queries until the final `-*` chunk. Generic (covers
233/// `member`, `memberOf`, …); a no-op for normal entries.
234async fn complete_ranged_attributes(
235    ldap: &mut ldap3::Ldap,
236    entry: &mut SearchEntry,
237) -> Result<(), Box<dyn Error>> {
238    // Attributes returned as `name;range=low-high` (AD may capitalise `Range`).
239    let ranged: Vec<(String, String)> = entry
240        .attrs
241        .keys()
242        .filter_map(|k| {
243            let (base, opt) = k.split_once(';')?;
244            if opt.to_ascii_lowercase().starts_with("range=") {
245                Some((k.to_string(), base.to_string()))
246            } else {
247                None
248            }
249        })
250        .collect();
251
252    for (ranged_key, base_attr) in ranged {
253        // Take the first chunk's values, re-keyed under `base_attr` later.
254        let mut values = entry.attrs.remove(&ranged_key).unwrap_or_default();
255
256        let mut next_start = match range_high(&ranged_key) {
257            Some(high) => high + 1,
258            None => {
259                // Already `-*`: single chunk, just rename.
260                entry.attrs.entry(base_attr).or_default().append(&mut values);
261                continue;
262            }
263        };
264
265        debug!("Ranged attribute '{}' on {} exceeds MaxValRange; retrieving remaining values", base_attr.bold().yellow(), entry.dn.bold());
266
267        // Pull the remaining chunks: `base_attr;range=<next_start>-*`.
268        loop {
269            let want = format!("{base_attr};range={next_start}-*");
270            let (rs, _res) = ldap
271                .search(&entry.dn, Scope::Base, "(objectClass=*)", vec![want.as_str()])
272                .await?
273                .success()?;
274
275            let Some(re) = rs.into_iter().next() else { break };
276            let chunk = SearchEntry::construct(re);
277
278            // Find the returned ranged key.
279            let found = chunk.attrs.iter().find_map(|(k, _)| {
280                let (b, opt) = k.split_once(';')?;
281                if b.eq_ignore_ascii_case(&base_attr)
282                    && opt.to_ascii_lowercase().starts_with("range=")
283                {
284                    Some((k.clone(), opt.ends_with("-*")))
285                } else {
286                    None
287                }
288            });
289
290            let Some((key, is_last)) = found else { break };
291
292            if let Some(v) = chunk.attrs.get(&key) {
293                values.extend(v.iter().cloned());
294            }
295
296            if is_last {
297                break; // final chunk
298            }
299            match range_high(&key) {
300                Some(high) => next_start = high + 1,
301                None => break,
302            }
303        }
304
305        trace!("'{}' on {}: {} values after ranged retrieval", base_attr, entry.dn, values.len());
306        entry.attrs.entry(base_attr).or_default().append(&mut values);
307    }
308
309    Ok(())
310}
311
312/// High bound of a `...;range=low-high` descriptor. `None` for `-*` (final
313/// chunk) or an unparsable range — both meaning "complete" to the caller.
314fn range_high(key: &str) -> Option<usize> {
315    let opt = key.split(';').nth(1)?;      // range=low-high
316    let spec = opt.split_once('=')?.1;     // low-high
317    let high = spec.split_once('-')?.1;    // high
318    high.parse::<usize>().ok()
319}
320
321/// Structure containing the LDAP connection arguments.
322struct LdapArgs {
323    s_url: String,
324    _s_dc: Vec<String>,
325    _s_email: String,
326    s_username: String,
327    s_password: String,
328    s_ntlm_password: Option<String>,
329}
330
331/// Function to prepare LDAP arguments.
332#[allow(clippy::too_many_arguments)]
333fn ldap_constructor(
334    ldaps: bool,
335    ip: Option<&str>,
336    port: Option<u16>,
337    domain: &str,
338    ldapfqdn: Option<&str>,
339    username: Option<&str>,
340    password: Option<&str>,
341    hashes: Option<&str>,
342    kerberos: bool,
343    use_cert: bool,
344) -> Result<LdapArgs, Box<dyn Error>> {
345    let s_url = prepare_ldap_url(ldaps, ip, port, domain);
346    let s_dc = prepare_ldap_dc(domain);
347    let use_ntlm = hashes.is_some();
348
349    // Username prompt (skipped for Kerberos and certificate auth)
350    let mut s = String::new();
351    let mut _s_username: String;
352    if username.is_none() && !kerberos && !use_cert {
353        print!("Username: ");
354        io::stdout().flush()?;
355        stdin().read_line(&mut s).expect("Did not enter a correct username");
356        io::stdout().flush()?;
357        if let Some('\n') = s.chars().next_back() { s.pop(); }
358        if let Some('\r') = s.chars().next_back() { s.pop(); }
359        _s_username = s.to_owned();
360    } else {
361        _s_username = username.unwrap_or("not set").to_owned();
362    }
363
364    // Format username and email
365    let mut s_email: String = "".to_owned();
366    if !_s_username.contains("@") {
367        s_email.push_str(&_s_username.to_string());
368        s_email.push_str("@");
369        s_email.push_str(domain);
370        if !use_ntlm {
371            _s_username = s_email.to_string();
372        }
373    } else {
374        s_email = _s_username.to_string().to_lowercase();
375    }
376
377    // For NTLM, format username as DOMAIN\user for sspi
378    if use_ntlm && !_s_username.contains("\\") && !_s_username.contains("@") {
379        let domain_upper = domain.split('.').next().unwrap_or(domain).to_uppercase();
380        _s_username = format!("{}\\{}", domain_upper, _s_username);
381    }
382
383    // Validate and build NTLM password from NT hash if provided
384    let s_ntlm_password = match hashes {
385        Some(hash) => {
386            let clean = hash.trim();
387            let nt = match clean.split_once(':') {
388                Some((_lm, nt)) => nt,
389                None => clean,
390            };
391            if nt.len() != 32 || !nt.chars().all(|c| c.is_ascii_hexdigit()) {
392                return Err("Invalid NT hash: must be exactly 32 hex characters (e.g. aad3b435b51404eeaad3b435b51404ee)".into());
393            }
394            Some(nt_hash_to_ntlm_password(nt))
395        }
396        None => None,
397    };
398
399    // Password prompt (skip for NTLM hash, Kerberos, and certificate auth)
400    let mut _s_password: String = String::new();
401    if !use_ntlm && !_s_username.contains("not set") && !kerberos && !use_cert {
402        _s_password = match password {
403            Some(p) => p.to_owned(),
404            None => rpassword::prompt_password("Password: ").unwrap_or("not set".to_string()),
405        };
406    } else {
407        _s_password = password.unwrap_or("not set").to_owned();
408    }
409
410    debug!("IP: {}", ip.unwrap_or("not set"));
411    debug!("PORT: {}", match port { Some(p) => p.to_string(), None => "not set".to_owned() });
412    debug!("FQDN: {}", ldapfqdn.unwrap_or("not set"));
413    debug!("Url: {}", s_url);
414    debug!("Domain: {}", domain);
415    debug!("Username: {}", _s_username);
416    debug!("Email: {}", s_email.to_lowercase());
417    if use_cert {
418        debug!("Auth: certificate (Pass-the-Certificate)");
419    } else if use_ntlm {
420        debug!("Auth: NTLM pass-the-hash");
421    } else {
422        debug!("Password: {}", _s_password);
423    }
424    debug!("DC: {:?}", s_dc);
425    debug!("Kerberos: {:?}", kerberos);
426
427    Ok(LdapArgs {
428        s_url: s_url.to_string(),
429        _s_dc: s_dc,
430        _s_email: s_email.to_string().to_lowercase(),
431        s_username: if use_ntlm { _s_username.to_string() } else { s_email.to_string().to_lowercase() },
432        s_password: _s_password.to_string(),
433        s_ntlm_password,
434    })
435}
436
437/// Encode an NT hash into a password string that triggers pass-the-hash
438/// in the sspi crate's NTLM implementation.
439fn nt_hash_to_ntlm_password(hex_hash: &str) -> String {
440    let upper = hex_hash.to_uppercase();
441    let bytes = upper.as_bytes();
442    let mut password = String::new();
443    for pair in bytes.chunks(2) {
444        let low_byte = pair[0] as u32;
445        let high_byte = if pair.len() > 1 { pair[1] as u32 } else { 0 };
446        let code_point = (high_byte << 8) | low_byte;
447        password.push(char::from_u32(code_point).unwrap_or('\0'));
448    }
449    for _ in 0..256 {
450        password.push('\0');
451    }
452    password
453}
454
455/// Function to prepare LDAP url.
456fn prepare_ldap_url(ldaps: bool, ip: Option<&str>, port: Option<u16>, domain: &str) -> String {
457    let protocol = if ldaps || port.unwrap_or(0) == 636 { "ldaps" } else { "ldap" };
458    let target = match ip { Some(ip) => ip, None => domain };
459    match port {
460        Some(port) => format!("{protocol}://{target}:{port}"),
461        None => format!("{protocol}://{target}"),
462    }
463}
464
465/// Function to prepare LDAP DC from DOMAIN.LOCAL
466pub fn prepare_ldap_dc(domain: &str) -> Vec<String> {
467    let mut dc: String = "".to_owned();
468    let mut naming_context: Vec<String> = Vec::new();
469    if !domain.contains(".") {
470        dc.push_str("DC=");
471        dc.push_str(domain);
472        naming_context.push(dc[..].to_string());
473    } else {
474        naming_context.push(domain_to_dc(domain));
475    }
476    naming_context.push(format!("{}{}", "CN=Configuration,", &dc[..]));
477    naming_context
478}
479
480/// Function to make GSSAPI ldap connection.
481#[cfg(not(feature = "nogssapi"))]
482async fn gssapi_connection(
483    ldap: &mut ldap3::Ldap,
484    ldapfqdn: &str,
485    domain: &str,
486) -> Result<(), Box<dyn Error>> {
487    ldap.sasl_gssapi_bind(ldapfqdn)
488        .await?
489        .success()
490        .map_err(|e| format!("Kerberos authentication to {} failed: {e}", domain.to_uppercase()))?;
491    info!("Connected to {} Active Directory!", domain.to_uppercase().bold().green());
492    Ok(())
493}
494
495/// Get all namingContext for DC
496pub async fn get_all_naming_contexts(ldap: &mut ldap3::Ldap) -> Result<Vec<String>, Box<dyn Error>> {
497    let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
498        Box::new(EntriesOnly::new()),
499        Box::new(PagedResults::new(999)),
500    ];
501    let mut search = ldap.streaming_search_with(
502        adapters,
503        "",
504        Scope::Base,
505        "(objectClass=*)",
506        vec!["namingContexts"],
507    ).await?;
508
509    let mut rs: Vec<SearchEntry> = Vec::new();
510    while let Some(entry) = search.next().await? {
511        rs.push(SearchEntry::construct(entry));
512    }
513    let res = search.finish().await.success();
514
515    let mut naming_contexts: Vec<String> = Vec::new();
516    match res {
517        Ok(_res) => {
518            debug!("All namingContexts collected!");
519            for result in rs {
520                for (_key, value) in &result.attrs {
521                    for naming_context in value {
522                        debug!("namingContext found: {}", &naming_context.bold().green());
523                        naming_contexts.push(naming_context.to_string());
524                    }
525                }
526            }
527            naming_contexts.sort_by_key(|cn| {
528                if cn.contains("CN=Schema") { 0 }
529                else if cn.to_lowercase().starts_with("dc=") { 1 }
530                else if cn.contains("CN=Configuration") { 2 }
531                else { 3 }
532            });
533            for (i, nc) in naming_contexts.iter().enumerate() {
534                trace!("NamingContext order [{}]: {}", i, nc);
535            }
536            return Ok(naming_contexts);
537        }
538        Err(err) => {
539            error!("No namingContexts found! Reason: {err}");
540        }
541    }
542    Ok(Vec::new())
543}
544
545// New type to implement Serialize and Deserialize for SearchEntry
546#[derive(Debug, Clone, bincode::Encode, bincode::Decode)]
547pub struct LdapSearchEntry {
548    pub dn: String,
549    pub attrs: HashMap<String, Vec<String>>,
550    pub bin_attrs: HashMap<String, Vec<Vec<u8>>>,
551}
552
553impl From<SearchEntry> for LdapSearchEntry {
554    fn from(entry: SearchEntry) -> Self {
555        LdapSearchEntry { dn: entry.dn, attrs: entry.attrs, bin_attrs: entry.bin_attrs }
556    }
557}
558
559impl From<LdapSearchEntry> for SearchEntry {
560    fn from(entry: LdapSearchEntry) -> Self {
561        SearchEntry { dn: entry.dn, attrs: entry.attrs, bin_attrs: entry.bin_attrs }
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn range_high_parsing() {
571        assert_eq!(range_high("member;range=0-1499"), Some(1499));
572        assert_eq!(range_high("member;range=1500-2999"), Some(2999));
573        assert_eq!(range_high("member;range=3000-*"), None); // final chunk
574        assert_eq!(range_high("member"), None); // not ranged
575        assert_eq!(range_high("member;range=bad"), None); // unparsable
576    }
577
578    #[test]
579    fn nt_hash_encoding_roundtrip() {
580        let hash = "aad3b435b51404eeaad3b435b51404ee";
581        let password = nt_hash_to_ntlm_password(hash);
582        let utf16_bytes: Vec<u8> = password.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
583        assert!(utf16_bytes.len() > 512);
584        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
585        assert_eq!(hash_portion.len(), 32);
586        assert_eq!(hash_portion, hash.to_uppercase().as_bytes());
587    }
588
589    #[test]
590    fn nt_hash_encoding_all_zeros() {
591        let hash = "00000000000000000000000000000000";
592        let password = nt_hash_to_ntlm_password(hash);
593        let utf16_bytes: Vec<u8> = password.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
594        assert!(utf16_bytes.len() > 512);
595        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
596        assert_eq!(hash_portion, b"00000000000000000000000000000000");
597    }
598
599    #[test]
600    fn nt_hash_encoding_all_f() {
601        let hash = "ffffffffffffffffffffffffffffffff";
602        let password = nt_hash_to_ntlm_password(hash);
603        let utf16_bytes: Vec<u8> = password.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
604        assert!(utf16_bytes.len() > 512);
605        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
606        assert_eq!(hash_portion, b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
607    }
608}