Skip to main content

rusthound_ce/
ldap.rs

1//! Run a LDAP enumeration and parse results
2//!
3//! This module will prepare your connection and request the LDAP server to retrieve all the information needed to create the json files.
4//!
5//! rusthound sends only one request to the LDAP server, if the result of this one is higher than the limit of the LDAP server limit it will be split in several requests to avoid having an error 4 (LDAP_SIZELIMIT_EXCEED).
6//!
7//! Example in rust
8//!
9//! ```ignore
10//! let search = ldap_search(...)
11//! ```
12
13// use crate::errors::Result;
14use crate::banner::progress_bar;
15use crate::storage::Storage;
16use crate::utils::format::domain_to_dc;
17
18use colored::Colorize;
19use indicatif::ProgressBar;
20use ldap3::adapters::{Adapter, EntriesOnly};
21use ldap3::{adapters::PagedResults, controls::RawControl, LdapConnAsync, LdapConnSettings};
22use ldap3::{Scope, SearchEntry};
23use log::{info, debug, error, trace};
24use std::io::{self, Write, stdin};
25use std::collections::HashMap;
26use std::error::Error;
27use std::process;
28
29/// Function to request all AD values.
30#[allow(clippy::too_many_arguments)]
31pub async fn ldap_search<S: Storage<LdapSearchEntry>>(
32    ldaps: bool,
33    ip: Option<&str>,
34    port: Option<u16>,
35    domain: &str,
36    ldapfqdn: &str,
37    username: Option<&str>,
38    password: Option<&str>,
39    hashes: Option<&str>,
40    kerberos: bool,
41    ldapfilter: &str,
42    storage: &mut S,
43) -> Result<usize, Box<dyn Error>> {
44    // Construct LDAP args
45    let ldap_args = ldap_constructor(
46        ldaps, ip, port, domain, ldapfqdn, username, password, hashes, kerberos,
47    )?;
48
49    // LDAP connection
50    let consettings = LdapConnSettings::new()
51        .set_conn_timeout(std::time::Duration::from_secs(10))
52        .set_no_tls_verify(true);
53    let (conn, mut ldap) = LdapConnAsync::with_settings(consettings, &ldap_args.s_url).await?;
54    ldap3::drive!(conn);
55
56    if let Some(ref ntlm_password) = ldap_args.s_ntlm_password {
57        debug!("Trying to connect with sasl_ntlm_bind() function (NTLM pass-the-hash)");
58        let res = ldap
59            .sasl_ntlm_bind(&ldap_args.s_username, ntlm_password)
60            .await?
61            .success();
62        match res {
63            Ok(_res) => {
64                info!(
65                    "Connected to {} Active Directory via NTLM!",
66                    domain.to_uppercase().bold().green()
67                );
68                info!("Starting data collection...");
69            }
70            Err(err) => {
71                error!(
72                    "Failed to authenticate to {} Active Directory via NTLM. Reason: {err}\n",
73                    domain.to_uppercase().bold().red()
74                );
75                process::exit(0x0100);
76            }
77        }
78    } else if !kerberos {
79        debug!("Trying to connect with simple_bind() function (username:password)");
80        let res = ldap
81            .simple_bind(&ldap_args.s_username, &ldap_args.s_password)
82            .await?
83            .success();
84        match res {
85            Ok(_res) => {
86                info!(
87                    "Connected to {} Active Directory!",
88                    domain.to_uppercase().bold().green()
89                );
90                info!("Starting data collection...");
91            }
92            Err(err) => {
93                error!(
94                    "Failed to authenticate to {} Active Directory. Reason: {err}\n",
95                    domain.to_uppercase().bold().red()
96                );
97                process::exit(0x0100);
98            }
99        }
100    } else {
101        debug!("Trying to connect with sasl_gssapi_bind() function (kerberos session)");
102        if !&ldapfqdn.contains("not set") {
103            #[cfg(not(feature = "nogssapi"))]
104            gssapi_connection(&mut ldap, &ldapfqdn, &domain).await?;
105            #[cfg(feature = "nogssapi")]
106            {
107                error!("Kerberos auth and GSSAPI not compatible with current os!");
108                process::exit(0x0100);
109            }
110        } else {
111            error!(
112                "Need Domain Controller FQDN to bind GSSAPI connection. Please use '{}'\n",
113                "-f DC01.DOMAIN.LAB".bold()
114            );
115            process::exit(0x0100);
116        }
117    }
118
119    // // Prepare LDAP result vector
120    let mut total = 0; // for progress bar
121
122    // Request all namingContexts for current DC
123    let res = match get_all_naming_contexts(&mut ldap).await {
124        Ok(res) => {
125            trace!("naming_contexts: {:?}", &res);
126            res
127        }
128        Err(err) => {
129            error!("No namingContexts found! Reason: {err}\n");
130            process::exit(0x0100);
131        }
132    };
133
134    // namingContexts: DC=domain,DC=local
135    // namingContexts: CN=Configuration,DC=domain,DC=local (needed for AD CS datas)
136    if res.iter().any(|s| s.contains("Configuration")) {
137        for cn in &res {
138            // Set control LDAP_SERVER_SD_FLAGS_OID to get nTSecurityDescriptor
139            // https://ldapwiki.com/wiki/LDAP_SERVER_SD_FLAGS_OID
140            let ctrls = RawControl {
141                ctype: String::from("1.2.840.113556.1.4.801"),
142                crit: true,
143                val: Some(vec![48, 3, 2, 1, 5]),
144            };
145            ldap.with_controls(ctrls.to_owned());
146
147            // Prepare filter
148            // let mut _s_filter: &str = "";
149            // if cn.contains("Configuration") {
150            //     _s_filter = "(|(objectclass=pKIEnrollmentService)(objectclass=pkicertificatetemplate)(objectclass=subschema)(objectclass=certificationAuthority)(objectclass=container))";
151            // } else {
152            //     _s_filter = "(objectClass=*)";
153            // }
154            //let _s_filter = "(objectClass=*)";
155            //let _s_filter = "(objectGuid=*)";
156            info!("Ldap filter : {}", ldapfilter.bold().green());
157            let _s_filter = ldapfilter;
158
159            // Every 999 max value in ldap response (err 4 ldap)
160            let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
161                Box::new(EntriesOnly::new()),
162                Box::new(PagedResults::new(999)),
163            ];
164
165            // Streaming search with adaptaters and filters
166            let mut search = ldap
167                .streaming_search_with(
168                    adapters, // Adapter which fetches Search results with a Paged Results control.
169                    cn,
170                    Scope::Subtree,
171                    _s_filter,
172                    vec!["*", "nTSecurityDescriptor"],
173                    // Without the presence of this control, the server returns an SD only when the SD attribute name is explicitly mentioned in the requested attribute list.
174                    // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/932a7a8d-8c93-4448-8093-c79b7d9ba499
175                )
176                .await?;
177
178            // Wait and get next values
179            let pb = ProgressBar::new(1);
180            let mut count = 0;
181            while let Some(entry) = search.next().await? {
182                let entry = SearchEntry::construct(entry);
183                //trace!("{:?}", &entry);
184                total += 1;
185                // Manage progress bar
186                count += 1;
187                progress_bar(
188                    pb.to_owned(),
189                    "LDAP objects retrieved".to_string(),
190                    count,
191                    "#".to_string(),
192                );
193
194                storage.add(entry.into())?;
195            }
196            pb.finish_and_clear();
197
198            let res = search.finish().await.success();
199            match res {
200                Ok(_res) => info!("All data collected for NamingContext {}", &cn.bold()),
201                Err(err) => {
202                    error!("No data collected on {}! Reason: {err}", &cn.bold().red());
203                }
204            }
205        }
206        // // If no result exit program
207        // if rs.is_empty() {
208        //     process::exit(0x0100);
209        // }
210
211        ldap.unbind().await?;
212    }
213
214    // drop ldap before final flush,
215    // otherwise it will warn about an i/o error
216    // "LDAP connection error: I/O error: Connection reset by peer (os error 54)"
217    drop(ldap);
218    if total == 0 {
219        error!("No LDAP objects found! Exiting...");
220        // std::fs::remove_file(cache_path)?; // TODO: return error so we can cleanup cache
221        process::exit(0x0100);
222    }
223
224    storage.flush()?;
225
226
227    // Return the vector with the result
228    Ok(total)
229}
230
231/// Structure containing the LDAP connection arguments.
232struct LdapArgs {
233    s_url: String,
234    _s_dc: Vec<String>,
235    _s_email: String,
236    s_username: String,
237    s_password: String,
238    s_ntlm_password: Option<String>,
239}
240
241/// Function to prepare LDAP arguments.
242fn ldap_constructor(
243    ldaps: bool,
244    ip: Option<&str>,
245    port: Option<u16>,
246    domain: &str,
247    ldapfqdn: &str,
248    username: Option<&str>,
249    password: Option<&str>,
250    hashes: Option<&str>,
251    kerberos: bool,
252) -> Result<LdapArgs, Box<dyn Error>> {
253    // Prepare ldap url
254    let s_url = prepare_ldap_url(ldaps, ip, port, domain);
255
256    // Prepare full DC chain
257    let s_dc = prepare_ldap_dc(domain);
258
259    let use_ntlm = hashes.is_some();
260
261    // Username prompt
262    let mut s = String::new();
263    let mut _s_username: String;
264    if username.is_none() && !kerberos {
265        print!("Username: ");
266        io::stdout().flush()?;
267        stdin()
268            .read_line(&mut s)
269            .expect("Did not enter a correct username");
270        io::stdout().flush()?;
271        if let Some('\n') = s.chars().next_back() {
272            s.pop();
273        }
274        if let Some('\r') = s.chars().next_back() {
275            s.pop();
276        }
277        _s_username = s.to_owned();
278    } else {
279        _s_username = username.unwrap_or("not set").to_owned();
280    }
281
282    // Format username and email
283    let mut s_email: String = "".to_owned();
284    if !_s_username.contains("@") {
285        s_email.push_str(&_s_username.to_string());
286        s_email.push_str("@");
287        s_email.push_str(domain);
288        if !use_ntlm {
289            _s_username = s_email.to_string();
290        }
291    } else {
292        s_email = _s_username.to_string().to_lowercase();
293    }
294
295    // For NTLM, format username as DOMAIN\user for sspi
296    if use_ntlm && !_s_username.contains("\\") && !_s_username.contains("@") {
297        let domain_upper = domain.split('.').next().unwrap_or(domain).to_uppercase();
298        _s_username = format!("{}\\{}", domain_upper, _s_username);
299    }
300
301    // Validate and build NTLM password from NT hash if provided
302    let s_ntlm_password = match hashes {
303        Some(hash) => {
304            let clean = hash.trim();
305            // Accept [NTHASH, :NTHASH, LMHASH:NTHASH]
306            let nt = match clean.split_once(':') {
307                Some((_lm, nt)) => nt,
308                None => clean,
309            };
310            if nt.len() != 32 || !nt.chars().all(|c| c.is_ascii_hexdigit()) {
311                error!("Invalid NT hash: must be exactly 32 hex characters (e.g. aad3b435b51404eeaad3b435b51404ee)");
312                process::exit(0x0100);
313            }
314            Some(nt_hash_to_ntlm_password(nt))
315        }
316        None => None,
317    };
318
319    // Password prompt (skip when using NTLM hash)
320    let mut _s_password: String = String::new();
321    if !use_ntlm && !_s_username.contains("not set") && !kerberos {
322        _s_password = match password {
323            Some(p) => p.to_owned(),
324            None => rpassword::prompt_password("Password: ").unwrap_or("not set".to_string()),
325        };
326    } else {
327        _s_password = password.unwrap_or("not set").to_owned();
328    }
329    
330    // Print infos if verbose mod is set
331    debug!("IP: {}", match ip {
332        Some(ip) => ip,
333        None => "not set"
334    });
335    debug!("PORT: {}", match port {
336        Some(p) => {
337            p.to_string()
338        },
339        None => "not set".to_owned()
340    });
341    debug!("FQDN: {}", ldapfqdn);
342    debug!("Url: {}", s_url);
343    debug!("Domain: {}", domain);
344    debug!("Username: {}", _s_username);
345    debug!("Email: {}", s_email.to_lowercase());
346    if use_ntlm {
347        debug!("Auth: NTLM pass-the-hash");
348    } else {
349        debug!("Password: {}", _s_password);
350    }
351    debug!("DC: {:?}", s_dc);
352    debug!("Kerberos: {:?}", kerberos);
353
354    Ok(LdapArgs {
355        s_url: s_url.to_string(),
356        _s_dc: s_dc,
357        _s_email: s_email.to_string().to_lowercase(),
358        s_username: if use_ntlm {
359            _s_username.to_string()
360        } else {
361            s_email.to_string().to_lowercase()
362        },
363        s_password: _s_password.to_string(),
364        s_ntlm_password,
365    })
366}
367
368/// Encode an NT hash into a password string that triggers pass-the-hash
369/// in the sspi crate's NTLM implementation.
370fn nt_hash_to_ntlm_password(hex_hash: &str) -> String {
371    let upper = hex_hash.to_uppercase();
372    let bytes = upper.as_bytes();
373
374    let mut password = String::new();
375
376    for pair in bytes.chunks(2) {
377        let low_byte = pair[0] as u32;
378        let high_byte = if pair.len() > 1 { pair[1] as u32 } else { 0 };
379        let code_point = (high_byte << 8) | low_byte;
380        password.push(char::from_u32(code_point).unwrap_or('\0'));
381    }
382
383    // Pad to exceed the 512-byte SSPI_CREDENTIALS_HASH_LENGTH_OFFSET
384    for _ in 0..256 {
385        password.push('\0');
386    }
387
388    password
389}
390
391/// Function to prepare LDAP url.
392fn prepare_ldap_url(
393    ldaps: bool,
394    ip: Option<&str>,
395    port: Option<u16>,
396    domain: &str
397) -> String {
398    let protocol = if ldaps || port.unwrap_or(0) == 636 {
399        "ldaps"
400    } else {
401        "ldap"
402    };
403
404    let target = match ip {
405        Some(ip) => ip,
406        None => domain,
407    };
408
409    match port {
410        Some(port) => {
411            format!("{protocol}://{target}:{port}")
412        }
413        None => {
414            format!("{protocol}://{target}")
415        }
416    }
417}
418
419/// Function to prepare LDAP DC from DOMAIN.LOCAL
420pub fn prepare_ldap_dc(domain: &str) -> Vec<String> {
421
422    let mut dc: String = "".to_owned();
423    let mut naming_context: Vec<String> = Vec::new();
424
425    // Format DC
426    if !domain.contains(".") {
427        dc.push_str("DC=");
428        dc.push_str(domain);
429        naming_context.push(dc[..].to_string());
430    }
431    else {
432        naming_context.push(domain_to_dc(domain));
433    }
434
435    // For ADCS values
436    naming_context.push(format!("{}{}", "CN=Configuration,", &dc[..])); 
437    naming_context
438}
439
440/// Function to make GSSAPI ldap connection.
441#[cfg(not(feature = "nogssapi"))]
442async fn gssapi_connection(
443    ldap: &mut ldap3::Ldap,
444    ldapfqdn: &str,
445    domain: &str,
446) -> Result<(), Box<dyn Error>> {
447    let res = ldap.sasl_gssapi_bind(ldapfqdn).await?.success();
448    match res {
449        Ok(_res) => {
450            info!("Connected to {} Active Directory!", domain.to_uppercase().bold().green());
451            info!("Starting data collection...");
452        }
453        Err(err) => {
454            error!("Failed to authenticate to {} Active Directory. Reason: {err}\n", domain.to_uppercase().bold().red());
455            process::exit(0x0100);
456        }
457    }
458    Ok(())
459}
460
461/// Get all namingContext for DC
462pub async fn get_all_naming_contexts(
463    ldap: &mut ldap3::Ldap
464) -> Result<Vec<String>, Box<dyn Error>> {
465    // Every 999 max value in ldap response (err 4 ldap)
466    let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
467        Box::new(EntriesOnly::new()),
468        Box::new(PagedResults::new(999)),
469    ];
470
471    // First LDAP request to get all namingContext
472    let mut search = ldap.streaming_search_with(
473        adapters,
474        "", 
475        Scope::Base,
476        "(objectClass=*)",
477        vec!["namingContexts"],
478    ).await?;
479
480    // Prepare LDAP result vector
481    let mut rs: Vec<SearchEntry> = Vec::new();
482    while let Some(entry) = search.next().await? {
483        let entry = SearchEntry::construct(entry);
484        rs.push(entry);
485    }
486    let res = search.finish().await.success();
487
488    // Prepare vector for all namingContexts result
489    let mut naming_contexts: Vec<String> = Vec::new();
490    match res {
491        Ok(_res) => {
492            debug!("All namingContexts collected!");
493            for result in rs {
494                let result_attrs: HashMap<String, Vec<String>> = result.attrs;
495
496                for (_key, value) in &result_attrs {
497                    for naming_context in value {
498                        debug!("namingContext found: {}",&naming_context.bold().green());
499                        naming_contexts.push(naming_context.to_string());
500                    }
501                }
502            }
503            
504            // Put CN=Schema first so schema_guid_map is complete before ACEs are parsed
505            naming_contexts.sort_by_key(|cn| {
506                if cn.contains("CN=Schema") { 0 }
507                else if cn.to_lowercase().starts_with("dc=") { 1 }
508                else if cn.contains("CN=Configuration") { 2 }
509                else { 3 }
510            });
511
512            // Trace sorted naming contexts order
513            for (i, nc) in naming_contexts.iter().enumerate() {
514                trace!("NamingContext order [{}]: {}", i, nc);
515            }
516
517            return Ok(naming_contexts)
518        }
519        Err(err) => {
520            error!("No namingContexts found! Reason: {err}");
521        }
522    }
523    // Empty result if no namingContexts found
524    Ok(Vec::new())
525}
526
527// New type to implement Serialize and Deserialize for SearchEntry
528#[derive(Debug, Clone, bincode::Encode, bincode::Decode)]
529pub struct LdapSearchEntry {
530    /// Entry DN.
531    pub dn: String,
532    /// Attributes.
533    pub attrs: HashMap<String, Vec<String>>,
534    /// Binary-valued attributes.
535    pub bin_attrs: HashMap<String, Vec<Vec<u8>>>,
536}
537
538impl From<SearchEntry> for LdapSearchEntry {
539    fn from(entry: SearchEntry) -> Self {
540        LdapSearchEntry {
541            dn: entry.dn,
542            attrs: entry.attrs,
543            bin_attrs: entry.bin_attrs,
544        }
545    }
546}
547
548impl From<LdapSearchEntry> for SearchEntry {
549    fn from(entry: LdapSearchEntry) -> Self {
550        SearchEntry {
551            dn: entry.dn,
552            attrs: entry.attrs,
553            bin_attrs: entry.bin_attrs,
554        }
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn nt_hash_encoding_roundtrip() {
564        let hash = "aad3b435b51404eeaad3b435b51404ee";
565        let password = nt_hash_to_ntlm_password(hash);
566
567        let utf16_bytes: Vec<u8> = password
568            .encode_utf16()
569            .flat_map(|u| u.to_le_bytes())
570            .collect();
571
572        assert!(utf16_bytes.len() > 512);
573
574        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
575        assert_eq!(hash_portion.len(), 32);
576
577        let expected_hex = hash.to_uppercase();
578        let expected_bytes = expected_hex.as_bytes();
579        assert_eq!(hash_portion, expected_bytes);
580    }
581
582    #[test]
583    fn nt_hash_encoding_all_zeros() {
584        let hash = "00000000000000000000000000000000";
585        let password = nt_hash_to_ntlm_password(hash);
586
587        let utf16_bytes: Vec<u8> = password
588            .encode_utf16()
589            .flat_map(|u| u.to_le_bytes())
590            .collect();
591
592        assert!(utf16_bytes.len() > 512);
593        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
594        assert_eq!(hash_portion, b"00000000000000000000000000000000");
595    }
596
597    #[test]
598    fn nt_hash_encoding_all_f() {
599        let hash = "ffffffffffffffffffffffffffffffff";
600        let password = nt_hash_to_ntlm_password(hash);
601
602        let utf16_bytes: Vec<u8> = password
603            .encode_utf16()
604            .flat_map(|u| u.to_le_bytes())
605            .collect();
606
607        assert!(utf16_bytes.len() > 512);
608        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
609        assert_eq!(hash_portion, b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
610    }
611}