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>
8.5 KiB
Loopback protocol findings
This note captures the first Npcap loopback decode pass for the MXAccess to LMX/NMX path.
Capture set
The repeatable capture runner is:
analysis\scripts\run_loopback_capture.ps1
It starts dumpcap on the Npcap loopback adapter and runs the x86 MXAccess
harness:
src\MxTraceHarness\bin\Release\net481\MxTraceHarness.exe
Focused loopback captures:
| Folder | Scenario |
|---|---|
captures\013-loopback-subscribe-scalars |
subscribe TestBool, TestInt, TestString |
captures\014-loopback-subscribe-array-bracketed |
subscribe TestStringArray[] |
captures\015-loopback-subscribe-invalid |
subscribe invalid reference |
captures\016-loopback-write-test-int-advised |
advised same-value write to TestInt |
Per-capture generated files:
| File | Purpose |
|---|---|
loopback.pcapng |
raw Npcap loopback capture |
harness.log |
timestamped MXAccess method and callback log |
dcerpc-49704.tsv |
tshark DCE/RPC frame index for the NMX service port |
nmx-conversations.tsv |
extracted IPv6 conversations involving port 49704 |
nmx-payload-packets.tsv |
payload packet index for the selected 49704 conversation |
nmx-stream-*.bin |
reassembled directional payloads for the selected 49704 conversation |
tcp-conversations.tsv |
all TCP payload conversations ranked by byte count |
tcp-payload-packets.tsv |
payload packet index for the top TCP conversations |
tcp-stream-*.bin |
reassembled directional payloads for the top TCP conversations |
Cross-capture summaries:
analysis\scripts\extract_nmx_loopback.py
analysis\scripts\extract_tcp_conversations.py
analysis\scripts\decode_tcp_payload_packets.py
analysis\scripts\decode_mixed_local_stream.py
analysis\scripts\summarize_dcerpc.py
analysis\network\dcerpc-loopback-summary.tsv
analysis\network\write-window-tcp-payloads.tsv
DCE/RPC service path
Wireshark decodes the main ::1:<ephemeral> <-> ::1:49704 traffic as DCE/RPC.
The captures show these DCE/RPC interface UUIDs:
| UUID | Observed use |
|---|---|
4e0c90df-e39d-4164-a421-ace89484c602 |
initial bind context, opnum 0 calls |
1981974b-6bf7-46cb-9640-0260bbb551ba |
altered context, main opnums 0, 2, 3, 5 |
The UUIDs were not present as direct keys under the checked COM registry areas:
HKCR\Interface
HKCR\Wow6432Node\Interface
HKCR\CLSID
HKCR\Wow6432Node\CLSID
HKCR\TypeLib
HKCR\Wow6432Node\TypeLib
This makes NmxSvcps.dll and WWProxyStub.dll high-value next targets. They
are likely proxy/stub components, but their interfaces are not exposed through
the simple COM registry lookup above.
DCE/RPC shape
For good scalar subscribe, good array subscribe, and successful advised write,
the main 49704 DCE/RPC shape is identical:
165 request/response pairs on ctx 1, opnum 3
10 request/response groups on ctx 0 opnum 0, ctx 1 opnum 0, ctx 1 opnum 2
3 request/response pairs on ctx 1, opnum 5
The invalid subscribe adds one extra group:
176 request/response pairs on ctx 1, opnum 3
11 request/response groups on ctx 0 opnum 0, ctx 1 opnum 0, ctx 1 opnum 2
3 request/response pairs on ctx 1, opnum 5
Interpretation: invalid item resolution does not fail at AddItem; it drives
additional NMX/RPC activity during AdviseSupervisory, matching the harness
callbacks where invalid names return configuration errors only after advise.
Write path split
The successful write capture has the standard main 49704 DCE/RPC conversation:
::1:55840 <-> ::1:49704
803 frames, about 84 kB
It also has a short additional 49704 DCE/RPC conversation:
::1:49768 <-> ::1:49704
192 frames, about 19 kB
The harness write timestamps were:
2026-04-25T05:13:58.0479762Z mx.write.begin
2026-04-25T05:13:58.0489758Z mx.write.end
2026-04-25T05:13:58.2561934Z mx.event.write-complete
With the pcap first frame at epoch 1777094027.708322300, this places the
write call around relative time 10.34s and the write-complete callback around
relative time 10.55s.
No DCE/RPC frames on port 49704 occur in the 10.30s to 10.62s window.
Instead, the active payload in that exact window is primarily TCP stream 0:
127.0.0.1:57415 <-> 127.0.0.1:57433
That stream is not decoded by Wireshark as DCE/RPC. Its payload has a compact binary framing pattern with frequent 12-byte control messages and small length-prefixed payloads. Example payload prefixes around the write window include little-endian values such as:
1a 00 00 00 ...
16 00 00 00 ...
22 00 00 00 ...
1e 00 00 00 ...
3f 00 00 00 ...
Interpretation: the native implementation probably uses DCE/RPC for service activation/session coordination and a separate local binary channel for at least part of the advised update/write-complete path. The managed replacement must account for both layers.
The stream has been extracted as:
captures\016-loopback-write-test-int-advised\tcp-stream-127_0_0_1_57415-to-127_0_0_1_57433.bin
captures\016-loopback-write-test-int-advised\tcp-stream-127_0_0_1_57433-to-127_0_0_1_57415.bin
The stream is now decoded with packet-boundary and mixed-record helpers:
analysis\scripts\decode_tcp_payload_packets.py
analysis\scripts\decode_mixed_local_stream.py
analysis\scripts\analyze_write_window.py
analysis\scripts\diff_write_window_records.py
The mixed-record model is:
- 12-byte control records:
int32 code_or_status,int32 token_low,int32 token_high. - Data records:
uint32 body_length, followed by the data body. - A positive control value can announce one or more following data records.
-1appears as acknowledgement/status.-2appears as a bidirectional status/control marker around write windows.
Usable value-change write captures added after this decode pass:
| Folder | Result |
|---|---|
captures\017-loopback-write-test-int-100 |
TestInt changed 99 -> 100 |
captures\020-loopback-write-test-int-102 |
TestInt changed 101 -> 102 |
captures\021-loopback-write-test-int-sequence-103-105 |
same-session writes 103, 104, 105 |
The detailed COM contract and managed-client implication note is:
docs\NMX-COM-Contracts.md
Same-session write sequence
captures\021-loopback-write-test-int-sequence-103-105 writes three int values
through one registered/advised MXAccess session:
103 at 2026-04-25T05:53:06.9746508Z
104 at 2026-04-25T05:53:07.6963047Z
105 at 2026-04-25T05:53:08.4180133Z
Each write produced a good data-change callback followed by a good write-complete callback. The top local payload stream remained:
127.0.0.1:57415 <-> 127.0.0.1:57433
The analyzer output is:
captures\021-loopback-write-test-int-sequence-103-105\write-window-mixed-records.tsv
analysis\network\write-window-body-diff-021-w0-vs-w1.tsv
analysis\network\write-window-body-diff-021-w1-vs-w2.tsv
Within the -0.10s to +0.12s write-complete windows, the repeated records are
the same families already seen in the single-write captures:
54 8f 63 40 e2 5e 31 40 ... 26-byte data body
1c 21 18 d0 c4 6f 33 bb ... 34-byte data body
98 04 33 cb 0c b4 7c 38 ... 67-byte data body
44 6b 99 d8 ec 1b bd b5 ... 52-byte data body
55 ce ff 62 b2 1b 3a 50 ... 30-byte data body
The raw int values 103, 104, and 105 were not isolated from the pcap-only
mixed stream decode. The most consistent byte changes in that layer are
counter-like fields at offset 14 in the 26-byte and 30-byte data bodies,
offset 0 in the 22-byte and 26-byte response bodies, and related token fields
in adjacent 12-byte controls. Those fields advance with the local message
sequence and should not be treated as the application value.
The application value was later isolated one layer higher with Frida hooks placed using headless Ghidra RVAs. See:
docs\Ghidra-Headless-Analysis.md
captures\023-frida-write-test-int-sequence-109-111\frida-events.tsv
Plaintext result
The selected 49704 stream binaries did not contain the test tag names in
ASCII or UTF-16LE. The captured protocol is therefore not a simple plaintext
tag-reference transport.
Current reconstruction hypothesis
The implementation path is:
ArchestrA.MXAccess.dll
-> LmxProxy.dll COM in-proc server
-> local DCE/RPC to NmxSvc.exe on ::1:49704
-> local binary channels for request/callback data
-> LMX/NMX runtime and Galaxy-derived security/type metadata
The next useful work is to decode the proxy/stub interfaces and the local binary stream structure, then tie decoded calls back to MXAccess harness events.