[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:
@@ -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)
|
||||
Generated
+238
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user