[M2/M3] mxaccess-rpc: tokio DCE/RPC TCP transport (DceRpcTcpClient port)
Lands the async DCE/RPC TCP client — the transport that bridges the M2
PDU codec to a real socket. Unblocks M3 stream B (mxaccess-nmx, the
NmxClient) and brings F9 (ResolveOxid wrappers) within reach.
New
- transport.rs (~700 LoC, 10 tests including 2 real-socket tokio tests)
— port of src/MxNativeClient/DceRpcTcpClient.cs.
- DceRpcTcpClient::connect/bind/bind_with_managed_ntlm_packet_integrity/
call/call_bound/call_bound_object — async over tokio::net::TcpStream.
- encode_packet_integrity_request: 4-byte 0xBB pad + 8-byte AuthTrailer
+ 16-byte NtlmClientContext::sign signature, frag_length and
auth_length rewritten in the embedded header per cs:201-250.
- encode_request_bytes: PFC_OBJECT_UUID flag (0x80) and inserted
16-byte object UUID slot per cs:269-278.
- TransportError enum unifies io / codec / NTLM / fault / not-connected
surfaces. Mirrors DceRpcFaultException as the typed Fault variant.
- NTLM_AUTH_CONTEXT_ID = 79232 = 0x13580 (cs:90,133) exposed publicly.
Deliberately skipped: BindWithNtlmConnect / BindWithNtlmPacketIntegrity
(SSPI flavours at cs:55-63,108-149) — those wrap .NET's
System.Net.Security.SspiClientContext, which has no portable analogue.
Managed-NTLM path covers what the production Rust client needs.
mxaccess-rpc/Cargo.toml: added tokio (workspace-pinned).
design/followups.md: F9 downgraded P1 → P2 (transport landed; only the
two pure-codec ResolveOxid wrappers remain).
Test count delta: 354 -> 364 (+10).
Open followups touched: F9 partially advanced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -42,11 +42,11 @@ move to `## Resolved` with a date + commit hash.
|
||||
**Why deferred:** The provider is a wrapper around `ole32::CoMarshalInterface` / `IStream` / `GlobalLock` / `GlobalSize`. It needs `windows-rs`, which is currently behind the `windows-com` feature in `mxaccess-rpc/Cargo.toml`. The pure-Rust parser stands alone for the inbound activation-response path that M2 wave 1 needs.
|
||||
**Resolves when:** `windows-rs` is wired into `mxaccess-rpc` (M2 wave 3 callback exporter needs to publish its own OBJREF for `IRemUnknown` / `INmxSvcCallback` registration) and an emitter port lands behind the `windows-com` feature.
|
||||
|
||||
### F9 — Port `ObjectExporterClient.cs` (4 NTLM-variant ResolveOxid transport wrappers)
|
||||
**Severity:** P1
|
||||
### F9 — `ObjectExporterClient.cs` ResolveOxid wrapper methods
|
||||
**Severity:** P2 (was P1 — downgraded after `DceRpcTcpClient` transport landed)
|
||||
**Source:** M2 wave 2, `crates/mxaccess-rpc/src/object_exporter.rs`
|
||||
**Why deferred:** Those four methods (`ResolveOxidUnauthenticated`, `ResolveOxidWithNtlmConnect`, `ResolveOxidWithNtlmPacketIntegrity`, `ResolveOxidWithManagedNtlmPacketIntegrity`) drive a `DceRpcTcpClient` that has not been ported. They are transport-layer code, not pure-codec, so they sit one wave below the OXID body codec that M2 wave 2 landed.
|
||||
**Resolves when:** A `DceRpcTcpClient` Rust port lands (likely M2 wave 3 alongside the callback exporter, or M3 if the NMX session takes the lead). Then the four `resolve_oxid_with_*` flavors can be a thin call layer over `encode_resolve_oxid_request` + `parse_resolve_oxid_result`.
|
||||
**Why deferred:** The transport prerequisite (`DceRpcTcpClient`) is now ported in `crates/mxaccess-rpc/src/transport.rs`. What remains is two thin wrapper methods that wire the codec to the transport: `resolve_oxid_unauthenticated(addr, oxid, protseqs) -> Result<ResolveOxidResult, _>` (mirrors `ObjectExporterClient.cs:14-30`) and `resolve_oxid_with_managed_ntlm_packet_integrity(addr, oxid, protseqs, ntlm) -> Result<ResolveOxidResult, _>` (mirrors `cs:66-81`). The two SSPI variants (`ResolveOxidWithNtlmConnect` at `cs:32-47` and `ResolveOxidWithNtlmPacketIntegrity` at `cs:49-64`) are .NET-specific (`System.Net.Security.SspiClientContext`) and explicitly out of scope.
|
||||
**Resolves when:** Both wrapper methods land, calling `DceRpcTcpClient::connect`/`bind`/`call_bound` against `IObjectExporter` opnum 0 and parsing via `parse_resolve_oxid_result` / `parse_resolve_oxid_failure`.
|
||||
|
||||
### F10 — `IObjectExporter::ResolveOxid2` (opnum 4) body codec
|
||||
**Severity:** P2
|
||||
|
||||
Generated
+1
@@ -225,6 +225,7 @@ dependencies = [
|
||||
"rand",
|
||||
"rc4",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -10,6 +10,7 @@ authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
hmac = "0.12"
|
||||
md-5 = "0.10"
|
||||
md4 = "0.10"
|
||||
|
||||
@@ -25,3 +25,4 @@ pub mod objref;
|
||||
pub mod orpc;
|
||||
pub mod pdu;
|
||||
pub mod rem_unknown;
|
||||
pub mod transport;
|
||||
|
||||
@@ -0,0 +1,862 @@
|
||||
//! DCE/RPC TCP transport — async port of `DceRpcTcpClient.cs`.
|
||||
//!
|
||||
//! Direct port of `src/MxNativeClient/DceRpcTcpClient.cs` over tokio.
|
||||
//! Provides:
|
||||
//!
|
||||
//! - [`DceRpcTcpClient::connect`] — open a TCP connection
|
||||
//! - [`DceRpcTcpClient::bind`] — unauthenticated bind (`cs:33-53`)
|
||||
//! - [`DceRpcTcpClient::bind_with_managed_ntlm_packet_integrity`] —
|
||||
//! NTLMv2 packet-integrity bind using [`crate::ntlm::NtlmClientContext`]
|
||||
//! (`cs:65-106`)
|
||||
//! - [`DceRpcTcpClient::call`] / [`DceRpcTcpClient::call_bound`] /
|
||||
//! [`DceRpcTcpClient::call_bound_object`] — request dispatch
|
||||
//! (`cs:151-182,252-282`)
|
||||
//!
|
||||
//! The `BindWithNtlmConnect` / `BindWithNtlmPacketIntegrity` flavours from
|
||||
//! the .NET reference (`cs:55-63,108-149`) wrap `System.Net.Security.SspiClientContext`,
|
||||
//! which is .NET-specific. They're explicitly out of scope for the Rust
|
||||
//! port — the managed-NTLM path is the only one we need (cite
|
||||
//! `design/00-overview.md` principle 3 and `design/40-protocol-invariants.md`).
|
||||
//!
|
||||
//! ## Packet integrity
|
||||
//!
|
||||
//! When [`DceRpcTcpClient::bind_with_managed_ntlm_packet_integrity`] is used,
|
||||
//! every subsequent `call*` PDU is wrapped per `cs:201-250`:
|
||||
//!
|
||||
//! ```text
|
||||
//! pdu_layout = unauthenticated_request
|
||||
//! || pad to 4-byte align (filled with 0xBB, cs:215)
|
||||
//! || DceRpcAuthTrailer (8 bytes)
|
||||
//! || 16-byte NTLM signature
|
||||
//! ```
|
||||
//!
|
||||
//! The header's `frag_length` is rewritten to the new full length and
|
||||
//! `auth_length` is set to 16 (the signature size). `NtlmClientContext::sign`
|
||||
//! is called over `pdu[0..length-16]` and the result is written into the
|
||||
//! trailing 16 bytes. Mirrors `cs:201-250` exactly.
|
||||
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::error::RpcError;
|
||||
use crate::guid::Guid;
|
||||
use crate::ntlm::{NtlmClientContext, NtlmError, SIGNATURE_LEN};
|
||||
use crate::pdu::{
|
||||
AuthLevel, AuthTrailer, AuthType, BindPdu, FaultPdu, PacketType, PduHeader,
|
||||
PresentationContext, ResponsePdu, SyntaxId,
|
||||
};
|
||||
|
||||
/// Errors raised by the TCP transport. Mirrors the wrap of
|
||||
/// `IOException` / `InvalidOperationException` / `DceRpcFaultException`
|
||||
/// at `DceRpcTcpClient.cs:170-174,205,245,393`.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum TransportError {
|
||||
/// I/O failure on the underlying TCP stream.
|
||||
#[error("transport I/O: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// PDU codec failure — wraps [`RpcError`].
|
||||
#[error("PDU codec: {0}")]
|
||||
Codec(#[from] RpcError),
|
||||
|
||||
/// NTLM signing failed (signing called before authenticate completed
|
||||
/// or buffer length issues). Mirrors `cs:245`.
|
||||
#[error("NTLM signing: {0}")]
|
||||
Ntlm(#[from] NtlmError),
|
||||
|
||||
/// `Connect()` was not called or the socket was closed
|
||||
/// (`cs:402-407`).
|
||||
#[error("DCE/RPC TCP client is not connected")]
|
||||
NotConnected,
|
||||
|
||||
/// A `Call*` arrived with `auth_level == PacketIntegrity` but no
|
||||
/// auth trailer was set up by a prior bind (`cs:203-206,245`).
|
||||
#[error("packet-integrity auth requested without an auth trailer")]
|
||||
AuthContextMissing,
|
||||
|
||||
/// Server returned a `Fault` PDU. Mirrors `DceRpcFaultException`
|
||||
/// (`cs:411-420`).
|
||||
#[error("DCE/RPC fault 0x{status:08x}")]
|
||||
Fault { status: u32 },
|
||||
|
||||
/// Server replied with a packet type the transport doesn't expect at
|
||||
/// that point in the conversation (e.g. a `Request` where a Response
|
||||
/// was expected).
|
||||
#[error("unexpected response packet type: {actual:?}")]
|
||||
UnexpectedResponsePacketType { actual: PacketType },
|
||||
}
|
||||
|
||||
/// Fixed `auth_context_id` used by the .NET reference for every
|
||||
/// authenticated PDU (`cs:90,133`). The same value is reused across the
|
||||
/// connection so the server can correlate the trailer back to the
|
||||
/// negotiated NTLM context.
|
||||
pub const NTLM_AUTH_CONTEXT_ID: u32 = 79232;
|
||||
|
||||
/// Fixed PDU header constants used for outbound frames (`cs:336-347`).
|
||||
const FRAME_VERSION: u8 = 5;
|
||||
const FRAME_VERSION_MINOR: u8 = 0;
|
||||
const FRAME_PACKET_FLAGS: u8 = 0x03;
|
||||
const FRAME_DATA_REPRESENTATION: u32 = 0x10;
|
||||
|
||||
/// `DceRpcTcpClient` — a single-connection async DCE/RPC client.
|
||||
///
|
||||
/// Construct with [`connect`](Self::connect), then call one of the
|
||||
/// `bind*` methods, then dispatch one or more `call*` requests, then drop
|
||||
/// to close the socket.
|
||||
///
|
||||
/// Not Clone (the underlying `TcpStream` is single-owner) and not Sync
|
||||
/// (mutable internal state — call id counter, NTLM context, auth trailer).
|
||||
pub struct DceRpcTcpClient {
|
||||
stream: TcpStream,
|
||||
next_call_id: u32,
|
||||
bound_context_id: u16,
|
||||
ntlm: Option<NtlmClientContext>,
|
||||
auth_trailer: Option<AuthTrailer>,
|
||||
auth_level: AuthLevel,
|
||||
}
|
||||
|
||||
impl DceRpcTcpClient {
|
||||
/// Open a TCP connection to `addr`. Mirrors `Connect()`
|
||||
/// (`DceRpcTcpClient.cs:26-31`).
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates [`std::io::Error`] from [`TcpStream::connect`].
|
||||
pub async fn connect(addr: SocketAddr) -> std::io::Result<Self> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
Ok(Self {
|
||||
stream,
|
||||
next_call_id: 1,
|
||||
bound_context_id: 0,
|
||||
ntlm: None,
|
||||
auth_trailer: None,
|
||||
auth_level: AuthLevel::None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Local socket address (for tests / diagnostics).
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates [`std::io::Error`] if the socket is invalid.
|
||||
pub fn local_addr(&self) -> std::io::Result<SocketAddr> {
|
||||
self.stream.local_addr()
|
||||
}
|
||||
|
||||
/// Currently negotiated `bound_context_id`. Set by the bind methods.
|
||||
#[must_use]
|
||||
pub fn bound_context_id(&self) -> u16 {
|
||||
self.bound_context_id
|
||||
}
|
||||
|
||||
/// Currently negotiated `auth_level`. Tracks `_authLevel` from the
|
||||
/// .NET reference (`cs:18,104,147`).
|
||||
#[must_use]
|
||||
pub fn auth_level(&self) -> AuthLevel {
|
||||
self.auth_level
|
||||
}
|
||||
|
||||
/// Unauthenticated bind. Mirrors `Bind`
|
||||
/// (`DceRpcTcpClient.cs:33-53`).
|
||||
///
|
||||
/// On success returns the response PDU header (typically `BindAck`
|
||||
/// per `[C706]` §12.6.4.4); the bound presentation context id is
|
||||
/// always 0 for this transport (the .NET reference only ever
|
||||
/// presents one context at a time).
|
||||
///
|
||||
/// # Errors
|
||||
/// I/O, codec, or unexpected packet type.
|
||||
pub async fn bind(
|
||||
&mut self,
|
||||
interface_id: Guid,
|
||||
version_major: u16,
|
||||
version_minor: u16,
|
||||
) -> Result<PduHeader, TransportError> {
|
||||
let call_id = self.next_call_id;
|
||||
self.next_call_id = self.next_call_id.wrapping_add(1);
|
||||
|
||||
let pdu = make_bind_pdu(interface_id, version_major, version_minor, call_id);
|
||||
let bytes = pdu.encode();
|
||||
|
||||
self.stream.write_all(&bytes).await?;
|
||||
let response = read_pdu(&mut self.stream).await?;
|
||||
let header = PduHeader::decode(&response)?;
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
/// Bind + Auth3 round-trip using the managed NTLMv2 packet-integrity
|
||||
/// path. Mirrors `BindWithManagedNtlmPacketIntegrity`
|
||||
/// (`cs:65-106`).
|
||||
///
|
||||
/// Takes ownership of the [`NtlmClientContext`] for the lifetime of
|
||||
/// the connection; subsequent `call*` requests are signed with it.
|
||||
/// The .NET reference creates the context via
|
||||
/// `ManagedNtlmClientContext.FromEnvironment()` (cs:70) — that
|
||||
/// helper is open follow-up F1 in the Rust port; for now the caller
|
||||
/// constructs `NtlmClientContext::new(user, password, domain, workstation)`
|
||||
/// explicitly.
|
||||
///
|
||||
/// # Errors
|
||||
/// I/O, codec, or NTLM (Type1/Type3 building).
|
||||
pub async fn bind_with_managed_ntlm_packet_integrity(
|
||||
&mut self,
|
||||
interface_id: Guid,
|
||||
version_major: u16,
|
||||
version_minor: u16,
|
||||
mut ntlm: NtlmClientContext,
|
||||
) -> Result<PduHeader, TransportError> {
|
||||
let call_id = self.next_call_id;
|
||||
self.next_call_id = self.next_call_id.wrapping_add(1);
|
||||
|
||||
let type1 = ntlm.create_type1();
|
||||
let pdu = make_bind_pdu(interface_id, version_major, version_minor, call_id);
|
||||
let trailer = AuthTrailer {
|
||||
auth_type: AuthType::WinNt,
|
||||
auth_level: AuthLevel::PacketIntegrity,
|
||||
auth_pad_length: 0,
|
||||
auth_reserved: 0,
|
||||
auth_context_id: NTLM_AUTH_CONTEXT_ID,
|
||||
};
|
||||
|
||||
let bind_with_auth = pdu.encode_with_auth(trailer, &type1);
|
||||
self.stream.write_all(&bind_with_auth).await?;
|
||||
|
||||
let response = read_pdu(&mut self.stream).await?;
|
||||
let response_header = PduHeader::decode(&response)?;
|
||||
let challenge = BindPdu::read_auth_value(&response)?;
|
||||
let mut inputs = crate::ntlm::OsInputs;
|
||||
let type3 = ntlm.create_type3(&challenge.token, &mut inputs)?;
|
||||
|
||||
let auth3_header = make_request_header(PacketType::Auth3, response_header.call_id);
|
||||
let auth3 = BindPdu::encode_auth3(auth3_header, trailer, &type3);
|
||||
self.stream.write_all(&auth3).await?;
|
||||
|
||||
self.bound_context_id = 0;
|
||||
self.ntlm = Some(ntlm);
|
||||
self.auth_trailer = Some(trailer);
|
||||
self.auth_level = AuthLevel::PacketIntegrity;
|
||||
Ok(response_header)
|
||||
}
|
||||
|
||||
/// Dispatch a Request on the explicit context id. Mirrors `Call`
|
||||
/// (`DceRpcTcpClient.cs:151-154`).
|
||||
///
|
||||
/// # Errors
|
||||
/// I/O, codec, NTLM signing, or `TransportError::Fault` if the server
|
||||
/// returned a Fault PDU.
|
||||
pub async fn call(
|
||||
&mut self,
|
||||
context_id: u16,
|
||||
opnum: u16,
|
||||
stub_data: &[u8],
|
||||
) -> Result<ResponsePdu, TransportError> {
|
||||
self.call_core(context_id, opnum, stub_data, None).await
|
||||
}
|
||||
|
||||
/// Dispatch a Request on the bound context with no object UUID.
|
||||
/// Mirrors `CallBound` (`cs:179-182`).
|
||||
///
|
||||
/// # Errors
|
||||
/// As for [`call`](Self::call).
|
||||
pub async fn call_bound(
|
||||
&mut self,
|
||||
opnum: u16,
|
||||
stub_data: &[u8],
|
||||
) -> Result<ResponsePdu, TransportError> {
|
||||
let cid = self.bound_context_id;
|
||||
self.call_core(cid, opnum, stub_data, None).await
|
||||
}
|
||||
|
||||
/// Dispatch a Request on the bound context with an object UUID
|
||||
/// (sets `PFC_OBJECT_UUID = 0x80` in the packet flags). Mirrors
|
||||
/// `CallBoundObject` (`cs:156-159`).
|
||||
///
|
||||
/// # Errors
|
||||
/// As for [`call`](Self::call).
|
||||
pub async fn call_bound_object(
|
||||
&mut self,
|
||||
object_uuid: Guid,
|
||||
opnum: u16,
|
||||
stub_data: &[u8],
|
||||
) -> Result<ResponsePdu, TransportError> {
|
||||
let cid = self.bound_context_id;
|
||||
self.call_core(cid, opnum, stub_data, Some(object_uuid))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn call_core(
|
||||
&mut self,
|
||||
context_id: u16,
|
||||
opnum: u16,
|
||||
stub_data: &[u8],
|
||||
object_uuid: Option<Guid>,
|
||||
) -> Result<ResponsePdu, TransportError> {
|
||||
let call_id = self.next_call_id;
|
||||
self.next_call_id = self.next_call_id.wrapping_add(1);
|
||||
|
||||
let header = make_request_header(PacketType::Request, call_id);
|
||||
let request = encode_request_bytes(header, context_id, opnum, stub_data, object_uuid);
|
||||
|
||||
let pdu = if self.auth_level == AuthLevel::PacketIntegrity {
|
||||
let trailer = self
|
||||
.auth_trailer
|
||||
.ok_or(TransportError::AuthContextMissing)?;
|
||||
let ntlm = self
|
||||
.ntlm
|
||||
.as_mut()
|
||||
.ok_or(TransportError::AuthContextMissing)?;
|
||||
encode_packet_integrity_request(&request, trailer, ntlm)?
|
||||
} else {
|
||||
request
|
||||
};
|
||||
|
||||
self.stream.write_all(&pdu).await?;
|
||||
let response = read_pdu(&mut self.stream).await?;
|
||||
let response_header = PduHeader::decode(&response)?;
|
||||
match response_header.packet_type {
|
||||
PacketType::Response => Ok(ResponsePdu::decode(&response)?),
|
||||
PacketType::Fault => {
|
||||
let fault = FaultPdu::decode(&response)?;
|
||||
Err(TransportError::Fault {
|
||||
status: fault.status,
|
||||
})
|
||||
}
|
||||
other => Err(TransportError::UnexpectedResponsePacketType { actual: other }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the standard outbound bind PDU (`cs:33-48,73-84,116-127`).
|
||||
fn make_bind_pdu(
|
||||
interface_id: Guid,
|
||||
version_major: u16,
|
||||
version_minor: u16,
|
||||
call_id: u32,
|
||||
) -> BindPdu {
|
||||
BindPdu {
|
||||
header: make_request_header(PacketType::Bind, call_id),
|
||||
max_transmit_fragment: 4280,
|
||||
max_receive_fragment: 4280,
|
||||
association_group_id: 0,
|
||||
presentation_contexts: vec![PresentationContext {
|
||||
context_id: 0,
|
||||
abstract_syntax: SyntaxId {
|
||||
uuid_bytes: *interface_id.as_bytes(),
|
||||
version_major,
|
||||
version_minor,
|
||||
},
|
||||
transfer_syntaxes: vec![SyntaxId::NDR20],
|
||||
}],
|
||||
reserved25_28: [0; 3],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a fresh outbound PDU header. Mirrors `CreateHeader`
|
||||
/// (`cs:336-347`). `fragment_length` and `auth_length` are 0; the PDU
|
||||
/// encoder fills `fragment_length` later. `packet_flags = 0x03` matches
|
||||
/// `cs:342`.
|
||||
fn make_request_header(packet_type: PacketType, call_id: u32) -> PduHeader {
|
||||
PduHeader {
|
||||
version: FRAME_VERSION,
|
||||
version_minor: FRAME_VERSION_MINOR,
|
||||
packet_type,
|
||||
packet_flags: FRAME_PACKET_FLAGS,
|
||||
data_representation: FRAME_DATA_REPRESENTATION,
|
||||
fragment_length: 0,
|
||||
auth_length: 0,
|
||||
call_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the unauthenticated `Request` PDU bytes. Mirrors
|
||||
/// `EncodeRequestBytes` (`DceRpcTcpClient.cs:252-282`).
|
||||
///
|
||||
/// Layout:
|
||||
///
|
||||
/// ```text
|
||||
/// offset size field
|
||||
/// 0 16 PduHeader
|
||||
/// 16 4 allocation_hint u32 LE = stub.len()
|
||||
/// 20 2 context_id u16 LE
|
||||
/// 22 2 opnum u16 LE
|
||||
/// 24..(24+16 if object) 16 object_uuid (only when PFC_OBJECT_UUID)
|
||||
/// stub_offset.. var stub_data
|
||||
/// ```
|
||||
///
|
||||
/// Sets `packet_flags |= 0x80` (`PFC_OBJECT_UUID`) when `object_uuid` is
|
||||
/// `Some`, mirroring `cs:269`.
|
||||
pub(crate) fn encode_request_bytes(
|
||||
header: PduHeader,
|
||||
context_id: u16,
|
||||
opnum: u16,
|
||||
stub_data: &[u8],
|
||||
object_uuid: Option<Guid>,
|
||||
) -> Vec<u8> {
|
||||
let object_length = if object_uuid.is_some() { 16 } else { 0 };
|
||||
let fixed_offset = PduHeader::LENGTH;
|
||||
let stub_offset = fixed_offset + 8 + object_length;
|
||||
let length = stub_offset + stub_data.len();
|
||||
let mut pdu = vec![0u8; length];
|
||||
|
||||
let request_header = PduHeader {
|
||||
packet_type: PacketType::Request,
|
||||
fragment_length: u16::try_from(length).unwrap_or(u16::MAX),
|
||||
auth_length: 0,
|
||||
packet_flags: {
|
||||
let base = if header.packet_flags == 0 {
|
||||
0x03
|
||||
} else {
|
||||
header.packet_flags
|
||||
};
|
||||
if object_uuid.is_some() {
|
||||
base | 0x80
|
||||
} else {
|
||||
base
|
||||
}
|
||||
},
|
||||
..header
|
||||
};
|
||||
let _ = request_header.encode(&mut pdu);
|
||||
|
||||
pdu[fixed_offset..fixed_offset + 4].copy_from_slice(
|
||||
&u32::try_from(stub_data.len())
|
||||
.unwrap_or(u32::MAX)
|
||||
.to_le_bytes(),
|
||||
);
|
||||
pdu[fixed_offset + 4..fixed_offset + 6].copy_from_slice(&context_id.to_le_bytes());
|
||||
pdu[fixed_offset + 6..fixed_offset + 8].copy_from_slice(&opnum.to_le_bytes());
|
||||
if let Some(uuid) = object_uuid {
|
||||
pdu[fixed_offset + 8..fixed_offset + 24].copy_from_slice(uuid.as_bytes());
|
||||
}
|
||||
pdu[stub_offset..stub_offset + stub_data.len()].copy_from_slice(stub_data);
|
||||
pdu
|
||||
}
|
||||
|
||||
/// Wrap an unauthenticated Request PDU with packet-integrity padding,
|
||||
/// auth trailer, and 16-byte NTLM signature. Mirrors
|
||||
/// `EncodePacketIntegrityRequest` (`DceRpcTcpClient.cs:201-250`).
|
||||
///
|
||||
/// Layout:
|
||||
///
|
||||
/// ```text
|
||||
/// 0..N unauthenticated PDU (header + body)
|
||||
/// N..N+pad 0xBB pad bytes to 4-byte boundary (cs:215)
|
||||
/// N+pad.. AuthTrailer (8 bytes; auth_pad_length set to pad)
|
||||
/// last 16 bytes NTLM signature over [0..length-16]
|
||||
/// ```
|
||||
///
|
||||
/// The PDU header inside is rewritten to set `fragment_length = length`
|
||||
/// and `auth_length = 16`.
|
||||
pub(crate) fn encode_packet_integrity_request(
|
||||
unauthenticated: &[u8],
|
||||
trailer: AuthTrailer,
|
||||
ntlm: &mut NtlmClientContext,
|
||||
) -> Result<Vec<u8>, TransportError> {
|
||||
let pad_length = align_up(unauthenticated.len(), 4) - unauthenticated.len();
|
||||
let length = unauthenticated.len() + pad_length + AuthTrailer::LENGTH + SIGNATURE_LEN;
|
||||
let mut pdu = vec![0u8; length];
|
||||
pdu[..unauthenticated.len()].copy_from_slice(unauthenticated);
|
||||
if pad_length > 0 {
|
||||
pdu[unauthenticated.len()..unauthenticated.len() + pad_length].fill(0xBB);
|
||||
}
|
||||
|
||||
// Rewrite the embedded PDU header.
|
||||
let parsed_header = PduHeader::decode(unauthenticated)?;
|
||||
let header = PduHeader {
|
||||
packet_type: PacketType::Request,
|
||||
packet_flags: if parsed_header.packet_flags == 0 {
|
||||
0x03
|
||||
} else {
|
||||
parsed_header.packet_flags
|
||||
},
|
||||
fragment_length: u16::try_from(length).unwrap_or(u16::MAX),
|
||||
auth_length: u16::try_from(SIGNATURE_LEN).unwrap_or(u16::MAX),
|
||||
..parsed_header
|
||||
};
|
||||
let _ = header.encode(&mut pdu);
|
||||
|
||||
// Write the auth trailer (with auth_pad_length reflecting our pad).
|
||||
let trailer = AuthTrailer {
|
||||
auth_pad_length: u8::try_from(pad_length).unwrap_or(u8::MAX),
|
||||
..trailer
|
||||
};
|
||||
let trailer_offset = unauthenticated.len() + pad_length;
|
||||
let mut trailer_buf = [0u8; AuthTrailer::LENGTH];
|
||||
trailer.encode(&mut trailer_buf)?;
|
||||
pdu[trailer_offset..trailer_offset + AuthTrailer::LENGTH].copy_from_slice(&trailer_buf);
|
||||
|
||||
// Sign over [0..length - SIGNATURE_LEN] and write the signature into the
|
||||
// trailing 16 bytes. The .NET reference fills the placeholder with 0x20
|
||||
// before signing (`cs:231`); since `Sign` reads only [0..length-16],
|
||||
// the placeholder doesn't affect the MAC, but we keep the same
|
||||
// initial bytes so any future test that compares full PDUs has a
|
||||
// consistent shape.
|
||||
pdu[length - SIGNATURE_LEN..].fill(0x20);
|
||||
let signature = ntlm.sign(&pdu[..length - SIGNATURE_LEN])?;
|
||||
pdu[length - SIGNATURE_LEN..].copy_from_slice(&signature);
|
||||
|
||||
Ok(pdu)
|
||||
}
|
||||
|
||||
const fn align_up(value: usize, alignment: usize) -> usize {
|
||||
let r = value % alignment;
|
||||
if r == 0 { value } else { value + alignment - r }
|
||||
}
|
||||
|
||||
/// Read one full PDU from `stream`. Mirrors `ReadPdu` + `ReadExact`
|
||||
/// (`DceRpcTcpClient.cs:372-400`). Returns the full bytes including the
|
||||
/// 16-byte header.
|
||||
async fn read_pdu(stream: &mut TcpStream) -> Result<Vec<u8>, TransportError> {
|
||||
let mut header_bytes = [0u8; PduHeader::LENGTH];
|
||||
stream.read_exact(&mut header_bytes).await?;
|
||||
let header = PduHeader::decode(&header_bytes)?;
|
||||
let frag = header.fragment_length as usize;
|
||||
if frag < PduHeader::LENGTH {
|
||||
return Err(TransportError::Codec(RpcError::InvalidFragmentLength {
|
||||
frag_length: frag,
|
||||
buffer_len: header_bytes.len(),
|
||||
auth_length: header.auth_length as usize,
|
||||
}));
|
||||
}
|
||||
let mut pdu = vec![0u8; frag];
|
||||
pdu[..PduHeader::LENGTH].copy_from_slice(&header_bytes);
|
||||
if frag > PduHeader::LENGTH {
|
||||
stream.read_exact(&mut pdu[PduHeader::LENGTH..]).await?;
|
||||
}
|
||||
Ok(pdu)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::indexing_slicing,
|
||||
clippy::panic
|
||||
)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn align_up_matches_dotnet_align() {
|
||||
assert_eq!(align_up(0, 4), 0);
|
||||
assert_eq!(align_up(1, 4), 4);
|
||||
assert_eq!(align_up(28, 4), 28);
|
||||
assert_eq!(align_up(29, 4), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntlm_auth_context_id_matches_dotnet() {
|
||||
// .NET hard-codes 79232 = 0x13580 at cs:90,133.
|
||||
assert_eq!(NTLM_AUTH_CONTEXT_ID, 79232);
|
||||
assert_eq!(NTLM_AUTH_CONTEXT_ID, 0x0001_3580);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_request_header_uses_v5_drep_0x10_flags_03() {
|
||||
let h = make_request_header(PacketType::Bind, 7);
|
||||
assert_eq!(h.version, 5);
|
||||
assert_eq!(h.version_minor, 0);
|
||||
assert_eq!(h.packet_type, PacketType::Bind);
|
||||
assert_eq!(h.packet_flags, 0x03);
|
||||
assert_eq!(h.data_representation, 0x10);
|
||||
assert_eq!(h.fragment_length, 0);
|
||||
assert_eq!(h.auth_length, 0);
|
||||
assert_eq!(h.call_id, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_request_bytes_no_object_uuid_layout() {
|
||||
let header = make_request_header(PacketType::Request, 42);
|
||||
let stub = [0xAAu8, 0xBB, 0xCC, 0xDD];
|
||||
let bytes = encode_request_bytes(header, 0, 6, &stub, None);
|
||||
|
||||
// Total = header(16) + 8 fixed + stub(4) = 28.
|
||||
assert_eq!(bytes.len(), 28);
|
||||
|
||||
// PFC_OBJECT_UUID bit must NOT be set (cs:269).
|
||||
assert_eq!(bytes[3] & 0x80, 0);
|
||||
|
||||
// allocation_hint = 4
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]),
|
||||
4
|
||||
);
|
||||
// context_id = 0
|
||||
assert_eq!(u16::from_le_bytes([bytes[20], bytes[21]]), 0);
|
||||
// opnum = 6
|
||||
assert_eq!(u16::from_le_bytes([bytes[22], bytes[23]]), 6);
|
||||
// stub bytes follow at offset 24.
|
||||
assert_eq!(&bytes[24..28], &stub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_request_bytes_with_object_uuid_sets_pfc_bit_and_inserts_uuid() {
|
||||
let header = make_request_header(PacketType::Request, 99);
|
||||
let stub = [0x01u8, 0x02];
|
||||
let uuid = Guid::new([0x11; 16]);
|
||||
let bytes = encode_request_bytes(header, 0, 0, &stub, Some(uuid));
|
||||
|
||||
// Total = header(16) + 8 fixed + 16 object UUID + 2 stub = 42.
|
||||
assert_eq!(bytes.len(), 42);
|
||||
|
||||
// PFC_OBJECT_UUID bit must be set (cs:269).
|
||||
assert_eq!(bytes[3] & 0x80, 0x80);
|
||||
|
||||
// Object UUID at offset 24..40.
|
||||
assert_eq!(&bytes[24..40], uuid.as_bytes());
|
||||
|
||||
// Stub at offset 40.
|
||||
assert_eq!(&bytes[40..42], &stub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_packet_integrity_request_pad_and_signature_layout() {
|
||||
// Build an unauthenticated request that's NOT 4-byte aligned to
|
||||
// exercise the pad branch (cs:208,213-216).
|
||||
let header = make_request_header(PacketType::Request, 1);
|
||||
let stub = [0xDE, 0xAD, 0xBE]; // 3 bytes -> total 27 (header 16 + 8 fixed + 3 stub)
|
||||
let unauth = encode_request_bytes(header, 0, 0, &stub, None);
|
||||
assert_eq!(unauth.len(), 27);
|
||||
|
||||
// Build an NTLM context that's authenticated enough for sign() to
|
||||
// succeed (Type1 + Type3 with fixed inputs).
|
||||
let mut ntlm = NtlmClientContext::new("U", "P", "D", Some(""));
|
||||
ntlm.create_type1();
|
||||
let challenge = make_dummy_challenge();
|
||||
ntlm.create_type3(
|
||||
&challenge,
|
||||
&mut crate::ntlm::FixedInputs {
|
||||
client_challenge: [0u8; 8],
|
||||
exported_session_key: [0u8; 16],
|
||||
filetime: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let trailer = AuthTrailer {
|
||||
auth_type: AuthType::WinNt,
|
||||
auth_level: AuthLevel::PacketIntegrity,
|
||||
auth_pad_length: 0,
|
||||
auth_reserved: 0,
|
||||
auth_context_id: NTLM_AUTH_CONTEXT_ID,
|
||||
};
|
||||
|
||||
let pdu = encode_packet_integrity_request(&unauth, trailer, &mut ntlm).unwrap();
|
||||
|
||||
// pad = 1 byte (27 -> 28); total = 27 + 1 + 8 + 16 = 52.
|
||||
assert_eq!(pdu.len(), 52);
|
||||
|
||||
// Pad byte at offset 27 must be 0xBB.
|
||||
assert_eq!(pdu[27], 0xBB);
|
||||
|
||||
// Trailer auth_pad_length at offset 28+2 = 30 (per AuthTrailer
|
||||
// encode: auth_type, auth_level, auth_pad_length, ...).
|
||||
assert_eq!(pdu[28], AuthType::WinNt.as_byte());
|
||||
assert_eq!(pdu[29], AuthLevel::PacketIntegrity.as_byte());
|
||||
assert_eq!(pdu[30], 1);
|
||||
|
||||
// Embedded header: fragment_length=52, auth_length=16.
|
||||
let h = PduHeader::decode(&pdu).unwrap();
|
||||
assert_eq!(h.fragment_length as usize, 52);
|
||||
assert_eq!(h.auth_length as usize, SIGNATURE_LEN);
|
||||
|
||||
// The trailing 16 bytes are the NTLM signature; they MUST not be
|
||||
// the 0x20 placeholder fill.
|
||||
assert_ne!(&pdu[36..52], &[0x20u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_packet_integrity_request_no_pad_when_already_aligned() {
|
||||
// 28-byte unauth (header 16 + 8 fixed + 4 stub) is already aligned.
|
||||
let header = make_request_header(PacketType::Request, 1);
|
||||
let stub = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
let unauth = encode_request_bytes(header, 0, 0, &stub, None);
|
||||
assert_eq!(unauth.len(), 28);
|
||||
|
||||
let mut ntlm = NtlmClientContext::new("U", "P", "D", Some(""));
|
||||
ntlm.create_type1();
|
||||
let challenge = make_dummy_challenge();
|
||||
ntlm.create_type3(
|
||||
&challenge,
|
||||
&mut crate::ntlm::FixedInputs {
|
||||
client_challenge: [0u8; 8],
|
||||
exported_session_key: [0u8; 16],
|
||||
filetime: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let trailer = AuthTrailer {
|
||||
auth_type: AuthType::WinNt,
|
||||
auth_level: AuthLevel::PacketIntegrity,
|
||||
auth_pad_length: 0,
|
||||
auth_reserved: 0,
|
||||
auth_context_id: NTLM_AUTH_CONTEXT_ID,
|
||||
};
|
||||
|
||||
let pdu = encode_packet_integrity_request(&unauth, trailer, &mut ntlm).unwrap();
|
||||
// Total = 28 + 0 (no pad) + 8 trailer + 16 sig = 52.
|
||||
assert_eq!(pdu.len(), 52);
|
||||
// auth_pad_length at offset 30 is 0.
|
||||
assert_eq!(pdu[30], 0);
|
||||
}
|
||||
|
||||
/// Build a minimum-viable Type2 challenge so create_type3 succeeds in
|
||||
/// tests. Mirrors what a real server would send.
|
||||
fn make_dummy_challenge() -> Vec<u8> {
|
||||
// 48-byte minimum Type2: signature(8) + msg_type(4) + target_name_fields(8)
|
||||
// + flags(4) + server_challenge(8) + reserved(8) + target_info_fields(8)
|
||||
// = 56 bytes when including target_info fields. Use the same scaffold
|
||||
// ntlm::tests uses internally.
|
||||
let mut buf = vec![0u8; 56];
|
||||
buf[..8].copy_from_slice(b"NTLMSSP\0");
|
||||
// message_type = 2
|
||||
buf[8..12].copy_from_slice(&2u32.to_le_bytes());
|
||||
// target_name fields zero (no target name)
|
||||
// flags
|
||||
buf[20..24].copy_from_slice(
|
||||
&(crate::ntlm::NEGOTIATE_UNICODE
|
||||
| crate::ntlm::NEGOTIATE_NTLM
|
||||
| crate::ntlm::NEGOTIATE_EXTENDED_SESSION_SECURITY
|
||||
| crate::ntlm::NEGOTIATE_TARGET_INFO
|
||||
| crate::ntlm::NEGOTIATE_KEY_EXCHANGE
|
||||
| crate::ntlm::NEGOTIATE_128
|
||||
| crate::ntlm::NEGOTIATE_56)
|
||||
.to_le_bytes(),
|
||||
);
|
||||
// server challenge bytes 24..32 left zero is fine
|
||||
// reserved 32..40 zero
|
||||
// target_info fields 40..48: length(2) + max_length(2) + offset(4)
|
||||
// We'll point target_info at offset 48 with length 8 (one EOL pair).
|
||||
buf[40..42].copy_from_slice(&8u16.to_le_bytes());
|
||||
buf[42..44].copy_from_slice(&8u16.to_le_bytes());
|
||||
buf[44..48].copy_from_slice(&48u32.to_le_bytes());
|
||||
// target_info: 4 bytes EOL marker (id=0, len=0) repeated to pad
|
||||
buf
|
||||
}
|
||||
|
||||
/// Round-trip test using a hand-rolled tokio echo-bind server.
|
||||
/// Verifies the client can `connect` -> `bind` -> read a BindAck.
|
||||
#[tokio::test]
|
||||
async fn bind_round_trip_with_local_listener() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
// Spawn a one-shot server that accepts one connection, reads a
|
||||
// Bind PDU, and writes back a minimal BindAck-shaped PDU.
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
// Read the 16-byte header.
|
||||
let mut hdr = [0u8; 16];
|
||||
sock.read_exact(&mut hdr).await.unwrap();
|
||||
let parsed = PduHeader::decode(&hdr).unwrap();
|
||||
// Drain the rest.
|
||||
let mut body = vec![0u8; parsed.fragment_length as usize - 16];
|
||||
sock.read_exact(&mut body).await.unwrap();
|
||||
|
||||
// Send a fake BindAck — header only, length=16, no body.
|
||||
let resp = PduHeader {
|
||||
version: 5,
|
||||
version_minor: 0,
|
||||
packet_type: PacketType::BindAck,
|
||||
packet_flags: 0x03,
|
||||
data_representation: 0x10,
|
||||
fragment_length: 16,
|
||||
auth_length: 0,
|
||||
call_id: parsed.call_id,
|
||||
};
|
||||
let mut out = [0u8; 16];
|
||||
resp.encode(&mut out).unwrap();
|
||||
sock.write_all(&out).await.unwrap();
|
||||
});
|
||||
|
||||
let mut client = DceRpcTcpClient::connect(addr).await.unwrap();
|
||||
let header = client.bind(Guid::new([0x99; 16]), 1, 0).await.unwrap();
|
||||
assert_eq!(header.packet_type, PacketType::BindAck);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
/// `call` over a server that echoes back a Fault must surface as
|
||||
/// `TransportError::Fault`.
|
||||
#[tokio::test]
|
||||
async fn call_returns_fault_when_server_responds_with_fault() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
|
||||
// 1. Drain the Bind, send a minimal BindAck.
|
||||
let mut hdr = [0u8; 16];
|
||||
sock.read_exact(&mut hdr).await.unwrap();
|
||||
let bind = PduHeader::decode(&hdr).unwrap();
|
||||
let mut body = vec![0u8; bind.fragment_length as usize - 16];
|
||||
sock.read_exact(&mut body).await.unwrap();
|
||||
let resp = PduHeader {
|
||||
version: 5,
|
||||
version_minor: 0,
|
||||
packet_type: PacketType::BindAck,
|
||||
packet_flags: 0x03,
|
||||
data_representation: 0x10,
|
||||
fragment_length: 16,
|
||||
auth_length: 0,
|
||||
call_id: bind.call_id,
|
||||
};
|
||||
let mut out = [0u8; 16];
|
||||
resp.encode(&mut out).unwrap();
|
||||
sock.write_all(&out).await.unwrap();
|
||||
|
||||
// 2. Drain the Request, reply with a Fault carrying status=0xDEADBEEF.
|
||||
sock.read_exact(&mut hdr).await.unwrap();
|
||||
let req = PduHeader::decode(&hdr).unwrap();
|
||||
let mut body = vec![0u8; req.fragment_length as usize - 16];
|
||||
sock.read_exact(&mut body).await.unwrap();
|
||||
let fault = FaultPdu {
|
||||
header: PduHeader {
|
||||
version: 5,
|
||||
version_minor: 0,
|
||||
packet_type: PacketType::Fault,
|
||||
packet_flags: 0x03,
|
||||
data_representation: 0x10,
|
||||
fragment_length: 0, // overwritten by encode
|
||||
auth_length: 0,
|
||||
call_id: req.call_id,
|
||||
},
|
||||
allocation_hint: 0,
|
||||
context_id: 0,
|
||||
cancel_count: 0,
|
||||
reserved23: 0,
|
||||
status: 0xDEAD_BEEF,
|
||||
stub_data: Vec::new(),
|
||||
};
|
||||
let bytes = fault.encode();
|
||||
sock.write_all(&bytes).await.unwrap();
|
||||
});
|
||||
|
||||
let mut client = DceRpcTcpClient::connect(addr).await.unwrap();
|
||||
let _ = client.bind(Guid::new([0x99; 16]), 1, 0).await.unwrap();
|
||||
let err = client.call(0, 0, &[]).await.unwrap_err();
|
||||
match err {
|
||||
TransportError::Fault { status } => assert_eq!(status, 0xDEAD_BEEF),
|
||||
other => panic!("expected Fault, got {other:?}"),
|
||||
}
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_context_missing_when_packet_integrity_set_without_trailer() {
|
||||
// Direct unit test: forcing auth_level high without setting up the
|
||||
// trailer/ntlm pair should yield `AuthContextMissing` from
|
||||
// call_core via the runtime gate. We simulate that gate inline.
|
||||
// (The full async path would need a server; this test just
|
||||
// confirms the variant exists and matches the documented error.)
|
||||
let err = TransportError::AuthContextMissing;
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("auth trailer"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user