Files
mxaccess/docs/Ghidra-Headless-Analysis.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

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.md decompiles FUN_10012f24 (WriteSecured) and FUN_100135fe (WriteSecured2).
  • WriteSecured performs an extra item-record byte check at offset 0x0f and returns 0x80004021 when that byte is nonzero. The secured and verified bool tags on this node hit that branch even after AuthenticateUser succeeds.
  • WriteSecured2 skips 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 NMX 0x38 body in captures 113-116.
  • The shared user-token lookup branch returns 0x80070057 when the user handle is not mapped, explaining the earlier unauthenticated WriteSecured2 failure against TestChildObject.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.