Skip to main content

smb2_client/
client.rs

1//! SmbClient — drives the SMB2 exchange up to a usable named-pipe transport for RPC.
2//! Flow: connect → negotiate → session-setup (NTLM, two round trips) → tree-connect IPC$
3//! → create(pipe) → transact(). Post-authentication messages are signed.
4
5use crate::header::{self, cmd};
6use crate::transport::SmbTransport;
7use crate::{msg, Result, SmbError};
8use ntlmssp::Ntlm;
9use rand::RngCore;
10
11/// A logon credential: a plaintext password or a raw NT hash (pass-the-hash).
12pub enum Cred<'a> {
13    Password(&'a str),
14    NtHash([u8; 16]),
15}
16
17pub struct SmbClient {
18    transport: SmbTransport,
19    message_id: u64,
20    session_id: u64,
21    tree_id: u32,
22    sign_key: Option<[u8; 16]>,
23    dialect: u16,
24}
25
26impl SmbClient {
27    pub async fn connect(host: &str) -> Result<Self> {
28        Ok(SmbClient {
29            transport: SmbTransport::connect(host).await?,
30            message_id: 0,
31            session_id: 0,
32            tree_id: 0,
33            sign_key: None,
34            dialect: 0,
35        })
36    }
37
38    /// Send one command, returning the full response message (header + body).
39    async fn call(&mut self, command: u16, body: &[u8]) -> Result<Vec<u8>> {
40        let mut m = header::build(
41            command,
42            self.message_id,
43            self.session_id,
44            self.tree_id,
45            self.sign_key.is_some(),
46        );
47        m.extend_from_slice(body);
48        if let Some(key) = &self.sign_key {
49            if self.dialect >= 0x0300 {
50                header::sign_v3(&mut m, key); // 3.0.x → AES-CMAC
51            } else {
52                header::sign(&mut m, key); // 2.x → HMAC-SHA256
53            }
54        }
55        self.message_id += 1;
56        self.transport.send(&m).await?;
57        let mut resp = self.transport.recv().await?;
58        // A server may answer asynchronously: an interim STATUS_PENDING response, then the
59        // real one on the same message id. Keep reading until the completion arrives.
60        while header::parse(&resp)
61            .map(|p| p.status)
62            .unwrap_or(crate::status::SUCCESS)
63            == crate::status::PENDING
64        {
65            resp = self.transport.recv().await?;
66        }
67        Ok(resp)
68    }
69
70    fn ok(resp: &[u8], expect: u16) -> Result<header::Parsed> {
71        let p = header::parse(resp)?;
72        if p.status != crate::status::SUCCESS {
73            return Err(SmbError::Status(p.status, expect));
74        }
75        Ok(p)
76    }
77
78    /// Unauthenticated NEGOTIATE probe: returns (dialect revision, signing_required). Signing
79    /// NOT required marks a host as an NTLM-relay target. Cheap — no session setup.
80    pub async fn probe_signing(&mut self) -> Result<(u16, bool)> {
81        let mut guid = [0u8; 16];
82        rand::thread_rng().fill_bytes(&mut guid);
83        let resp = self.call(cmd::NEGOTIATE, &msg::negotiate(&guid)).await?;
84        Self::ok(&resp, cmd::NEGOTIATE)?;
85        // NEGOTIATE response body @64: StructureSize(2), SecurityMode(2), DialectRevision(2).
86        let security_mode = u16::from_le_bytes([resp[66], resp[67]]);
87        let dialect = u16::from_le_bytes([resp[68], resp[69]]);
88        Ok((dialect, security_mode & 0x0002 != 0)) // 0x2 = SMB2_NEGOTIATE_SIGNING_REQUIRED
89    }
90
91    /// Negotiate + NTLM session setup with a plaintext password.
92    pub async fn login(
93        &mut self,
94        host: &str,
95        domain: &str,
96        user: &str,
97        password: &str,
98    ) -> Result<()> {
99        self.login_cred(host, domain, user, Cred::Password(password))
100            .await
101    }
102
103    /// Pass-the-ticket: negotiate + Kerberos session setup with a pre-built GSS/SPNEGO AP-REQ
104    /// blob and the 16-byte GSS session key (the AP-REQ authenticator subkey). Single-shot — no
105    /// NTLM challenge round-trip. Subsequent messages are signed with the Kerberos session key.
106    pub async fn login_kerberos(&mut self, gss_blob: &[u8], session_key: &[u8; 16]) -> Result<()> {
107        let mut guid = [0u8; 16];
108        rand::thread_rng().fill_bytes(&mut guid);
109        let resp = self.call(cmd::NEGOTIATE, &msg::negotiate(&guid)).await?;
110        Self::ok(&resp, cmd::NEGOTIATE)?;
111        self.dialect = u16::from_le_bytes([resp[68], resp[69]]);
112
113        let key = if self.dialect >= 0x0300 {
114            header::kdf_signing_key(session_key)
115        } else {
116            *session_key
117        };
118        // SESSION_SETUP with the Kerberos AP-REQ (single-shot; session_id assigned in the reply).
119        let resp = self
120            .call(cmd::SESSION_SETUP, &msg::session_setup(gss_blob))
121            .await?;
122        let p = header::parse(&resp)?;
123        self.session_id = p.session_id;
124        if p.status != crate::status::SUCCESS {
125            return Err(SmbError::Status(p.status, cmd::SESSION_SETUP));
126        }
127        self.sign_key = Some(key); // sign everything from here on
128        Ok(())
129    }
130
131    /// Null / anonymous session: negotiate + NTLM session setup with empty
132    /// domain / user / password. On a DC that still permits anonymous IPC$
133    /// (`RestrictAnonymous=0`) this yields a session usable for
134    /// SAMR / LSAT RID-cycling and share enumeration; on a hardened DC
135    /// (2019+ default) the server returns `STATUS_ACCESS_DENIED` /
136    /// `STATUS_LOGON_FAILURE` and the caller reports the box as hardened.
137    ///
138    /// Uses an empty-credential NTLMv2 exchange (the classic
139    /// `""` / `""` / `""` null bind). No signing key results from an
140    /// anonymous logon, so the session is unsigned — callers must not
141    /// attempt signed operations on it.
142    pub async fn login_null(&mut self, host: &str) -> Result<()> {
143        self.login_cred(host, "", "", Cred::Password("")).await
144    }
145
146    /// Pass-the-hash: negotiate + NTLM session setup with a raw NT hash.
147    pub async fn login_hash(
148        &mut self,
149        host: &str,
150        domain: &str,
151        user: &str,
152        nt: &[u8; 16],
153    ) -> Result<()> {
154        self.login_cred(host, domain, user, Cred::NtHash(*nt)).await
155    }
156
157    /// Negotiate + NTLM session setup with either a password or an NT hash.
158    pub async fn login_cred(
159        &mut self,
160        host: &str,
161        domain: &str,
162        user: &str,
163        cred: Cred<'_>,
164    ) -> Result<()> {
165        // NEGOTIATE
166        let mut guid = [0u8; 16];
167        rand::thread_rng().fill_bytes(&mut guid);
168        let resp = self.call(cmd::NEGOTIATE, &msg::negotiate(&guid)).await?;
169        Self::ok(&resp, cmd::NEGOTIATE)?;
170        // DialectRevision is at response body offset 4 → absolute 68; it selects the signing algo.
171        self.dialect = u16::from_le_bytes([resp[68], resp[69]]);
172
173        // SESSION_SETUP #1: NTLM NEGOTIATE wrapped in SPNEGO negTokenInit.
174        let ntlm = Ntlm::new();
175        let init = crate::spnego::negotiate_init(ntlm.negotiate());
176        let resp = self
177            .call(cmd::SESSION_SETUP, &msg::session_setup(&init))
178            .await?;
179        let p = header::parse(&resp)?;
180        if p.status != crate::status::MORE_PROCESSING_REQUIRED {
181            return Err(SmbError::Status(p.status, cmd::SESSION_SETUP));
182        }
183        self.session_id = p.session_id;
184
185        // The server CHALLENGE (Type 2) is embedded in a SPNEGO negTokenResp.
186        let blob = msg::session_setup_token(&resp)?;
187        let challenge = crate::spnego::find_ntlm(&blob).ok_or(SmbError::BadToken)?;
188
189        // Build AUTHENTICATE; the exported session key becomes our signing key.
190        let (type3, session_key) = match cred {
191            Cred::Password(pw) => ntlm.authenticate(challenge, domain, user, pw, host),
192            Cred::NtHash(nt) => ntlm.authenticate_hash(challenge, domain, user, &nt, host),
193        }
194        .map_err(|e| SmbError::Ntlm(e.to_string()))?;
195
196        // Derive the signing key: 2.x uses the session key directly, 3.0.x derives an
197        // AES-CMAC key from it (SP800-108 KDF).
198        let key = if self.dialect >= 0x0300 {
199            header::kdf_signing_key(&session_key)
200        } else {
201            session_key
202        };
203        // SESSION_SETUP #2: AUTHENTICATE (Type 3). SMB 3.x requires the final session setup to
204        // be signed with the new key; 2.x leaves it unsigned (matching Windows).
205        if self.dialect >= 0x0300 {
206            self.sign_key = Some(key);
207        }
208        let token = crate::spnego::negotiate_resp(&type3);
209        let resp = self
210            .call(cmd::SESSION_SETUP, &msg::session_setup(&token))
211            .await?;
212        Self::ok(&resp, cmd::SESSION_SETUP)?;
213        self.sign_key = Some(key); // sign everything from here on
214        Ok(())
215    }
216
217    /// Connect to a share, e.g. `\\dc01\IPC$`.
218    pub async fn tree_connect(&mut self, unc: &str) -> Result<()> {
219        let resp = self
220            .call(cmd::TREE_CONNECT, &msg::tree_connect(unc))
221            .await?;
222        let p = Self::ok(&resp, cmd::TREE_CONNECT)?;
223        self.tree_id = p.tree_id;
224        Ok(())
225    }
226
227    /// Open a named pipe on the connected tree and return its FileId.
228    pub async fn open_pipe(&mut self, name: &str) -> Result<[u8; 16]> {
229        let resp = self.call(cmd::CREATE, &msg::create_pipe(name)).await?;
230        Self::ok(&resp, cmd::CREATE)?;
231        msg::create_file_id(&resp)
232    }
233
234    /// Read up to `max` bytes from a pipe (SMB2 READ). Returns empty at end-of-pipe. Used to
235    /// drain RPC response fragments that don't fit one FSCTL_PIPE_TRANSCEIVE.
236    pub async fn read_pipe(&mut self, file_id: &[u8; 16], max: u32) -> Result<Vec<u8>> {
237        let resp = self
238            .call(cmd::READ, &msg::read_req(file_id, 0, max))
239            .await?;
240        let p = header::parse(&resp)?;
241        if p.status != crate::status::SUCCESS {
242            return Ok(Vec::new()); // END_OF_FILE / no more data
243        }
244        msg::read_output(&resp)
245    }
246
247    /// Write to a pipe/file with no read back — used for a fire-and-forget RPC AUTH3.
248    pub async fn write_pipe(&mut self, file_id: &[u8; 16], data: &[u8]) -> Result<()> {
249        let resp = self
250            .call(cmd::WRITE, &msg::write_req(file_id, 0, data))
251            .await?;
252        Self::ok(&resp, cmd::WRITE)?;
253        Ok(())
254    }
255
256    /// One RPC round trip over the pipe (FSCTL_PIPE_TRANSCEIVE): send `data`, return output.
257    pub async fn transact(&mut self, file_id: &[u8; 16], data: &[u8]) -> Result<Vec<u8>> {
258        let resp = self
259            .call(cmd::IOCTL, &msg::ioctl_transceive(file_id, data))
260            .await?;
261        Self::ok(&resp, cmd::IOCTL)?;
262        msg::ioctl_output(&resp)
263    }
264
265    /// Read a whole file off the currently-connected disk share and delete it on close.
266    /// `path` is relative to the share root (e.g. `Windows\Temp\out.txt`). Retries the open
267    /// while the file does not yet exist — an async writer (e.g. our exec child) may still be
268    /// starting. Returns the file contents (possibly empty). Tree-connect the share first.
269    pub async fn read_file_delete(&mut self, path: &str) -> Result<Vec<u8>> {
270        use crate::status;
271        const ACCESS: u32 = 0x0013_0081; // READ_DATA | READ_ATTRS | READ_CONTROL | DELETE | SYNCHRONIZE
272        const SHARE: u32 = 0x0000_0007; // R | W | D
273        const OPEN: u32 = 0x0000_0001; // FILE_OPEN (fail if absent)
274        const OPTS: u32 = 0x0000_1060; // NON_DIRECTORY | SYNCHRONOUS_IO_NONALERT | DELETE_ON_CLOSE
275
276        // Poll for the file: it may not exist yet (writer still spawning →
277        // OBJECT_NAME_NOT_FOUND) or the writer may still hold it without share-delete (→
278        // SHARING_VIOLATION). Both are transient; wait for the child to finish and release.
279        // Cap: 12 attempts × 250 ms = 3 s. When the writer legitimately succeeded the file
280        // shows up in well under a second; longer polling just draws out the failure case
281        // (e.g. `reg save HKLM\SAM` refused on a hardened DC, file will never appear).
282        let mut file_id = None;
283        let mut last = status::OBJECT_NAME_NOT_FOUND;
284        for attempt in 0..12 {
285            let resp = self
286                .call(
287                    cmd::CREATE,
288                    &msg::create_file(path, ACCESS, SHARE, OPEN, OPTS),
289                )
290                .await?;
291            let p = header::parse(&resp)?;
292            if p.status == status::SUCCESS {
293                file_id = Some(msg::create_file_id(&resp)?);
294                break;
295            }
296            last = p.status;
297            if p.status != status::OBJECT_NAME_NOT_FOUND && p.status != status::SHARING_VIOLATION {
298                return Err(SmbError::Status(p.status, cmd::CREATE));
299            }
300            if attempt < 11 {
301                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
302            }
303        }
304        let file_id = file_id.ok_or(SmbError::Status(last, cmd::CREATE))?;
305
306        // Read to EOF in 64 KiB chunks.
307        let mut data = Vec::new();
308        loop {
309            let resp = self
310                .call(
311                    cmd::READ,
312                    &msg::read_req(&file_id, data.len() as u64, 0x0001_0000),
313                )
314                .await?;
315            let p = header::parse(&resp)?;
316            if p.status == status::END_OF_FILE {
317                break;
318            }
319            if p.status != status::SUCCESS {
320                break;
321            }
322            let chunk = msg::read_output(&resp)?;
323            if chunk.is_empty() {
324                break;
325            }
326            data.extend_from_slice(&chunk);
327            if chunk.len() < 0x0001_0000 {
328                break;
329            }
330        }
331        // CLOSE triggers the delete-on-close.
332        let _ = self.call(cmd::CLOSE, &msg::close_req(&file_id)).await;
333        Ok(data)
334    }
335
336    /// Enumerate a directory on the currently-connected disk share (SMB2
337    /// QUERY_DIRECTORY, FileDirectoryInformation). `path` is relative to the
338    /// share root (`""` = the root itself). Read-only: opens the directory
339    /// handle, drains entries until STATUS_NO_MORE_FILES, and closes. `.`/`..`
340    /// are filtered out. Tree-connect the share first.
341    pub async fn list_directory(&mut self, path: &str) -> Result<Vec<msg::DirEntry>> {
342        use crate::status;
343        // FILE_LIST_DIRECTORY | READ_ATTRS | SYNCHRONIZE
344        const ACCESS: u32 = 0x0010_0081;
345        const SHARE: u32 = 0x0000_0007; // R | W | D
346        const OPEN: u32 = 0x0000_0001; // FILE_OPEN
347        const OPTS: u32 = 0x0000_0021; // FILE_DIRECTORY_FILE | SYNCHRONOUS_IO_NONALERT
348
349        let resp = self
350            .call(
351                cmd::CREATE,
352                &msg::create_file(path, ACCESS, SHARE, OPEN, OPTS),
353            )
354            .await?;
355        Self::ok(&resp, cmd::CREATE)?;
356        let dir_id = msg::create_file_id(&resp)?;
357
358        let mut entries = Vec::new();
359        // Bound the number of QUERY_DIRECTORY round trips (each returns many
360        // entries); a real directory closes out in a handful of calls.
361        for _ in 0..4096 {
362            let resp = self
363                .call(
364                    cmd::QUERY_DIRECTORY,
365                    &msg::query_directory_req(&dir_id, "*", 0x0001_0000),
366                )
367                .await?;
368            let p = header::parse(&resp)?;
369            if p.status == status::NO_MORE_FILES {
370                break;
371            }
372            if p.status != status::SUCCESS {
373                let _ = self.call(cmd::CLOSE, &msg::close_req(&dir_id)).await;
374                return Err(SmbError::Status(p.status, cmd::QUERY_DIRECTORY));
375            }
376            let batch = msg::parse_directory_info(&resp)?;
377            if batch.is_empty() {
378                break;
379            }
380            entries.extend(batch);
381        }
382        let _ = self.call(cmd::CLOSE, &msg::close_req(&dir_id)).await;
383        Ok(entries)
384    }
385
386    /// Read a whole file off the currently-connected disk share, read-only, and
387    /// close WITHOUT deleting it (unlike [`SmbClient::read_file_delete`],
388    /// which is for our own exec output). `path` is relative to the share
389    /// root. Fails if the file is absent. Tree-connect the share first.
390    pub async fn read_file(&mut self, path: &str) -> Result<Vec<u8>> {
391        use crate::status;
392        // 0.2.4: broadened per g0h4n's PR #1. The Windows SMB client
393        // canonically requests READ_DATA | READ_ATTRS | READ_EA |
394        // READ_CONTROL | SYNCHRONIZE for a plain-file open; matching that
395        // avoids servers that reject the narrower 0x0010_0081 mask, and
396        // aligns behaviour with `smbclient` / other well-known clients.
397        const ACCESS: u32 = 0x0012_0089; // READ_DATA | READ_ATTRS | READ_EA | READ_CONTROL | SYNCHRONIZE
398        const SHARE: u32 = 0x0000_0007; // R | W | D — allow concurrent writers (industry default)
399        const OPEN: u32 = 0x0000_0001; // FILE_OPEN
400        const OPTS: u32 = 0x0000_0060; // NON_DIRECTORY | SYNCHRONOUS_IO_NONALERT (no DELETE_ON_CLOSE)
401
402        let resp = self
403            .call(
404                cmd::CREATE,
405                &msg::create_file(path, ACCESS, SHARE, OPEN, OPTS),
406            )
407            .await?;
408        Self::ok(&resp, cmd::CREATE)?;
409        let file_id = msg::create_file_id(&resp)?;
410
411        let mut data = Vec::new();
412        loop {
413            let resp = self
414                .call(
415                    cmd::READ,
416                    &msg::read_req(&file_id, data.len() as u64, 0x0001_0000),
417                )
418                .await?;
419            let p = header::parse(&resp)?;
420            if p.status == status::END_OF_FILE || p.status != status::SUCCESS {
421                break;
422            }
423            let chunk = msg::read_output(&resp)?;
424            if chunk.is_empty() {
425                break;
426            }
427            data.extend_from_slice(&chunk);
428            if chunk.len() < 0x0001_0000 {
429                break;
430            }
431        }
432        let _ = self.call(cmd::CLOSE, &msg::close_req(&file_id)).await;
433        Ok(data)
434    }
435}