Skip to main content

rusthound_ce/transport/
ldap.rs

1//! LDAP authentication and collection.
2//!
3//! Public entry point:
4//!   * [`ldap_auth`] : connect + authenticate, returns a ready `Ldap` session.
5//!
6//! The full library workflow (auth + collect + parse + modules + output) is
7//! `api::run_collection`, which takes the authenticated session from `ldap_auth`.
8//! Collection itself is done by the crate-internal `collect_from_ldap_into`.
9
10use crate::args::Options;
11use crate::banner::progress_bar;
12use crate::storage::Storage;
13use crate::utils::format::domain_to_dc;
14
15use colored::Colorize;
16use indicatif::ProgressBar;
17use ldap3::adapters::{Adapter, EntriesOnly};
18use ldap3::exop::WhoAmI;
19use ldap3::{adapters::PagedResults, controls::RawControl, LdapConnAsync, LdapConnSettings};
20use ldap3::{Scope, SearchEntry};
21use log::{info, debug, error, trace};
22use std::io::{self, Write, stdin};
23use std::collections::HashMap;
24use std::error::Error;
25
26/// Connect to the Domain Controller and authenticate, returning a ready
27/// `ldap3::Ldap` session. The method is chosen from `options`:
28///
29///   - certificate  : `pfx` or `crt`/`key` present (Pass-the-Certificate)
30///   - pass-the-hash: `hashes` present (NTLM)
31///   - Kerberos     : `kerberos == true` (ccache from KRB5CCNAME)
32///   - simple bind  : otherwise (`username` / `password`)
33///
34/// Certificate auth uses LDAP 389 + StartTLS by default, or LDAPS 636 with
35/// `options.ldaps`. Returns `Err` on failure (no `process::exit`), and never
36/// unbinds — the caller owns the returned session.
37pub async fn ldap_auth(options: &Options) -> Result<ldap3::Ldap, Box<dyn Error>> {
38    let use_cert = options.pfx.is_some() || options.crt.is_some();
39
40    // Certificate transport: StartTLS by default, LDAPS with --ldaps.
41    let starttls = use_cert && !options.ldaps;
42    let effective_ldaps = if use_cert { !starttls } else { options.ldaps };
43    let effective_port = if use_cert {
44        options.port.or(Some(if starttls { 389 } else { 636 }))
45    } else {
46        options.port
47    };
48
49    let ldap_args = ldap_constructor(
50        effective_ldaps,
51        options.ip.as_deref(),
52        effective_port,
53        &options.domain,
54        options.ldapfqdn.as_deref(),
55        options.username.as_deref(),
56        options.password.as_deref(),
57        options.hashes.as_deref(),
58        options.kerberos,
59        use_cert,
60    )?;
61
62    let mut consettings = LdapConnSettings::new()
63        .set_conn_timeout(std::time::Duration::from_secs(10))
64        .set_no_tls_verify(true);
65    if use_cert {
66        let config = crate::transport::cert::build_client_config(
67            options.pfx.as_deref(),
68            options.pfx_pass.as_deref(),
69            options.crt.as_deref(),
70            options.key.as_deref(),
71        )?;
72        consettings = consettings.set_config(config);
73        if starttls {
74            consettings = consettings.set_starttls(true);
75        }
76    }
77
78    let (conn, mut ldap) = LdapConnAsync::with_settings(consettings, &ldap_args.s_url).await?;
79    ldap3::drive!(conn);
80
81    let domain = &options.domain;
82
83    if use_cert {
84        // Pass-the-Certificate: SASL EXTERNAL over StartTLS, or implicit
85        // Schannel mapping over LDAPS. Confirm the mapped identity with whoami.
86        if starttls {
87            debug!("Certificate authentication (StartTLS + SASL EXTERNAL)");
88            ldap.sasl_external_bind()
89                .await
90                .and_then(|r| r.success())
91                .map_err(|e| format!("certificate SASL EXTERNAL bind failed (try --ldaps): {e}"))?;
92        } else {
93            debug!("Certificate authentication (LDAPS, implicit Schannel mapping)");
94        }
95        let who = ldap
96            .extended(WhoAmI)
97            .await
98            .map(|r| r.success())
99            .map_err(|e| format!("certificate whoami request failed: {e}"))?
100            .map_err(|e| format!("certificate whoami failed: {e}"))?
101            .0
102            .val
103            .as_ref()
104            .map(|v| String::from_utf8_lossy(v).to_string())
105            .unwrap_or_default();
106        if who.is_empty() {
107            return Err(format!(
108                "certificate not mapped by {} (empty whoami); check the cert SID and DC enforcement (KB5014754)",
109                domain.to_uppercase()
110            )
111            .into());
112        }
113        info!(
114            "Connected to {} Active Directory via certificate as {}!",
115            domain.to_uppercase().bold().green(),
116            who.bold().green()
117        );
118    } else if let Some(ref ntlm_password) = ldap_args.s_ntlm_password {
119        debug!("NTLM pass-the-hash (sasl_ntlm_bind)");
120        ldap.sasl_ntlm_bind(&ldap_args.s_username, ntlm_password)
121            .await?
122            .success()
123            .map_err(|e| format!("NTLM authentication to {} failed: {e}", domain.to_uppercase()))?;
124        info!("Connected to {} Active Directory via NTLM!", domain.to_uppercase().bold().green());
125    } else if !options.kerberos {
126        debug!("Simple bind (username:password)");
127        ldap.simple_bind(&ldap_args.s_username, &ldap_args.s_password)
128            .await?
129            .success()
130            .map_err(|e| format!("authentication to {} failed: {e}", domain.to_uppercase()))?;
131        info!("Connected to {} Active Directory!", domain.to_uppercase().bold().green());
132    } else {
133        debug!("Kerberos (sasl_gssapi_bind)");
134        let fqdn = options
135            .ldapfqdn
136            .as_deref()
137            .filter(|f| !f.is_empty())
138            .ok_or("Kerberos requires the Domain Controller FQDN (set options.ldapfqdn, e.g. DC01.DOMAIN.LOCAL)")?;
139        #[cfg(not(feature = "nogssapi"))]
140        {
141            gssapi_connection(&mut ldap, fqdn, domain).await?;
142        }
143        #[cfg(feature = "nogssapi")]
144        {
145            let _ = fqdn;
146            return Err("Kerberos/GSSAPI is not available in this build (nogssapi feature)".into());
147        }
148    }
149
150    Ok(ldap)
151}
152
153/// Collect every namingContext of the DC into `storage`, returning the number
154/// of objects collected. Walks each context with the SD-flags and show-deleted
155/// controls and streams entries into `storage`. Returns `Err` on failure (no
156/// `process::exit`) and does not unbind/drop the session — the caller owns it.
157pub(crate) async fn collect_from_ldap_into<S: Storage<LdapSearchEntry>>(
158    ldap: &mut ldap3::Ldap,
159    ldapfilter: &str,
160    storage: &mut S,
161) -> Result<usize, Box<dyn Error>> {
162    let mut total = 0usize;
163
164    let res = get_all_naming_contexts(ldap).await?;
165    trace!("naming_contexts: {:?}", &res);
166
167    if !res.iter().any(|s| s.contains("Configuration")) {
168        return Err("no Configuration namingContext found (is the target a Domain Controller?)".into());
169    }
170
171    for cn in &res {
172        // Control 1: LDAP_SERVER_SD_FLAGS_OID to get nTSecurityDescriptor.
173        let sd_flags = RawControl {
174            ctype: String::from("1.2.840.113556.1.4.801"),
175            crit: true,
176            val: Some(vec![48, 3, 2, 1, 5]), // SEQUENCE { INTEGER 5 }
177        };
178        // Control 2: LDAP_SERVER_SHOW_DELETED_OID.
179        let show_deleted = RawControl {
180            ctype: String::from("1.2.840.113556.1.4.417"),
181            crit: false,
182            val: None,
183        };
184        ldap.with_controls(vec![sd_flags, show_deleted]);
185
186        info!("Ldap filter : {}", ldapfilter.bold().green());
187
188        let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
189            Box::new(EntriesOnly::new()),
190            Box::new(PagedResults::new(999)),
191        ];
192
193        let mut search = ldap
194            .streaming_search_with(
195                adapters,
196                cn,
197                Scope::Subtree,
198                ldapfilter,
199                vec!["*", "nTSecurityDescriptor"],
200            )
201            .await?;
202
203        let pb = ProgressBar::new(1);
204        let mut count = 0;
205        while let Some(entry) = search.next().await? {
206            let entry = SearchEntry::construct(entry);
207            total += 1;
208            count += 1;
209            progress_bar(
210                pb.to_owned(),
211                "LDAP objects retrieved".to_string(),
212                count,
213                "#".to_string(),
214            );
215            storage.add(entry.into())?;
216        }
217        pb.finish_and_clear();
218
219        match search.finish().await.success() {
220            Ok(_) => info!("All data collected for NamingContext {}", &cn.bold()),
221            Err(err) => error!("No data collected on {}! Reason: {err}", &cn.bold().red()),
222        }
223    }
224
225    storage.flush()?;
226    Ok(total)
227}
228
229/// Structure containing the LDAP connection arguments.
230struct LdapArgs {
231    s_url: String,
232    _s_dc: Vec<String>,
233    _s_email: String,
234    s_username: String,
235    s_password: String,
236    s_ntlm_password: Option<String>,
237}
238
239/// Function to prepare LDAP arguments.
240#[allow(clippy::too_many_arguments)]
241fn ldap_constructor(
242    ldaps: bool,
243    ip: Option<&str>,
244    port: Option<u16>,
245    domain: &str,
246    ldapfqdn: Option<&str>,
247    username: Option<&str>,
248    password: Option<&str>,
249    hashes: Option<&str>,
250    kerberos: bool,
251    use_cert: bool,
252) -> Result<LdapArgs, Box<dyn Error>> {
253    let s_url = prepare_ldap_url(ldaps, ip, port, domain);
254    let s_dc = prepare_ldap_dc(domain);
255    let use_ntlm = hashes.is_some();
256
257    // Username prompt (skipped for Kerberos and certificate auth)
258    let mut s = String::new();
259    let mut _s_username: String;
260    if username.is_none() && !kerberos && !use_cert {
261        print!("Username: ");
262        io::stdout().flush()?;
263        stdin().read_line(&mut s).expect("Did not enter a correct username");
264        io::stdout().flush()?;
265        if let Some('\n') = s.chars().next_back() { s.pop(); }
266        if let Some('\r') = s.chars().next_back() { s.pop(); }
267        _s_username = s.to_owned();
268    } else {
269        _s_username = username.unwrap_or("not set").to_owned();
270    }
271
272    // Format username and email
273    let mut s_email: String = "".to_owned();
274    if !_s_username.contains("@") {
275        s_email.push_str(&_s_username.to_string());
276        s_email.push_str("@");
277        s_email.push_str(domain);
278        if !use_ntlm {
279            _s_username = s_email.to_string();
280        }
281    } else {
282        s_email = _s_username.to_string().to_lowercase();
283    }
284
285    // For NTLM, format username as DOMAIN\user for sspi
286    if use_ntlm && !_s_username.contains("\\") && !_s_username.contains("@") {
287        let domain_upper = domain.split('.').next().unwrap_or(domain).to_uppercase();
288        _s_username = format!("{}\\{}", domain_upper, _s_username);
289    }
290
291    // Validate and build NTLM password from NT hash if provided
292    let s_ntlm_password = match hashes {
293        Some(hash) => {
294            let clean = hash.trim();
295            let nt = match clean.split_once(':') {
296                Some((_lm, nt)) => nt,
297                None => clean,
298            };
299            if nt.len() != 32 || !nt.chars().all(|c| c.is_ascii_hexdigit()) {
300                return Err("Invalid NT hash: must be exactly 32 hex characters (e.g. aad3b435b51404eeaad3b435b51404ee)".into());
301            }
302            Some(nt_hash_to_ntlm_password(nt))
303        }
304        None => None,
305    };
306
307    // Password prompt (skip for NTLM hash, Kerberos, and certificate auth)
308    let mut _s_password: String = String::new();
309    if !use_ntlm && !_s_username.contains("not set") && !kerberos && !use_cert {
310        _s_password = match password {
311            Some(p) => p.to_owned(),
312            None => rpassword::prompt_password("Password: ").unwrap_or("not set".to_string()),
313        };
314    } else {
315        _s_password = password.unwrap_or("not set").to_owned();
316    }
317
318    debug!("IP: {}", ip.unwrap_or("not set"));
319    debug!("PORT: {}", match port { Some(p) => p.to_string(), None => "not set".to_owned() });
320    debug!("FQDN: {}", ldapfqdn.unwrap_or("not set"));
321    debug!("Url: {}", s_url);
322    debug!("Domain: {}", domain);
323    debug!("Username: {}", _s_username);
324    debug!("Email: {}", s_email.to_lowercase());
325    if use_cert {
326        debug!("Auth: certificate (Pass-the-Certificate)");
327    } else if use_ntlm {
328        debug!("Auth: NTLM pass-the-hash");
329    } else {
330        debug!("Password: {}", _s_password);
331    }
332    debug!("DC: {:?}", s_dc);
333    debug!("Kerberos: {:?}", kerberos);
334
335    Ok(LdapArgs {
336        s_url: s_url.to_string(),
337        _s_dc: s_dc,
338        _s_email: s_email.to_string().to_lowercase(),
339        s_username: if use_ntlm { _s_username.to_string() } else { s_email.to_string().to_lowercase() },
340        s_password: _s_password.to_string(),
341        s_ntlm_password,
342    })
343}
344
345/// Encode an NT hash into a password string that triggers pass-the-hash
346/// in the sspi crate's NTLM implementation.
347fn nt_hash_to_ntlm_password(hex_hash: &str) -> String {
348    let upper = hex_hash.to_uppercase();
349    let bytes = upper.as_bytes();
350    let mut password = String::new();
351    for pair in bytes.chunks(2) {
352        let low_byte = pair[0] as u32;
353        let high_byte = if pair.len() > 1 { pair[1] as u32 } else { 0 };
354        let code_point = (high_byte << 8) | low_byte;
355        password.push(char::from_u32(code_point).unwrap_or('\0'));
356    }
357    for _ in 0..256 {
358        password.push('\0');
359    }
360    password
361}
362
363/// Function to prepare LDAP url.
364fn prepare_ldap_url(ldaps: bool, ip: Option<&str>, port: Option<u16>, domain: &str) -> String {
365    let protocol = if ldaps || port.unwrap_or(0) == 636 { "ldaps" } else { "ldap" };
366    let target = match ip { Some(ip) => ip, None => domain };
367    match port {
368        Some(port) => format!("{protocol}://{target}:{port}"),
369        None => format!("{protocol}://{target}"),
370    }
371}
372
373/// Function to prepare LDAP DC from DOMAIN.LOCAL
374pub fn prepare_ldap_dc(domain: &str) -> Vec<String> {
375    let mut dc: String = "".to_owned();
376    let mut naming_context: Vec<String> = Vec::new();
377    if !domain.contains(".") {
378        dc.push_str("DC=");
379        dc.push_str(domain);
380        naming_context.push(dc[..].to_string());
381    } else {
382        naming_context.push(domain_to_dc(domain));
383    }
384    naming_context.push(format!("{}{}", "CN=Configuration,", &dc[..]));
385    naming_context
386}
387
388/// Function to make GSSAPI ldap connection.
389#[cfg(not(feature = "nogssapi"))]
390async fn gssapi_connection(
391    ldap: &mut ldap3::Ldap,
392    ldapfqdn: &str,
393    domain: &str,
394) -> Result<(), Box<dyn Error>> {
395    ldap.sasl_gssapi_bind(ldapfqdn)
396        .await?
397        .success()
398        .map_err(|e| format!("Kerberos authentication to {} failed: {e}", domain.to_uppercase()))?;
399    info!("Connected to {} Active Directory!", domain.to_uppercase().bold().green());
400    Ok(())
401}
402
403/// Get all namingContext for DC
404pub async fn get_all_naming_contexts(ldap: &mut ldap3::Ldap) -> Result<Vec<String>, Box<dyn Error>> {
405    let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
406        Box::new(EntriesOnly::new()),
407        Box::new(PagedResults::new(999)),
408    ];
409    let mut search = ldap.streaming_search_with(
410        adapters,
411        "",
412        Scope::Base,
413        "(objectClass=*)",
414        vec!["namingContexts"],
415    ).await?;
416
417    let mut rs: Vec<SearchEntry> = Vec::new();
418    while let Some(entry) = search.next().await? {
419        rs.push(SearchEntry::construct(entry));
420    }
421    let res = search.finish().await.success();
422
423    let mut naming_contexts: Vec<String> = Vec::new();
424    match res {
425        Ok(_res) => {
426            debug!("All namingContexts collected!");
427            for result in rs {
428                for (_key, value) in &result.attrs {
429                    for naming_context in value {
430                        debug!("namingContext found: {}", &naming_context.bold().green());
431                        naming_contexts.push(naming_context.to_string());
432                    }
433                }
434            }
435            naming_contexts.sort_by_key(|cn| {
436                if cn.contains("CN=Schema") { 0 }
437                else if cn.to_lowercase().starts_with("dc=") { 1 }
438                else if cn.contains("CN=Configuration") { 2 }
439                else { 3 }
440            });
441            for (i, nc) in naming_contexts.iter().enumerate() {
442                trace!("NamingContext order [{}]: {}", i, nc);
443            }
444            return Ok(naming_contexts);
445        }
446        Err(err) => {
447            error!("No namingContexts found! Reason: {err}");
448        }
449    }
450    Ok(Vec::new())
451}
452
453// New type to implement Serialize and Deserialize for SearchEntry
454#[derive(Debug, Clone, bincode::Encode, bincode::Decode)]
455pub struct LdapSearchEntry {
456    pub dn: String,
457    pub attrs: HashMap<String, Vec<String>>,
458    pub bin_attrs: HashMap<String, Vec<Vec<u8>>>,
459}
460
461impl From<SearchEntry> for LdapSearchEntry {
462    fn from(entry: SearchEntry) -> Self {
463        LdapSearchEntry { dn: entry.dn, attrs: entry.attrs, bin_attrs: entry.bin_attrs }
464    }
465}
466
467impl From<LdapSearchEntry> for SearchEntry {
468    fn from(entry: LdapSearchEntry) -> Self {
469        SearchEntry { dn: entry.dn, attrs: entry.attrs, bin_attrs: entry.bin_attrs }
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn nt_hash_encoding_roundtrip() {
479        let hash = "aad3b435b51404eeaad3b435b51404ee";
480        let password = nt_hash_to_ntlm_password(hash);
481        let utf16_bytes: Vec<u8> = password.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
482        assert!(utf16_bytes.len() > 512);
483        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
484        assert_eq!(hash_portion.len(), 32);
485        assert_eq!(hash_portion, hash.to_uppercase().as_bytes());
486    }
487
488    #[test]
489    fn nt_hash_encoding_all_zeros() {
490        let hash = "00000000000000000000000000000000";
491        let password = nt_hash_to_ntlm_password(hash);
492        let utf16_bytes: Vec<u8> = password.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
493        assert!(utf16_bytes.len() > 512);
494        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
495        assert_eq!(hash_portion, b"00000000000000000000000000000000");
496    }
497
498    #[test]
499    fn nt_hash_encoding_all_f() {
500        let hash = "ffffffffffffffffffffffffffffffff";
501        let password = nt_hash_to_ntlm_password(hash);
502        let utf16_bytes: Vec<u8> = password.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
503        assert!(utf16_bytes.len() > 512);
504        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
505        assert_eq!(hash_portion, b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
506    }
507}