Skip to main content

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