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>
14 KiB
Ghidra headless analysis
This note captures the headless Ghidra pass used to identify native MX/LMX/NMX function boundaries and build Frida hook targets.
Tooling
Ghidra was reused from:
C:\Users\dohertj2\Desktop\focas\tools\ghidra_12.0.4_PUBLIC
Java was available but not on PATH, so headless commands set:
JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.10.7-hotspot
The AVEVA binaries were staged under a path without spaces:
analysis\ghidra\input
The headless post-script is:
analysis\ghidra\scripts\MxNmxExport.java
It exports metadata, not full decompiled source:
analysis\ghidra\exports\*.ghidra.md
analysis\ghidra\exports\*.functions.tsv
analysis\ghidra\exports\*.string-refs.tsv
analysis\ghidra\exports\*.call-refs.tsv
Static targets
High-value functions from LmxProxy.dll:
| Function | RVA | Evidence |
|---|---|---|
CLMXProxyServer::Write variant A |
0x12c0c |
references CLMXProxyServer::Write - Server Handle |
CLMXProxyServer::Write variant B |
0x13280 |
references CLMXProxyServer::Write - Server Handle |
CLMXProxyServer::WriteSecured variant A |
0x12f24 |
references secured-write strings |
CLMXProxyServer::WriteSecured variant B |
0x135fe |
references secured-write strings |
CLMXProxyServer::AdviseSupervisory |
0x142b4 |
references advise-supervisory strings |
High-value functions from NmxAdptr.dll:
| Function | RVA | Evidence |
|---|---|---|
CNmxAdapter::TransferData |
0x10996 |
references CNmxAdapter::TransferData strings |
ProcessDataReceived |
0x112da |
references invalid NMX request/response strings |
CNmxAdapter::PutRequest |
0x15169 |
references CNmxAdapter::PutRequest strings |
CNmxAdapter::PutRequestEx |
0x159c3 |
references CNmxAdapter::PutRequestEx strings |
LmxProxy.dll has no direct Winsock callsites in the exported call refs. It is
COM, VARIANT, BSTR, and SAFEARRAY heavy. NmxAdptr.dll and NmxSvc.exe
contain the relevant NMX transport/body functions.
NmxSvcps.dll is confirmed as a MIDL proxy/stub DLL. Its exports call:
NdrDllGetClassObject
NdrDllCanUnloadNow
NdrDllRegisterProxy
NdrDllUnregisterProxy
NdrCStdStubBuffer_Release
Runtime hook result
Frida hook script:
analysis\frida\mx-nmx-trace.js
analysis\scripts\run_frida_mx_trace.ps1
analysis\scripts\extract_frida_trace.py
Successful traces:
captures\022-frida-write-test-int-sequence-106-108
captures\023-frida-write-test-int-sequence-109-111
captures\024-frida-write-test-bool-sequence
captures\025-frida-write-test-float-sequence
captures\026-frida-write-test-double-sequence
captures\027-frida-write-test-string-sequence
captures\028-frida-write-test-datetime-sequence
captures\029-frida-write-test-int-array
captures\030-frida-write-test-bool-array
captures\031-frida-write-test-float-array
captures\032-frida-write-test-double-array
captures\033-frida-write-test-string-array
captures\035-frida-write-test-datetime-array-full
captures\036-frida-write-secured-test-int
captures\037-frida-write-secured2-test-int
captures\038-frida-write-secured-protectedvalue
captures\039-frida-write-secured-verified-protectedvalue1
captures\040-frida-write-normal-secured-protectedvalue
captures\041-frida-write-normal-verified-protectedvalue1
captures\042-frida-write2-test-int-timestamp
captures\043-frida-loopback-write-test-int-115
captures\044-frida-loopback-write-test-int-123456789
Trace 023 proves the scalar write value is visible before the localhost
transport. Traces 024 through 028 extend that result across bool, float,
double, string, and datetime writes.
At CLMXProxyServer::Write variant A:
| Field | Observed value |
|---|---|
args[1] |
session handle 1 |
args[2] |
item handle 1 |
args[3] |
0x3, consistent with VT_I4 |
args[5] |
requested int value: 0x6d, 0x6e, 0x6f |
args[7] |
user/security id 1 |
At CNmxAdapter::PutRequest:
| Field | Observed value |
|---|---|
args[6] |
body size 0x28 / 40 |
args[7] |
body pointer |
body offset 18 |
requested int value as little-endian int32 |
The repeated 40-byte write body shape is:
37 01 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00
00 02 <value:int32-le> ff ff 00 00 00 00 00 00
00 00 c9 14 b1 08 <write-index:int32-le>
At CNmxAdapter::TransferData, the same 40-byte body is wrapped in an 86-byte
buffer. The scalar value appears at offset 64, which is 46 + 18.
At CNmxAdapter::ProcessDataReceived, the callback/update body for the write
value is 88 bytes and carries the scalar value at offset 84.
Generated parser output:
captures\023-frida-write-test-int-sequence-109-111\frida-events.tsv
Value-hit summary from that TSV:
| Function | Body size | Hit |
|---|---|---|
CNmxAdapter::PutRequest |
40 |
109@18, 110@18, 111@18 |
CNmxAdapter::TransferData |
86 |
109@64, 110@64, 111@64 |
CNmxAdapter::ProcessDataReceived |
88 |
109@84, 110@84, 111@84 |
Write-body matrix
The machine-readable matrix is:
analysis\frida\write-body-matrix.tsv
Observed write body encodings:
| Type | COM carrier | PutRequest |
TransferData |
ProcessDataReceived |
Encoding notes |
|---|---|---|---|---|---|
int |
VT_I4 / 0x3, args[5] |
size 40, value offset 18 |
size 86, value offset 64 |
size 88, value offset 84 |
little-endian int32 |
bool |
VT_BOOL / 0xb, args[5] |
size 37, value offset 18 |
size 83, value offset 64 |
size 85, value offset 84 |
true is ff ff ff 00 in the write body and ff in the data-change body; false is 00 ff ff 00 and 00 |
float |
VT_R4 / 0x4, args[5] |
size 40, value offset 18 |
size 86, value offset 64 |
size 88, value offset 84 |
little-endian float32 |
double |
VT_R8 / 0x5, args[5]/args[6] |
size 44, value offset 18 |
size 90, value offset 64 |
size 92, value offset 84 |
little-endian float64 |
string |
VT_BSTR / 0x8 |
size 58 or 60, value offset 26 |
size 104 or 106, value offset 72 |
size 106 or 108, value offset 92 |
UTF-16LE string payload; body size follows string length |
datetime |
VT_DATE / 0x7, args[5]/args[6] |
size 86, value offset 26 |
size 132, value offset 72 |
size 98, value offset 88 |
outbound write uses a UTF-16LE display string like 4/25/2026 2:30:00 AM; callback/update uses FILETIME |
The repeated numeric write bodies show a stable 18-byte prefix, scalar slot, and
trailing status/counter fields. Variable-width types move the value to offset
26, leaving an 8-byte descriptor before the UTF-16LE data.
Array write-body matrix
The machine-readable array matrix is:
analysis\frida\write-array-body-matrix.tsv
Observed array write body encodings:
| Type | COM carrier | PutRequest |
TransferData |
ProcessDataReceived |
Encoding notes |
|---|---|---|---|---|---|
int[] |
SAFEARRAY VT_I4 / 0x2003 |
size 86, first value offset 28 |
size 132, first value offset 74 |
size 134, first value offset 94 |
descriptor kind 0x42, count 10, width 4, packed little-endian int32 |
bool[] |
SAFEARRAY VT_BOOL / 0x200b |
size 66, first value offset 28 |
size 112, first value offset 74 |
size 114, first value offset 94 |
descriptor kind 0x41, count 10, width 2; capture 098 confirmed the x86 COM automation path can project a requested non-repeating .NET bool[] into a paired/shifted VARIANT_BOOL wire payload |
float[] |
SAFEARRAY VT_R4 / 0x2004 |
size 86, first value offset 28 |
size 132, first value offset 74 |
size 134, first value offset 94 |
descriptor kind 0x43, count 10, width 4, packed little-endian float32 |
double[] |
SAFEARRAY VT_R8 / 0x2005 |
size 126, first value offset 28 |
size 172, first value offset 74 |
size 174, first value offset 94 |
descriptor kind 0x44, count 10, width 8, packed little-endian float64 |
string[] |
SAFEARRAY VT_BSTR / 0x2008 |
size 256, first string bytes at offset 41 |
size 302, first string bytes at offset 87 |
size 304, first string bytes at offset 107 |
descriptor kind 0x45; each element is a length-prefixed scalar string-style UTF-16LE record |
datetime[] |
SAFEARRAY VT_DATE / 0x2007 |
size 596, first string bytes at offset 41 |
size 642, first string bytes at offset 87 |
size 214, first FILETIME at offset 94 |
outbound values are per-element UTF-16LE display strings; callback/update uses packed FILETIME records |
Array bodies use an 11-byte descriptor beginning at body offset 17:
kind_byte 00 00 00 00 element_count:uint16 element_width_or_code:uint32
Packed numeric array values begin at body offset 28. String and datetime
arrays also begin their element records at offset 28, with the first actual
UTF-16LE character at offset 41.
Write mode matrix
The machine-readable write-mode matrix is:
analysis\frida\write-mode-matrix.tsv
Findings:
| Scenario | Result |
|---|---|
WriteSecured against TestChildObject.TestInt |
rejected before value-bearing PutRequest with 0x80004021 |
WriteSecured2 against TestChildObject.TestInt |
rejected before value-bearing PutRequest with E_INVALIDARG |
WriteSecured against TestMachine_001.ProtectedValue (SecuredWrite) |
rejected before value-bearing PutRequest with 0x80004021 |
WriteSecured against TestMachine_001.ProtectedValue1 (VerifiedWrite) |
rejected before value-bearing PutRequest with 0x80004021 |
normal Write against ProtectedValue with fourth argument 2 |
succeeds; same bool body shape as scalar bool writes |
normal Write against ProtectedValue1 with fourth argument 3 |
succeeds; same bool body shape as scalar bool writes |
Write2 against TestChildObject.TestInt |
succeeds; PutRequest size remains 40, value stays at offset 18, FILETIME appears at offset 24 |
Implication: for MXAccess public automation, the supported secured/verified
route is the regular Write method with the fourth argument set to the Galaxy
security classification. The public WriteSecured* methods are present in the
type library but did not produce a supported value-bearing request in these
captures.
Later authenticated captures and headless decompile refined this:
analysis\ghidra\exports\LmxProxy.dll.write-secured-decompile.mddecompilesFUN_10012f24(WriteSecured) andFUN_100135fe(WriteSecured2).WriteSecuredperforms an extra item-record byte check at offset0x0fand returns0x80004021when that byte is nonzero. The secured and verified bool tags on this node hit that branch even afterAuthenticateUsersucceeds.WriteSecured2skips that item-record flag check. It resolves current and verifier user handles to 16-byte authenticator tokens, copies both the value and timestamp variants, and calls the downstream vtable slot that emitted the observed NMX0x38body in captures113-116.- The shared user-token lookup branch returns
0x80070057when the user handle is not mapped, explaining the earlier unauthenticatedWriteSecured2failure againstTestChildObject.TestInt.
Transport correlation
Combined Frida plus loopback capture:
captures\043-frida-loopback-write-test-int-115
captures\044-frida-loopback-write-test-int-123456789
analysis\scripts\run_frida_loopback_capture.ps1
analysis\scripts\map_frida_to_tcp.py
captures\043-frida-loopback-write-test-int-115\frida-to-tcp-map.tsv
captures\044-frida-loopback-write-test-int-123456789\frida-to-tcp-map.tsv
For the same write of TestChildObject.TestInt = 115, the mapper extracted the
exact Frida PutRequest, TransferData, and callback bodies and searched the
reassembled TCP streams. Result:
| Needle | TCP result |
|---|---|
raw int32 value 115 |
present in several streams, including ::1:49704, but surrounding bytes show DCE/RPC call IDs/metadata and unrelated product records |
exact 40-byte PutRequest body |
not found |
exact 86-byte TransferData body |
not found |
| exact 88-byte callback body | not found |
Capture 044 repeats the test with 123456789, which avoids DCE/RPC call-ID
ambiguity. That raw scalar is absent from the full pcap payload scan, the
::1:49704 DCE/RPC stubs, and the mixed 127.0.0.1:57415 <-> 57433 stream.
This means the native adapter bodies are not copied verbatim onto TCP, and the write value is not exposed as a plain little-endian scalar in the transport for the distinctive-value capture. The next transport task is to decode the DCE/RPC/NDR layer and the mixed local stream messages structurally.
Managed codec artifact
The first .NET 10 managed implementation artifact is:
src\MxNativeCodec\MxNativeCodec.csproj
src\MxNativeCodec.Tests\MxNativeCodec.Tests.csproj
This is intentionally template-based. It preserves unknown tag/session/header
fields from an observed PutRequest body, then rewrites the typed value slot and
write index. The tests round-trip the real Frida bytes for bool, int, float,
double, string, datetime, timestamped Write2 int, and the observed
int/bool/float/double/string array write bodies.
Verification command:
dotnet run --project src\MxNativeCodec.Tests\MxNativeCodec.Tests.csproj -c Release
The harness also now accepts typed array writes:
--type=int[] --value="1;2;3"
--type=string[] --value="A;B;C"
For multiple whole-array writes in one session, --values= can use | between
array values so element commas are not ambiguous.
Implication
The prior pcap-only decode showed local stream framing but did not isolate the application scalar. Headless Ghidra plus Frida closes that gap: the native NMX adapter receives compact bodies where scalar and string/date values are plainly encoded before they enter localhost transport. The next implementation task is to turn this matrix into encoder/decoder tests, then broaden the same approach to arrays, quality/status responses, tag binding messages, and secured-write variants.