Files
mxaccess/analysis/frida/mx-nmx-trace.js
T
Joseph Doherty 808fea18a0 [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>
2026-05-06 05:42:57 -04:00

610 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = {};
function now() {
return new Date().toISOString();
}
function emit(event) {
event.time = now();
console.log(JSON.stringify(event));
}
function hexDumpSafe(ptr, length) {
try {
if (ptr.isNull() || length <= 0) return "";
const capped = Math.min(length, maxDump);
return ptr.readByteArray(capped);
} catch (e) {
return null;
}
}
function toHex(arrayBuffer) {
if (arrayBuffer === null || arrayBuffer === "") return "";
const bytes = new Uint8Array(arrayBuffer);
let out = [];
for (let i = 0; i < bytes.length; i++) out.push(bytes[i].toString(16).padStart(2, "0"));
return out.join(" ");
}
function dumpBytes(ptrValue, length) {
return toHex(hexDumpSafe(ptrValue, length));
}
function readStdWString(base, offset) {
try {
const obj = base.add(offset);
const length = obj.add(0x10).readU32();
const capacity = obj.add(0x14).readU32();
const data = capacity < 8 ? obj : obj.readPointer();
if (length > 1024 || data.isNull()) {
return { length, capacity, value: "" };
}
return { length, capacity, value: data.readUtf16String(length) };
} catch (e) {
return { error: e.message };
}
}
function readBstr(ptrValue) {
try {
if (ptrValue.isNull()) return "";
return ptrValue.readUtf16String();
} catch (e) {
return "";
}
}
function readMxHandle(ptrValue) {
try {
if (ptrValue.isNull()) return null;
return {
raw: dumpBytes(ptrValue, 20),
w0: ptrValue.add(0).readU32(),
w1: ptrValue.add(4).readU32(),
w2: ptrValue.add(8).readU32(),
w3: ptrValue.add(12).readU32(),
w4: ptrValue.add(16).readU32()
};
} catch (e) {
return { error: e.message };
}
}
function readPreboundReference(ptrValue) {
try {
if (ptrValue.isNull()) return null;
const err = ptrValue.add(0xac).readPointer();
return {
ptr: ptrValue.toString(),
referenceString: readStdWString(ptrValue, 0x18),
contextString: readStdWString(ptrValue, 0x34),
auxString: readStdWString(ptrValue, 0x70),
mxReference: ptrValue.add(0x50).readPointer().toString(),
flags10: ptrValue.add(0x10).readU32(),
word14: ptrValue.add(0x14).readU32(),
word4c: ptrValue.add(0x4c).readU32(),
word54: ptrValue.add(0x54).readU32(),
word58: ptrValue.add(0x58).readU32(),
word5c: ptrValue.add(0x5c).readU32(),
word60: ptrValue.add(0x60).readU32(),
word64: ptrValue.add(0x64).readU32(),
word68: ptrValue.add(0x68).readU32(),
word6c: ptrValue.add(0x6c).readU32(),
worda0: ptrValue.add(0xa0).readU32(),
worda4: ptrValue.add(0xa4).readU32(),
status: ptrValue.add(0xa8).readU32(),
flagb0: ptrValue.add(0xb0).readU8(),
errorText: readBstr(err),
raw: dumpBytes(ptrValue, 0xb4)
};
} catch (e) {
return { error: e.message, ptr: ptrValue.toString() };
}
}
function argValue(args, index) {
try {
return args[index].toString();
} catch (e) {
return "";
}
}
function intArg(args, index) {
try {
return args[index].toInt32();
} catch (e) {
return null;
}
}
function uintArg(args, index) {
try {
return args[index].toUInt32();
} catch (e) {
return null;
}
}
function ptrArg(args, index) {
try {
return args[index];
} catch (e) {
return ptr("0");
}
}
function hook(moduleName, rva, name, handlers) {
const key = moduleName + "!" + name;
if (installed[key]) return;
const module = Process.findModuleByName(moduleName);
if (module === null) return;
const address = module.base.add(rva);
Interceptor.attach(address, handlers(address, module));
installed[key] = true;
emit({ event: "hook.installed", module: moduleName, name, base: module.base.toString(), rva: "0x" + rva.toString(16), address: address.toString() });
}
function hookPlainArgs(moduleName, rva, name, argCount) {
hook(moduleName, rva, name, function (address, module) {
return {
onEnter(args) {
let values = [];
for (let i = 0; i < argCount; i++) values.push(argValue(args, i));
emit({
event: "call.enter",
module: moduleName,
name,
address: address.toString(),
ecx: this.context.ecx ? this.context.ecx.toString() : "",
args: values
});
},
onLeave(retval) {
emit({ event: "call.leave", module: moduleName, name, retval: retval.toString() });
}
};
});
}
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 {
onEnter(args) {
this.userIdOut = ptrArg(args, 4);
const password = readBstr(ptrArg(args, 3));
emit({
event: "call.enter",
module: "LmxProxy.dll",
name: "CLMXProxyServer.AuthenticateUser",
address: address.toString(),
ecx: this.context.ecx ? this.context.ecx.toString() : "",
serverHandle: intArg(args, 1),
user: readBstr(ptrArg(args, 2)),
passwordLength: password.length,
userIdOut: this.userIdOut.toString()
});
},
onLeave(retval) {
let userId = null;
try {
if (!this.userIdOut.isNull()) userId = this.userIdOut.readS32();
} catch (e) {
userId = null;
}
emit({
event: "call.leave",
module: "LmxProxy.dll",
name: "CLMXProxyServer.AuthenticateUser",
retval: retval.toString(),
userId
});
}
};
});
}
function hookLmxPrebindReference() {
hook("Lmx.dll", 0xea780, "MxConnection.PrebindReference", function (address, module) {
return {
onEnter(args) {
this.self = ptrArg(args, 0);
this.out = ptrArg(args, 2);
emit({
event: "lmx.prebind.enter",
module: "Lmx.dll",
name: "MxConnection.PrebindReference",
self: this.self.toString(),
outPtr: this.out.toString(),
referencePtr: ptrArg(args, 1).toString(),
reference: ptrArg(args, 1).readUtf16String()
});
},
onLeave(retval) {
let handle = null;
try {
if (!this.out.isNull()) handle = this.out.readS32();
} catch (e) {
handle = null;
}
emit({ event: "lmx.prebind.leave", module: "Lmx.dll", name: "MxConnection.PrebindReference", handle });
}
};
});
}
function hookLmxUserRegisterPreboundReference() {
hook("Lmx.dll", 0xe1920, "MxConnection.UserRegisterPreboundReference", function (address, module) {
return {
onEnter(args) {
this.self = ptrArg(args, 0);
this.out = ptrArg(args, 4);
emit({
event: "lmx.user-register-prebound.enter",
module: "Lmx.dll",
name: "MxConnection.UserRegisterPreboundReference",
self: this.self.toString(),
preboundHandle: intArg(args, 1),
callback: argValue(args, 2),
userData: intArg(args, 3),
outPtr: this.out.toString()
});
},
onLeave(retval) {
let mxReferenceHandle = null;
try {
if (!this.out.isNull()) mxReferenceHandle = this.out.readS32();
} catch (e) {
mxReferenceHandle = null;
}
emit({
event: "lmx.user-register-prebound.leave",
module: "Lmx.dll",
name: "MxConnection.UserRegisterPreboundReference",
retval: retval.toString(),
mxReferenceHandle
});
}
};
});
}
function hookLmxReferenceHandleReader() {
hook("Lmx.dll", 0x5f730, "IMxReference.GetMxHandle", function (address, module) {
return {
onEnter(args) {
this.out = ptrArg(args, 0);
this.ref = this.context.ecx ? this.context.ecx : ptr("0");
},
onLeave(retval) {
emit({
event: "lmx.mxhandle.read",
module: "Lmx.dll",
name: "IMxReference.GetMxHandle",
referencePtr: this.ref.toString(),
outPtr: this.out.toString(),
handle: readMxHandle(this.out),
retval: retval.toString()
});
}
};
});
}
function hookLmxFixupMxHandle() {
hook("Lmx.dll", 0x8f8b0, "AccessManager.FixUpMxHandle", function (address, module) {
return {
onEnter(args) {
this.out = ptrArg(args, 0);
emit({
event: "lmx.fixup-mxhandle.enter",
module: "Lmx.dll",
name: "AccessManager.FixUpMxHandle",
accessManager: this.context.ecx ? this.context.ecx.toString() : "",
outPtr: this.out.toString(),
inWords: [uintArg(args, 1), uintArg(args, 2), uintArg(args, 3), uintArg(args, 4), uintArg(args, 5), uintArg(args, 6)]
});
},
onLeave(retval) {
emit({
event: "lmx.fixup-mxhandle.leave",
module: "Lmx.dll",
name: "AccessManager.FixUpMxHandle",
outPtr: this.out.toString(),
handle: readMxHandle(this.out),
retval: retval.toString()
});
}
};
});
}
function hookLmxResolveReference() {
hook("Lmx.dll", 0x113d40, "PreboundReference.Resolve", function (address, module) {
return {
onEnter(args) {
this.prebound = this.context.ecx ? this.context.ecx : ptr("0");
emit({
event: "lmx.prebound-resolve.enter",
module: "Lmx.dll",
name: "PreboundReference.Resolve",
prebound: readPreboundReference(this.prebound)
});
},
onLeave(retval) {
emit({
event: "lmx.prebound-resolve.leave",
module: "Lmx.dll",
name: "PreboundReference.Resolve",
prebound: readPreboundReference(this.prebound),
retval: retval.toString()
});
}
};
});
}
function hookLmxResolveCallbacks() {
hook("Lmx.dll", 0x1155a0, "PreboundReference.OnPlatformResolveReferenceResults", function (address, module) {
return {
onEnter(args) {
this.prebound = this.context.ecx ? this.context.ecx : ptr("0");
this.reference = this.context.edx ? this.context.edx : ptr("0");
emit({
event: "lmx.platform-resolve-results.enter",
module: "Lmx.dll",
name: "PreboundReference.OnPlatformResolveReferenceResults",
prebound: readPreboundReference(this.prebound),
referencePtr: this.reference.toString()
});
},
onLeave(retval) {
emit({
event: "lmx.platform-resolve-results.leave",
module: "Lmx.dll",
name: "PreboundReference.OnPlatformResolveReferenceResults",
prebound: readPreboundReference(this.prebound),
retval: retval.toString()
});
}
};
});
hook("Lmx.dll", 0x114a90, "PreboundReference.OnSetAttributeResult", function (address, module) {
return {
onEnter(args) {
this.prebound = this.context.ecx ? this.context.ecx : ptr("0");
emit({
event: "lmx.set-attribute-result.enter",
module: "Lmx.dll",
name: "PreboundReference.OnSetAttributeResult",
prebound: readPreboundReference(this.prebound),
correlationId: this.context.edx ? this.context.edx.toString() : "",
pValue: argValue(args, 0),
status: argValue(args, 3)
});
},
onLeave(retval) {
emit({
event: "lmx.set-attribute-result.leave",
module: "Lmx.dll",
name: "PreboundReference.OnSetAttributeResult",
prebound: readPreboundReference(this.prebound),
retval: retval.toString()
});
}
};
});
}
function hookNmxPutRequest(moduleName, rva, name, ex) {
hook(moduleName, rva, name, function (address, module) {
return {
onEnter(args) {
// Ghidra sees this as a C++ method. On x86 thiscall, ECX is likely `this`
// and args[0] is the first stack argument. We log broad argument state
// and dump plausible size/payload pairs for later alignment.
const candidates = [];
for (let sizeIndex = 3; sizeIndex <= 8; sizeIndex++) {
const size = uintArg(args, sizeIndex);
const dataPtr = ptrArg(args, sizeIndex + 1);
if (size !== null && size > 0 && size <= 65536 && !dataPtr.isNull()) {
candidates.push({
sizeIndex,
ptrIndex: sizeIndex + 1,
size,
ptr: dataPtr.toString(),
hex: toHex(hexDumpSafe(dataPtr, size))
});
}
}
let values = [];
for (let i = 0; i < 10; i++) values.push(argValue(args, i));
emit({
event: "nmx.enter",
module: moduleName,
name,
address: address.toString(),
ecx: this.context.ecx ? this.context.ecx.toString() : "",
args: values,
candidates
});
},
onLeave(retval) {
emit({ event: "nmx.leave", module: moduleName, name, retval: retval.toString() });
}
};
});
}
function installKnownHooks() {
hookPlainArgs("LmxProxy.dll", 0x12c0c, "CLMXProxyServer.Write.variantA", 10);
hookPlainArgs("LmxProxy.dll", 0x13280, "CLMXProxyServer.Write.variantB", 13);
hookPlainArgs("LmxProxy.dll", 0x12f24, "CLMXProxyServer.WriteSecured.variantA", 10);
hookPlainArgs("LmxProxy.dll", 0x135fe, "CLMXProxyServer.WriteSecured.variantB", 14);
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);
hookAuthenticateUser();
hookNmxPutRequest("NmxAdptr.dll", 0x10996, "CNmxAdapter.TransferData", false);
hookNmxPutRequest("NmxAdptr.dll", 0x112da, "CNmxAdapter.ProcessDataReceived", false);
hookNmxPutRequest("NmxAdptr.dll", 0x15169, "CNmxAdapter.PutRequest", false);
hookNmxPutRequest("NmxAdptr.dll", 0x159c3, "CNmxAdapter.PutRequestEx", true);
hookLmxPrebindReference();
hookLmxUserRegisterPreboundReference();
hookLmxReferenceHandleReader();
hookLmxFixupMxHandle();
hookLmxResolveReference();
hookLmxResolveCallbacks();
}
const timer = setInterval(function () {
installKnownHooks();
if (installed["LmxProxy.dll!CLMXProxyServer.Write.variantA"] && installed["NmxAdptr.dll!CNmxAdapter.PutRequest"]) {
clearInterval(timer);
}
}, 100);
installKnownHooks();