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>
329 lines
14 KiB
Markdown
329 lines
14 KiB
Markdown
# 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:
|
|
|
|
```text
|
|
C:\Users\dohertj2\Desktop\focas\tools\ghidra_12.0.4_PUBLIC
|
|
```
|
|
|
|
Java was available but not on `PATH`, so headless commands set:
|
|
|
|
```text
|
|
JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.10.7-hotspot
|
|
```
|
|
|
|
The AVEVA binaries were staged under a path without spaces:
|
|
|
|
```text
|
|
analysis\ghidra\input
|
|
```
|
|
|
|
The headless post-script is:
|
|
|
|
```text
|
|
analysis\ghidra\scripts\MxNmxExport.java
|
|
```
|
|
|
|
It exports metadata, not full decompiled source:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
NdrDllGetClassObject
|
|
NdrDllCanUnloadNow
|
|
NdrDllRegisterProxy
|
|
NdrDllUnregisterProxy
|
|
NdrCStdStubBuffer_Release
|
|
```
|
|
|
|
## Runtime hook result
|
|
|
|
Frida hook script:
|
|
|
|
```text
|
|
analysis\frida\mx-nmx-trace.js
|
|
analysis\scripts\run_frida_mx_trace.ps1
|
|
analysis\scripts\extract_frida_trace.py
|
|
```
|
|
|
|
Successful traces:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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`:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
dotnet run --project src\MxNativeCodec.Tests\MxNativeCodec.Tests.csproj -c Release
|
|
```
|
|
|
|
The harness also now accepts typed array writes:
|
|
|
|
```text
|
|
--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.
|