Files
mxaccess/docs/MXAccess-Reverse-Engineering.md
T
Joseph Doherty fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Initial project state: .NET reference, design, Rust port (M0+M1), evidence
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>
2026-05-05 06:21:00 -04:00

398 lines
13 KiB
Markdown

# MXAccess reverse-engineering analysis
## Executive summary
The primary `ArchestrA.MXAccess.dll` is not a protocol implementation. It is a
.NET primary interop assembly generated from `LMXPROXYLib`. All useful methods
on `LMXProxyServerClass` are `extern` COM calls. The actual implementation is
native, 32-bit, and registered as an in-process COM server:
```text
net48 x86 caller
-> ArchestrA.MXAccess.dll
-> COM CLSID {C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}
-> C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll
-> C:\Program Files (x86)\Common Files\ArchestrA\Framework\Bin\Lmx.dll
-> C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxAdptr.dll
-> C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxSvc.exe
-> runtime engines over NMX
```
That is why the current OPC UA interface is constrained to .NET Framework and
x86. The binding itself is not forcing .NET 4.8; the 32-bit COM/native stack is.
The cleanest modern replacement is not "port the interop assembly"; it is either:
1. keep a 32-bit sidecar process and expose a modern IPC API to .NET 10/Rust, or
2. implement the lower LMX/NMX client protocol directly from the native type
libraries plus runtime traces.
Option 2 is possible but not complete from static decompilation alone because
the proprietary message bodies are assembled inside native code, not in
`ArchestrA.MXAccess.dll`.
## Evidence
### Managed DLL
`ArchestrA.MXAccess.dll`:
- File path: `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
- Assembly: `ArchestrA.MxAccess, Version=3.2.0.0, PublicKeyToken=23106a86e706d0ae`
- File description/product: `Assembly imported from type library 'LMXPROXYLib'.`
- Decompilation contains only COM-imported interfaces, structs, enums, delegates,
and event sink helpers.
- `LMXProxyServerClass` methods are all `runtime managed internalcall`, meaning
the CLR dispatches them through COM; there is no managed implementation body to port.
### COM registration
`LMXProxy.LMXProxyServer` is registered only in the 32-bit COM registry view:
```text
HKCR\Wow6432Node\CLSID\{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}
(default) = LMXProxyServer Class
InprocServer32 = C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll
ThreadingModel = Apartment
ProgID = LMXProxy.LMXProxyServer.1
VersionIndependentProgID = LMXProxy.LMXProxyServer
TypeLib = {C36ECF28-3EF3-4528-9843-81D7FDC86328}
```
Type libraries:
```text
{77E896EC-B3E3-4DE4-B96F-F32570164211}\3.2
LMXProxy 3.2 Type Library
C:\Program Files (x86)\ArchestrA\Framework\Bin\MXAccess32.tlb
{C36ECF28-3EF3-4528-9843-81D7FDC86328}\1.0
LMXProxy 1.0 Type Library
C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll
{C36ECF28-3EF3-4528-9843-81D7FDC86328}\2.0
LMXProxy 2.0 Type Library
C:\Program Files (x86)\ArchestrA\Framework\Bin\MXAccess20.tlb
```
### Native binaries
| Binary | Role inferred from registration/imports/strings | Architecture |
| --- | --- | --- |
| `LmxProxy.dll` | Public MXAccess COM wrapper. Implements `LMXProxy.LMXProxyServer`. Imports licensing wrapper and COM/OLE Automation APIs. | PE32 x86 |
| `Lmx.dll` | Main local message exchange implementation. Exposes many COM objects including `IDataClient`, `IDataConsumer`, `Nmx`, bootstrap, runtime object, and data variant surfaces. | PE32 x86 |
| `NmxAdptr.dll` | In-process adapter over NMX service. Imports Winsock and exposes `WonderWare.Nmx.CNmxAdapter`. | PE32 x86 |
| `NmxSvc.exe` | Out-of-process NMX service. Registered as `NmxSvc.NmxService`; listens on TCP/UDP port `5026` on this machine. | PE32 x86 |
| `NmxSvcps.dll` | MIDL proxy/stub for NMX service COM interfaces. | PE32 x86 |
`LmxProxy.dll` exports only standard COM DLL exports:
```text
DllCanUnloadNow
DllGetClassObject
DllRegisterServer
DllUnregisterServer
```
This means there is no flat C ABI to P/Invoke from modern .NET or Rust.
`LmxProxy.dll` imports `LicAPINativeWrapper.dll`, including:
```text
CreateClientConnection
AddLicenseRequestInfo
AcquireLicense
GetLicenseAcquisitionError
ReleaseLicense
GetDeviceIdentity
```
Any native replacement has to account for AVEVA licensing behavior, either by
using a supported client surface or by keeping a licensed sidecar.
## Runtime model inferred from MXAccess
The current high-level API is handle based:
1. `Register(clientName)` returns an `hLMXServerHandle`.
2. `AddItem(handle, fullReference)` resolves a Galaxy/runtime attribute and
returns an item handle.
3. `Advise` or `AdviseSupervisory` subscribes to updates.
4. Updates arrive through COM connection-point events:
`OnDataChange`, `OnWriteComplete`, and `OperationComplete`.
5. `Write`/`Write2` submit writes and completion is reported asynchronously.
6. `UnAdvise`, `RemoveItem`, and `Unregister` clean up.
The existing OPC UA host uses the canonical pattern correctly:
- All COM work runs on one STA thread.
- Reads are implemented as transient subscribe -> first `OnDataChange` -> unsubscribe because MXAccess exposes no direct synchronous read.
- Writes are submitted with `Write` and then matched with `OnWriteComplete`.
- Subscriptions use `AdviseSupervisory`.
## Lower LMX data-client surface
`Lmx.dll` exposes a richer COM interface named `IDataClient`. It is still x86 COM,
but it is closer to the underlying data model than `LMXProxyServer`:
```csharp
void Initialize(string namespaceName);
IntPtr Connect(string endPointUri, ulong timeout, ref _IUserToken userToken, out uint clientId);
IArchestrAResult[] Connect2(...);
long CreateSubscription2(...);
IArchestrAResult[] RegisterItems2(IItemIdentity2[] items, ...);
IArchestrAResult[] AddMonitoredItems2(long subscriptionId, IMonitoredItem2[] items, ...);
IArchestrAResult[] Publish2(long subscriptionId, out DataChangeUpdate[] updates, out uint resultCount);
IArchestrAResult[] Read2(IItemIdentity2[] items, out DataChangeUpdate[] updates, out uint updatesCount);
IArchestrAResult[] Write2(WriteRequest2[] writes, ...);
```
Important structs:
```csharp
struct IItemIdentity2 {
ushort ReferenceType;
ushort type;
string ContextName;
string Name;
ulong id;
}
struct IMonitoredItem2 {
byte Active;
ushort ReferenceType;
ushort type;
string ContextName;
string Name;
ulong id;
ulong SampleInterval;
ulong TimeDeadband;
}
struct DataChangeUpdate {
ulong ItemId;
int StatusCode;
uint HighDateTime;
uint LowDateTime;
int quality;
IDataVariant value;
}
struct IDataVariant {
ushort type;
int length;
byte[] Payload;
}
```
This surface suggests AVEVA has a newer OPC UA-like internal data client in the
LMX layer. It may be a better reverse-engineering target than `LMXProxyServer`,
but it is not registered as a 64-bit component on this machine. It still goes
through the x86 native stack.
## NMX service surface
`NmxSvc.exe` is registered as an out-of-process COM local server:
```text
CLSID {AE24BD51-2E80-44CC-905B-E5446C942BEB}
LocalServer32 = "C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxSvc.exe"
ProgID = NmxSvc.NmxService.1
```
Key `INmxService2` methods:
```csharp
void RegisterEngine2(int localEngineId, string engineName, int version, INmxSvcCallback callback);
void UnRegisterEngine(int localEngineId);
void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId);
void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, int size, ref byte msgBody);
void AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
void RemoveSubscriberEngine(...);
void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks);
void GetPartnerVersion(int galaxyId, int platformId, int engineId, out int version);
```
`NmxAdptr.dll` exposes `WonderWare.Nmx.CNmxAdapter` as an in-process COM adapter:
```text
CLSID {42DB0511-28BE-11D3-80C0-00104B5F96A7}
InprocServer32 = C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxAdptr.dll
ProgID = WonderWare.Nmx.CNmxAdapter.1
ThreadingModel = Apartment
```
Key `INmx4` methods:
```csharp
void Initialize2(int platformId, int engineId, int version, int queueExtent);
void InitializeAnonymous2(out int platformId, out int engineId, int version);
void PutRequest2(int galaxyId, int platformId, int engineId, byte priority, byte type, int size, ref byte data, out int requestHandle);
void GetResponse2(byte type, out int responseCode, out int requestHandle, out int size, IntPtr data);
void PutMessageNoReply(...);
void GetPartnerVersion(...);
```
Static strings identify NMX message categories:
```text
NMXMTYPE_CONNECT_INFO
NMXMTYPE_CONNECT_INFO_REQ
NMXMTYPE_ENGINE_DATA
NMXMTYPE_GET_HEARTBEAT_RATE
NMXMTYPE_PLATFORM_HEARTBEAT
NMXMTYPE_SET_HEARTBEAT_RATE
NMXMTYPE_VERSION_ERROR
NMXMTYPE_HEARTBEAT_DISCONNECT_REQ
```
On this machine, the running `NmxSvc.exe` process is listening on:
```text
TCP 10.100.0.48:5026
UDP 10.100.0.48:5026
```
That gives a network tracing target for the direct-native path.
## Why direct x64 COM is blocked
There are two different issues:
1. `ArchestrA.MXAccess.dll` is a .NET Framework-era interop assembly. Modern
.NET can consume many COM interfaces, but the generated event provider and
TLB-imported types are built for the old COM interop shape.
2. The actual COM class is an x86 in-process DLL. A 64-bit process cannot load
`LmxProxy.dll`, regardless of whether the caller is .NET Framework, .NET 10,
or Rust.
An out-of-process COM local server can cross bitness, but `LMXProxyServer` is
not registered as a local server. It is an in-proc DLL.
## Implementation options
### Option A: Keep and formalize the 32-bit sidecar
This is the shortest reliable path.
Build or keep a small x86 process that owns:
- COM apartment initialization.
- `LMXProxy.LMXProxyServer` lifetime.
- register/add/advise/write/unadvise cleanup.
- reconnect and subscription replay.
Expose a stable IPC API to the modern service:
- named pipes for same-machine .NET/Rust services,
- gRPC over named pipes or loopback TCP if cross-language tooling matters,
- protobuf messages for `Read`, `Write`, `Subscribe`, `Unsubscribe`, and status events.
This is effectively what the existing `OtOpcUaGalaxyHost` / proxy work has moved
toward. It removes the OPC UA server's net48/x86 constraint without attempting
to clone AVEVA's internal protocol.
### Option B: Use lower LMX COM interfaces from an x86 sidecar
Instead of the high-level `LMXProxyServer`, use `Lmx.dll`'s `IDataClient` /
`IDataConsumer` APIs inside the sidecar. Potential benefits:
- direct `Read2` API rather than transient subscribe for reads,
- batch register/read/write/monitor operations,
- explicit `IDataVariant` payloads,
- closer alignment with OPC UA-like status codes.
Risks:
- less documented than MXAccess,
- still x86 COM,
- endpoint URI, namespace, token setup, and licensing behavior need live probing.
### Option C: Direct NMX protocol client in Rust or .NET
This is the true native replacement path.
Required work:
1. Discover galaxy/platform/engine ids for target attributes.
2. Reconstruct NMX message envelope layout used by `PutRequest2` and
`NmxSvc.TransferData`.
3. Reconstruct LMX item registration, subscription, write, and completion
message bodies.
4. Reproduce heartbeat/version negotiation.
5. Reproduce security/licensing behavior or find a supported token flow.
6. Validate behavior across platform stopped/off-scan, engine restart, deploy,
security-denied write, and repository-busy cases.
Static type-library analysis gives method names and high-level message routes,
but not the binary message schemas. Those schemas are assembled in native code.
The next step for this option is live tracing.
## Proposed tracing plan
1. Build a minimal x86 MXAccess harness that:
- registers as a known client name,
- adds one known good tag and one bad tag,
- subscribes with `AdviseSupervisory`,
- writes a toggled value,
- unregisters cleanly.
2. Capture process activity:
- Process Monitor filters for `LmxProxy.dll`, `Lmx.dll`, `NmxAdptr.dll`,
`NmxSvc.exe`.
- ETW or API Monitor for COM method calls if available.
- Wireshark or `netsh trace` on TCP/UDP `5026`.
3. Correlate high-level actions to low-level packets:
- `Register`
- `AddItem`
- `AdviseSupervisory`
- first data change
- `Write`
- `OnWriteComplete`
- `UnAdvise` / `RemoveItem`
4. Repeat against:
- local object attribute,
- remote platform attribute,
- stopped engine,
- security-denied write,
- secured/verified write.
5. Use `NmxAdptr.INmx4` as a stepping stone:
- call `InitializeAnonymous2`,
- issue controlled `PutRequest2` from an x86 harness,
- compare buffers and responses with MXAccess traffic.
## Current recommendation
For production, do not block the OPC UA server on a full native NMX clone. The
low-risk path is:
1. keep the x86 MXAccess host as the vendor-bound adapter,
2. make the main OPC UA interface .NET 10 or Rust,
3. communicate through a narrow, well-tested IPC contract,
4. continue reverse-engineering `IDataClient` and NMX in parallel as an R&D path.
The direct native path may still be worth pursuing, but it should be treated as
a protocol-reconstruction project with packet captures and failure-mode testing,
not as a decompile-only port.
## Artifacts produced
Generated files in this project folder:
```text
analysis/decompiled-mxaccess/
analysis/interop/
analysis/decompiled-interop/
```
Installed tools used:
```text
dotnet tool install --global ilspycmd
python pefile
Windows SDK TlbImp.exe
```