[M2] mxaccess-rpc: NMX metadata + callback messages + OBJREF builder

Lands the codec-only prerequisites for M2 wave 3 (callback exporter).
The TCP server itself (port of ManagedCallbackExporter.cs's TcpListener
+ accept loop) follows next iteration in the mxaccess-callback crate.

New modules
- nmx_metadata.rs (9 tests) — port of NmxProcedureMetadata.cs.
  INmxService2 + INmxSvcCallback IIDs, NdrProcedureDescriptor with
  per-opnum metadata for the 9 INmxService2 procedures (opnums 3..11)
  and 2 INmxSvcCallback procedures (opnums 3, 4).
- nmx_callback_messages.rs (8 tests) — port of NmxSvcCallbackMessages.cs.
  parse_callback_request decodes OrpcThis + i32 size + i32 max_count +
  body bytes; encode_callback_response produces the 12-byte OrpcThat +
  HRESULT response.

objref.rs additions
- ComObjRefBuilder::create_standard_objref (8 tests) — port of the
  second class in ManagedCallbackExporter.cs:337-393. Pure-Rust OBJREF
  emitter that builds 68-byte header + dual-string array. Note this is
  *not* the Win32 CoMarshalInterface-based ComObjRefProvider.cs (still
  open as F6); it's the higher-level emitter the callback exporter
  uses to build OBJREF bytes from primitives.
- CALLBACK_OBJREF_AUTH_SERVICES const exposes the 7-entry auth-service
  tower-id table (NTLM SSP through Kerberos extension) the .NET
  reference advertises in every callback OBJREF.

Test count delta: 319 -> 344 (+25; mxaccess-rpc 102 -> 127, codec
unchanged at 215, parity unchanged at 2). All four DoD gates green.
Open followups touched: none new; F6 advances toward resolution but
the windows-rs Win32 wrapper part stays open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 07:23:44 -04:00
parent 30138629d3
commit ecbf282f6d
4 changed files with 758 additions and 0 deletions
+286
View File
@@ -318,6 +318,149 @@ fn read_u64_le(bytes: &[u8], offset: usize) -> u64 {
const _: () = assert!(OBJREF_HEADER_LEN == 68);
const _: () = assert!(OBJREF_SIGNATURE == 0x574F_454D);
// ---------------------------------------------------------------------------
// ComObjRefBuilder — pure-Rust OBJREF emitter.
// Direct port of the second class in
// `src/MxNativeClient/ManagedCallbackExporter.cs:337-393`.
// ---------------------------------------------------------------------------
/// Auth-service tower IDs the .NET reference advertises in every callback
/// OBJREF. Mirrors the hard-coded array at
/// `ManagedCallbackExporter.cs:362`. Each id appears in the security-binding
/// portion of the dual-string array followed by `0xFFFF` and a terminator.
///
/// IDs in order: NTLM SSP (0x0009), GSS Negotiate (0x001E), Kerberos (0x0010),
/// SSL/TLS (0x000A), Schannel (0x0016), DPA (0x001F), Kerberos extension
/// (0x000E). The Rust port carries the same set verbatim — no synthesis.
pub const CALLBACK_OBJREF_AUTH_SERVICES: [u16; 7] =
[0x0009, 0x001E, 0x0010, 0x000A, 0x0016, 0x001F, 0x000E];
/// Builds standard OBJREF byte buffers for the callback exporter to publish.
///
/// Mirrors the static `ComObjRefBuilder` class
/// (`src/MxNativeClient/ManagedCallbackExporter.cs:337-393`). The .NET reference
/// only ever emits *standard* OBJREFs (`flags = 1`); the Rust port matches.
///
/// This is the higher-level emitter that builds OBJREF bytes from primitives.
/// It is **not** the Win32 `CoMarshalInterface`-based emitter from
/// `ComObjRefProvider.cs` — that wrapper around `ole32` is still tracked as
/// open follow-up F6 (it requires `windows-rs` and the M2 wave 3 callback
/// exporter to register the emitted OBJREF with COM).
pub struct ComObjRefBuilder;
impl ComObjRefBuilder {
/// Build a standard-OBJREF buffer for a given IID, OXID/OID/IPID, and one
/// or more `ncacn_ip_tcp` string bindings (e.g. `"hostname[5985]"`).
/// Mirrors `ComObjRefBuilder.CreateStandardObjRef`
/// (`ManagedCallbackExporter.cs:339-392`).
///
/// # Layout (`cs:348-389`)
///
/// ```text
/// offset size field
/// 0 4 signature u32 LE = 0x574F454D ("MEOW")
/// 4 4 flags u32 LE = 1
/// 8 16 iid GUID
/// 24 4 std_flags u32 LE
/// 28 4 public_refs u32 LE
/// 32 8 oxid u64 LE
/// 40 8 oid u64 LE
/// 48 16 ipid GUID
/// 64 2 entries u16 LE (count of u16 code units below)
/// 66 2 security_offset u16 LE (in u16 code units)
/// 68 .. dual-string array (variable-length u16 LE words)
/// ```
///
/// # `entries` and `security_offset`
///
/// `entries` is the **total u16-code-unit count** of the dual-string
/// array (string bindings + 0 separator + 7 security entries + final 0).
/// `security_offset` is the index (in u16 units) where security bindings
/// begin — `cs:348` computes this as
/// `sum(1 + binding.len() + 1 for binding in stringBindings) + 1`, i.e.
/// per-binding `tower_id` (1 word) + `binding.len()` ASCII chars (one
/// word each) + null terminator (1 word), plus the trailing 0 separator
/// that ends the string section.
///
/// # Panics
///
/// Never panics. All length math saturates: bindings longer than
/// `u16::MAX - HEADER_LEN/2 - SECURITY_TAIL_LEN` are not representable
/// in the 16-bit `entries` field, and the .NET reference does not guard
/// against this either; callers are expected to keep bindings short
/// (typical `hostname[port]` is < 100 chars).
#[must_use]
pub fn create_standard_objref(
iid: Guid,
std_flags: u32,
public_refs: u32,
oxid: u64,
oid: u64,
ipid: Guid,
string_bindings: &[&str],
) -> Vec<u8> {
// security_offset = sum_{b in string_bindings}(1 + b.len() + 1) + 1
// (cs:348). u16-truncating cast mirrors `(ushort)`.
let security_offset: u16 = string_bindings
.iter()
.map(|b| 1 + b.len() + 1)
.sum::<usize>()
.saturating_add(1)
.min(u16::MAX as usize) as u16;
// Build the u16 word array.
let mut words: Vec<u16> = Vec::new();
// String-bindings section: per binding, [0x0007 (ncacn_ip_tcp), each
// ASCII char as u16, terminator 0] (cs:350-359).
for binding in string_bindings {
words.push(0x0007);
for ch in binding.chars() {
words.push(ch as u16);
}
words.push(0);
}
// 0 separator that ends the string section (cs:361).
words.push(0);
// Security-bindings section: 7 hard-coded tower entries, each
// [tower_id, 0xFFFF, 0] (cs:362-367).
for &auth in &CALLBACK_OBJREF_AUTH_SERVICES {
words.push(auth);
words.push(0xFFFF);
words.push(0);
}
// Final terminator (cs:369).
words.push(0);
// u16-truncating cast mirrors `(ushort)words.Count` (cs:371).
let entries: u16 = words.len().min(u16::MAX as usize) as u16;
let mut buffer = vec![0u8; OBJREF_HEADER_LEN + words.len() * 2];
// Fixed 68-byte header (cs:373-382).
buffer[0..4].copy_from_slice(&OBJREF_SIGNATURE.to_le_bytes());
buffer[4..8].copy_from_slice(&1u32.to_le_bytes()); // flags = 1 (OBJREF_STANDARD)
buffer[8..24].copy_from_slice(iid.as_bytes());
buffer[24..28].copy_from_slice(&std_flags.to_le_bytes());
buffer[28..32].copy_from_slice(&public_refs.to_le_bytes());
buffer[32..40].copy_from_slice(&oxid.to_le_bytes());
buffer[40..48].copy_from_slice(&oid.to_le_bytes());
buffer[48..64].copy_from_slice(ipid.as_bytes());
buffer[64..66].copy_from_slice(&entries.to_le_bytes());
buffer[66..68].copy_from_slice(&security_offset.to_le_bytes());
// Dual-string array body (cs:384-389).
let mut offset = OBJREF_HEADER_LEN;
for word in &words {
buffer[offset..offset + 2].copy_from_slice(&word.to_le_bytes());
offset += 2;
}
buffer
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
@@ -626,4 +769,147 @@ mod tests {
assert_eq!(OBJREF_HEADER_LEN, 68);
assert_eq!(OBJREF_SIGNATURE, 0x574F_454D);
}
// ---- ComObjRefBuilder tests --------------------------------------
#[test]
fn builder_emits_meow_header_and_flags() {
let iid = Guid::new([0xAA; 16]);
let ipid = Guid::new([0xBB; 16]);
let buf = ComObjRefBuilder::create_standard_objref(
iid,
0x280,
5,
0x0123_4567_89AB_CDEF,
0xFEDC_BA98_7654_3210,
ipid,
&["host[5985]"],
);
assert!(buf.len() >= OBJREF_HEADER_LEN);
// Signature
assert_eq!(&buf[0..4], &OBJREF_SIGNATURE.to_le_bytes());
// flags = 1 (OBJREF_STANDARD)
assert_eq!(&buf[4..8], &1u32.to_le_bytes());
// IID
assert_eq!(&buf[8..24], iid.as_bytes());
// std_flags
assert_eq!(&buf[24..28], &0x280u32.to_le_bytes());
// public_refs
assert_eq!(&buf[28..32], &5u32.to_le_bytes());
// OXID/OID
assert_eq!(&buf[32..40], &0x0123_4567_89AB_CDEFu64.to_le_bytes());
assert_eq!(&buf[40..48], &0xFEDC_BA98_7654_3210u64.to_le_bytes());
// IPID
assert_eq!(&buf[48..64], ipid.as_bytes());
}
#[test]
fn builder_round_trips_through_parser() {
// The emitted OBJREF must parse back through ComObjRef::parse with
// the same key fields.
let iid = Guid::new([0x11; 16]);
let ipid = Guid::new([0x22; 16]);
let buf = ComObjRefBuilder::create_standard_objref(
iid,
0x280,
5,
0x1111_2222_3333_4444,
0x5555_6666_7777_8888,
ipid,
&["DESKTOP[12345]"],
);
let parsed = ComObjRef::parse(&buf).unwrap();
assert_eq!(parsed.signature, OBJREF_SIGNATURE);
assert_eq!(parsed.flags, 1);
assert_eq!(parsed.iid, iid);
assert_eq!(parsed.standard_flags, 0x280);
assert_eq!(parsed.public_refs, 5);
assert_eq!(parsed.oxid, 0x1111_2222_3333_4444);
assert_eq!(parsed.oid, 0x5555_6666_7777_8888);
assert_eq!(parsed.ipid, ipid);
// First decoded entry should be the ncacn_ip_tcp string binding,
// and at least one security binding (auth-service tail) follows.
let first = &parsed.dual_string_entries_decoded[0];
assert_eq!(first.tower_id, 0x0007);
assert_eq!(first.protocol, "ncacn_ip_tcp");
assert_eq!(first.value, "DESKTOP[12345]");
assert!(!first.is_security_binding);
let security_count = parsed
.dual_string_entries_decoded
.iter()
.filter(|e| e.is_security_binding)
.count();
assert!(
security_count >= 1,
"expected at least one security binding, got {security_count}"
);
}
#[test]
fn builder_security_offset_matches_dotnet_formula() {
// security_offset = sum(1 + binding.len() + 1) + 1 (cs:348).
// For one binding "host[12]" (8 chars): 1 + 8 + 1 + 1 = 11.
let buf = ComObjRefBuilder::create_standard_objref(
Guid::ZERO,
0,
5,
0,
0,
Guid::ZERO,
&["host[12]"],
);
let security_offset = u16::from_le_bytes([buf[66], buf[67]]);
assert_eq!(security_offset, 11);
}
#[test]
fn builder_two_bindings_security_offset() {
// Two bindings: "a[1]" (4) + "b[2]" (4):
// (1+4+1) + (1+4+1) + 1 = 13.
let buf = ComObjRefBuilder::create_standard_objref(
Guid::ZERO,
0,
5,
0,
0,
Guid::ZERO,
&["a[1]", "b[2]"],
);
let security_offset = u16::from_le_bytes([buf[66], buf[67]]);
assert_eq!(security_offset, 13);
}
#[test]
fn builder_emits_seven_security_entries() {
// Each security entry contributes 3 u16 words [tower_id, 0xFFFF, 0].
// Total security words = 7 * 3 = 21, plus a trailing 0 = 22.
// String section for one binding "h[1]" (4 chars): 1+4+1+1 = 7 words.
// Total entries = 7 + 22 = 29.
let buf =
ComObjRefBuilder::create_standard_objref(Guid::ZERO, 0, 5, 0, 0, Guid::ZERO, &["h[1]"]);
let entries = u16::from_le_bytes([buf[64], buf[65]]);
assert_eq!(entries, 29);
}
#[test]
fn builder_auth_services_table_matches_dotnet_order() {
// The auth-service tower ids in the security tail must appear in the
// order the .NET reference writes them (cs:362).
assert_eq!(
CALLBACK_OBJREF_AUTH_SERVICES,
[0x0009, 0x001E, 0x0010, 0x000A, 0x0016, 0x001F, 0x000E]
);
}
#[test]
fn builder_total_buffer_length_matches_words_count() {
// entries * 2 + HEADER_LEN
let buf =
ComObjRefBuilder::create_standard_objref(Guid::ZERO, 0, 5, 0, 0, Guid::ZERO, &["x[1]"]);
let entries = u16::from_le_bytes([buf[64], buf[65]]) as usize;
assert_eq!(buf.len(), OBJREF_HEADER_LEN + entries * 2);
}
}