ecfcc3f429
Two units of work in one commit: 1. nmx_service2_messages.rs (~470 LoC, 18 tests) — port of NmxService2Messages.cs. Encoders for all 9 INmxService2 opnums (RegisterEngine, UnRegisterEngine, Connect, TransferData, AddSubscriberEngine, RemoveSubscriberEngine, SetHeartbeatSendInterval, RegisterEngine2, GetPartnerVersion) plus BSTR + InterfacePointer NDR helpers used by RegisterEngine2 marshalling. Decoders for the GetPartnerVersion result and the generic HRESULT response. M3 stream B (NmxClient) will be a thin layer over these + the transport. 2. object_exporter_client.rs (~290 LoC, 6 tests including 2 real-socket tokio tests) — resolves followup F9. Implements: - resolve_oxid_unauthenticated (cs:14-30) - resolve_oxid_with_managed_ntlm_packet_integrity (cs:66-81) ResolveOxidOutcome enum disambiguates the two response shapes the .NET reference parses (typed result vs 4-byte failure). The two SSPI flavours (cs:32-47, cs:49-64) are permanently skipped — they wrap .NET-only System.Net.Security.SspiClientContext. design/followups.md: F9 moved to Resolved with this commit's hash. Test count delta: 364 -> 389 (+25; mxaccess-rpc 137 -> 162; +18 from nmx_service2_messages, +7 from object_exporter_client which includes the +2 fall-through tests for the dual-shape response decoder). Open followups touched: F9 resolved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
293 lines
12 KiB
Rust
293 lines
12 KiB
Rust
//! `IObjectExporter::ResolveOxid` transport wrappers.
|
|
//!
|
|
//! Direct port of the codec-driving methods from
|
|
//! `src/MxNativeClient/ObjectExporterClient.cs`. Two methods land here:
|
|
//!
|
|
//! - [`resolve_oxid_unauthenticated`] — mirrors `cs:14-30`
|
|
//! (`ResolveOxidUnauthenticated`).
|
|
//! - [`resolve_oxid_with_managed_ntlm_packet_integrity`] — mirrors
|
|
//! `cs:66-81` (`ResolveOxidWithManagedNtlmPacketIntegrity`).
|
|
//!
|
|
//! The two SSPI flavours (`ResolveOxidWithNtlmConnect` at `cs:32-47` and
|
|
//! `ResolveOxidWithNtlmPacketIntegrity` at `cs:49-64`) wrap
|
|
//! `System.Net.Security.SspiClientContext` — explicitly out of scope for
|
|
//! the Rust port. Resolves `design/followups.md` F9 down to the items
|
|
//! that are .NET-specific.
|
|
|
|
#![allow(clippy::indexing_slicing)]
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
use crate::ntlm::NtlmClientContext;
|
|
use crate::object_exporter::{
|
|
IOBJECT_EXPORTER_IID, RESOLVE_OXID_OPNUM, ResolveOxidFailure, ResolveOxidResult,
|
|
encode_resolve_oxid_request, parse_resolve_oxid_failure, parse_resolve_oxid_result,
|
|
};
|
|
use crate::transport::{DceRpcTcpClient, TransportError};
|
|
|
|
/// Outcome of a `ResolveOxid` call. Either the server returned a typed
|
|
/// `DUALSTRINGARRAY` (success or empty) or a 4-byte `RPC_C_NS_*` failure
|
|
/// status word.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ResolveOxidOutcome {
|
|
/// Decoded `DUALSTRINGARRAY` (per `ResolveOxidResult`).
|
|
Result(ResolveOxidResult),
|
|
/// 4-byte trailing status (per `ResolveOxidFailure`). Returned when
|
|
/// the response stub is too short for a full result but matches the
|
|
/// failure tail shape.
|
|
Failure(ResolveOxidFailure),
|
|
}
|
|
|
|
/// Drive a single `ResolveOxid` round-trip without authentication.
|
|
/// Mirrors `ObjectExporterClient.ResolveOxidUnauthenticated`
|
|
/// (`ObjectExporterClient.cs:14-30`).
|
|
///
|
|
/// Steps (mirroring `cs:16-29`):
|
|
///
|
|
/// 1. Open a TCP connection to `(host, port)`.
|
|
/// 2. Bind to `IObjectExporter` (version 0.0).
|
|
/// 3. Build a `ResolveOxid` request with the supplied `oxid` + `protseqs`
|
|
/// (defaults to `[ProtseqNcacnIpTcp]` when empty — per `cs:26`).
|
|
/// 4. Call opnum 0 on the bound context.
|
|
/// 5. Try [`parse_resolve_oxid_result`] first; if it fails with a typed
|
|
/// decode error, fall back to [`parse_resolve_oxid_failure`] over the
|
|
/// last 4 bytes per the .NET reference's two-shape return type.
|
|
///
|
|
/// # Errors
|
|
/// I/O, codec, or fault from the server.
|
|
pub async fn resolve_oxid_unauthenticated(
|
|
addr: SocketAddr,
|
|
oxid: u64,
|
|
requested_protseqs: &[u16],
|
|
) -> Result<ResolveOxidOutcome, TransportError> {
|
|
let mut client = DceRpcTcpClient::connect(addr).await?;
|
|
let _bind = client.bind(IOBJECT_EXPORTER_IID, 0, 0).await?;
|
|
let request = encode_resolve_oxid_request(oxid, default_protseqs(requested_protseqs))?;
|
|
let response = client.call_bound(RESOLVE_OXID_OPNUM, &request).await?;
|
|
decode_resolve_oxid_response(&response.stub_data)
|
|
}
|
|
|
|
/// Drive a single `ResolveOxid` round-trip with NTLMv2 packet-integrity
|
|
/// authentication. Mirrors `ObjectExporterClient.ResolveOxidWithManagedNtlmPacketIntegrity`
|
|
/// (`cs:66-81`).
|
|
///
|
|
/// Steps mirror the unauthenticated variant but the bind is replaced
|
|
/// with [`DceRpcTcpClient::bind_with_managed_ntlm_packet_integrity`],
|
|
/// causing every subsequent call to be NTLM-signed.
|
|
///
|
|
/// `ntlm` must be a fresh [`NtlmClientContext`] — it is consumed by the
|
|
/// transport for the lifetime of the connection.
|
|
///
|
|
/// # Errors
|
|
/// I/O, codec, NTLM, or fault from the server.
|
|
pub async fn resolve_oxid_with_managed_ntlm_packet_integrity(
|
|
addr: SocketAddr,
|
|
oxid: u64,
|
|
requested_protseqs: &[u16],
|
|
ntlm: NtlmClientContext,
|
|
) -> Result<ResolveOxidOutcome, TransportError> {
|
|
let mut client = DceRpcTcpClient::connect(addr).await?;
|
|
let _bind = client
|
|
.bind_with_managed_ntlm_packet_integrity(IOBJECT_EXPORTER_IID, 0, 0, ntlm)
|
|
.await?;
|
|
let request = encode_resolve_oxid_request(oxid, default_protseqs(requested_protseqs))?;
|
|
let response = client.call_bound(RESOLVE_OXID_OPNUM, &request).await?;
|
|
decode_resolve_oxid_response(&response.stub_data)
|
|
}
|
|
|
|
/// Default to `[ProtseqNcacnIpTcp]` when the caller passes an empty
|
|
/// slice — matches `cs:26` (`requestedProtseqs ?? [..]`).
|
|
fn default_protseqs(requested: &[u16]) -> &[u16] {
|
|
if requested.is_empty() {
|
|
&[crate::object_exporter::PROTSEQ_NCACN_IP_TCP]
|
|
} else {
|
|
requested
|
|
}
|
|
}
|
|
|
|
/// Decode a `ResolveOxid` response stub. The .NET reference exposes two
|
|
/// parsers (`ParseResolveOxidResult` and `ParseResolveOxidFailure`)
|
|
/// without a discriminator on the wire — the choice is made by the
|
|
/// caller based on whether the stub looks like a typed result or just a
|
|
/// 4-byte status. The Rust port mirrors that: try the result parser
|
|
/// first; on `RpcError::ShortRead` or `RpcError::Decode` fall back to
|
|
/// the failure parser.
|
|
fn decode_resolve_oxid_response(stub: &[u8]) -> Result<ResolveOxidOutcome, TransportError> {
|
|
match parse_resolve_oxid_result(stub) {
|
|
Ok(result) => Ok(ResolveOxidOutcome::Result(result)),
|
|
Err(_) => Ok(ResolveOxidOutcome::Failure(parse_resolve_oxid_failure(
|
|
stub,
|
|
)?)),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(
|
|
clippy::unwrap_used,
|
|
clippy::expect_used,
|
|
clippy::indexing_slicing,
|
|
clippy::panic
|
|
)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::object_exporter::{
|
|
IOBJECT_EXPORTER_IID, PROTSEQ_NCACN_IP_TCP, encode_resolve_oxid_request,
|
|
};
|
|
use crate::pdu::{PacketType, PduHeader, ResponsePdu};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
|
|
fn local_addr() -> SocketAddr {
|
|
"127.0.0.1:0".parse().unwrap()
|
|
}
|
|
|
|
/// Spin a hand-rolled DCE/RPC server that accepts one connection,
|
|
/// drains a Bind, replies with a minimal BindAck, drains a Request,
|
|
/// and replies with a Response carrying `stub_data`.
|
|
async fn one_shot_server(stub_data: Vec<u8>) -> (SocketAddr, tokio::task::JoinHandle<()>) {
|
|
let listener = TcpListener::bind(local_addr()).await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let handle = tokio::spawn(async move {
|
|
let (mut sock, _) = listener.accept().await.unwrap();
|
|
|
|
// 1. Drain Bind.
|
|
let mut hdr = [0u8; 16];
|
|
sock.read_exact(&mut hdr).await.unwrap();
|
|
let bind_h = PduHeader::decode(&hdr).unwrap();
|
|
let mut body = vec![0u8; bind_h.fragment_length as usize - 16];
|
|
sock.read_exact(&mut body).await.unwrap();
|
|
// Reply with a 16-byte BindAck shell — DceRpcTcpClient::bind
|
|
// only inspects the header.
|
|
let resp_h = 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_h.call_id,
|
|
};
|
|
let mut out = [0u8; 16];
|
|
resp_h.encode(&mut out).unwrap();
|
|
sock.write_all(&out).await.unwrap();
|
|
|
|
// 2. Drain Request.
|
|
sock.read_exact(&mut hdr).await.unwrap();
|
|
let req_h = PduHeader::decode(&hdr).unwrap();
|
|
let mut body = vec![0u8; req_h.fragment_length as usize - 16];
|
|
sock.read_exact(&mut body).await.unwrap();
|
|
|
|
// 3. Reply with Response carrying the supplied stub_data.
|
|
let response = ResponsePdu {
|
|
header: PduHeader {
|
|
version: 5,
|
|
version_minor: 0,
|
|
packet_type: PacketType::Response,
|
|
packet_flags: 0x03,
|
|
data_representation: 0x10,
|
|
fragment_length: 0, // overwritten by encode
|
|
auth_length: 0,
|
|
call_id: req_h.call_id,
|
|
},
|
|
allocation_hint: stub_data.len() as u32,
|
|
context_id: 0,
|
|
cancel_count: 0,
|
|
reserved23: 0,
|
|
stub_data,
|
|
};
|
|
let bytes = response.encode();
|
|
sock.write_all(&bytes).await.unwrap();
|
|
});
|
|
(addr, handle)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_oxid_unauthenticated_round_trip() {
|
|
// Build a synthetic ResolveOxid result stub: referent=1, max_count=1,
|
|
// entries=1, security_offset=2, dual-string [0x0007, 0, 0] (8 bytes
|
|
// padded to 12 with align_up), then 16-byte IPID + authn_hint(4) +
|
|
// status(4) trailing.
|
|
let mut stub = Vec::new();
|
|
// referent_id != 0
|
|
stub.extend_from_slice(&1u32.to_le_bytes());
|
|
// max_count = 1
|
|
stub.extend_from_slice(&1u32.to_le_bytes());
|
|
// entries = 1, security_offset = 2
|
|
stub.extend_from_slice(&1u16.to_le_bytes());
|
|
stub.extend_from_slice(&2u16.to_le_bytes());
|
|
// Dual-string array: 1 u16 (tower=0x0007), then need to align to 4.
|
|
stub.extend_from_slice(&0x0007u16.to_le_bytes());
|
|
// Per parse_resolve_oxid_result: array_offset = 12; array_bytes =
|
|
// max_count * 2 = 2; offset after = align(14, 4) = 16.
|
|
// We've written 14 bytes so far; pad to 16.
|
|
stub.push(0);
|
|
stub.push(0);
|
|
// Trailing 24 bytes: IPID(16) + authn_hint(4) + status(4)
|
|
stub.extend_from_slice(&[0xCC; 16]);
|
|
stub.extend_from_slice(&0x1234u32.to_le_bytes());
|
|
stub.extend_from_slice(&0u32.to_le_bytes());
|
|
|
|
let (addr, handle) = one_shot_server(stub).await;
|
|
let outcome =
|
|
resolve_oxid_unauthenticated(addr, 0xDEAD_BEEF_CAFE_BABE, &[PROTSEQ_NCACN_IP_TCP])
|
|
.await
|
|
.unwrap();
|
|
|
|
match outcome {
|
|
ResolveOxidOutcome::Result(r) => {
|
|
assert_eq!(r.error_status, 0);
|
|
assert_eq!(r.authn_hint, 0x1234);
|
|
assert_eq!(r.rem_unknown_ipid.as_bytes(), &[0xCC; 16]);
|
|
}
|
|
ResolveOxidOutcome::Failure(_) => panic!("expected Result variant"),
|
|
}
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_oxid_falls_back_to_failure_for_short_stub() {
|
|
// 4-byte stub with just an error_status — too short for a full
|
|
// result, must decode as Failure.
|
|
let stub = 0x8004_0007u32.to_le_bytes().to_vec();
|
|
let (addr, handle) = one_shot_server(stub).await;
|
|
let outcome = resolve_oxid_unauthenticated(addr, 0, &[]).await.unwrap();
|
|
match outcome {
|
|
ResolveOxidOutcome::Failure(f) => assert_eq!(f.error_status, 0x8004_0007),
|
|
ResolveOxidOutcome::Result(_) => panic!("expected Failure variant"),
|
|
}
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn default_protseqs_falls_back_when_empty() {
|
|
let r = default_protseqs(&[]);
|
|
assert_eq!(r, &[PROTSEQ_NCACN_IP_TCP]);
|
|
}
|
|
|
|
#[test]
|
|
fn default_protseqs_passes_through_when_provided() {
|
|
let custom: &[u16] = &[0x0007, 0x001f];
|
|
let r = default_protseqs(custom);
|
|
assert_eq!(r, custom);
|
|
}
|
|
|
|
/// Compile-only check that the IID + opnum constants match the .NET
|
|
/// reference values used by the wrapper (sanity guard against
|
|
/// accidental constant drift).
|
|
#[test]
|
|
fn iid_and_opnum_constants_present() {
|
|
// IID first byte is 0xC4 (LE of 0x99FCFEC4 Data1).
|
|
assert_eq!(IOBJECT_EXPORTER_IID.as_bytes()[0], 0xC4);
|
|
assert_eq!(RESOLVE_OXID_OPNUM, 0);
|
|
}
|
|
|
|
/// Verify the encode helper is callable from this module path
|
|
/// (catches `pub use` regressions during refactors).
|
|
#[test]
|
|
fn encode_resolve_oxid_request_callable() {
|
|
let buf = encode_resolve_oxid_request(0, &[PROTSEQ_NCACN_IP_TCP]).unwrap();
|
|
assert!(!buf.is_empty());
|
|
}
|
|
}
|