Skip to main content

rusthound_ce/transport/
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: Option<&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 let Some(fqdn) = ldapfqdn.filter(|f| !f.is_empty()) {
103            #[cfg(not(feature = "nogssapi"))]
104            gssapi_connection(&mut ldap, fqdn, &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            //  Control 1: Set control LDAP_SERVER_SD_FLAGS_OID to get nTSecurityDescriptor
139            // https://ldapwiki.com/wiki/LDAP_SERVER_SD_FLAGS_OID
140            let sd_flags = RawControl {
141                ctype: String::from("1.2.840.113556.1.4.801"),
142                crit: true,
143                val: Some(vec![48, 3, 2, 1, 5]),    // SEQUENCE { INTEGER 5 }
144            };
145
146            // Control 2: LDAP_SERVER_SHOW_DELETED_OID (deleted objects)
147            // https://ldapwiki.com/wiki/IsDeleted 
148            let show_deleted = RawControl {
149                ctype: String::from("1.2.840.113556.1.4.417"),
150                crit: false,    // false ignored if not supported
151                val: None,
152            };
153
154            let controls = vec![sd_flags, show_deleted];
155            ldap.with_controls(controls.to_owned());
156
157            // Prepare filter
158            // let mut _s_filter: &str = "";
159            // if cn.contains("Configuration") {
160            //     _s_filter = "(|(objectclass=pKIEnrollmentService)(objectclass=pkicertificatetemplate)(objectclass=subschema)(objectclass=certificationAuthority)(objectclass=container))";
161            // } else {
162            //     _s_filter = "(objectClass=*)";
163            // }
164            //let _s_filter = "(objectClass=*)";
165            //let _s_filter = "(objectGuid=*)";
166            info!("Ldap filter : {}", ldapfilter.bold().green());
167            let _s_filter = ldapfilter;
168
169            // Every 999 max value in ldap response (err 4 ldap)
170            let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
171                Box::new(EntriesOnly::new()),
172                Box::new(PagedResults::new(999)),
173            ];
174
175            // Streaming search with adaptaters and filters
176            let mut search = ldap
177                .streaming_search_with(
178                    adapters, // Adapter which fetches Search results with a Paged Results control.
179                    cn,
180                    Scope::Subtree,
181                    _s_filter,
182                    vec!["*", "nTSecurityDescriptor"],
183                    // 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.
184                    // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/932a7a8d-8c93-4448-8093-c79b7d9ba499
185                )
186                .await?;
187
188            // Wait and get next values
189            let pb = ProgressBar::new(1);
190            let mut count = 0;
191            while let Some(entry) = search.next().await? {
192                let entry = SearchEntry::construct(entry);
193                //trace!("{:?}", &entry);
194                total += 1;
195                // Manage progress bar
196                count += 1;
197                progress_bar(
198                    pb.to_owned(),
199                    "LDAP objects retrieved".to_string(),
200                    count,
201                    "#".to_string(),
202                );
203
204                storage.add(entry.into())?;
205            }
206            pb.finish_and_clear();
207
208            let res = search.finish().await.success();
209            match res {
210                Ok(_res) => info!("All data collected for NamingContext {}", &cn.bold()),
211                Err(err) => {
212                    error!("No data collected on {}! Reason: {err}", &cn.bold().red());
213                }
214            }
215        }
216        // // If no result exit program
217        // if rs.is_empty() {
218        //     process::exit(0x0100);
219        // }
220
221        ldap.unbind().await?;
222    }
223
224    // drop ldap before final flush,
225    // otherwise it will warn about an i/o error
226    // "LDAP connection error: I/O error: Connection reset by peer (os error 54)"
227    drop(ldap);
228    if total == 0 {
229        error!("No LDAP objects found! Exiting...");
230        // std::fs::remove_file(cache_path)?; // TODO: return error so we can cleanup cache
231        process::exit(0x0100);
232    }
233
234    storage.flush()?;
235
236
237    // Return the vector with the result
238    Ok(total)
239}
240
241/// Structure containing the LDAP connection arguments.
242struct LdapArgs {
243    s_url: String,
244    _s_dc: Vec<String>,
245    _s_email: String,
246    s_username: String,
247    s_password: String,
248    s_ntlm_password: Option<String>,
249}
250
251/// Function to prepare LDAP arguments.
252fn ldap_constructor(
253    ldaps: bool,
254    ip: Option<&str>,
255    port: Option<u16>,
256    domain: &str,
257    ldapfqdn: Option<&str>,
258    username: Option<&str>,
259    password: Option<&str>,
260    hashes: Option<&str>,
261    kerberos: bool,
262) -> Result<LdapArgs, Box<dyn Error>> {
263    // Prepare ldap url
264    let s_url = prepare_ldap_url(ldaps, ip, port, domain);
265
266    // Prepare full DC chain
267    let s_dc = prepare_ldap_dc(domain);
268
269    let use_ntlm = hashes.is_some();
270
271    // Username prompt
272    let mut s = String::new();
273    let mut _s_username: String;
274    if username.is_none() && !kerberos {
275        print!("Username: ");
276        io::stdout().flush()?;
277        stdin()
278            .read_line(&mut s)
279            .expect("Did not enter a correct username");
280        io::stdout().flush()?;
281        if let Some('\n') = s.chars().next_back() {
282            s.pop();
283        }
284        if let Some('\r') = s.chars().next_back() {
285            s.pop();
286        }
287        _s_username = s.to_owned();
288    } else {
289        _s_username = username.unwrap_or("not set").to_owned();
290    }
291
292    // Format username and email
293    let mut s_email: String = "".to_owned();
294    if !_s_username.contains("@") {
295        s_email.push_str(&_s_username.to_string());
296        s_email.push_str("@");
297        s_email.push_str(domain);
298        if !use_ntlm {
299            _s_username = s_email.to_string();
300        }
301    } else {
302        s_email = _s_username.to_string().to_lowercase();
303    }
304
305    // For NTLM, format username as DOMAIN\user for sspi
306    if use_ntlm && !_s_username.contains("\\") && !_s_username.contains("@") {
307        let domain_upper = domain.split('.').next().unwrap_or(domain).to_uppercase();
308        _s_username = format!("{}\\{}", domain_upper, _s_username);
309    }
310
311    // Validate and build NTLM password from NT hash if provided
312    let s_ntlm_password = match hashes {
313        Some(hash) => {
314            let clean = hash.trim();
315            // Accept [NTHASH, :NTHASH, LMHASH:NTHASH]
316            let nt = match clean.split_once(':') {
317                Some((_lm, nt)) => nt,
318                None => clean,
319            };
320            if nt.len() != 32 || !nt.chars().all(|c| c.is_ascii_hexdigit()) {
321                error!("Invalid NT hash: must be exactly 32 hex characters (e.g. aad3b435b51404eeaad3b435b51404ee)");
322                process::exit(0x0100);
323            }
324            Some(nt_hash_to_ntlm_password(nt))
325        }
326        None => None,
327    };
328
329    // Password prompt (skip when using NTLM hash)
330    let mut _s_password: String = String::new();
331    if !use_ntlm && !_s_username.contains("not set") && !kerberos {
332        _s_password = match password {
333            Some(p) => p.to_owned(),
334            None => rpassword::prompt_password("Password: ").unwrap_or("not set".to_string()),
335        };
336    } else {
337        _s_password = password.unwrap_or("not set").to_owned();
338    }
339    
340    // Print infos if verbose mod is set
341    debug!("IP: {}", match ip {
342        Some(ip) => ip,
343        None => "not set"
344    });
345    debug!("PORT: {}", match port {
346        Some(p) => {
347            p.to_string()
348        },
349        None => "not set".to_owned()
350    });
351    debug!("FQDN: {}", ldapfqdn.unwrap_or("not set"));
352    debug!("Url: {}", s_url);
353    debug!("Domain: {}", domain);
354    debug!("Username: {}", _s_username);
355    debug!("Email: {}", s_email.to_lowercase());
356    if use_ntlm {
357        debug!("Auth: NTLM pass-the-hash");
358    } else {
359        debug!("Password: {}", _s_password);
360    }
361    debug!("DC: {:?}", s_dc);
362    debug!("Kerberos: {:?}", kerberos);
363
364    Ok(LdapArgs {
365        s_url: s_url.to_string(),
366        _s_dc: s_dc,
367        _s_email: s_email.to_string().to_lowercase(),
368        s_username: if use_ntlm {
369            _s_username.to_string()
370        } else {
371            s_email.to_string().to_lowercase()
372        },
373        s_password: _s_password.to_string(),
374        s_ntlm_password,
375    })
376}
377
378/// Encode an NT hash into a password string that triggers pass-the-hash
379/// in the sspi crate's NTLM implementation.
380fn nt_hash_to_ntlm_password(hex_hash: &str) -> String {
381    let upper = hex_hash.to_uppercase();
382    let bytes = upper.as_bytes();
383
384    let mut password = String::new();
385
386    for pair in bytes.chunks(2) {
387        let low_byte = pair[0] as u32;
388        let high_byte = if pair.len() > 1 { pair[1] as u32 } else { 0 };
389        let code_point = (high_byte << 8) | low_byte;
390        password.push(char::from_u32(code_point).unwrap_or('\0'));
391    }
392
393    // Pad to exceed the 512-byte SSPI_CREDENTIALS_HASH_LENGTH_OFFSET
394    for _ in 0..256 {
395        password.push('\0');
396    }
397
398    password
399}
400
401/// Function to prepare LDAP url.
402fn prepare_ldap_url(
403    ldaps: bool,
404    ip: Option<&str>,
405    port: Option<u16>,
406    domain: &str
407) -> String {
408    let protocol = if ldaps || port.unwrap_or(0) == 636 {
409        "ldaps"
410    } else {
411        "ldap"
412    };
413
414    let target = match ip {
415        Some(ip) => ip,
416        None => domain,
417    };
418
419    match port {
420        Some(port) => {
421            format!("{protocol}://{target}:{port}")
422        }
423        None => {
424            format!("{protocol}://{target}")
425        }
426    }
427}
428
429/// Function to prepare LDAP DC from DOMAIN.LOCAL
430pub fn prepare_ldap_dc(domain: &str) -> Vec<String> {
431
432    let mut dc: String = "".to_owned();
433    let mut naming_context: Vec<String> = Vec::new();
434
435    // Format DC
436    if !domain.contains(".") {
437        dc.push_str("DC=");
438        dc.push_str(domain);
439        naming_context.push(dc[..].to_string());
440    }
441    else {
442        naming_context.push(domain_to_dc(domain));
443    }
444
445    // For ADCS values
446    naming_context.push(format!("{}{}", "CN=Configuration,", &dc[..])); 
447    naming_context
448}
449
450/// Function to make GSSAPI ldap connection.
451#[cfg(not(feature = "nogssapi"))]
452async fn gssapi_connection(
453    ldap: &mut ldap3::Ldap,
454    ldapfqdn: &str,
455    domain: &str,
456) -> Result<(), Box<dyn Error>> {
457    let res = ldap.sasl_gssapi_bind(ldapfqdn).await?.success();
458    match res {
459        Ok(_res) => {
460            info!("Connected to {} Active Directory!", domain.to_uppercase().bold().green());
461            info!("Starting data collection...");
462        }
463        Err(err) => {
464            error!("Failed to authenticate to {} Active Directory. Reason: {err}\n", domain.to_uppercase().bold().red());
465            process::exit(0x0100);
466        }
467    }
468    Ok(())
469}
470
471/// Get all namingContext for DC
472pub async fn get_all_naming_contexts(
473    ldap: &mut ldap3::Ldap
474) -> Result<Vec<String>, Box<dyn Error>> {
475    // Every 999 max value in ldap response (err 4 ldap)
476    let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
477        Box::new(EntriesOnly::new()),
478        Box::new(PagedResults::new(999)),
479    ];
480
481    // First LDAP request to get all namingContext
482    let mut search = ldap.streaming_search_with(
483        adapters,
484        "", 
485        Scope::Base,
486        "(objectClass=*)",
487        vec!["namingContexts"],
488    ).await?;
489
490    // Prepare LDAP result vector
491    let mut rs: Vec<SearchEntry> = Vec::new();
492    while let Some(entry) = search.next().await? {
493        let entry = SearchEntry::construct(entry);
494        rs.push(entry);
495    }
496    let res = search.finish().await.success();
497
498    // Prepare vector for all namingContexts result
499    let mut naming_contexts: Vec<String> = Vec::new();
500    match res {
501        Ok(_res) => {
502            debug!("All namingContexts collected!");
503            for result in rs {
504                let result_attrs: HashMap<String, Vec<String>> = result.attrs;
505
506                for (_key, value) in &result_attrs {
507                    for naming_context in value {
508                        debug!("namingContext found: {}",&naming_context.bold().green());
509                        naming_contexts.push(naming_context.to_string());
510                    }
511                }
512            }
513            
514            // Put CN=Schema first so schema_guid_map is complete before ACEs are parsed
515            naming_contexts.sort_by_key(|cn| {
516                if cn.contains("CN=Schema") { 0 }
517                else if cn.to_lowercase().starts_with("dc=") { 1 }
518                else if cn.contains("CN=Configuration") { 2 }
519                else { 3 }
520            });
521
522            // Trace sorted naming contexts order
523            for (i, nc) in naming_contexts.iter().enumerate() {
524                trace!("NamingContext order [{}]: {}", i, nc);
525            }
526
527            return Ok(naming_contexts)
528        }
529        Err(err) => {
530            error!("No namingContexts found! Reason: {err}");
531        }
532    }
533    // Empty result if no namingContexts found
534    Ok(Vec::new())
535}
536
537// New type to implement Serialize and Deserialize for SearchEntry
538#[derive(Debug, Clone, bincode::Encode, bincode::Decode)]
539pub struct LdapSearchEntry {
540    /// Entry DN.
541    pub dn: String,
542    /// Attributes.
543    pub attrs: HashMap<String, Vec<String>>,
544    /// Binary-valued attributes.
545    pub bin_attrs: HashMap<String, Vec<Vec<u8>>>,
546}
547
548impl From<SearchEntry> for LdapSearchEntry {
549    fn from(entry: SearchEntry) -> Self {
550        LdapSearchEntry {
551            dn: entry.dn,
552            attrs: entry.attrs,
553            bin_attrs: entry.bin_attrs,
554        }
555    }
556}
557
558impl From<LdapSearchEntry> for SearchEntry {
559    fn from(entry: LdapSearchEntry) -> Self {
560        SearchEntry {
561            dn: entry.dn,
562            attrs: entry.attrs,
563            bin_attrs: entry.bin_attrs,
564        }
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn nt_hash_encoding_roundtrip() {
574        let hash = "aad3b435b51404eeaad3b435b51404ee";
575        let password = nt_hash_to_ntlm_password(hash);
576
577        let utf16_bytes: Vec<u8> = password
578            .encode_utf16()
579            .flat_map(|u| u.to_le_bytes())
580            .collect();
581
582        assert!(utf16_bytes.len() > 512);
583
584        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
585        assert_eq!(hash_portion.len(), 32);
586
587        let expected_hex = hash.to_uppercase();
588        let expected_bytes = expected_hex.as_bytes();
589        assert_eq!(hash_portion, expected_bytes);
590    }
591
592    #[test]
593    fn nt_hash_encoding_all_zeros() {
594        let hash = "00000000000000000000000000000000";
595        let password = nt_hash_to_ntlm_password(hash);
596
597        let utf16_bytes: Vec<u8> = password
598            .encode_utf16()
599            .flat_map(|u| u.to_le_bytes())
600            .collect();
601
602        assert!(utf16_bytes.len() > 512);
603        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
604        assert_eq!(hash_portion, b"00000000000000000000000000000000");
605    }
606
607    #[test]
608    fn nt_hash_encoding_all_f() {
609        let hash = "ffffffffffffffffffffffffffffffff";
610        let password = nt_hash_to_ntlm_password(hash);
611
612        let utf16_bytes: Vec<u8> = password
613            .encode_utf16()
614            .flat_map(|u| u.to_le_bytes())
615            .collect();
616
617        assert!(utf16_bytes.len() > 512);
618        let hash_portion = &utf16_bytes[..utf16_bytes.len() - 512];
619        assert_eq!(hash_portion, b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
620    }
621}