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    /// Pass-the-hash: negotiate + NTLM session setup with a raw NT hash.
132    pub async fn login_hash(
133        &mut self,
134        host: &str,
135        domain: &str,
136        user: &str,
137        nt: &[u8; 16],
138    ) -> Result<()> {
139        self.login_cred(host, domain, user, Cred::NtHash(*nt)).await
140    }
141
142    /// Negotiate + NTLM session setup with either a password or an NT hash.
143    pub async fn login_cred(
144        &mut self,
145        host: &str,
146        domain: &str,
147        user: &str,
148        cred: Cred<'_>,
149    ) -> Result<()> {
150        // NEGOTIATE
151        let mut guid = [0u8; 16];
152        rand::thread_rng().fill_bytes(&mut guid);
153        let resp = self.call(cmd::NEGOTIATE, &msg::negotiate(&guid)).await?;
154        Self::ok(&resp, cmd::NEGOTIATE)?;
155        // DialectRevision is at response body offset 4 → absolute 68; it selects the signing algo.
156        self.dialect = u16::from_le_bytes([resp[68], resp[69]]);
157
158        // SESSION_SETUP #1: NTLM NEGOTIATE wrapped in SPNEGO negTokenInit.
159        let ntlm = Ntlm::new();
160        let init = crate::spnego::negotiate_init(ntlm.negotiate());
161        let resp = self
162            .call(cmd::SESSION_SETUP, &msg::session_setup(&init))
163            .await?;
164        let p = header::parse(&resp)?;
165        if p.status != crate::status::MORE_PROCESSING_REQUIRED {
166            return Err(SmbError::Status(p.status, cmd::SESSION_SETUP));
167        }
168        self.session_id = p.session_id;
169
170        // The server CHALLENGE (Type 2) is embedded in a SPNEGO negTokenResp.
171        let blob = msg::session_setup_token(&resp)?;
172        let challenge = crate::spnego::find_ntlm(&blob).ok_or(SmbError::BadToken)?;
173
174        // Build AUTHENTICATE; the exported session key becomes our signing key.
175        let (type3, session_key) = match cred {
176            Cred::Password(pw) => ntlm.authenticate(challenge, domain, user, pw, host),
177            Cred::NtHash(nt) => ntlm.authenticate_hash(challenge, domain, user, &nt, host),
178        }
179        .map_err(|e| SmbError::Ntlm(e.to_string()))?;
180
181        // Derive the signing key: 2.x uses the session key directly, 3.0.x derives an
182        // AES-CMAC key from it (SP800-108 KDF).
183        let key = if self.dialect >= 0x0300 {
184            header::kdf_signing_key(&session_key)
185        } else {
186            session_key
187        };
188        // SESSION_SETUP #2: AUTHENTICATE (Type 3). SMB 3.x requires the final session setup to
189        // be signed with the new key; 2.x leaves it unsigned (matching Windows).
190        if self.dialect >= 0x0300 {
191            self.sign_key = Some(key);
192        }
193        let token = crate::spnego::negotiate_resp(&type3);
194        let resp = self
195            .call(cmd::SESSION_SETUP, &msg::session_setup(&token))
196            .await?;
197        Self::ok(&resp, cmd::SESSION_SETUP)?;
198        self.sign_key = Some(key); // sign everything from here on
199        Ok(())
200    }
201
202    /// Connect to a share, e.g. `\\dc01\IPC$`.
203    pub async fn tree_connect(&mut self, unc: &str) -> Result<()> {
204        let resp = self
205            .call(cmd::TREE_CONNECT, &msg::tree_connect(unc))
206            .await?;
207        let p = Self::ok(&resp, cmd::TREE_CONNECT)?;
208        self.tree_id = p.tree_id;
209        Ok(())
210    }
211
212    /// Open a named pipe on the connected tree and return its FileId.
213    pub async fn open_pipe(&mut self, name: &str) -> Result<[u8; 16]> {
214        let resp = self.call(cmd::CREATE, &msg::create_pipe(name)).await?;
215        Self::ok(&resp, cmd::CREATE)?;
216        msg::create_file_id(&resp)
217    }
218
219    /// Read up to `max` bytes from a pipe (SMB2 READ). Returns empty at end-of-pipe. Used to
220    /// drain RPC response fragments that don't fit one FSCTL_PIPE_TRANSCEIVE.
221    pub async fn read_pipe(&mut self, file_id: &[u8; 16], max: u32) -> Result<Vec<u8>> {
222        let resp = self
223            .call(cmd::READ, &msg::read_req(file_id, 0, max))
224            .await?;
225        let p = header::parse(&resp)?;
226        if p.status != crate::status::SUCCESS {
227            return Ok(Vec::new()); // END_OF_FILE / no more data
228        }
229        msg::read_output(&resp)
230    }
231
232    /// Write to a pipe/file with no read back — used for a fire-and-forget RPC AUTH3.
233    pub async fn write_pipe(&mut self, file_id: &[u8; 16], data: &[u8]) -> Result<()> {
234        let resp = self
235            .call(cmd::WRITE, &msg::write_req(file_id, 0, data))
236            .await?;
237        Self::ok(&resp, cmd::WRITE)?;
238        Ok(())
239    }
240
241    /// One RPC round trip over the pipe (FSCTL_PIPE_TRANSCEIVE): send `data`, return output.
242    pub async fn transact(&mut self, file_id: &[u8; 16], data: &[u8]) -> Result<Vec<u8>> {
243        let resp = self
244            .call(cmd::IOCTL, &msg::ioctl_transceive(file_id, data))
245            .await?;
246        Self::ok(&resp, cmd::IOCTL)?;
247        msg::ioctl_output(&resp)
248    }
249
250    /// Read a whole file off the currently-connected disk share and delete it on close.
251    /// `path` is relative to the share root (e.g. `Windows\Temp\out.txt`). Retries the open
252    /// while the file does not yet exist — an async writer (e.g. our exec child) may still be
253    /// starting. Returns the file contents (possibly empty). Tree-connect the share first.
254    pub async fn read_file_delete(&mut self, path: &str) -> Result<Vec<u8>> {
255        use crate::status;
256        const ACCESS: u32 = 0x0013_0081; // READ_DATA | READ_ATTRS | READ_CONTROL | DELETE | SYNCHRONIZE
257        const SHARE: u32 = 0x0000_0007; // R | W | D
258        const OPEN: u32 = 0x0000_0001; // FILE_OPEN (fail if absent)
259        const OPTS: u32 = 0x0000_1060; // NON_DIRECTORY | SYNCHRONOUS_IO_NONALERT | DELETE_ON_CLOSE
260
261        // Poll for the file: it may not exist yet (writer still spawning →
262        // OBJECT_NAME_NOT_FOUND) or the writer may still hold it without share-delete (→
263        // SHARING_VIOLATION). Both are transient; wait for the child to finish and release.
264        let mut file_id = None;
265        let mut last = status::OBJECT_NAME_NOT_FOUND;
266        for attempt in 0..40 {
267            let resp = self
268                .call(
269                    cmd::CREATE,
270                    &msg::create_file(path, ACCESS, SHARE, OPEN, OPTS),
271                )
272                .await?;
273            let p = header::parse(&resp)?;
274            if p.status == status::SUCCESS {
275                file_id = Some(msg::create_file_id(&resp)?);
276                break;
277            }
278            last = p.status;
279            if p.status != status::OBJECT_NAME_NOT_FOUND && p.status != status::SHARING_VIOLATION {
280                return Err(SmbError::Status(p.status, cmd::CREATE));
281            }
282            if attempt < 39 {
283                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
284            }
285        }
286        let file_id = file_id.ok_or(SmbError::Status(last, cmd::CREATE))?;
287
288        // Read to EOF in 64 KiB chunks.
289        let mut data = Vec::new();
290        loop {
291            let resp = self
292                .call(
293                    cmd::READ,
294                    &msg::read_req(&file_id, data.len() as u64, 0x0001_0000),
295                )
296                .await?;
297            let p = header::parse(&resp)?;
298            if p.status == status::END_OF_FILE {
299                break;
300            }
301            if p.status != status::SUCCESS {
302                break;
303            }
304            let chunk = msg::read_output(&resp)?;
305            if chunk.is_empty() {
306                break;
307            }
308            data.extend_from_slice(&chunk);
309            if chunk.len() < 0x0001_0000 {
310                break;
311            }
312        }
313        // CLOSE triggers the delete-on-close.
314        let _ = self.call(cmd::CLOSE, &msg::close_req(&file_id)).await;
315        Ok(data)
316    }
317}