[F46] analysis/frida: Suspend/Activate hooks + R5 next-step

Closes the wire-side gap left by capture 077 in F44's R5 walk. The Frida
script now hooks the production LmxProxy.dll dispatchers so a future live
re-run on the AVEVA host can answer "does CLMXProxyServer issue a separate
ORPC method for Suspend/Activate, or are they synthesised client-side?"

Hooks added in `analysis/frida/mx-nmx-trace.js`:
- `LmxProxy.dll!CLMXProxyServer.Suspend`  @ RVA 0x13d9c (FUN_10013d9c)
- `LmxProxy.dll!CLMXProxyServer.Activate` @ RVA 0x14028 (FUN_10014028)

Both RVAs were extracted from
`analysis/ghidra/exports/LmxProxy.dll.string-refs.tsv` rows 119/122 (the
`CLMXProxyServer::Suspend - Server Handle` / `Activate - Server Handle`
log strings each xref one function — same pattern as the existing
AdviseSupervisory hook at 0x142b4). The hooks emit `mx.suspend.begin/end`
and `mx.activate.begin/end` events with serverHandle, itemHandle, and the
`MxStatus*` out parameter decoded as 4 x int16 (Success / Category /
DetectedBy / Detail per `src/MxNativeCodec/MxStatus.cs`). Naming matches
the F46 spec's `mx.<verb>.begin / end` grep convention rather than the
generic `call.enter / leave` shape because we want to filter these out
of large traces without false positives from other LmxProxy entrypoints.

No `Resume` / `Reactivate` exports exist in `LmxProxy.dll` — verified
against `analysis/ghidra/exports/LmxProxy.dll.ghidra.md` (no such string
xrefs) and the decompiled `ILMXProxyServer5` / `ILMXProxyServer4`
interfaces under `analysis/decompiled-mxaccess/ArchestrA/MxAccess/`
(only Suspend and Activate are declared on the dispatch interface).

The script's top-of-file comment now carries the live re-run procedure
(rebuild MxTraceHarness x86, attach Frida with `--scenario=suspend-advised`
then `--scenario=activate-advised`, save under
`captures/NNN-frida-suspend-activate-instrumented/`, grep the new TSV for
`mx.suspend.*` / `mx.activate.*` and correlate with `nmx.enter` events
in the same time window). Live capture is intentionally deferred to the
maintainer per the F46 spec — this dev box has no AVEVA install.

`design/70-risks-and-open-questions.md` R5 status updated:
- Title flag `(filed as F45)` -> `(filed as F46, hook landed pending live re-run)`
  (the docs/M6-buffered-evidence.md footnote referenced F45 from before
  F45 / F46 were de-conflicted by commit 2120dfa).
- New "Next step - F46" paragraph documents the two hooked RVAs, the
  out-param decode shape, and the verified absence of Resume / Reactivate
  symbols.
- "Current best answer" paragraph re-points the residual ORPC question
  at F46.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-06 05:42:24 -04:00
parent c7e71e4424
commit 808fea18a0
2 changed files with 148 additions and 6 deletions
+129
View File
@@ -1,5 +1,55 @@
// Frida hooks generated from headless Ghidra RVAs.
// Usage: frida -f <MxTraceHarness.exe> -l analysis/frida/mx-nmx-trace.js -- <harness args>
//
// F46 — Suspend / Activate instrumentation procedure
// ---------------------------------------------------
// The `mx.suspend.*` and `mx.activate.*` events below close the wire-side gap
// left by capture 077 (`captures/077-frida-suspend-advised-scanstate/`). The
// hooks attach to `LmxProxy.dll!CLMXProxyServer.Suspend` (RVA 0x13d9c, FUN_10013d9c)
// and `LmxProxy.dll!CLMXProxyServer.Activate` (RVA 0x14028, FUN_10014028) — the
// two RVAs were extracted from `analysis/ghidra/exports/LmxProxy.dll.string-refs.tsv`
// (rows tagged `CLMXProxyServer::Suspend - Server Handle` and
// `CLMXProxyServer::Activate - Server Handle`). The export table does NOT
// expose `Resume` or `Reactivate` symbols anywhere in `LmxProxy.dll`,
// `Lmx.dll`, or the `ILMXProxyServer5` interface — verified against
// `analysis/ghidra/exports/LmxProxy.dll.ghidra.md` and the decompiled
// interface at `analysis/decompiled-mxaccess/ArchestrA/MxAccess/ILMXProxyServer5.cs`.
//
// To re-run capture 077 with the new hooks active (left for the maintainer
// on the live AVEVA host):
//
// 1. Rebuild the x86 trace harness:
// msbuild src\MxTraceHarness\MxTraceHarness.csproj /p:Configuration=Release
// 2. Suspend-advised scenario:
// frida ^
// -f src\MxTraceHarness\bin\Release\net481\MxTraceHarness.exe ^
// -l analysis\frida\mx-nmx-trace.js ^
// -- --scenario=suspend-advised ^
// --tag=TestChildObject.ScanState ^
// --write-delay-ms=1000 ^
// --duration=3 ^
// --log=captures\NNN-frida-suspend-activate-instrumented\harness.log ^
// --client=MxFridaTrace-NNN
// 3. Activate-advised scenario (re-runs Suspend then Activate):
// frida ^
// -f src\MxTraceHarness\bin\Release\net481\MxTraceHarness.exe ^
// -l analysis\frida\mx-nmx-trace.js ^
// -- --scenario=activate-advised ^
// --tag=TestChildObject.ScanState ^
// --write-delay-ms=1000 ^
// --duration=3 ^
// --log=captures\NNN-frida-suspend-activate-instrumented\harness.log ^
// --client=MxFridaTrace-NNN
// 4. Save the resulting `frida-events.tsv` (plus `harness.log`,
// `frida-command.txt`, `frida.stdout.jsonl`) under
// `captures/NNN-frida-suspend-activate-instrumented/` (next free NNN).
// 5. Grep for `mx.suspend.begin|mx.suspend.end|mx.activate.begin|mx.activate.end`
// in the new TSV. If any matching `nmx.enter` / `lmx.*` events appear in
// the same time window — typed decode the body and update
// `analysis/proxy/nmxsvcps-procedures.tsv` + `docs/M6-buffered-evidence.md`.
// If no NMX traffic accompanies the hook fires — Suspend/Activate are
// confirmed client-side-only and R5 in `design/70-risks-and-open-questions.md`
// moves to "fully settled — client-side only".
const maxDump = 4096;
const installed = {};
@@ -173,6 +223,79 @@ function hookPlainArgs(moduleName, rva, name, argCount) {
});
}
function readMxStatusOut(ptrValue) {
// MxStatus on the wire is 4 × int16 = 8 bytes:
// short Success, short Category, short DetectedBy, short Detail.
// See src/MxNativeCodec/MxStatus.cs and the .NET reference's
// `out MxStatus pMxStatus` parameter on ILMXProxyServer5.{Suspend,Activate}.
try {
if (ptrValue.isNull()) return null;
return {
raw: dumpBytes(ptrValue, 8),
success: ptrValue.add(0).readS16(),
category: ptrValue.add(2).readS16(),
detectedBy: ptrValue.add(4).readS16(),
detail: ptrValue.add(6).readS16()
};
} catch (e) {
return { error: e.message };
}
}
function hookSuspendActivate(rva, name, eventVerb) {
// CLMXProxyServer::Suspend / Activate are __stdcall member methods:
// HRESULT Suspend(int hLMXServerHandle, int hItem, MxStatus* pMxStatusOut)
// After Frida's __stdcall lowering, args[0] = this (because the prologue
// pushes ECX into the stack frame the same way AdviseSupervisory does at
// RVA 0x142b4), args[1] = serverHandle, args[2] = itemHandle,
// args[3] = MxStatus* out. Mirrors the AdviseSupervisory hookPlainArgs
// shape but with typed out-param decoding (cf. hookAuthenticateUser).
hook("LmxProxy.dll", rva, name, function (address, module) {
return {
onEnter(args) {
this.statusOut = ptrArg(args, 3);
this.serverHandle = intArg(args, 1);
this.itemHandle = intArg(args, 2);
emit({
event: "mx." + eventVerb + ".begin",
module: "LmxProxy.dll",
name,
address: address.toString(),
ecx: this.context.ecx ? this.context.ecx.toString() : "",
serverHandle: this.serverHandle,
itemHandle: this.itemHandle,
statusOutPtr: this.statusOut.toString()
});
},
onLeave(retval) {
emit({
event: "mx." + eventVerb + ".end",
module: "LmxProxy.dll",
name,
retval: retval.toString(),
serverHandle: this.serverHandle,
itemHandle: this.itemHandle,
status: readMxStatusOut(this.statusOut)
});
}
};
});
}
function hookSuspend() {
// FUN_10013d9c, RVA 0x13d9c; matched on the
// `CLMXProxyServer::Suspend - Server Handle ` string xref in
// analysis/ghidra/exports/LmxProxy.dll.string-refs.tsv:119.
hookSuspendActivate(0x13d9c, "CLMXProxyServer.Suspend", "suspend");
}
function hookActivate() {
// FUN_10014028, RVA 0x14028; matched on the
// `CLMXProxyServer::Activate - Server Handle ` string xref in
// analysis/ghidra/exports/LmxProxy.dll.string-refs.tsv:122.
hookSuspendActivate(0x14028, "CLMXProxyServer.Activate", "activate");
}
function hookAuthenticateUser() {
hook("LmxProxy.dll", 0x1399f, "CLMXProxyServer.AuthenticateUser", function (address, module) {
return {
@@ -452,6 +575,12 @@ function installKnownHooks() {
hookPlainArgs("LmxProxy.dll", 0x1121d, "CLMXProxyServer.AddBufferedItem", 5);
hookPlainArgs("LmxProxy.dll", 0x0fc80, "CLMXProxyServer.SetBufferedUpdateInterval", 3);
hookPlainArgs("LmxProxy.dll", 0x142b4, "CLMXProxyServer.AdviseSupervisory", 5);
// F46: Suspend / Activate wire-side instrumentation. No `Resume` / `Reactivate`
// exports exist in LmxProxy.dll's symbol table — verified against
// analysis/ghidra/exports/LmxProxy.dll.ghidra.md and the
// ILMXProxyServer5 / ILMXProxyServer4 decompiled interfaces.
hookSuspend();
hookActivate();
hookPlainArgs("LmxProxy.dll", 0x163c0, "CProxy_ILMXProxyServerEvents2.Fire_OnBufferedDataChange", 8);
hookPlainArgs("LmxProxy.dll", 0x16b50, "CUserConnectionCallback.OnSetAttributeResult", 4);
hookPlainArgs("LmxProxy.dll", 0x16d4b, "CUserConnectionCallback.OperationComplete", 4);
+19 -6
View File
@@ -60,12 +60,13 @@ The `OnBufferedDataChange` **public event shape** the wwtools api-notes describe
**Settles when:** indefinitely deferred — see Open evidence gaps table. Settle criteria depends on the same Ghidra mapping table as R3, which does not exist in `analysis/ghidra/` and has no owner. Reopen if a future capture or decompiled output produces evidence.
### R5 — Activate / Suspend behaviour **(partially observed — F44 documented client-side trigger; wire-side residual gap filed as F45)**
### R5 — Activate / Suspend behaviour **(partially observed — F44 documented client-side trigger; wire-side residual gap filed as F46, hook landed pending live re-run)**
**Severity: P2** (downgraded from P1 — client-side acceptance criteria are
now documented; LMX-proxy wire emission remains unconfirmed)
**Status (2026-05-06): PARTIALLY OBSERVED.** F44's evidence walk on
**Status (2026-05-06): PARTIALLY OBSERVED — Frida hooks ready, live capture pending.**
F44's evidence walk on
`captures/077-frida-suspend-advised-scanstate/` (per `docs/M6-buffered-evidence.md`)
documents:
@@ -83,15 +84,27 @@ What capture 077 could **not** answer: whether the production
(e.g. an `ILMXProxyServer5` opnum) or also handles them client-side. Capture
077's Frida script did not hook
`LmxProxy.dll!CLMXProxyServer.Suspend`/`.Activate`, so the wire-side
behaviour is invisible. Filed as **F45** in `design/followups.md` to
re-instrument and capture.
behaviour is invisible.
**Next step — F46.** `analysis/frida/mx-nmx-trace.js` now carries
`Interceptor.attach` blocks for `LmxProxy.dll!CLMXProxyServer.Suspend`
(RVA `0x13d9c`, `FUN_10013d9c`) and `.Activate` (RVA `0x14028`,
`FUN_10014028`), emitting `mx.suspend.begin/end` and
`mx.activate.begin/end` events with the `MxStatus*` out-parameter
decoded as 4 × int16. No `Resume` / `Reactivate` symbols exist in
`LmxProxy.dll` — verified against
`analysis/ghidra/exports/LmxProxy.dll.ghidra.md` and the decompiled
`ILMXProxyServer5` / `ILMXProxyServer4` interfaces. R5 stays open
until a live re-run on the AVEVA host produces
`captures/NNN-frida-suspend-activate-instrumented/` per the procedure
documented at the top of `analysis/frida/mx-nmx-trace.js`.
**Current best answer:** expose `Session::suspend(item)` and
`Session::activate(item)` returning `Result<MxStatus, Error>`. The success
criteria match the .NET reference's client-side gating: the item must have
an active subscription. If F45's wire capture later proves the LMX proxy
an active subscription. If F46's wire capture later proves the LMX proxy
issues a separate ORPC method, add the wire emission here in M6 follow-up.
Do not build callback-driven state transitions on top until F45 settles.
Do not build callback-driven state transitions on top until F46 settles.
**Settles when:** F45 produces a Frida capture instrumenting
`LmxProxy.dll!CLMXProxyServer.Suspend` / `.Activate` and either confirms a