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    let sid_index = build_sid_index(users);
82
83    // 2) Select ACTIVE targets only (enabled + pwdLastSet within the expiry window).
84    //    The host is the computer FQDN (properties.name); no fqdn->ip lookup.
85    let expiry_days = DEFAULT_EXPIRY_DAYS;
86    let targets: Vec<(String, String)> = computers
87        .iter()
88        .filter(|c| c.is_active(expiry_days))
89        .map(|c| (c.properties().name().clone(), c.object_identifier().clone()))
90        .collect();
91
92    info!("[sessions] {} active target(s) after expiry/enabled filter", targets.len());
93
94    // 3) Enumerate with bounded concurrency (throttle) instead of a serial loop.
95    let sem = Arc::new(Semaphore::new(DEFAULT_CONCURRENCY));
96    let domain = args.domain.clone();
97    let user = smb_user(args.username.as_deref().unwrap_or_default());
98    let password = args.password.clone().unwrap_or_default();
99    let nt_hash = parse_hash(args.hashes.as_deref()); // "LM:NT" | ":NT" | "NT" -> [u8;16]
100    let method = args.collection_method.clone();
101
102    // Kerberos: ccache path from KRB5CCNAME when --kerberos is set (computed once).
103    let kerberos_ccache: Option<String> = if args.kerberos {
104        std::env::var("KRB5CCNAME").ok()
105    } else {
106        None
107    };
108    // KDC (the DC) to request cifs/<host> service tickets from: FQDN, else IP, else domain.
109    let kdc: String = args
110        .ldapfqdn
111        .clone()
112        .filter(|s| !s.is_empty())
113        .or_else(|| args.ip.clone())
114        .unwrap_or_else(|| args.domain.clone());
115
116    let findings: Vec<HostFindings> = stream::iter(targets)
117        .map(|(host, computer_sid)| {
118            let (sem, domain, user, password, method) =
119                (sem.clone(), domain.clone(), user.clone(), password.clone(), method.clone());
120            let nt_hash = nt_hash;
121            let kerberos_ccache = kerberos_ccache.clone();
122            let kdc = kdc.clone();
123            async move {
124                let _permit = sem.acquire().await.unwrap();
125                enumerate_host(
126                    &host, computer_sid, &domain, &user, &password,
127                    nt_hash.as_ref(), kerberos_ccache.as_deref(), &kdc, &method,
128                ).await
129            }
130        })
131        .buffer_unordered(DEFAULT_CONCURRENCY)
132        .collect()
133        .await;
134
135    // 4) Fold findings back into the matching Computer objects (resolving names -> SIDs).
136    let mut total_sessions = 0usize;
137    for hf in &findings {
138        total_sessions += apply_findings(computers, hf, &sid_index, &args.domain);
139        for e in &hf.errors { warn!("{e}"); }
140    }
141    info!("[sessions] {total_sessions} session(s) enumerated in total across {} host(s)",
142          findings.len());
143
144    Ok(())
145}
146
147// Per-host enumeration (adapted from HasSession-rs enumerate_host)
148#[allow(clippy::too_many_arguments)]
149async fn enumerate_host(
150    host: &str, computer_sid: String,
151    domain: &str, user: &str, password: &str,
152    nt_hash: Option<&[u8; 16]>,
153    kerberos_ccache: Option<&str>,
154    kdc: &str,
155    method: &CollectionMethod,
156) -> HostFindings {
157    // SharpHound-style reachability pre-check: 445 open within budget
158    if !is_reachable(host, DEFAULT_PORT_TIMEOUT_MS).await {
159        trace!("[{host}] 445/tcp unreachable - skip");
160        return HostFindings {
161            computer_sid,
162            smb_sessions: Vec::new(), logged_on: Vec::new(), registry: Vec::new(),
163            errors: vec![format!("{host}: 445/tcp unreachable")],
164        };
165    }
166
167    // The RPC body accumulates into its OWN locals and returns them, so it never
168    // aliases the outer findings the timeout wrapper also needs (avoids E0499).
169    let work = async {
170        let mut smb_sessions = Vec::new();
171        let mut logged_on    = Vec::new();
172        let mut registry     = Vec::new();
173        let mut errors       = Vec::new();
174
175        // Inner block uses `?` for the fatal connect/auth/tree steps; the error
176        // is folded into `errors` instead of bubbling out of `work`.
177        let fatal: Result<(), String> = async {
178            // connect + SESSION_SETUP: Kerberos ticket, or password / pass the hash.
179            let mut smb = if let Some(ccache) = kerberos_ccache {
180                // Kerberos: build a cifs/<host> ticket for THIS host.
181                let spn = format!("cifs/{host}");
182                let (gss_blob, session_key) =
183                    crate::transport::kerberos::kerberos_material_for(ccache, &spn, kdc)
184                        .await
185                        .map_err(|e| format!("{host} krb: {e}"))?;
186                let auth = SmbAuth::Kerberos { gss_blob: &gss_blob, session_key: &session_key };
187                connect_ipc(host, domain, user, auth).await.map_err(|e| format!("{host}: {e}"))?
188            } else {
189                let auth = match nt_hash {
190                    Some(h) => SmbAuth::Hash(h),
191                    None    => SmbAuth::Password(password),
192                };
193                connect_ipc(host, domain, user, auth).await.map_err(|e| format!("{host}: {e}"))?
194            };
195
196            if method.srvsvc() {
197                match srvsvc_sessions(&mut smb, host).await {
198                    Ok((_, 5)) => errors.push(format!("[{host}] SRVSVC rc=5 ACCESS_DENIED (hardened / non-admin)")),
199                    Ok((s, _)) => smb_sessions = s,
200                    Err(e)     => errors.push(format!("{host} SRVSVC: {e}")),
201                }
202            }
203            if method.wkssvc() {
204                match enum_wksta(&mut smb, host).await {
205                    Ok((_, 5)) => errors.push(format!("[{host}] WKSSVC rc=5 (local admin required)")),
206                    Ok((u, _)) => logged_on = dedup_wksta(u),
207                    Err(e)     => errors.push(format!("{host} WKSSVC: {e}")),
208                }
209            }
210            if method.registry() {
211                match enum_registry(&mut smb, domain, user, password, nt_hash, host).await {
212                    Ok(sids) => registry = sids,
213                    Err(e)   => errors.push(format!("{host} WINREG: {e} (RemoteRegistry stopped?)")),
214                }
215            }
216            Ok(())
217        }.await;
218
219        if let Err(e) = fatal { errors.push(e); }
220        (smb_sessions, logged_on, registry, errors)
221    };
222
223    // whole-host budget so a slow-but-open host can't stall a worker
224    match timeout(Duration::from_millis(DEFAULT_HOST_TIMEOUT_MS), work).await {
225        Ok((smb_sessions, logged_on, registry, errors)) => HostFindings {
226            computer_sid, smb_sessions, logged_on, registry, errors,
227        },
228        Err(_elapsed) => HostFindings {
229            computer_sid,
230            smb_sessions: Vec::new(), logged_on: Vec::new(), registry: Vec::new(),
231            errors: vec![format!("{host}: per-host timeout")],
232        },
233    }
234}
235
236// Reachability + activity helpers moved to transport::smb::is_reachable
237// and Computer::is_active (shared with the local-group and webclient modules).
238
239// Isolated RPC calls (unchanged from HasSession-rs)
240async fn srvsvc_sessions(smb: &mut SmbClient, host: &str) -> anyhow::Result<(Vec<SmbSession>, u32)> {
241    let pipe = open_rpc_pipe(smb, host, "srvsvc").await?;
242    let mut srv = SrvsvcClient::bind(smb, pipe).await?;
243    let (sessions, rc) = srv.enum_sessions().await?;
244    Ok((sessions.into_iter()
245        .map(|s| SmbSession { user: s.user, _client: s.client }).collect(), rc))
246}
247
248async fn enum_wksta(smb: &mut SmbClient, host: &str) -> anyhow::Result<(Vec<WkstaUser>, u32)> {
249    let pipe = open_rpc_pipe(smb, host, "wkssvc").await?;
250    let mut wk = WkstaUserClient::bind(smb, pipe).await?;
251    Ok(wk.enum_users().await?)
252}
253
254async fn enum_registry(smb: &mut SmbClient, domain: &str, user: &str,
255                       password: &str, nt_hash: Option<&[u8; 16]>, host: &str)
256    -> anyhow::Result<Vec<RegistrySession>>
257{
258    let mut reg = match nt_hash {
259        Some(h) => RegistryClient::connect_hash(smb, domain, user, h, host).await
260                       .map_err(|e| anyhow::anyhow!("{e}"))?,
261        None    => RegistryClient::connect(smb, domain, user, password, host).await
262                       .map_err(|e| anyhow::anyhow!("{e}"))?,
263    };
264    reg.logged_on_sids().await.map_err(|e| anyhow::anyhow!("{e}"))
265}
266
267/// Build the principal -> SID lookup from the users collected during LDAP.
268///
269/// `User::properties().name()` is already "SAMACCOUNTNAME@DOMAIN.FQDN" (UPPER),
270/// which we key directly. We also index the bare SAMAccountName as a fallback,
271/// because SRVSVC/WKSSVC hand back a bare username (no realm). Bare-SAM keys can
272/// collide across trusted domains; the fully-qualified key is always tried first.
273fn build_sid_index(users: &[User]) -> HashMap<String, String> {
274    let mut idx = HashMap::with_capacity(users.len() * 2);
275    for u in users {
276        let sid = u.object_identifier().clone();
277        if sid.is_empty() { continue; }
278        let upn = u.properties().name().to_uppercase(); // SAM@DOMAIN.FQDN
279        if let Some(sam) = upn.split('@').next() {
280            idx.entry(sam.to_string()).or_insert_with(|| sid.clone());
281        }
282        idx.insert(upn, sid);
283    }
284    idx
285}
286
287/// Resolve a principal string coming from an RPC call to a domain SID.
288///
289/// Handles "DOMAIN\\user", "DOMAIN/user" and bare "user"; drops empty / "?" /
290/// machine ("$") principals. Tries the fully-qualified key first, then bare SAM.
291fn resolve(raw: &str, idx: &HashMap<String, String>, domain: &str) -> Option<String> {
292    let bare = raw.rsplit(['\\', '/']).next().unwrap_or(raw).trim();
293    if bare.is_empty() || bare == "?" || bare.ends_with('$') {
294        return None;
295    }
296    let sam = bare.to_uppercase();
297    let upn = format!("{sam}@{}", domain.to_uppercase());
298    idx.get(&upn).or_else(|| idx.get(&sam)).cloned()
299}
300
301/// Construct a { UserSID, ComputerSID } link (fields are private -> use mutators).
302fn mk_link(user_sid: String, computer_sid: String) -> UserComputerSession {
303    let mut ucs = UserComputerSession::new();
304    *ucs.user_sid_mut() = user_sid;
305    *ucs.computer_sid_mut() = computer_sid;
306    ucs
307}
308
309/// De-duplicate WKSSVC logon sessions and drop machine accounts (noise on DCs).
310fn dedup_wksta(users: Vec<WkstaUser>) -> Vec<WkstaUser> {
311    use std::collections::BTreeSet;
312    let mut seen = BTreeSet::new();
313    users.into_iter()
314        .filter(|u| !u.username.ends_with('$'))
315        .filter(|u| seen.insert((u.logon_domain.clone(), u.username.clone())))
316        .collect()
317}
318
319/// Write one host's findings into the matching Computer object, and return the
320/// number of session links written for that host.
321///
322/// SRVSVC   -> Sessions            (resolve username -> UserSID)
323/// WKSSVC   -> PrivilegedSessions  (resolve username -> UserSID)
324/// WINREG   -> RegistrySessions    (r.sid is already a SID, no resolution)
325///
326/// Each block sets Collected = true; unresolved principals are logged at warn!
327/// rather than silently dropped, matching SharpHound's behaviour.
328fn apply_findings(
329    computers: &mut [Computer],
330    hf: &HostFindings,
331    sid_index: &HashMap<String, String>,
332    domain: &str,
333) -> usize {
334    let computer = match computers
335        .iter_mut()
336        .find(|c| c.object_identifier() == &hf.computer_sid)
337    {
338        Some(c) => c,
339        None => {
340            warn!("[sessions] no computer object for SID {}", hf.computer_sid);
341            return 0;
342        }
343    };
344    let comp_sid = hf.computer_sid.clone();
345    let fqdn = computer.properties().name().clone(); // clone before the mutable borrows
346    let mut count = 0usize;
347
348    // SRVSVC -> Sessions
349    {
350        let s = computer.sessions_mut();
351        for sess in &hf.smb_sessions {
352            match resolve(&sess.user, sid_index, domain) {
353                Some(user_sid) => {
354                    trace!("[SRVSVC] {} has session on {fqdn}", sess.user);
355                    s.results_mut().push(mk_link(user_sid, comp_sid.clone()));
356                    count += 1;
357                }
358                None => warn!("[{comp_sid}] unresolved SRVSVC principal '{}'", sess.user),
359            }
360        }
361        *s.collected_mut() = true;
362    }
363
364    // WKSSVC -> PrivilegedSessions
365    {
366        let p = computer.privileged_sessions_mut();
367        for u in &hf.logged_on {
368            match resolve(&u.username, sid_index, domain) {
369                Some(user_sid) => {
370                    trace!("[WKSSVC] {}\\{} has session on {fqdn}", u.logon_domain, u.username);
371                    p.results_mut().push(mk_link(user_sid, comp_sid.clone()));
372                    count += 1;
373                }
374                None => warn!("[{comp_sid}] unresolved WKSSVC principal '{}\\{}'",
375                               u.logon_domain, u.username),
376            }
377        }
378        *p.collected_mut() = true;
379    }
380
381    // WINREG -> RegistrySessions (SIDs already; no resolution needed)
382    {
383        let r = computer.registry_sessions_mut();
384        for reg in &hf.registry {
385            if reg.sid.is_empty() { continue; }
386            trace!("[WINREG] {} has session on {fqdn}", reg.sid);
387            r.results_mut().push(mk_link(reg.sid.clone(), comp_sid.clone()));
388            count += 1;
389        }
390        *r.collected_mut() = true;
391    }
392
393    debug!("[sessions] Total {count} session(s) on {fqdn}");
394    count
395}
396
397/// Parse a hash string into the 16-byte NT hash for pass-the-hash.
398///
399/// Accepts "LMHASH:NTHASH", ":NTHASH" or a bare 32-hex "NTHASH". Returns None
400/// when absent or malformed (caller then falls back to password auth).
401fn parse_hash(h: Option<&str>) -> Option<[u8; 16]> {
402    let raw = h?.trim();
403    let nt = raw.rsplit(':').next().unwrap_or(raw).trim();
404    if nt.len() != 32 || !nt.bytes().all(|b| b.is_ascii_hexdigit()) {
405        return None;
406    }
407    let mut out = [0u8; 16];
408    for (i, byte) in out.iter_mut().enumerate() {
409        *byte = u8::from_str_radix(&nt[i * 2..i * 2 + 2], 16).ok()?;
410    }
411    Some(out)
412}