Skip to main content

rusthound_ce/modules/session/
mod.rs

1//! Session-collection module for RustHound-CE  (issue #46 - HasSession)
2//! <https://bloodhound.specterops.io/resources/edges/has-session#hassession>
3//! <https://github.com/g0h4n/HasSession-rs>
4//!
5//! Runs AFTER the LDAP phase, from `modules::run_modules`, and only when the
6//! collection method actually contacts machines (i.e. NOT `DCOnly`).
7//!
8//! Three native RPC paths (all provided by the `dcerpc` crate), mapped to the
9//! BloodHound CE computer schema:
10//!
11//!   SRVSVC / NetrSessionEnum   -> Computer.Sessions            (HasSession)
12//!   WKSSVC / NetrWkstaUserEnum -> Computer.PrivilegedSessions  (LoggedOn)
13//!   WINREG / HKEY_USERS        -> Computer.RegistrySessions    (LoggedOn)
14//!
15//! SharpHound-style behaviour baked in:
16//!   * reachability pre-check on 445 with a hard timeout (skip dead hosts);
17//!   * "active computer" filter based on pwdLastSet age (ComputerExpiryDays);
18//!   * DCOnly never reaches this module;
19//!   * bounded concurrency (throttle) instead of a serial loop;
20//!   * names resolved to SIDs using the already-collected LDAP data.
21//!
22//! Authentication reuses the SMB transport: password, pass the hash, or a
23//! Kerberos ticket (pass the ticket) when --kerberos is set. Kerberos material
24//! is built per host, since the AP-REQ targets that host's cifs/<host> SPN.
25//!
26//! The target host is the computer FQDN (properties.name, from dNSHostName).
27//! We do NOT use the fqdn->ip map: connections go to the FQDN and rely on DNS.
28
29use std::collections::HashMap;
30use std::error::Error;
31use std::sync::Arc;
32
33use futures::stream::{self, StreamExt};
34use log::{debug, info, trace, warn};
35use tokio::sync::Semaphore;
36use tokio::time::{timeout, Duration};
37
38use dcerpc::rrp::{RegistryClient, RegistrySession};
39use dcerpc::srvsvc::SrvsvcClient;
40use dcerpc::wkssvc::{WkstaUser, WkstaUserClient};
41use smb2_client::SmbClient;
42use crate::transport::smb::{connect_ipc, is_reachable, open_rpc_pipe, smb_user, SmbAuth};
43
44use crate::args::{CollectionMethod, Options};
45use crate::objects::common::UserComputerSession;
46use crate::objects::computer::Computer;
47use crate::objects::user::User;
48
49const DEFAULT_CONCURRENCY: usize = 10;      // ~ SharpHound --Throttle
50const DEFAULT_PORT_TIMEOUT_MS: u64 = 1_500; // 445 pre-check budget
51const DEFAULT_HOST_TIMEOUT_MS: u64 = 8_000; // whole per-host RPC budget
52const DEFAULT_EXPIRY_DAYS: i64 = 60;        // ~ SharpHound --ComputerExpiryDays
53
54// ----
55// Raw per-host findings (kept close to HasSession-rs)
56// ----
57
58struct SmbSession { user: String, _client: String }
59
60struct HostFindings {
61    computer_sid: String,
62    smb_sessions: Vec<SmbSession>,      // SRVSVC
63    logged_on:    Vec<WkstaUser>,       // WKSSVC
64    registry:     Vec<RegistrySession>, // WINREG
65    errors:       Vec<String>,
66}
67
68// Entry point called by run_modules
69pub async fn run(
70    args:      &Options,
71    users:     &[User],           // needed to resolve RPC principal names -> SIDs
72    computers: &mut Vec<Computer>,
73) -> Result<(), Box<dyn Error>> {
74    // Hard guard: DCOnly must never touch a machine.
75    if !args.collection_method.does_session() {
76        debug!("[sessions] collection method does not contact hosts - skipping");
77        return Ok(());
78    }
79
80    // 1) Build the name -> SID resolution table from the LDAP data collected upstream.
81    trace!("[sessions] building SID index from {} user(s)", users.len());
82    let sid_index = build_sid_index(users);
83    trace!("[sessions] SID index built");
84
85    // 2) Select ACTIVE targets only (enabled + pwdLastSet within the expiry window).
86    //    The host is the computer FQDN (properties.name); no fqdn->ip lookup.
87    let expiry_days = DEFAULT_EXPIRY_DAYS;
88    let targets: Vec<(String, String)> = computers
89        .iter()
90        .filter(|c| c.is_active(expiry_days))
91        .map(|c| (c.properties().name().clone(), c.object_identifier().clone()))
92        .collect();
93
94    info!("[sessions] {} active target(s) after expiry/enabled filter", targets.len());
95    for (h, _) in &targets {
96        trace!("[sessions] target host: {h}");
97    }
98
99    // 3) Enumerate with bounded concurrency (throttle) instead of a serial loop.
100    let sem = Arc::new(Semaphore::new(DEFAULT_CONCURRENCY));
101    let domain = args.domain.clone();
102    let user = smb_user(args.username.as_deref().unwrap_or_default());
103    let password = args.password.clone().unwrap_or_default();
104    let nt_hash = parse_hash(args.hashes.as_deref()); // "LM:NT" | ":NT" | "NT" -> [u8;16]
105    let method = args.collection_method.clone();
106
107    // Kerberos: ccache path from KRB5CCNAME when --kerberos is set (computed once).
108    let kerberos_ccache: Option<String> = if args.kerberos {
109        std::env::var("KRB5CCNAME").ok()
110    } else {
111        None
112    };
113    // KDC (the DC) to request cifs/<host> service tickets from: FQDN, else IP, else domain.
114    let kdc: String = args
115        .ldapfqdn
116        .clone()
117        .filter(|s| !s.is_empty())
118        .or_else(|| args.ip.clone())
119        .unwrap_or_else(|| args.domain.clone());
120
121    trace!("[sessions] starting concurrent enumeration (concurrency={DEFAULT_CONCURRENCY})");
122    let findings: Vec<HostFindings> = stream::iter(targets)
123        .map(|(host, computer_sid)| {
124            let (sem, domain, user, password, method) =
125                (sem.clone(), domain.clone(), user.clone(), password.clone(), method.clone());
126            let nt_hash = nt_hash;
127            let kerberos_ccache = kerberos_ccache.clone();
128            let kdc = kdc.clone();
129            async move {
130                trace!("[sessions] [{host}] task scheduled, awaiting concurrency permit");
131                let _permit = sem.acquire().await.unwrap();
132                trace!("[sessions] [{host}] permit acquired, entering enumerate_host");
133                let hf = enumerate_host(
134                    &host, computer_sid, &domain, &user, &password,
135                    nt_hash.as_ref(), kerberos_ccache.as_deref(), &kdc, &method,
136                ).await;
137                trace!("[sessions] [{host}] enumerate_host returned ({} error(s))", hf.errors.len());
138                hf
139            }
140        })
141        .buffer_unordered(DEFAULT_CONCURRENCY)
142        .collect()
143        .await;
144
145    trace!("[sessions] all {} host task(s) completed, folding findings", findings.len());
146
147    // 4) Fold findings back into the matching Computer objects (resolving names -> SIDs).
148    let mut total_sessions = 0usize;
149    for hf in &findings {
150        total_sessions += apply_findings(computers, hf, &sid_index, &args.domain);
151        for e in &hf.errors { warn!("{e}"); }
152    }
153    info!("[sessions] {total_sessions} session(s) enumerated in total across {} host(s)",
154          findings.len());
155
156    Ok(())
157}
158
159// Per-host enumeration (adapted from HasSession-rs enumerate_host)
160#[allow(clippy::too_many_arguments)]
161async fn enumerate_host(
162    host: &str, computer_sid: String,
163    domain: &str, user: &str, password: &str,
164    nt_hash: Option<&[u8; 16]>,
165    kerberos_ccache: Option<&str>,
166    kdc: &str,
167    method: &CollectionMethod,
168) -> HostFindings {
169    trace!("[sessions] [{host}] enumerate_host: begin, running 445 pre-check");
170    // SharpHound-style reachability pre-check: 445 open within budget
171    if !is_reachable(host, DEFAULT_PORT_TIMEOUT_MS).await {
172        trace!("[{host}] 445/tcp unreachable - skip");
173        return HostFindings {
174            computer_sid,
175            smb_sessions: Vec::new(), logged_on: Vec::new(), registry: Vec::new(),
176            errors: vec![format!("{host}: 445/tcp unreachable")],
177        };
178    }
179
180    // The RPC body accumulates into its OWN locals and returns them, so it never
181    // aliases the outer findings the timeout wrapper also needs (avoids E0499).
182    let work = async {
183        let mut smb_sessions = Vec::new();
184        let mut logged_on    = Vec::new();
185        let mut registry     = Vec::new();
186        let mut errors       = Vec::new();
187
188        // Inner block uses `?` for the fatal connect/auth/tree steps; the error
189        // is folded into `errors` instead of bubbling out of `work`.
190        let fatal: Result<(), String> = async {
191            // connect + SESSION_SETUP: Kerberos ticket, or password / pass the hash.
192            let mut smb = if let Some(ccache) = kerberos_ccache {
193                // Kerberos: build a cifs/<host> ticket for THIS host.
194                let spn = format!("cifs/{host}");
195                let (gss_blob, session_key) =
196                    crate::transport::kerberos::kerberos_material_for(ccache, &spn, kdc)
197                        .await
198                        .map_err(|e| format!("{host} krb: {e}"))?;
199                let auth = SmbAuth::Kerberos { gss_blob: &gss_blob, session_key: &session_key };
200                connect_ipc(host, domain, user, auth).await.map_err(|e| format!("{host}: {e}"))?
201            } else {
202                let auth = match nt_hash {
203                    Some(h) => SmbAuth::Hash(h),
204                    None    => SmbAuth::Password(password),
205                };
206                connect_ipc(host, domain, user, auth).await.map_err(|e| format!("{host}: {e}"))?
207            };
208
209            if method.srvsvc() {
210                match srvsvc_sessions(&mut smb, host).await {
211                    Ok((_, 5)) => errors.push(format!("[{host}] SRVSVC rc=5 ACCESS_DENIED (hardened / non-admin)")),
212                    Ok((s, _)) => smb_sessions = s,
213                    Err(e)     => errors.push(format!("{host} SRVSVC: {e}")),
214                }
215            }
216            if method.wkssvc() {
217                match enum_wksta(&mut smb, host).await {
218                    Ok((_, 5)) => errors.push(format!("[{host}] WKSSVC rc=5 (local admin required)")),
219                    Ok((u, _)) => logged_on = dedup_wksta(u),
220                    Err(e)     => errors.push(format!("{host} WKSSVC: {e}")),
221                }
222            }
223            if method.registry() {
224                match enum_registry(&mut smb, domain, user, password, nt_hash, host).await {
225                    Ok(sids) => registry = sids,
226                    Err(e)   => errors.push(format!("{host} WINREG: {e} (RemoteRegistry stopped?)")),
227                }
228            }
229            Ok(())
230        }.await;
231
232        if let Err(e) = fatal { errors.push(e); }
233        (smb_sessions, logged_on, registry, errors)
234    };
235
236    // whole-host budget so a slow-but-open host can't stall a worker
237    match timeout(Duration::from_millis(DEFAULT_HOST_TIMEOUT_MS), work).await {
238        Ok((smb_sessions, logged_on, registry, errors)) => HostFindings {
239            computer_sid, smb_sessions, logged_on, registry, errors,
240        },
241        Err(_elapsed) => HostFindings {
242            computer_sid,
243            smb_sessions: Vec::new(), logged_on: Vec::new(), registry: Vec::new(),
244            errors: vec![format!("{host}: per-host timeout")],
245        },
246    }
247}
248
249// Reachability + activity helpers moved to transport::smb::is_reachable
250// and Computer::is_active (shared with the local-group and webclient modules).
251
252// Isolated RPC calls (unchanged from HasSession-rs)
253async fn srvsvc_sessions(smb: &mut SmbClient, host: &str) -> anyhow::Result<(Vec<SmbSession>, u32)> {
254    let pipe = open_rpc_pipe(smb, host, "srvsvc").await?;
255    let mut srv = SrvsvcClient::bind(smb, pipe).await?;
256    let (sessions, rc) = srv.enum_sessions().await?;
257    Ok((sessions.into_iter()
258        .map(|s| SmbSession { user: s.user, _client: s.client }).collect(), rc))
259}
260
261async fn enum_wksta(smb: &mut SmbClient, host: &str) -> anyhow::Result<(Vec<WkstaUser>, u32)> {
262    let pipe = open_rpc_pipe(smb, host, "wkssvc").await?;
263    let mut wk = WkstaUserClient::bind(smb, pipe).await?;
264    Ok(wk.enum_users().await?)
265}
266
267async fn enum_registry(smb: &mut SmbClient, domain: &str, user: &str,
268                       password: &str, nt_hash: Option<&[u8; 16]>, host: &str)
269    -> anyhow::Result<Vec<RegistrySession>>
270{
271    let mut reg = match nt_hash {
272        Some(h) => RegistryClient::connect_hash(smb, domain, user, h, host).await
273                       .map_err(|e| anyhow::anyhow!("{e}"))?,
274        None    => RegistryClient::connect(smb, domain, user, password, host).await
275                       .map_err(|e| anyhow::anyhow!("{e}"))?,
276    };
277    reg.logged_on_sids().await.map_err(|e| anyhow::anyhow!("{e}"))
278}
279
280/// Build the principal -> SID lookup from the users collected during LDAP.
281///
282/// `User::properties().name()` is already "SAMACCOUNTNAME@DOMAIN.FQDN" (UPPER),
283/// which we key directly. We also index the bare SAMAccountName as a fallback,
284/// because SRVSVC/WKSSVC hand back a bare username (no realm). Bare-SAM keys can
285/// collide across trusted domains; the fully-qualified key is always tried first.
286fn build_sid_index(users: &[User]) -> HashMap<String, String> {
287    let mut idx = HashMap::with_capacity(users.len() * 2);
288    for u in users {
289        let sid = u.object_identifier().clone();
290        if sid.is_empty() { continue; }
291        let upn = u.properties().name().to_uppercase(); // SAM@DOMAIN.FQDN
292        if let Some(sam) = upn.split('@').next() {
293            idx.entry(sam.to_string()).or_insert_with(|| sid.clone());
294        }
295        idx.insert(upn, sid);
296    }
297    idx
298}
299
300/// Resolve a principal string coming from an RPC call to a domain SID.
301///
302/// Handles "DOMAIN\\user", "DOMAIN/user" and bare "user"; drops empty / "?" /
303/// machine ("$") principals. Tries the fully-qualified key first, then bare SAM.
304fn resolve(raw: &str, idx: &HashMap<String, String>, domain: &str) -> Option<String> {
305    let bare = raw.rsplit(['\\', '/']).next().unwrap_or(raw).trim();
306    if bare.is_empty() || bare == "?" || bare.ends_with('$') {
307        return None;
308    }
309    let sam = bare.to_uppercase();
310    let upn = format!("{sam}@{}", domain.to_uppercase());
311    idx.get(&upn).or_else(|| idx.get(&sam)).cloned()
312}
313
314/// Construct a { UserSID, ComputerSID } link (fields are private -> use mutators).
315fn mk_link(user_sid: String, computer_sid: String) -> UserComputerSession {
316    let mut ucs = UserComputerSession::new();
317    *ucs.user_sid_mut() = user_sid;
318    *ucs.computer_sid_mut() = computer_sid;
319    ucs
320}
321
322/// De-duplicate WKSSVC logon sessions and drop machine accounts (noise on DCs).
323fn dedup_wksta(users: Vec<WkstaUser>) -> Vec<WkstaUser> {
324    use std::collections::BTreeSet;
325    let mut seen = BTreeSet::new();
326    users.into_iter()
327        .filter(|u| !u.username.ends_with('$'))
328        .filter(|u| seen.insert((u.logon_domain.clone(), u.username.clone())))
329        .collect()
330}
331
332/// Write one host's findings into the matching Computer object, and return the
333/// number of session links written for that host.
334///
335/// SRVSVC   -> Sessions            (resolve username -> UserSID)
336/// WKSSVC   -> PrivilegedSessions  (resolve username -> UserSID)
337/// WINREG   -> RegistrySessions    (r.sid is already a SID, no resolution)
338///
339/// Each block sets Collected = true; unresolved principals are logged at warn!
340/// rather than silently dropped, matching SharpHound's behaviour.
341fn apply_findings(
342    computers: &mut [Computer],
343    hf: &HostFindings,
344    sid_index: &HashMap<String, String>,
345    domain: &str,
346) -> usize {
347    let computer = match computers
348        .iter_mut()
349        .find(|c| c.object_identifier() == &hf.computer_sid)
350    {
351        Some(c) => c,
352        None => {
353            warn!("[sessions] no computer object for SID {}", hf.computer_sid);
354            return 0;
355        }
356    };
357    let comp_sid = hf.computer_sid.clone();
358    let fqdn = computer.properties().name().clone(); // clone before the mutable borrows
359    let mut count = 0usize;
360
361    // SRVSVC -> Sessions
362    {
363        let s = computer.sessions_mut();
364        for sess in &hf.smb_sessions {
365            match resolve(&sess.user, sid_index, domain) {
366                Some(user_sid) => {
367                    trace!("[SRVSVC] {} has session on {fqdn}", sess.user);
368                    s.results_mut().push(mk_link(user_sid, comp_sid.clone()));
369                    count += 1;
370                }
371                None => warn!("[{comp_sid}] unresolved SRVSVC principal '{}'", sess.user),
372            }
373        }
374        *s.collected_mut() = true;
375    }
376
377    // WKSSVC -> PrivilegedSessions
378    {
379        let p = computer.privileged_sessions_mut();
380        for u in &hf.logged_on {
381            match resolve(&u.username, sid_index, domain) {
382                Some(user_sid) => {
383                    trace!("[WKSSVC] {}\\{} has session on {fqdn}", u.logon_domain, u.username);
384                    p.results_mut().push(mk_link(user_sid, comp_sid.clone()));
385                    count += 1;
386                }
387                None => warn!("[{comp_sid}] unresolved WKSSVC principal '{}\\{}'",
388                               u.logon_domain, u.username),
389            }
390        }
391        *p.collected_mut() = true;
392    }
393
394    // WINREG -> RegistrySessions (SIDs already; no resolution needed)
395    {
396        let r = computer.registry_sessions_mut();
397        for reg in &hf.registry {
398            if reg.sid.is_empty() { continue; }
399            trace!("[WINREG] {} has session on {fqdn}", reg.sid);
400            r.results_mut().push(mk_link(reg.sid.clone(), comp_sid.clone()));
401            count += 1;
402        }
403        *r.collected_mut() = true;
404    }
405
406    debug!("[sessions] Total {count} session(s) on {fqdn}");
407    count
408}
409
410/// Parse a hash string into the 16-byte NT hash for pass-the-hash.
411///
412/// Accepts "LMHASH:NTHASH", ":NTHASH" or a bare 32-hex "NTHASH". Returns None
413/// when absent or malformed (caller then falls back to password auth).
414fn parse_hash(h: Option<&str>) -> Option<[u8; 16]> {
415    let raw = h?.trim();
416    let nt = raw.rsplit(':').next().unwrap_or(raw).trim();
417    if nt.len() != 32 || !nt.bytes().all(|b| b.is_ascii_hexdigit()) {
418        return None;
419    }
420    let mut out = [0u8; 16];
421    for (i, byte) in out.iter_mut().enumerate() {
422        *byte = u8::from_str_radix(&nt[i * 2..i * 2 + 2], 16).ok()?;
423    }
424    Some(out)
425}