fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Layout:
- src/ .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
MxAsbClient, probes, tests, harnesses. Executable spec.
- design/ Architectural plan for the Rust port (M0–M6), error
model, protocol invariants, risks (R1–R16), adversarial
review log (review.md).
- rust/ Rust workspace. M0 skeleton + M1 codec parity.
mxaccess-codec: 215 unit tests + 2 cross-implementation
parity tests (byte-identical against .NET reference).
Other crates are M0 stubs awaiting M2+.
- captures/ Frida + netsh + pcap evidence per CLAUDE.md
("captures are evidence, not throwaway logs").
- analysis/ Decompiled C# (frida/proxy/decompiled-*),
Ghidra exports for native DLLs (`exports/` only —
working state at `projects/` and AVEVA's input
binaries at `input/` are gitignored).
- docs/ Reverse-engineering reference docs.
- tools/ Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/ Rust CI: fmt + build + test + clippy on Windows.
- LICENSE MIT (Joseph Doherty, 2026).
Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly
Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
671 lines
24 KiB
Markdown
671 lines
24 KiB
Markdown
# NMX COM contracts and managed-client implications
|
|
|
|
This note captures the COM/type-library layer that sits below MXAccess and above
|
|
the local NMX transport.
|
|
|
|
## Native binaries inspected
|
|
|
|
Primary installed files:
|
|
|
|
```text
|
|
C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll
|
|
C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxAdptr.dll
|
|
C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxSvc.exe
|
|
C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxSvcps.dll
|
|
C:\Program Files (x86)\ArchestrA\Framework\Bin\WWProxyStub.dll
|
|
```
|
|
|
|
No x64 equivalents were found under `C:\Program Files`; only x86 installed
|
|
copies and Galaxy file-repository copies were present.
|
|
|
|
Generated inspection reports:
|
|
|
|
```text
|
|
analysis\native\LmxProxy.dll.md
|
|
analysis\native\NmxAdptr.dll.md
|
|
analysis\native\NmxSvcps.dll.md
|
|
analysis\native\WWProxyStub.dll.md
|
|
```
|
|
|
|
`NmxSvcps.dll` is a MIDL COM proxy/stub DLL. It exports:
|
|
|
|
```text
|
|
DllCanUnloadNow
|
|
DllGetClassObject
|
|
DllRegisterServer
|
|
DllUnregisterServer
|
|
```
|
|
|
|
It imports the expected RPC proxy/stub helpers from `RPCRT4.dll`:
|
|
|
|
```text
|
|
NdrDllGetClassObject
|
|
NdrDllRegisterProxy
|
|
NdrDllUnregisterProxy
|
|
CStdStubBuffer_*
|
|
IUnknown_*_Proxy
|
|
```
|
|
|
|
## Key NMX service contracts
|
|
|
|
The already decompiled interop tree contains the type-library contracts:
|
|
|
|
```text
|
|
analysis\decompiled-interop\Interop.NmxSvc
|
|
analysis\decompiled-interop\Interop.NmxAdptr
|
|
```
|
|
|
|
Important interfaces:
|
|
|
|
| Interface | GUID | Important methods |
|
|
| --- | --- | --- |
|
|
| `INmxService` | `575008DB-845D-46C6-A906-F6F8CA86F315` | `RegisterEngine`, `UnRegisterEngine`, `Connect`, `TransferData`, subscriber and heartbeat methods |
|
|
| `INmxService2` | `2630A513-A974-4B1A-8025-457A9A7C56B8` | `RegisterEngine2`, `GetPartnerVersion` |
|
|
| `INmxSvcCallback` | `B49F92F7-C748-4169-8ECA-A0670B012746` | `DataReceived`, `StatusReceived` |
|
|
| `INmxNotify` | `73849AEA-472A-4715-B8C6-1C806AF12DFC` | `ConnectionEstablished`, `ConnectionClosed` |
|
|
| `INmx4` | `84168012-B544-4217-A145-32819C607435` | `PutRequest2`, `GetResponse2`, `Initialize2`, `InitializeAnonymous2` |
|
|
|
|
Core transport methods:
|
|
|
|
```csharp
|
|
void INmxService.TransferData(
|
|
int lRemoteGalaxyID,
|
|
int lRemotePlatformID,
|
|
int lRemoteEngineID,
|
|
int lSize,
|
|
ref byte pMsgBody);
|
|
|
|
void INmxSvcCallback.DataReceived(
|
|
int dwBufferSize,
|
|
ref sbyte lpDataBuffer);
|
|
|
|
void INmxSvcCallback.StatusReceived(
|
|
int dwBufferSize,
|
|
ref sbyte lpStatusBuffer);
|
|
|
|
void INmx4.PutRequest2(
|
|
int dwClusterId,
|
|
int dwPlatformId,
|
|
int dwEngineId,
|
|
byte byPriority,
|
|
byte byType,
|
|
int dwSize,
|
|
ref byte pData,
|
|
out int pdwRequestHandle);
|
|
|
|
void INmx4.GetResponse2(
|
|
byte byType,
|
|
out int pdwResponseCode,
|
|
out int pdwRequestHandle,
|
|
out int pdwSize,
|
|
IntPtr pData);
|
|
```
|
|
|
|
## Local mixed stream
|
|
|
|
The localhost `127.0.0.1:57415 <-> 127.0.0.1:57433` stream is not plain
|
|
DCE/RPC. It is a compact mixed protocol:
|
|
|
|
- 12-byte control records: `int32 code_or_status`, `int32 token_low`,
|
|
`int32 token_high`.
|
|
- Data records: `uint32 body_length`, followed by `body_length` bytes.
|
|
- A positive control `code_or_status` often announces the total byte count of
|
|
one or more following data records.
|
|
- `-1` appears as a normal acknowledgement/status control.
|
|
- `-2` appears as a bidirectional status/control marker around write windows.
|
|
|
|
The parser for this stream is:
|
|
|
|
```text
|
|
analysis\scripts\decode_mixed_local_stream.py
|
|
```
|
|
|
|
Generated mixed-stream decodes:
|
|
|
|
```text
|
|
captures\016-loopback-write-test-int-advised\mixed-stream-57415-to-57433.tsv
|
|
captures\016-loopback-write-test-int-advised\mixed-stream-57433-to-57415.tsv
|
|
captures\017-loopback-write-test-int-100\mixed-stream-57415-to-57433.tsv
|
|
captures\017-loopback-write-test-int-100\mixed-stream-57433-to-57415.tsv
|
|
captures\020-loopback-write-test-int-102\mixed-stream-57415-to-57433.tsv
|
|
captures\020-loopback-write-test-int-102\mixed-stream-57433-to-57415.tsv
|
|
```
|
|
|
|
Write-window extraction and diff helpers:
|
|
|
|
```text
|
|
analysis\scripts\analyze_write_window.py
|
|
analysis\scripts\diff_write_window_records.py
|
|
```
|
|
|
|
## Controlled write captures
|
|
|
|
Usable value-change captures:
|
|
|
|
| Folder | Write path |
|
|
| --- | --- |
|
|
| `captures\017-loopback-write-test-int-100` | `TestInt` changed from `99` to `100` |
|
|
| `captures\020-loopback-write-test-int-102` | `TestInt` changed from `101` to `102` |
|
|
| `captures\021-loopback-write-test-int-sequence-103-105` | `TestInt` changed from `102` to `103`, `104`, then `105` in one session |
|
|
|
|
`captures\018-loopback-write-test-int-101` has a successful harness log but a
|
|
header-only pcap, so it should not be used for packet analysis. The rerun
|
|
`captures\019-loopback-write-test-int-101-rerun` captured correctly, but it was
|
|
a same-value write because the attribute was already `101`.
|
|
|
|
The same-session sequence shows that the decoded write-window records do not
|
|
carry the requested `int32` values as plain little-endian scalar payloads. The
|
|
visible moving fields are mostly local sequence tokens and opaque body fields.
|
|
For a managed implementation, this raises the priority of tracing the in-process
|
|
native API boundary before the payload enters the localhost transport.
|
|
|
|
That boundary has now been traced with headless Ghidra-derived Frida hooks. See:
|
|
|
|
```text
|
|
docs\Ghidra-Headless-Analysis.md
|
|
captures\023-frida-write-test-int-sequence-109-111\frida-events.tsv
|
|
```
|
|
|
|
The key result is that `CLMXProxyServer::Write` receives the raw `int32` scalar
|
|
directly, and `CNmxAdapter::PutRequest` receives a 40-byte body with the scalar
|
|
at offset `18`. `CNmxAdapter::TransferData` wraps that body in an 86-byte
|
|
message, placing the scalar at offset `64`. The corresponding
|
|
`ProcessDataReceived` update body carries the scalar at offset `84`.
|
|
|
|
Additional Frida captures generalized the write body across common scalar
|
|
types:
|
|
|
|
| Type | `PutRequest` body | `TransferData` body | Callback/update body | Encoding |
|
|
| --- | --- | --- | --- | --- |
|
|
| bool | size `37`, value offset `18` | size `83`, value offset `64` | size `85`, value offset `84` | `VT_BOOL`; true `ff ff ff 00` in write body and `ff` in data-change body; false `00 ff ff 00` and `00` |
|
|
| int | size `40`, value offset `18` | size `86`, value offset `64` | size `88`, value offset `84` | little-endian `int32` |
|
|
| float | size `40`, value offset `18` | size `86`, value offset `64` | size `88`, value offset `84` | little-endian `float32` |
|
|
| double | size `44`, value offset `18` | size `90`, value offset `64` | size `92`, value offset `84` | little-endian `float64` |
|
|
| string | size `58` or `60`, value offset `26` | size `104` or `106`, value offset `72` | size `106` or `108`, value offset `92` | UTF-16LE |
|
|
| datetime | size `86`, value offset `26` | size `132`, value offset `72` | size `98`, value offset `88` | outbound UTF-16LE display string; callback/update FILETIME |
|
|
|
|
The matrix is saved at:
|
|
|
|
```text
|
|
analysis\frida\write-body-matrix.tsv
|
|
```
|
|
|
|
Array writes are also captured:
|
|
|
|
```text
|
|
analysis\frida\write-array-body-matrix.tsv
|
|
```
|
|
|
|
Write-mode captures are saved at:
|
|
|
|
```text
|
|
analysis\frida\write-mode-matrix.tsv
|
|
```
|
|
|
|
The first combined Frida plus loopback correlation is:
|
|
|
|
```text
|
|
captures\043-frida-loopback-write-test-int-115
|
|
captures\043-frida-loopback-write-test-int-115\frida-to-tcp-map.tsv
|
|
captures\044-frida-loopback-write-test-int-123456789
|
|
captures\044-frida-loopback-write-test-int-123456789\frida-to-tcp-map.tsv
|
|
```
|
|
|
|
Numeric arrays use an array descriptor at body offset `17`, then packed values
|
|
at offset `28`. The descriptor is:
|
|
|
|
```text
|
|
kind_byte 00 00 00 00 element_count:uint16 element_width_or_code:uint32
|
|
```
|
|
|
|
Observed kind bytes are `0x41` bool, `0x42` int, `0x43` float, `0x44` double,
|
|
and `0x45` variable-width string/date. String and datetime arrays use
|
|
per-element variable records; outbound datetime array writes encode display
|
|
strings, while callback/update bodies encode FILETIME values. The bool-array
|
|
capture succeeded but did not preserve the requested alternating pattern, so
|
|
that path is documented as unresolved pending a targeted follow-up.
|
|
|
|
Secured and verified attributes did not require the COM `WriteSecured` methods.
|
|
Those methods returned before value-bearing NMX requests in the tested cases.
|
|
The supported public path was normal `Write` with the fourth argument set to the
|
|
Galaxy security classification (`2` for `SecuredWrite`, `3` for
|
|
`VerifiedWrite`). Timestamped `Write2` keeps the scalar value slot and embeds a
|
|
FILETIME after the value.
|
|
|
|
The adapter body is still not the wire format. Capture `043` proves that exact
|
|
`PutRequest`, `TransferData`, and callback bodies are absent from the
|
|
reassembled TCP streams. Capture `044` uses the distinctive value `123456789`
|
|
and shows the raw scalar is absent from the full pcap payload scan, parsed
|
|
DCE/RPC stubs, and mixed local stream. A native managed client therefore needs a
|
|
structural DCE/RPC/NDR decoder in addition to the adapter-body codec.
|
|
|
|
The initial managed codec is:
|
|
|
|
```text
|
|
src\MxNativeCodec\MxNativeCodec.csproj
|
|
src\MxNativeCodec.Tests\MxNativeCodec.Tests.csproj
|
|
```
|
|
|
|
It does not yet synthesize every unknown header field. It uses captured
|
|
`PutRequest` bodies as templates and proves that scalar typed value slots,
|
|
timestamped scalar bodies, packed numeric arrays, string-array records, and the
|
|
write index can be decoded and re-encoded in .NET 10 x64 managed code.
|
|
|
|
## Implication for .NET 10 x64
|
|
|
|
The public COM contracts are useful as a schema, but the installed runtime path
|
|
is x86. A full managed .NET 10 x64 implementation cannot simply load these
|
|
in-proc COM components.
|
|
|
|
The service boundary was traced directly in capture `046`:
|
|
|
|
```text
|
|
captures\046-service-boundary-write-test-int-123456791
|
|
```
|
|
|
|
The same 86-byte write body appears at:
|
|
|
|
```text
|
|
CNmxAdapter.TransferData
|
|
CNmxService.TransferData
|
|
CNmxControler.TransferData
|
|
CNmxControler.DataReceived
|
|
CNmxControler.ProcessDataReceivedForEngine
|
|
```
|
|
|
|
The distinctive scalar `123456791` is at body offset `64` in every one of those
|
|
86-byte bodies.
|
|
|
|
The `TransferData` service body is a 46-byte NMX envelope followed by the
|
|
adapter `PutRequest` body. The envelope stores the inner body length at offset
|
|
`2`. For the observed `TestInt=123456791` write:
|
|
|
|
Do not send a bare 46-byte envelope as a normal probe payload. If the adapter
|
|
receives a header-only or length-mismatched `TransferData` body, System Platform
|
|
can log `NMX Header ... buffer size pktHeader.dwDataSize ... doesn't match
|
|
received message size ...`. The local COM and managed DCE/RPC harnesses now
|
|
validate this envelope length before sending.
|
|
|
|
| Body | Size | Value offset |
|
|
| --- | ---: | ---: |
|
|
| `CNmxAdapter.PutRequest` | 40 | 18 |
|
|
| `INmxService2.TransferData` / `CNmxAdapter.TransferData` | 86 | 64 |
|
|
|
|
The managed codec now includes `NmxTransferEnvelopeTemplate` to decode and
|
|
re-encode this observed envelope form.
|
|
|
|
The .NET 10 x64 probe can activate `NmxSvc.NmxService`, but the first
|
|
`INmxService2` method call fails with `0x8007000B` because COM tries to load the
|
|
32-bit `NmxSvcps.dll` proxy/stub into the x64 process. The probe is:
|
|
|
|
```text
|
|
src\MxNativeClient.Probe
|
|
```
|
|
|
|
The same probe can marshal the activated object as remote `IUnknown` and dump a
|
|
standard OBJREF:
|
|
|
|
```text
|
|
analysis\proxy\nmxservice-objref-context2.txt
|
|
```
|
|
|
|
The OBJREF exposes a stable OXID plus per-activation OID/IPID values and
|
|
binding towers. This proves a managed implementation can obtain the object
|
|
identity without loading `NmxSvcps.dll`; the remaining step is to implement the
|
|
ORPC `QueryInterface` and method call flow manually.
|
|
|
|
The .NET 10 client scaffold now has the first managed protocol primitives:
|
|
|
|
```text
|
|
src\MxNativeClient\DceRpcPdu.cs
|
|
src\MxNativeClient\DceRpcTcpClient.cs
|
|
src\MxNativeClient\OrpcStructures.cs
|
|
src\MxNativeClient\RemUnknownMessages.cs
|
|
src\MxNativeClient\ObjectExporterMessages.cs
|
|
src\MxNativeClient\ObjectExporterClient.cs
|
|
src\MxNativeClient.Tests\MxNativeClient.Tests.csproj
|
|
```
|
|
|
|
Those tests parse and re-encode real bind/alter-context style traffic from
|
|
capture `046` and parse a captured request PDU. The capture parser now records
|
|
presentation-context UUIDs in `dcerpc-stream-pdus.tsv`, which makes it easier to
|
|
separate useful DCE/RPC structure from traffic that is unrelated to the NMX
|
|
service body.
|
|
|
|
The visible DCE/RPC stream does not begin request stubs with `ORPCTHIS`, so it
|
|
is not the direct `INmxService2` ORPC method channel. The managed ORPC work is
|
|
therefore being built from the DCOM specification plus the OBJREF returned by
|
|
local COM activation. `IRemUnknown::RemQueryInterface` composition is now
|
|
represented in code; a live RPC binding and response validation remain pending.
|
|
|
|
The first live managed RPC probe binds to `IObjectExporter` on RPCSS and sends
|
|
`ResolveOxid` for the OBJREF OXID. The request is accepted, but the unauthenticated
|
|
call returns `0x00000005` (`ERROR_ACCESS_DENIED`). That matches the Python and
|
|
Impacket reference behavior, so the next blocker is RPC authentication rather
|
|
than the OXID NDR layout.
|
|
|
|
The DCOM activation and scalar call path has now been proven with an Impacket
|
|
reference probe:
|
|
|
|
```text
|
|
analysis\scripts\probe_dcom_inmxservice2.py
|
|
analysis\proxy\dcom-inmxservice2-getpartner-probe.txt
|
|
```
|
|
|
|
That probe uses packet privacy, activates CLSID
|
|
`{AE24BD51-2E80-44CC-905B-E5446C942BEB}`, requests
|
|
`INmxService2` `{2630A513-A974-4B1A-8025-457A9A7C56B8}`, binds to the returned
|
|
OXID endpoint, and calls `GetPartnerVersion` opnum `11`. The service returns
|
|
`partner_version=6` and `ErrorCode=0x00000000`.
|
|
|
|
This changes the remaining work from "prove DCOM can reach NmxSvc without the
|
|
x86 proxy" to "port packet-private DCOM authentication and the decoded NDR
|
|
method stubs into the .NET 10 client." The public method schema, target CLSID,
|
|
service endpoint, object identity flow, and at least one working service method
|
|
are now verified.
|
|
|
|
The .NET 10 managed client now reproduces the critical scalar path without the
|
|
AVEVA x86 proxy and without using SSPI `MakeSignature`:
|
|
|
|
```text
|
|
analysis\proxy\managed-remqi-and-getpartner-probe.txt
|
|
src\MxNativeClient\ManagedNtlmClientContext.cs
|
|
src\MxNativeClient\DceRpcTcpClient.cs
|
|
src\MxNativeClient\NmxService2Messages.cs
|
|
```
|
|
|
|
The managed probe obtains an `IUnknown` OBJREF, resolves the OXID with a
|
|
managed NTLMv2 packet-integrity DCE/RPC call, calls
|
|
`IRemUnknown::RemQueryInterface` for `INmxService2`, and then invokes
|
|
`INmxService2::GetPartnerVersion`. The live result is
|
|
`managed_getpartner_version=6` and `managed_getpartner_hresult=0x00000000`.
|
|
|
|
The remaining client-side proxy work is now concentrated on the non-scalar COM
|
|
methods: marshaling a managed callback object for `RegisterEngine2`, encoding
|
|
the correlated `byte[size]` parameter for `TransferData`, and implementing the
|
|
callback endpoint that receives `DataReceived` and `StatusReceived`.
|
|
|
|
`TransferData` has since been encoded and live-probed:
|
|
|
|
```text
|
|
analysis\proxy\managed-transferdata-control-probe.txt
|
|
```
|
|
|
|
The service returned `0x80041101`, which is an application-level HRESULT after
|
|
the ORPC call reached `NmxSvc.exe`; it was not a DCE/RPC/NDR failure. That
|
|
confirms the `byte[size]` request shape.
|
|
|
|
The x64 callback marshal probe is:
|
|
|
|
```text
|
|
analysis\proxy\callback-marshal-probe.txt
|
|
```
|
|
|
|
It fails with `0x80040154 REGDB_E_CLASSNOTREG` when trying to marshal
|
|
`INmxSvcCallback`. This is the same architecture problem in reverse: the
|
|
client-side x64 process has no registered `NmxSvcps.dll` proxy/stub for the
|
|
callback IID. A full managed client therefore needs to export the callback
|
|
object itself over DCE/RPC/ORPC.
|
|
|
|
Therefore the viable managed path is to implement the observed local contracts
|
|
directly:
|
|
|
|
1. Recreate enough of the NMX service/session behavior represented by
|
|
`INmxService`, `INmxSvcCallback`, and `INmx4`.
|
|
2. Encode/decode the NMX adapter message bodies identified by the Frida trace.
|
|
3. Replace the 32-bit `NmxSvcps.dll` MIDL proxy with a managed NDR/DCOM proxy
|
|
for `NmxSvc.exe`.
|
|
4. Use the Galaxy repository for tag/type/security metadata rather than
|
|
depending on the x86 MXAccess wrapper.
|
|
|
|
The next hard blocker is no longer the basic `int32` value location. It is the
|
|
managed replacement for the MIDL proxy/stub plus synthesis of add-item,
|
|
advise/unadvise, remove-item, and status/error request bodies.
|
|
|
|
See also:
|
|
|
|
```text
|
|
docs\DotNet10-Native-Library-Plan.md
|
|
analysis\proxy\nmxsvcps-proxy-layout.tsv
|
|
analysis\proxy\nmxsvcps-procedures.tsv
|
|
```
|
|
|
|
## Decoded proxy/stub procedure table
|
|
|
|
The proxy/stub MIDL procedure bytecode is extracted by:
|
|
|
|
```text
|
|
analysis\scripts\extract_nmxsvcps_proc_formats.py
|
|
```
|
|
|
|
The output is:
|
|
|
|
```text
|
|
analysis\proxy\nmxsvcps-procedures.tsv
|
|
analysis\proxy\type-format-snippets\
|
|
```
|
|
|
|
The core service opnums recovered from `NmxSvcps.dll` are:
|
|
|
|
| Interface | Method | Opnum | Parameter shape |
|
|
| --- | --- | ---: | --- |
|
|
| `INmxService2` | `RegisterEngine` | 3 | `int`, `BSTR`, `INmxSvcCallback*`, `HRESULT` |
|
|
| `INmxService2` | `UnRegisterEngine` | 4 | `int`, `HRESULT` |
|
|
| `INmxService2` | `Connect` | 5 | `int`, `int`, `int`, `int`, `HRESULT` |
|
|
| `INmxService2` | `TransferData` | 6 | `int`, `int`, `int`, `int size`, `byte[size]`, `HRESULT` |
|
|
| `INmxService2` | `AddSubscriberEngine` | 7 | `int`, `int`, `int`, `int`, `HRESULT` |
|
|
| `INmxService2` | `RemoveSubscriberEngine` | 8 | `int`, `int`, `int`, `int`, `HRESULT` |
|
|
| `INmxService2` | `SetHeartbeatSendInterval` | 9 | `int`, `int`, `HRESULT` |
|
|
| `INmxService2` | `RegisterEngine2` | 10 | `int`, `BSTR`, `int version`, `INmxSvcCallback*`, `HRESULT` |
|
|
| `INmxService2` | `GetPartnerVersion` | 11 | `int`, `int`, `int`, `out int`, `HRESULT` |
|
|
| `INmxSvcCallback` | `DataReceived` | 3 | `int size`, `sbyte[size]`, `HRESULT` |
|
|
| `INmxSvcCallback` | `StatusReceived` | 4 | `int size`, `sbyte[size]`, `HRESULT` |
|
|
|
|
Important NDR type-format offsets:
|
|
|
|
| Offset | Usage |
|
|
| --- | --- |
|
|
| `0x0006` | callback byte array correlated to `dwBufferSize` |
|
|
| `0x002c` | `BSTR_UserMarshal` string |
|
|
| `0x0036` | `INmxSvcCallback` interface pointer |
|
|
| `0x004c` | `TransferData` byte array correlated to `lSize` |
|
|
| `0x005c` | `INmxNotify` interface pointer |
|
|
|
|
This confirms that the missing x64/full-managed layer is not the public method
|
|
schema. The remaining native dependency is the DCOM/ORPC transport and the NDR
|
|
interpreter behavior normally provided by `NmxSvcps.dll`.
|
|
|
|
## RegisterEngine2 marshaling findings
|
|
|
|
The direct x86 COM harness is:
|
|
|
|
```text
|
|
src\NmxComHarness\NmxComHarness.csproj
|
|
src\NmxComHarness\Program.cs
|
|
```
|
|
|
|
It bypasses `ArchestrA.MXAccess.dll` and invokes
|
|
`NmxSvc.NmxService.RegisterEngine2` directly through the installed 32-bit
|
|
`NmxSvcps.dll` proxy. The focused Frida hook is:
|
|
|
|
```text
|
|
analysis\frida\nmx-com-proxy-trace.js
|
|
```
|
|
|
|
Captured runs:
|
|
|
|
```text
|
|
captures\052-frida-direct-nmx-registerengine2-marshals-retry
|
|
captures\053-frida-direct-nmx-registerengine2-null-stub
|
|
captures\054-frida-direct-nmx-registerengine2-callback-stub
|
|
analysis\proxy\x86-callback-objref-probe.txt
|
|
analysis\proxy\x86-registerengine2-null-callback-probe.txt
|
|
analysis\proxy\managed-registerengine2-null-callback-probe.txt
|
|
```
|
|
|
|
`BSTR_UserMarshal` writes the string as:
|
|
|
|
```text
|
|
char_count:uint32
|
|
byte_length:uint32
|
|
char_count:uint32
|
|
utf16_payload_without_null
|
|
```
|
|
|
|
For `NmxComProxyWire5`, the exact captured bytes were:
|
|
|
|
```text
|
|
10 00 00 00 20 00 00 00 10 00 00 00
|
|
4e 00 6d 00 78 00 43 00 6f 00 6d 00 50 00 72 00
|
|
6f 00 78 00 79 00 57 00 69 00 72 00 65 00 35 00
|
|
```
|
|
|
|
The generated proxy also writes a 4-byte user-marshal marker before that BSTR
|
|
payload:
|
|
|
|
```text
|
|
55 73 65 72
|
|
```
|
|
|
|
Interpreted little-endian, that value is `0x72657355` (`"User"`).
|
|
|
|
The managed encoder in `src\MxNativeClient\NmxService2Messages.cs` now
|
|
reproduces the null-callback `RegisterEngine2` request:
|
|
|
|
```text
|
|
ORPCTHIS
|
|
localEngineId:int32
|
|
0x72657355:uint32
|
|
BSTR_UserMarshal(engineName)
|
|
padding to 4-byte boundary
|
|
version:int32
|
|
callback:null-interface-pointer:uint32 = 0
|
|
```
|
|
|
|
The live .NET 10 x64 probe reaches `NmxSvc.exe` and returns a non-failing COM
|
|
success code:
|
|
|
|
```text
|
|
managed_register2_null_hresult=0x00000001
|
|
managed_unregister_after_register_hresult=0x00000001
|
|
```
|
|
|
|
This confirms that the managed DCOM/NDR path can perform the service
|
|
registration lifecycle without the x86 proxy when no callback endpoint is
|
|
required.
|
|
|
|
For a non-null callback, the x86 proxy wraps the callback OBJREF as:
|
|
|
|
```text
|
|
0x00020000:uint32
|
|
objref_size:uint32
|
|
objref_size:uint32
|
|
objref_bytes
|
|
```
|
|
|
|
The same capture showed `objref_size=0x44` for a compact same-machine standard
|
|
OBJREF. A separately marshaled x86 callback stream produced a 366-byte standard
|
|
OBJREF with dual-string bindings. The managed callback exporter can therefore
|
|
use the same MInterfacePointer wrapper around an OBJREF that advertises the
|
|
managed callback endpoint.
|
|
|
|
## Callback OBJREF experiments
|
|
|
|
Two managed callback OBJREF strategies have now been tested.
|
|
|
|
### Synthetic managed TCP OBJREF
|
|
|
|
`ManagedCallbackExporter` can build a standard OBJREF that advertises a managed
|
|
TCP listener:
|
|
|
|
```text
|
|
src\MxNativeClient\ManagedCallbackExporter.cs
|
|
analysis\proxy\managed-registerengine2-callback-probe.txt
|
|
analysis\proxy\managed-registerengine2-callback-loopback-probe.txt
|
|
analysis\proxy\managed-registerengine2-callback-fixed-port-probe.txt
|
|
analysis\proxy\managed-callback-fixed-port-tcp-poll.txt
|
|
analysis\proxy\managed-callback-nmxsvc-tcp-poll.txt
|
|
```
|
|
|
|
Without security bindings the service rejects the callback OBJREF with
|
|
`0x8001011D`. Adding the default security binding sequence seen in x86
|
|
`CoMarshalInterface` changes the failure to `0x800706BA` (`RPC server
|
|
unavailable`), but the managed listener sees no inbound connection. TCP polling
|
|
also shows no SYN to the advertised port.
|
|
|
|
Inference: for standard OBJREFs, COM is not treating the embedded string binding
|
|
as a direct object endpoint. It is resolving the OXID through the local COM/OXID
|
|
resolver machinery. A purely synthetic OXID that is not registered with RPCSS is
|
|
not enough.
|
|
|
|
### COM-registered IUnknown OBJREF patched to callback IID
|
|
|
|
The probe can also ask the local x64 COM runtime to marshal a managed
|
|
`IUnknown` OBJREF, then replace only the OBJREF IID with `INmxSvcCallback`:
|
|
|
|
```text
|
|
analysis\proxy\managed-registerengine2-callback-com-iunknown-objref-probe.txt
|
|
analysis\proxy\managed-registerengine2-callback-com-iunknown-self-transfer-probe.txt
|
|
```
|
|
|
|
That OBJREF is backed by a real RPCSS-registered OXID/OID/IPID. With this form,
|
|
`NmxSvc.exe` accepts a non-null callback pointer:
|
|
|
|
```text
|
|
managed_register2_callback_hresult=0x00000000
|
|
managed_unregister_after_callback_register_hresult=0x00000000
|
|
```
|
|
|
|
A self-directed `TransferData` probe also returns `0x00000000`:
|
|
|
|
```text
|
|
managed_callback_self_transfer_hresult=0x00000000
|
|
```
|
|
|
|
No managed callback event was delivered during that self-transfer probe. This
|
|
means the COM-registered patched OBJREF is currently a registration proof, not
|
|
the final callback solution. The final managed implementation still needs an
|
|
endpoint whose OXID/IPID can be resolved by COM and whose request dispatch is
|
|
handled by managed code rather than the missing x64 `NmxSvcps.dll` proxy/stub.
|
|
|
|
### x64 type-library marshaling for INmxSvcCallback
|
|
|
|
The x64 callback marshal failure can be removed without AVEVA's 32-bit
|
|
`NmxSvcps.dll` by registering type-library metadata for the callback interface
|
|
and using the Windows standard automation proxy/stub:
|
|
|
|
```text
|
|
analysis\scripts\register_x64_callback_typelib.ps1
|
|
analysis\proxy\typelib\NmxComHarness.tlb
|
|
analysis\proxy\callback-marshal-after-typelib-probe.txt
|
|
```
|
|
|
|
The script exports `src\NmxComHarness\bin\Release\net481\NmxComHarness.exe` to
|
|
a TLB, registers it with `LoadTypeLibEx(REGKIND_REGISTER)`, and sets:
|
|
|
|
```text
|
|
HKLM\SOFTWARE\Classes\Interface\{B49F92F7-C748-4169-8ECA-A0670B012746}
|
|
ProxyStubClsid32 = {00020424-0000-0000-C000-000000000046}
|
|
TypeLib = {4DBF23F3-069E-3D29-B67F-4C7850F588B3}, Version 1.0
|
|
NumMethods = 5
|
|
```
|
|
|
|
After that registration, the x64 managed process can call
|
|
`CoMarshalInterface` for `INmxSvcCallback` directly. The probe now emits a
|
|
standard 366-byte OBJREF for the callback IID instead of
|
|
`REGDB_E_CLASSNOTREG`.
|
|
|
|
Using the real marshaled callback OBJREF, `NmxSvc.exe` accepts non-null
|
|
`RegisterEngine2`, `Connect`, `AddSubscriberEngine`, `TransferData`, and
|
|
`UnRegisterEngine` calls:
|
|
|
|
```text
|
|
analysis\proxy\managed-registerengine2-callback-com-real-probe.txt
|
|
analysis\proxy\x86-registerengine2-self-transfer-callback-probe.txt
|
|
```
|
|
|
|
The same synthetic self-transfer route does not produce a callback in the x86
|
|
harness either. Therefore the absence of a callback event in the current probe
|
|
is caused by the synthetic NMX message/session body, not by inability to marshal
|
|
the callback interface after the type-library registration.
|