[M2] mxaccess-rpc: NTLMv2 + DCE/RPC PDU + OBJREF parser (wave 1)

Lands M2 wave 1 — three pure-Rust modules under crates/mxaccess-rpc with
60 unit tests. Each is a 1:1 port of one .NET reference file:

- ntlm.rs (1137 LoC, 19 tests) — `ManagedNtlmClientContext.cs`. NTLMv2
  challenge/response, Type1/Type3 builders, sign() with RC4-sealed checksum
  and per-call sequence advance. Manual `Debug` impl that hides credentials;
  not Clone (rc4 0.2 cipher state is non-Clone). Pure-Rust crypto via
  hmac/md-5/md4/rc4 v0.2/rand v0.8 (rc4 0.2 chosen per design/review.md:78).
- pdu.rs (1573 LoC, 33 tests) — `DceRpcPdu.cs` + auth-trailer types from
  `DceRpcAuthentication.cs`. Bind/AlterContext/Auth3/Request/Response/Fault
  PDUs, NDR20 transfer syntax, auth_value with 4-byte alignment padding,
  preserved-byte fields per CLAUDE.md unknown-bytes rule.
- objref.rs (~470 LoC, 11 tests including a 366-byte captured OBJREF
  round-trip) — `ComObjRef.cs`. MEOW signature, OXID/OID/IPID, dual-string
  array with printable-ASCII escaping and security-binding boundary.
  ComObjRefProvider.cs deferred (windows-rs Win32 wrapper — see F6).

Every wire-byte claim cites src/MxNativeClient/<file>.cs:LINE per
CLAUDE.md "no fabricated protocol behaviour" rule.

Test count delta: 217 → 277 (+60)
Open followups touched: F1–F8 (new — see design/followups.md)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 06:54:39 -04:00
parent 16f2c148e5
commit 95bd218183
7 changed files with 3763 additions and 2 deletions
+59
View File
@@ -0,0 +1,59 @@
# Followups
Open work items deferred during /loop iterations. Triaged at the top of
every iteration. New items are appended under `## Open`; resolved items
move to `## Resolved` with a date + commit hash.
## Open
### F1 — NTLM consumer-layer helpers (workstation default + from_env constructor)
**Severity:** P3
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs`
**Why deferred:** The .NET reference's `Environment.MachineName` default for `workstation` and its `FromEnvironment()` constructor (`ManagedNtlmClientContext.cs:38`, `:41-49`) read host state and env vars — both side effects that don't belong in a pure codec module. The constructor takes `workstation: Option<&str>` so callers can wire either later.
**Resolves when:** M2 wave 2 transport (or the M2 example `connect-nmx.rs`) wires `NtlmClientContext::new(.., Some(hostname()?))` and provides a small `from_env` helper at the consumer layer.
### F2 — NTLM verify_signature path + constant-time MAC compare (server-to-client direction)
**Severity:** P2
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs`
**Why deferred:** The .NET `ManagedNtlmClientContext` only implements client-to-server signing (`cs:30,124`); there is no implementation of server-to-client sign/seal keys or `verify_signature`. Both are needed when the callback exporter receives a signed inbound frame from `NmxSvc.exe`, but no such fixture exists yet.
**Resolves when:** M2 wave 3 (callback exporter) captures an `INmxSvcCallback::StatusReceived` frame with an `auth_value` trailer per `design/60-roadmap.md:56` (DoD #3) and a fixture lands under `tests/fixtures/m2-status-frame/`. Add `subtle = "2"` and gate the byte compare behind `ConstantTimeEq` at the same time.
### F3 — Cross-domain NTLM Type1/2/3 fixture
**Severity:** P2
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs`
**Why deferred:** All current NTLM fixtures are single-domain (the local AVEVA install). Tracked separately in `design/70-risks-and-open-questions.md` R8 (P1 risk) and the open-evidence-gaps table.
**Resolves when:** A multi-domain AVEVA test harness lands and a successful cross-domain authenticate round-trip captures Type1/2/3 bytes. Notes: this clears R8.
### F4 — BindAck / AlterContextResponse body parser
**Severity:** P2
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/pdu.rs`
**Why deferred:** The .NET reference (`DceRpcPdu.cs:217-262`) parses Bind and AlterContext into the same struct but does not decode the corresponding *response* body (result list + secondary address). The Rust port's `BindPdu::decode` accepts `BindAck` packet type but does not interpret the body. The negotiated transfer syntax — needed before opnum dispatch — is currently inferred from request-side context.
**Resolves when:** A captured BindAck frame from `captures/013-loopback-subscribe-scalars/nmx-stream-*.bin` is decoded and the body shape is documented in `docs/Loopback-Protocol-Findings.md`.
### F5 — Captured DCE/RPC bind-frame fixture round-trip
**Severity:** P2
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/pdu.rs`
**Why deferred:** Existing PDU tests build hand-constructed `[C706]`-conformant frames. A capture-driven round-trip (extract bind/alter PDUs from `captures/013-loopback-subscribe-scalars/nmx-stream-*.bin`, decode → encode → assert byte-identical) would be stronger evidence of parity with the live wire.
**Resolves when:** Bytes from that capture are extracted into `tests/fixtures/m2-pdu/` and the round-trip test lands.
### F6 — Port `ComObjRefProvider.cs` (OBJREF emitter via Win32 CoMarshalInterface)
**Severity:** P2
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/objref.rs`
**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.
### F7 — Consolidate `Guid` type across `mxaccess-rpc`
**Severity:** P3
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/{objref.rs,pdu.rs}`
**Why deferred:** `objref::Guid` is a self-contained `[u8; 16]` newtype with `Display` matching `.NET Guid.ToString("D")`. `pdu::SyntaxId` uses raw `[u8; 16]` for IIDs. Both work but a single shared type would be cleaner.
**Resolves when:** A small consolidation lands — either `pub use objref::Guid as Guid;` from `pdu`, or both move to a shared `crate::guid` module. Trivial; pick during M2 wave 2 when the next agent touches the crate.
### F8 — `RpcError` is duplicated across `objref` and `pdu` modules
**Severity:** P3
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/{objref.rs,pdu.rs}`
**Why deferred:** Each module defined its own `RpcError` enum with a partial set of variants. Both are sound in isolation but the crate-public `RpcError` should be a single union. Not blocking — they don't collide because each module re-exports its own.
**Resolves when:** M2 wave 2 (OXID + `IRemUnknown::RemQueryInterface`) needs a third error surface. At that point, hoist `RpcError` to `crates/mxaccess-rpc/src/error.rs` mirroring `mxaccess-codec/src/error.rs`, and have each module use the shared enum.
## Resolved
(none yet)
+238
View File
@@ -2,6 +2,144 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
dependencies = [
"block-buffer 0.12.0",
"crypto-common 0.2.1",
"inout",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [
"hybrid-array",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "hybrid-array"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
dependencies = [
"typenum",
]
[[package]]
name = "inout"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"hybrid-array",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "md4"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda"
dependencies = [
"digest",
]
[[package]]
name = "mxaccess"
version = "0.0.0"
@@ -60,6 +198,23 @@ dependencies = [
[[package]]
name = "mxaccess-rpc"
version = "0.0.0"
dependencies = [
"hmac",
"md-5",
"md4",
"rand",
"rc4",
"thiserror",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
@@ -79,6 +234,51 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rc4"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "840038b674daa9f7a7957440d937951d15c0143c056e631e529141fd780e0c92"
dependencies = [
"cipher",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@@ -110,8 +310,46 @@ dependencies = [
"syn",
]
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+6
View File
@@ -9,6 +9,12 @@ rust-version.workspace = true
authors.workspace = true
[dependencies]
thiserror = { workspace = true }
hmac = "0.12"
md-5 = "0.10"
md4 = "0.10"
rc4 = "0.2"
rand = "0.8"
[features]
default = []
+9 -2
View File
@@ -1,10 +1,17 @@
//! `mxaccess-rpc` — DCE/RPC + NTLMv2 + OBJREF + OXID + IRemUnknown::RemQueryInterface.
//!
//! M0 stub. Real implementation lands in M2 — see `design/60-roadmap.md`.
//! M2 wave 1 landed: `ntlm`, `pdu`, `objref`. OXID resolution and
//! `IRemUnknown::RemQueryInterface` follow in wave 2; the callback exporter
//! in wave 3 — see `design/60-roadmap.md` and `design/dependencies.md`.
//!
//! Internal `unsafe` is permitted only for `windows-rs` COM activation paths
//! (per `design/00-overview.md` principle 3); all such calls must be wrapped
//! in safe abstractions at the crate boundary.
//! in safe abstractions at the crate boundary. Wave 1 modules are pure-Rust
//! and contain no `unsafe`.
// `mxaccess-rpc` is the only crate where internal unsafe is permitted (for
// windows-rs COM calls). Public API stays safe.
pub mod ntlm;
pub mod objref;
pub mod pdu;
File diff suppressed because it is too large Load Diff
+672
View File
@@ -0,0 +1,672 @@
//! `ComObjRef` — DCOM OBJREF parser.
//!
//! Direct port of `src/MxNativeClient/ComObjRef.cs`. Parses the marshalled
//! interface byte stream produced by `CoMarshalInterface` (per `[MS-DCOM]`
//! §2.2.18) into a structured form the RPC layer can inspect for OXID/OID/IPID
//! and the dual-string-array bindings (string + security towers).
//!
//! The .NET reference parses 68 fixed bytes followed by the dual-string array,
//! which is decoded character-by-character and used purely for diagnostics.
//! The Rust port mirrors that parser shape exactly — every field offset,
//! the `Math.Min(entries, data.Length / 2)` bound, and the printable-ASCII
//! escaping of each UTF-16 code unit are 1:1 with `ComObjRef.cs:18-117`.
//!
//! `ComObjRefProvider.cs` is **not** ported here — it is a thin wrapper around
//! Win32 `CoMarshalInterface` / `IStream` / `GlobalLock` and produces OBJREF
//! bytes by calling into ole32. That belongs behind `windows-rs` in a later
//! M2/M3 wave; the pure-Rust parser stands alone and is what M2 wave 1 needs
//! for OBJREF inspection on inbound activation responses. See followup F1
//! in this module's report.
// Direct byte indexing — every access is guarded by an explicit length check
// and the result reads as a 1:1 mirror of the .NET `BinaryPrimitives` calls.
// `.get(n)?` would obscure the byte map. Mirrors the rationale documented in
// `crates/mxaccess-codec/src/reference_handle.rs:7-11`.
#![allow(clippy::indexing_slicing)]
use std::fmt::Write as _;
use thiserror::Error;
/// Encoded layout per `ComObjRef.cs:25-39`:
///
/// ```text
/// offset size field
/// 0 4 signature u32 LE = 0x574F454D ("MEOW")
/// 4 4 flags u32 LE (1 = OBJREF_STANDARD; only standard parsed)
/// 8 16 iid GUID
/// 24 4 std_flags u32 LE (STDOBJREF flags)
/// 28 4 public_refs u32 LE
/// 32 8 oxid u64 LE
/// 40 8 oid u64 LE
/// 48 16 ipid GUID
/// 64 2 dual_string_entries u16 LE (count of u16 code units in the array)
/// 66 2 dual_string_security_offset u16 LE (boundary index between string and security bindings)
/// 68 .. dual-string array (variable; UTF-16LE code units terminated by 0x0000 per entry)
/// ```
///
/// **Header length** is 68 bytes; the dual-string array follows starting at
/// offset 68. The `dual_string_entries` count is bounded by the actual byte
/// length of the trailing bytes via `min(entries, data.len() / 2)`
/// (`ComObjRef.cs:59`).
pub const OBJREF_HEADER_LEN: usize = 68;
/// "MEOW" — the OBJREF signature (`ComObjRef.cs:29`, also `[MS-DCOM]` §2.2.18.1).
pub const OBJREF_SIGNATURE: u32 = 0x574F_454D;
const FLAGS_OFFSET: usize = 4;
const IID_OFFSET: usize = 8;
const STD_FLAGS_OFFSET: usize = 24;
const PUBLIC_REFS_OFFSET: usize = 28;
const OXID_OFFSET: usize = 32;
const OID_OFFSET: usize = 40;
const IPID_OFFSET: usize = 48;
const DUAL_STRING_ENTRIES_OFFSET: usize = 64;
const DUAL_STRING_SECURITY_OFFSET_OFFSET: usize = 66;
/// 16-byte GUID. Stored as little-endian wire bytes for the first three groups
/// (Data1 u32 LE, Data2 u16 LE, Data3 u16 LE) followed by 8 big-endian
/// `Data4` bytes — matches the byte layout produced by .NET
/// `new Guid(ReadOnlySpan<byte>)` (`ComObjRef.cs:31,36`).
///
/// Kept as a self-contained type to avoid pulling `uuid` into `mxaccess-rpc`;
/// the sibling DCE/RPC PDU codec may consolidate to a shared type at the
/// loop-driver level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Guid(pub [u8; 16]);
impl Guid {
pub const fn new(bytes: [u8; 16]) -> Self {
Self(bytes)
}
pub const fn as_bytes(&self) -> &[u8; 16] {
&self.0
}
}
impl std::fmt::Display for Guid {
/// Mirrors .NET `Guid.ToString("D")`: dashed hex, lowercase, e.g.
/// `b49f92f7-c748-4169-8eca-a0670b012746`. The first three groups are
/// little-endian on the wire so are byte-swapped on display.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let b = &self.0;
write!(
f,
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
b[3],
b[2],
b[1],
b[0],
b[5],
b[4],
b[7],
b[6],
b[8],
b[9],
b[10],
b[11],
b[12],
b[13],
b[14],
b[15],
)
}
}
/// Errors produced by the OBJREF parser.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RpcError {
/// Buffer too short to satisfy a fixed-layout read.
#[error("short read: expected {expected} bytes, got {actual}")]
ShortRead { expected: usize, actual: usize },
}
/// One decoded entry of the OBJREF dual-string array. `value` is the
/// printable-ASCII escaping of the UTF-16 string per `ComObjRef.cs:82-91` —
/// non-printable code units appear as `<XXXX>` lowercase hex. `is_security_binding`
/// is set when the entry's start offset (in u16 units) is at or past
/// `DualStringSecurityOffset`.
///
/// Mirrors `ComDualStringEntry` (`ComObjRef.cs:138-145`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ComDualStringEntry {
pub tower_id: u16,
pub protocol: &'static str,
pub value: String,
pub is_security_binding: bool,
}
impl ComDualStringEntry {
/// Mirrors `ComDualStringEntry.ToDiagnosticString` (`ComObjRef.cs:140-144`):
/// `"<kind>:0x<tower_id_lc>:<protocol>:<value>"`.
pub fn to_diagnostic_string(&self) -> String {
let kind = if self.is_security_binding {
"security"
} else {
"string"
};
format!(
"{}:0x{:04x}:{}:{}",
kind, self.tower_id, self.protocol, self.value
)
}
}
/// Parsed DCOM OBJREF (standard form).
///
/// Mirrors `ComObjRef` record (`ComObjRef.cs:5-16`). All eleven fields of the
/// .NET record are preserved including `signature`, `flags`, `std_flags`,
/// `dual_string_entries`, and `dual_string_security_offset` — even though the
/// signature is a known constant, the parser does not validate it (the .NET
/// reference doesn't either; bytes are surfaced verbatim per CLAUDE.md
/// preserve-unknown-bytes rule).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ComObjRef {
pub signature: u32,
pub flags: u32,
pub iid: Guid,
pub standard_flags: u32,
pub public_refs: u32,
pub oxid: u64,
pub oid: u64,
pub ipid: Guid,
/// Raw entry-count field — measured in **u16 code units**, not entries.
/// Preserved verbatim from the wire even when it overruns the buffer; the
/// parse loop bounds itself by `min(entries, data.len() / 2)`
/// (`ComObjRef.cs:59`).
pub dual_string_entries: u16,
/// Boundary (in u16 code-unit indices) between string bindings and
/// security bindings within the dual-string array (`ComObjRef.cs:98`).
pub dual_string_security_offset: u16,
pub dual_string_entries_decoded: Vec<ComDualStringEntry>,
}
impl ComObjRef {
/// Header length (68 bytes) before the dual-string array.
pub const HEADER_LEN: usize = OBJREF_HEADER_LEN;
/// Parse an OBJREF buffer. Mirrors `ComObjRef.Parse` (`ComObjRef.cs:18-40`)
/// byte-for-byte: 68-byte fixed header followed by a UTF-16LE
/// dual-string array bounded by `min(entries, tail.len() / 2)`.
///
/// The signature field is read but not validated — the .NET reference
/// surfaces it verbatim so callers can diff against captures.
///
/// # Errors
///
/// - [`RpcError::ShortRead`] if `buffer.len() < 68`
/// (matches `ComObjRef.cs:20-23`).
pub fn parse(buffer: &[u8]) -> Result<Self, RpcError> {
if buffer.len() < Self::HEADER_LEN {
return Err(RpcError::ShortRead {
expected: Self::HEADER_LEN,
actual: buffer.len(),
});
}
let dual_string_entries = read_u16_le(buffer, DUAL_STRING_ENTRIES_OFFSET);
let security_offset = read_u16_le(buffer, DUAL_STRING_SECURITY_OFFSET_OFFSET);
let mut iid_bytes = [0u8; 16];
iid_bytes.copy_from_slice(&buffer[IID_OFFSET..IID_OFFSET + 16]);
let mut ipid_bytes = [0u8; 16];
ipid_bytes.copy_from_slice(&buffer[IPID_OFFSET..IPID_OFFSET + 16]);
let tail = &buffer[Self::HEADER_LEN..];
let decoded = decode_dual_string_array(tail, dual_string_entries, security_offset);
Ok(Self {
signature: read_u32_le(buffer, 0),
flags: read_u32_le(buffer, FLAGS_OFFSET),
iid: Guid(iid_bytes),
standard_flags: read_u32_le(buffer, STD_FLAGS_OFFSET),
public_refs: read_u32_le(buffer, PUBLIC_REFS_OFFSET),
oxid: read_u64_le(buffer, OXID_OFFSET),
oid: read_u64_le(buffer, OID_OFFSET),
ipid: Guid(ipid_bytes),
dual_string_entries,
dual_string_security_offset: security_offset,
dual_string_entries_decoded: decoded,
})
}
/// Diagnostic line emitter — byte-identical to `ToDiagnosticLines`
/// (`ComObjRef.cs:42-55`). The output is intended for matching against
/// Frida-captured probe output (`captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt`).
pub fn to_diagnostic_lines(&self) -> Vec<String> {
let dual_strings = self
.dual_string_entries_decoded
.iter()
.map(ComDualStringEntry::to_diagnostic_string)
.collect::<Vec<_>>()
.join("|");
vec![
format!("objref_signature=0x{:08X}", self.signature),
format!("objref_flags=0x{:08X}", self.flags),
format!("objref_iid={}", self.iid),
format!("std_flags=0x{:08X}", self.standard_flags),
format!("std_public_refs={}", self.public_refs),
format!("std_oxid=0x{:016X}", self.oxid),
format!("std_oid=0x{:016X}", self.oid),
format!("std_ipid={}", self.ipid),
format!("dual_string_entries={}", self.dual_string_entries),
format!(
"dual_string_security_offset={}",
self.dual_string_security_offset
),
format!("dual_strings={}", dual_strings),
]
}
}
/// Decode the trailing dual-string array. Mirrors
/// `DecodeDualStringArray` (`ComObjRef.cs:57-102`).
///
/// The loop walks `i` in u16 code-unit indices, capped by
/// `min(entries, data.len() / 2)`. Each entry begins with a 16-bit
/// `tower_id`; if zero, it terminates the string-binding region (the
/// `continue` skips to the next index without producing an entry — same as
/// the .NET source). Otherwise the following u16 code units up to (but not
/// including) the next 0x0000 terminator form the entry's value, escaped
/// printable-ASCII per the `0x20..=0x7e` rule.
fn decode_dual_string_array(
data: &[u8],
entries: u16,
security_offset: u16,
) -> Vec<ComDualStringEntry> {
let entries = entries as usize;
let count = entries.min(data.len() / 2);
let mut strings = Vec::new();
let mut i: usize = 0;
while i < count {
let entry_start = i;
let tower_id = read_u16_le(data, i * 2);
i += 1;
if tower_id == 0 {
continue;
}
let mut text = String::new();
while i < count {
let value = read_u16_le(data, i * 2);
i += 1;
if value == 0 {
break;
}
if (0x20..=0x7e).contains(&value) {
// Safe: 0x20..=0x7e is printable ASCII, valid UTF-8.
text.push(value as u8 as char);
} else {
// Non-printable: emit "<XXXX>" lowercase hex (mirrors .NET
// `value.ToString("x4", InvariantCulture)`).
// write! to a String never fails; ignore the Result.
let _ = write!(&mut text, "<{:04x}>", value);
}
}
strings.push(ComDualStringEntry {
tower_id,
protocol: protocol_tower_name(tower_id),
value: text,
is_security_binding: entry_start >= security_offset as usize,
});
}
strings
}
/// Protocol-tower name table per `ComObjRef.cs:104-117`. Returns `"unknown"`
/// for unrecognised tower ids — mirrors the `_ =>` fall-through.
pub const fn protocol_tower_name(tower_id: u16) -> &'static str {
match tower_id {
0x0007 => "ncacn_ip_tcp",
0x0008 => "ncadg_ip_udp",
0x0009 => "ncacn_np",
0x000f => "ncacn_spx",
0x0010 => "ncacn_nb_nb",
0x0016 => "ncadg_ip_udp_or_netbios",
0x001f => "ncalrpc",
_ => "unknown",
}
}
#[inline]
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
#[inline]
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
#[inline]
fn read_u64_le(bytes: &[u8], offset: usize) -> u64 {
u64::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
bytes[offset + 4],
bytes[offset + 5],
bytes[offset + 6],
bytes[offset + 7],
])
}
// Compile-time invariant: header length matches the documented byte layout.
const _: () = assert!(OBJREF_HEADER_LEN == 68);
const _: () = assert!(OBJREF_SIGNATURE == 0x574F_454D);
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
/// Hand-built OBJREF: signature + flags=1 + sample IID + std_flags +
/// public_refs=5 + fake OXID/OID/IPID + dual_string array containing one
/// `ncacn_ip_tcp` entry then a `0x0000` terminator. Returns the bytes.
fn build_minimal_objref() -> Vec<u8> {
let mut buf = Vec::new();
// signature "MEOW" 0x574F454D LE
buf.extend_from_slice(&0x574F_454Du32.to_le_bytes());
// flags = 1 (OBJREF_STANDARD)
buf.extend_from_slice(&1u32.to_le_bytes());
// iid (16 bytes; arbitrary)
buf.extend_from_slice(&[
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10,
]);
// std_flags
buf.extend_from_slice(&0u32.to_le_bytes());
// public_refs = 5
buf.extend_from_slice(&5u32.to_le_bytes());
// oxid
buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes());
// oid
buf.extend_from_slice(&0xAABB_CCDD_EEFF_0011u64.to_le_bytes());
// ipid
buf.extend_from_slice(&[
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad,
0xae, 0xaf,
]);
// Build the dual-string array:
// tower_id 0x0007 (ncacn_ip_tcp)
// "AB" as UTF-16LE
// 0x0000 terminator
// That's 4 u16 code units = 8 bytes.
let array_units: [u16; 4] = [0x0007, b'A' as u16, b'B' as u16, 0x0000];
let dual_entries: u16 = array_units.len() as u16;
let security_offset: u16 = dual_entries; // no security bindings
// dual_string_entries (count of u16 code units)
buf.extend_from_slice(&dual_entries.to_le_bytes());
// dual_string_security_offset
buf.extend_from_slice(&security_offset.to_le_bytes());
// header now exactly 68 bytes
assert_eq!(buf.len(), 68);
for unit in array_units {
buf.extend_from_slice(&unit.to_le_bytes());
}
buf
}
#[test]
fn parse_minimal_objref() {
let bytes = build_minimal_objref();
let parsed = ComObjRef::parse(&bytes).unwrap();
assert_eq!(parsed.signature, 0x574F_454D);
assert_eq!(parsed.flags, 1);
assert_eq!(parsed.standard_flags, 0);
assert_eq!(parsed.public_refs, 5);
assert_eq!(parsed.oxid, 0x1122_3344_5566_7788);
assert_eq!(parsed.oid, 0xAABB_CCDD_EEFF_0011);
assert_eq!(parsed.dual_string_entries, 4);
assert_eq!(parsed.dual_string_security_offset, 4);
assert_eq!(parsed.dual_string_entries_decoded.len(), 1);
let entry = &parsed.dual_string_entries_decoded[0];
assert_eq!(entry.tower_id, 0x0007);
assert_eq!(entry.protocol, "ncacn_ip_tcp");
assert_eq!(entry.value, "AB");
// entry_start (0) < security_offset (4) → string binding.
assert!(!entry.is_security_binding);
}
#[test]
fn diagnostic_lines_format_minimal() {
let bytes = build_minimal_objref();
let parsed = ComObjRef::parse(&bytes).unwrap();
let lines = parsed.to_diagnostic_lines();
// Per ComObjRef.cs:42-55 there are exactly 11 lines.
assert_eq!(lines.len(), 11);
assert_eq!(lines[0], "objref_signature=0x574F454D");
assert_eq!(lines[1], "objref_flags=0x00000001");
assert_eq!(lines[3], "std_flags=0x00000000");
assert_eq!(lines[4], "std_public_refs=5");
assert_eq!(lines[5], "std_oxid=0x1122334455667788");
assert_eq!(lines[6], "std_oid=0xAABBCCDDEEFF0011");
assert_eq!(lines[8], "dual_string_entries=4");
assert_eq!(lines[9], "dual_string_security_offset=4");
assert_eq!(lines[10], "dual_strings=string:0x0007:ncacn_ip_tcp:AB");
}
#[test]
fn parse_rejects_short_buffer() {
// 67-byte buffer (one shy of header) must error, not panic.
let err = ComObjRef::parse(&[0u8; 67]).unwrap_err();
match err {
RpcError::ShortRead { expected, actual } => {
assert_eq!(expected, 68);
assert_eq!(actual, 67);
}
}
}
#[test]
fn parse_accepts_exact_header_no_array() {
// 68 bytes with dual_string_entries=0 → no decoded entries.
let mut buf = vec![0u8; 68];
// signature
buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes());
let parsed = ComObjRef::parse(&buf).unwrap();
assert_eq!(parsed.dual_string_entries, 0);
assert_eq!(parsed.dual_string_security_offset, 0);
assert!(parsed.dual_string_entries_decoded.is_empty());
}
#[test]
fn protocol_tower_name_table() {
// All 7 documented tower ids per ComObjRef.cs:106-117.
assert_eq!(protocol_tower_name(0x0007), "ncacn_ip_tcp");
assert_eq!(protocol_tower_name(0x0008), "ncadg_ip_udp");
assert_eq!(protocol_tower_name(0x0009), "ncacn_np");
assert_eq!(protocol_tower_name(0x000f), "ncacn_spx");
assert_eq!(protocol_tower_name(0x0010), "ncacn_nb_nb");
assert_eq!(protocol_tower_name(0x0016), "ncadg_ip_udp_or_netbios");
assert_eq!(protocol_tower_name(0x001f), "ncalrpc");
// Fall-through.
assert_eq!(protocol_tower_name(0x0000), "unknown");
assert_eq!(protocol_tower_name(0xFFFF), "unknown");
}
#[test]
fn dual_string_array_overrun_bounded() {
// Build a header that claims 1000 dual-string code units but only
// includes 4 bytes (= 2 code units) of trailing data. The parser
// must bound itself via min(entries, data.len()/2) per
// ComObjRef.cs:59 and not read past the end.
let mut buf = build_minimal_objref();
// Truncate the trailing dual-string bytes back to 0 and lie about
// entries=1000.
buf.truncate(68);
buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2]
.copy_from_slice(&1000u16.to_le_bytes());
let parsed = ComObjRef::parse(&buf).unwrap();
// The wire-declared entry count is preserved verbatim per
// CLAUDE.md unknown-bytes rule.
assert_eq!(parsed.dual_string_entries, 1000);
// But the loop bound prevents any decoding.
assert!(parsed.dual_string_entries_decoded.is_empty());
}
#[test]
fn security_binding_flag_split() {
// Build a dual-string array with one string binding then one security
// binding. Layout (u16 code units):
// [0] tower=0x0007 (string binding starts at index 0)
// [1] 'A'
// [2] 0x0000 terminator
// [3] tower=0x0007 (security binding starts at index 3)
// [4] 'B'
// [5] 0x0000 terminator
// dual_string_entries = 6, security_offset = 3 (entries with start
// index >= 3 are security bindings).
let mut buf = vec![0u8; 68];
buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes());
let entries: u16 = 6;
let sec_off: u16 = 3;
buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2]
.copy_from_slice(&entries.to_le_bytes());
buf[DUAL_STRING_SECURITY_OFFSET_OFFSET..DUAL_STRING_SECURITY_OFFSET_OFFSET + 2]
.copy_from_slice(&sec_off.to_le_bytes());
for unit in [0x0007u16, b'A' as u16, 0x0000, 0x0007, b'B' as u16, 0x0000] {
buf.extend_from_slice(&unit.to_le_bytes());
}
let parsed = ComObjRef::parse(&buf).unwrap();
assert_eq!(parsed.dual_string_entries_decoded.len(), 2);
assert_eq!(parsed.dual_string_entries_decoded[0].value, "A");
assert!(!parsed.dual_string_entries_decoded[0].is_security_binding);
assert_eq!(parsed.dual_string_entries_decoded[1].value, "B");
assert!(parsed.dual_string_entries_decoded[1].is_security_binding);
}
#[test]
fn non_printable_codeunit_escaped_as_hex() {
// tower=0x0007, then a non-printable u16 (0x0100), then 'a', then 0x0000.
let mut buf = vec![0u8; 68];
buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes());
let entries: u16 = 4;
buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2]
.copy_from_slice(&entries.to_le_bytes());
buf[DUAL_STRING_SECURITY_OFFSET_OFFSET..DUAL_STRING_SECURITY_OFFSET_OFFSET + 2]
.copy_from_slice(&entries.to_le_bytes());
for unit in [0x0007u16, 0x0100, b'a' as u16, 0x0000] {
buf.extend_from_slice(&unit.to_le_bytes());
}
let parsed = ComObjRef::parse(&buf).unwrap();
assert_eq!(parsed.dual_string_entries_decoded.len(), 1);
// Expect "<0100>a" per the printable-ASCII escape rule
// (ComObjRef.cs:82-91).
assert_eq!(parsed.dual_string_entries_decoded[0].value, "<0100>a");
}
/// Captured OBJREF from `captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt:6`
/// (`managed_callback_objref_hex`). 366 bytes; produced by the .NET
/// managed-callback exporter via `MarshalIUnknownObjRef` in the live
/// probe. Used to verify that the Rust parser interprets a real-world
/// OBJREF identically to the .NET reference.
const CAPTURED_OBJREF_HEX: &str = "4D454F5701000000F7929FB448C769418ECAA0670B012746800A000005000000750CC6C8BA9B1EFD3BF71E12FEE5B5C1022C0000DC32FFFF8AC67645E6ED23FF95007F0007004400450053004B0054004F0050002D0036004A004C0033004B004B004F0000000700310030002E003100300030002E0030002E0034003800000007003100370032002E00320039002E003200320034002E0031000000070066006400650031003A0061006500340031003A0038006100300030003A0034003500320061003A0062006200340031003A0065006500370065003A0035006600640034003A0064006300310038000000070066006400650031003A0061006500340031003A0038006100300030003A0034003500320061003A0035003000620031003A0038003400360066003A0037006200350031003A006500610034003000000000000900FFFF00001E00FFFF00001000FFFF00000A00FFFF00001600FFFF00001F00FFFF00000E00FFFF00000000";
fn hex_decode(hex: &str) -> Vec<u8> {
let bytes = hex.as_bytes();
assert!(bytes.len() % 2 == 0);
let mut out = Vec::with_capacity(bytes.len() / 2);
for chunk in bytes.chunks(2) {
let hi = (chunk[0] as char).to_digit(16).unwrap() as u8;
let lo = (chunk[1] as char).to_digit(16).unwrap() as u8;
out.push((hi << 4) | lo);
}
out
}
#[test]
fn captured_objref_parses() {
let bytes = hex_decode(CAPTURED_OBJREF_HEX);
// probe.stdout.txt:5 reports managed_callback_objref_size=366.
assert_eq!(bytes.len(), 366);
let parsed = ComObjRef::parse(&bytes).unwrap();
// Signature is the canonical "MEOW".
assert_eq!(parsed.signature, OBJREF_SIGNATURE);
// OBJREF_STANDARD.
assert_eq!(parsed.flags, 1);
// public_refs = 5 (per the captured bytes 28..32 = 05 00 00 00).
assert_eq!(parsed.public_refs, 5);
// Captured bytes at offset 64..68 are `95 00 7F 00`:
// dual_string_entries (u16 LE) = 0x0095 = 149
// dual_string_security_offset (u16 LE) = 0x007F = 127
// 149 u16 units from offset 68 onwards exactly fills the remaining
// 366 - 68 = 298 bytes (149 * 2), so the entries count saturates the
// buffer — confirming the parser's `min(entries, data.len()/2)`
// bound at `ComObjRef.cs:59` produces the same effective walk length.
assert_eq!(parsed.dual_string_entries, 0x0095);
assert_eq!(parsed.dual_string_security_offset, 0x007F);
// First decoded string-binding is the hostname over ncacn_ip_tcp.
// The probe was run on host DESKTOP-6JL3KKO per the captured UTF-16
// bytes immediately following the dual-string header.
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-6JL3KKO");
assert!(!first.is_security_binding);
// Subsequent string-bindings are the IPv4 + IPv6 endpoint addresses.
// Confirm we got at least 4 string-bindings (host + v4 + 2x v6) plus
// the security-binding entries.
assert!(parsed.dual_string_entries_decoded.len() >= 5);
// At least one entry must be a security binding (entries past
// security_offset). The "0900FFFF" sequence in the captured bytes
// decodes as tower=0x0009 (ncacn_np) with a single non-printable
// u16 0xFFFF — appears in the security-binding tail.
assert!(
parsed
.dual_string_entries_decoded
.iter()
.any(|e| e.is_security_binding),
"expected at least one security binding in captured OBJREF"
);
// The diagnostic emitter must produce exactly 11 lines.
let lines = parsed.to_diagnostic_lines();
assert_eq!(lines.len(), 11);
assert_eq!(lines[0], "objref_signature=0x574F454D");
}
#[test]
fn guid_display_matches_dotnet_d_format() {
// .NET Guid("F7929FB4-48C7-6941-8ECA-A0670B012746".replace order):
// The 16-byte sequence F7 92 9F B4 48 C7 69 41 8E CA A0 67 0B 01 27 46
// displays as "b49f92f7-c748-4169-8eca-a0670b012746" — first three
// groups are byte-swapped (LE on wire, BE in display).
let g = Guid([
0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01,
0x27, 0x46,
]);
assert_eq!(format!("{}", g), "b49f92f7-c748-4169-8eca-a0670b012746");
}
#[test]
fn header_length_constant() {
assert_eq!(ComObjRef::HEADER_LEN, 68);
assert_eq!(OBJREF_HEADER_LEN, 68);
assert_eq!(OBJREF_SIGNATURE, 0x574F_454D);
}
}
File diff suppressed because it is too large Load Diff