wowdump 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +21 -53
  3. package/dist/adapters/reader.js +33 -0
  4. package/dist/analysis/disassemble.js +77 -0
  5. package/dist/{frida-runtime.js → analysis/frida-runtime.js} +48 -48
  6. package/dist/analysis/runtime-script.js +36 -0
  7. package/dist/cli.js +255 -189
  8. package/dist/core/profile-engine.js +238 -0
  9. package/dist/frida-worker.js +54 -55
  10. package/dist/{reader-broker.js → reader/broker.js} +15 -0
  11. package/dist/{reader-client.js → reader/client.js} +1 -1
  12. package/dist/{windows-launcher.js → reader/launcher.js} +16 -4
  13. package/dist/reader/main.js +100 -0
  14. package/dist/reader/protocol.js +1 -0
  15. package/dist/reader/windows.js +242 -0
  16. package/dist/reader-main.js +1 -66
  17. package/dist/toolchain.js +102 -573
  18. package/package.json +9 -10
  19. package/skills/wowdump/SKILL.md +22 -15
  20. package/skills/wowdump/references/commands.md +63 -0
  21. package/skills/wowdump/references/disassemble.md +18 -0
  22. package/skills/wowdump/references/dynamic.md +54 -0
  23. package/skills/wowdump/references/evidence-workflow.md +41 -0
  24. package/skills/wowdump/references/profiles.md +34 -0
  25. package/skills/wowdump/references/request-schema.md +28 -0
  26. package/skills/wowdump/references/workflow.md +44 -0
  27. package/skills/wowdump/scripts/dynamic-session.js +133 -0
  28. package/dist/agent.js +0 -1332
  29. package/dist/discovery.js +0 -48
  30. package/dist/dry-run.js +0 -36
  31. package/dist/error-log.js +0 -71
  32. package/dist/focused-session.js +0 -89
  33. package/dist/ghidra.js +0 -769
  34. package/dist/main.js +0 -66
  35. package/dist/observability.js +0 -41
  36. package/dist/processes.js +0 -44
  37. package/dist/session.js +0 -42
  38. package/dist/storage.js +0 -12
  39. package/dist/windows-reader.js +0 -102
  40. package/dist/wow-analysis.js +0 -1405
  41. package/skills/wowdump/commands.md +0 -44
  42. /package/dist/{adapters.js → core/build-adapters.js} +0 -0
  43. /package/dist/{types.js → core/types.js} +0 -0
@@ -0,0 +1,238 @@
1
+ export class ProfileEngineError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, message, details) {
5
+ super(message);
6
+ this.name = "ProfileEngineError";
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ }
11
+ const SCALAR_SIZES = {
12
+ u8: 1, i8: 1, u16: 2, i16: 2, u32: 4, i32: 4,
13
+ u64: 8, i64: 8, f32: 4, f64: 8, pointer: 8,
14
+ bytes: undefined, utf8: undefined, utf16: undefined
15
+ };
16
+ function record(value, name) {
17
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
18
+ throw new ProfileEngineError("PROFILE_INVALID", `${name} must be an object`);
19
+ }
20
+ return value;
21
+ }
22
+ function hex(value, name) {
23
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
24
+ return BigInt(value);
25
+ if (typeof value !== "string" || !/^0x[0-9a-f]+$/i.test(value.trim())) {
26
+ throw new ProfileEngineError("PROFILE_INVALID", `${name} must be a 0x-prefixed hexadecimal value`);
27
+ }
28
+ return BigInt(value);
29
+ }
30
+ function hexText(value) {
31
+ return `0x${value.toString(16)}`;
32
+ }
33
+ function integer(value, name, maximum = 16 * 1024 * 1024) {
34
+ const result = typeof value === "number" ? value : Number(value);
35
+ if (!Number.isSafeInteger(result) || result < 0 || result > maximum) {
36
+ throw new ProfileEngineError("PROFILE_INVALID", `${name} must be an integer between 0 and ${maximum}`);
37
+ }
38
+ return result;
39
+ }
40
+ function scalarType(value, name) {
41
+ if (typeof value !== "string" || !(value in SCALAR_SIZES)) {
42
+ throw new ProfileEngineError("PROFILE_INVALID", `${name} has an unsupported scalar type`);
43
+ }
44
+ return value;
45
+ }
46
+ function layoutOf(field) {
47
+ const layout = field.layout;
48
+ if (layout === undefined)
49
+ return { kind: "scalar", type: field.type ?? "pointer" };
50
+ return record(layout, "field.layout");
51
+ }
52
+ function layoutSize(layout) {
53
+ const kind = String(layout.kind ?? "scalar");
54
+ if (kind === "scalar") {
55
+ const type = scalarType(layout.type, "layout.type");
56
+ const size = SCALAR_SIZES[type];
57
+ if (size === undefined)
58
+ return integer(layout.size, "layout.size", 16 * 1024 * 1024);
59
+ return size;
60
+ }
61
+ if (kind === "struct") {
62
+ let size = layout.size === undefined ? 0 : integer(layout.size, "struct.size");
63
+ const fields = record(layout.fields ?? {}, "struct.fields");
64
+ for (const value of Object.values(fields)) {
65
+ const field = record(value, "struct field");
66
+ const offset = integer(hex(field.offset, "field.offset"), "field.offset");
67
+ size = Math.max(size, offset + layoutSize(field.layout ? record(field.layout, "field.layout") : field));
68
+ }
69
+ return size;
70
+ }
71
+ if (kind === "array") {
72
+ const item = record(layout.item, "array.item");
73
+ const stride = integer(layout.stride ?? layoutSize(item), "array.stride");
74
+ const count = layout.count;
75
+ if (typeof count === "number" || typeof count === "string")
76
+ return stride * integer(count, "array.count", 100000);
77
+ return stride * integer(layout.maxItems, "array.maxItems", 100000);
78
+ }
79
+ if (kind === "linked_list") {
80
+ const item = record(layout.item, "linked_list.item");
81
+ return Math.max(layoutSize(item), integer(hex(layout.nextOffset ?? "0x8", "nextOffset"), "nextOffset") + 8);
82
+ }
83
+ throw new ProfileEngineError("PROFILE_INVALID", `unsupported layout kind ${kind}`);
84
+ }
85
+ function decodeScalar(type, bytes) {
86
+ switch (type) {
87
+ case "u8": return bytes.readUInt8(0);
88
+ case "i8": return bytes.readInt8(0);
89
+ case "u16": return bytes.readUInt16LE(0);
90
+ case "i16": return bytes.readInt16LE(0);
91
+ case "u32": return bytes.readUInt32LE(0);
92
+ case "i32": return bytes.readInt32LE(0);
93
+ case "u64": return hexText(bytes.readBigUInt64LE(0));
94
+ case "i64": return bytes.readBigInt64LE(0).toString();
95
+ case "f32": return bytes.readFloatLE(0);
96
+ case "f64": return bytes.readDoubleLE(0);
97
+ case "pointer": return hexText(bytes.readBigUInt64LE(0));
98
+ case "bytes": return bytes.toString("hex");
99
+ case "utf8": return bytes.toString("utf8").replace(/\0.*$/s, "");
100
+ case "utf16": return bytes.toString("utf16le").replace(/\0.*$/s, "");
101
+ }
102
+ }
103
+ function decodeInline(layout, bytes, base = 0) {
104
+ const kind = String(layout.kind ?? "scalar");
105
+ if (kind === "scalar") {
106
+ const type = scalarType(layout.type, "layout.type");
107
+ const size = SCALAR_SIZES[type] ?? integer(layout.size, "layout.size");
108
+ return decodeScalar(type, bytes.subarray(base, base + size));
109
+ }
110
+ if (kind === "struct") {
111
+ const result = {};
112
+ const fields = record(layout.fields ?? {}, "struct.fields");
113
+ for (const [name, value] of Object.entries(fields)) {
114
+ const field = record(value, `struct field ${name}`);
115
+ const offset = integer(hex(field.offset, `${name}.offset`), `${name}.offset`);
116
+ result[name] = decodeInline(field.layout ? record(field.layout, `${name}.layout`) : field, bytes, base + offset);
117
+ }
118
+ return result;
119
+ }
120
+ if (kind === "array") {
121
+ const item = record(layout.item, "array.item");
122
+ const stride = integer(layout.stride ?? layoutSize(item), "array.stride");
123
+ const count = integer(layout.count ?? layout.maxItems, "array.count", 100000);
124
+ return Array.from({ length: count }, (_, index) => decodeInline(item, bytes, base + index * stride));
125
+ }
126
+ throw new ProfileEngineError("PROFILE_INVALID", `inline layout ${kind} is not supported`);
127
+ }
128
+ function profileId(profile) {
129
+ return typeof profile.id === "string" && profile.id ? profile.id : String(profile.buildKey ?? "profile");
130
+ }
131
+ function rootSpec(value, name) {
132
+ const root = record(value, `${name}.root`);
133
+ if (root.rva === undefined && root.address === undefined)
134
+ throw new ProfileEngineError("PROFILE_INVALID", `${name}.root requires rva or address`);
135
+ return root;
136
+ }
137
+ export class ProfileEngine {
138
+ async read(request, adapter) {
139
+ const profile = record(request.profile, "profile");
140
+ const buildKey = String(profile.buildKey ?? "");
141
+ if (!buildKey)
142
+ throw new ProfileEngineError("PROFILE_INVALID", "profile.buildKey is required");
143
+ if (request.buildKey && request.buildKey !== buildKey) {
144
+ throw new ProfileEngineError("BUILD_MISMATCH", `profile build ${buildKey} does not match ${request.buildKey}`);
145
+ }
146
+ const status = String(profile.readerStatus ?? profile.status ?? (profile.confidence === "reader_ready" ? "reader_ready" : ""));
147
+ if (status !== "reader_ready")
148
+ throw new ProfileEngineError("PROFILE_NOT_READY", "profile is not reader_ready", { status });
149
+ const modules = await adapter.modules(request.pid);
150
+ const moduleSpec = profile.module && typeof profile.module === "object" ? record(profile.module, "profile.module") : {};
151
+ const moduleName = String(moduleSpec.name ?? profile.moduleName ?? "Wow.exe");
152
+ const module = modules.find(item => item.name.toLowerCase() === moduleName.toLowerCase());
153
+ if (!module)
154
+ throw new ProfileEngineError("MODULE_NOT_FOUND", `${moduleName} was not found`, { modules: modules.map(item => item.name) });
155
+ const definitions = record(profile.fields ?? profile.readers, "profile.fields");
156
+ const names = request.fields?.length ? [...request.fields] : Object.keys(definitions);
157
+ const fields = {};
158
+ const reads = [];
159
+ for (const name of names) {
160
+ const definition = record(definitions[name], `field ${name}`);
161
+ fields[name] = await this.readField(request.pid, hex(module.base, "module.base"), name, definition, adapter, reads);
162
+ }
163
+ return { ok: true, command: "profile.read", pid: request.pid, buildKey, profileId: profileId(profile), module, fields, reads };
164
+ }
165
+ async readField(pid, moduleBase, fieldName, definition, adapter, reads) {
166
+ let address = definition.root === undefined ? moduleBase : await this.resolveRoot(pid, moduleBase, rootSpec(definition.root, fieldName), adapter, fieldName, reads);
167
+ const chainValue = definition.pointerChain ?? definition.chain;
168
+ const chain = Array.isArray(chainValue) ? chainValue : [];
169
+ for (const value of chain) {
170
+ const step = record(value, `${fieldName}.pointerChain`);
171
+ address += hex(step.offset ?? "0x0", `${fieldName}.pointerChain.offset`);
172
+ if (step.dereference === false)
173
+ continue;
174
+ const pointer = await this.readBytes(pid, address, 8, fieldName, adapter, reads);
175
+ address = BigInt(`0x${pointer.toString("hex").match(/../g).reverse().join("")}`);
176
+ const mask = step.mask === undefined ? 0n : hex(step.mask, `${fieldName}.pointerChain.mask`);
177
+ if (mask)
178
+ address &= mask;
179
+ if (address === 0n)
180
+ throw new ProfileEngineError("NULL_POINTER", `${fieldName} resolved to a null pointer`);
181
+ }
182
+ return this.readLayout(pid, address, layoutOf(definition), fieldName, adapter, reads);
183
+ }
184
+ async resolveRoot(pid, moduleBase, root, adapter, field, reads) {
185
+ let address = root.address === undefined ? moduleBase + hex(root.rva, `${field}.root.rva`) : hex(root.address, `${field}.root.address`);
186
+ if (root.dereference === true) {
187
+ const bytes = await this.readBytes(pid, address, 8, field, adapter, reads);
188
+ address = BigInt(`0x${bytes.toString("hex").match(/../g).reverse().join("")}`);
189
+ }
190
+ return address;
191
+ }
192
+ async readLayout(pid, address, layout, field, adapter, reads) {
193
+ const kind = String(layout.kind ?? "scalar");
194
+ if (kind === "linked_list") {
195
+ const item = record(layout.item, `${field}.item`);
196
+ const nextOffset = integer(hex(layout.nextOffset ?? "0x8", `${field}.nextOffset`), `${field}.nextOffset`);
197
+ const itemSize = Math.max(layoutSize(item), nextOffset + 8);
198
+ const maxItems = integer(layout.maxItems ?? 64, `${field}.maxItems`, 100000);
199
+ const values = [];
200
+ const seen = new Set();
201
+ while (address !== 0n && values.length < maxItems) {
202
+ const key = hexText(address);
203
+ if (seen.has(key))
204
+ throw new ProfileEngineError("POINTER_CYCLE", `${field} linked list contains a cycle`, { address: key });
205
+ seen.add(key);
206
+ if (layout.stopOnTagged !== false && (address & 1n) !== 0n)
207
+ break;
208
+ const bytes = await this.readBytes(pid, address, itemSize, field, adapter, reads);
209
+ values.push(decodeInline(item, bytes));
210
+ const nextBytes = bytes.subarray(nextOffset, nextOffset + 8);
211
+ address = nextBytes.length < 8 ? 0n : BigInt(`0x${nextBytes.toString("hex").match(/../g).reverse().join("")}`);
212
+ }
213
+ return values;
214
+ }
215
+ const size = layout.kind === "scalar" && layout.type && SCALAR_SIZES[scalarType(layout.type, `${field}.type`)] === undefined
216
+ ? integer(layout.size, `${field}.size`)
217
+ : layoutSize(layout);
218
+ const bytes = await this.readBytes(pid, address, size, field, adapter, reads);
219
+ if (kind === "array" && layout.count === undefined && layout.maxItems !== undefined) {
220
+ const item = record(layout.item, `${field}.item`);
221
+ const stride = integer(layout.stride ?? layoutSize(item), `${field}.stride`);
222
+ const count = integer(layout.maxItems, `${field}.maxItems`, 100000);
223
+ return Array.from({ length: count }, (_, index) => decodeInline(item, bytes, index * stride));
224
+ }
225
+ return decodeInline(layout, bytes);
226
+ }
227
+ async readBytes(pid, address, size, field, adapter, reads) {
228
+ const result = await adapter.read(pid, hexText(address), size);
229
+ if (!result.ok || typeof result.dataHex !== "string")
230
+ throw new ProfileEngineError("READ_FAILED", `${field} read failed`, { address: hexText(address), size, error: result.error ?? null });
231
+ const bytesRead = Number(result.bytesRead ?? result.dataHex.length / 2);
232
+ const bytes = Buffer.from(result.dataHex, "hex");
233
+ reads.push({ field, address: hexText(address), size, bytesRead, dataHex: result.dataHex, complete: result.complete === true && bytesRead === size });
234
+ if (bytesRead !== size || bytes.length < size)
235
+ throw new ProfileEngineError("PARTIAL_READ", `${field} read was incomplete`, { address: hexText(address), size, bytesRead });
236
+ return bytes.subarray(0, size);
237
+ }
238
+ }
@@ -1,85 +1,84 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { createInterface } from "node:readline";
3
- import { FridaCommandRuntime } from "./frida-runtime.js";
3
+ import { FridaCommandRuntime } from "./analysis/frida-runtime.js";
4
4
  function record(value) {
5
5
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
6
6
  }
7
- function requiredPositive(value, name) {
8
- const number = typeof value === "number" ? value : Number(value);
7
+ function positive(value, name) {
8
+ const number = Number(value);
9
9
  if (!Number.isSafeInteger(number) || number < 1)
10
10
  throw new Error(`${name} must be a positive integer`);
11
11
  return number;
12
12
  }
13
- function hex(value, name) {
14
- if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
15
- return `0x${value.toString(16)}`;
16
- if (typeof value !== "string" || !/^(?:0x)?[0-9a-f]+$/i.test(value.trim()))
17
- throw new Error(`${name} must be hexadecimal`);
18
- return `0x${BigInt(value).toString(16)}`;
19
- }
20
- function add(base, offset) {
21
- return `0x${(BigInt(base) + BigInt(offset)).toString(16)}`;
22
- }
13
+ /**
14
+ * Elevated Frida execution boundary. Analysis belongs to the caller-supplied
15
+ * GumJS source; this process only attaches, loads, calls, and cleans it up.
16
+ */
23
17
  async function run(input) {
24
- if (input.command !== "runtime-export")
18
+ if (input.command !== "dynamic-script")
25
19
  throw new Error("unsupported worker command");
26
- const pid = requiredPositive(input.pid, "pid");
20
+ const pid = positive(input.pid, "pid");
27
21
  const buildKey = String(input.build ?? input.buildKey ?? "").trim();
28
22
  if (!buildKey)
29
23
  throw new Error("build is required");
30
- const profileFile = typeof input.staticProfile === "string" ? input.staticProfile : undefined;
31
- const profile = profileFile ? record(JSON.parse(await readFile(profileFile, "utf8"))) : {};
32
- const moduleProfile = record(profile.module);
33
- const moduleName = typeof moduleProfile.name === "string" ? moduleProfile.name : "Wow.exe";
24
+ const source = typeof input.source === "string"
25
+ ? input.source
26
+ : typeof input.script === "string" ? await readFile(input.script, "utf8") : "";
27
+ if (!source.trim())
28
+ throw new Error("script or source is required");
34
29
  const runtime = new FridaCommandRuntime({ artifactDir: process.env.WOW_ANALYZE_DIR });
30
+ let sessionId;
31
+ let loaded = false;
35
32
  try {
36
33
  const attached = await runtime.execute({ operation: "attach", pid, buildKey, allowUnmatched: true });
37
- const sessionId = String(attached.sessionId);
34
+ sessionId = String(attached.sessionId);
38
35
  const modules = await runtime.execute({ operation: "modules", sessionId, pid, buildKey });
39
- const list = Array.isArray(modules.modules) ? modules.modules : [];
40
- const module = list.find(item => String(item.name ?? "").toLowerCase() === moduleName.toLowerCase())
41
- ?? list.find(item => String(item.name ?? "").toLowerCase() === "wow.exe");
42
- if (!module || typeof module.base !== "string")
43
- throw new Error(`${moduleName} module was not found`);
44
- const moduleBase = module.base;
45
- const moduleSize = Number(module.size ?? 0);
46
- const candidates = [
47
- ...(Array.isArray(profile.signatures) ? profile.signatures : []),
48
- ...(Array.isArray(profile.providers) ? profile.providers : []),
49
- ...(Array.isArray(profile.functions) ? profile.functions : [])
50
- ].map(record);
51
- const limit = Math.min(requiredPositive(input.maxEvents ?? 100, "max-events"), 10000);
52
- const records = [];
53
- for (const [index, candidate] of candidates.slice(0, limit).entries()) {
54
- const rvaValue = candidate.rva;
55
- if (rvaValue === undefined)
56
- continue;
57
- const rva = hex(rvaValue, `profile record ${index}.rva`);
58
- const address = add(moduleBase, rva);
59
- const size = Math.min(Math.max(Number(candidate.size ?? 16), 1), 256);
60
- const read = await runtime.execute({ operation: "read_memory", sessionId, address, size, pid, buildKey });
61
- records.push({
62
- name: typeof candidate.name === "string" ? candidate.name : `record_${index}`,
63
- rva,
64
- runtimeAddress: address,
65
- bytesHex: read.bytesHex ?? null,
66
- source: "frida",
67
- confidence: "unverified"
36
+ const module = Array.isArray(modules.modules)
37
+ ? modules.modules.find(item => String(item.name ?? "").toLowerCase() === "wow.exe")
38
+ : undefined;
39
+ await runtime.execute({
40
+ operation: "script_load",
41
+ sessionId,
42
+ pid,
43
+ buildKey,
44
+ source: `globalThis.__WOWDUMP_INPUT__ = Object.freeze(${JSON.stringify(input.args ?? {})});\n${source}`,
45
+ scriptId: "dynamic"
46
+ });
47
+ loaded = true;
48
+ let value;
49
+ let messages = [];
50
+ if (typeof input.exportName === "string" && input.exportName.trim()) {
51
+ const called = await runtime.execute({
52
+ operation: "script_call",
53
+ sessionId,
54
+ scriptId: "dynamic",
55
+ exportName: input.exportName,
56
+ args: Array.isArray(input.callArgs) ? input.callArgs : []
68
57
  });
58
+ value = called.value;
59
+ messages = Array.isArray(called.messages) ? called.messages : [];
69
60
  }
61
+ const durationMs = Math.min(120_000, positive(input.durationMs ?? 100, "duration-ms"));
62
+ if (value === undefined)
63
+ await new Promise(resolve => setTimeout(resolve, durationMs));
70
64
  return {
71
65
  ok: true,
72
- command: "analyze.runtime-export",
66
+ command: "analyze.dynamic",
73
67
  pid,
74
68
  buildKey,
75
- module: { name: module.name, moduleBase, moduleSize },
76
- moduleBase,
77
- moduleSize,
78
- records,
79
- evidence: [{ kind: "frida-runtime-read", source: "frida", moduleBase, count: records.length }]
69
+ module: module && typeof module.base === "string"
70
+ ? { name: module.name, moduleBase: module.base, moduleSize: module.size ?? null }
71
+ : null,
72
+ exportName: typeof input.exportName === "string" ? input.exportName : null,
73
+ value,
74
+ messages,
75
+ script: typeof input.script === "string" ? input.script : "<inline>",
76
+ evidence: [{ kind: "frida-gumjs", source: "frida", moduleBase: module?.base ?? null }]
80
77
  };
81
78
  }
82
79
  finally {
80
+ if (sessionId && loaded)
81
+ await runtime.execute({ operation: "script_unload", sessionId, scriptId: "dynamic" }).catch(() => undefined);
83
82
  await runtime.close();
84
83
  }
85
84
  }
@@ -85,6 +85,7 @@ export class ReaderBroker {
85
85
  backend;
86
86
  now;
87
87
  onShutdown;
88
+ runFrida;
88
89
  handles = new Map();
89
90
  watches = new Map();
90
91
  idleTimer;
@@ -95,6 +96,7 @@ export class ReaderBroker {
95
96
  this.idleTimeoutMs = Math.max(1, Math.floor(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS));
96
97
  this.now = options.now ?? Date.now;
97
98
  this.onShutdown = options.onShutdown;
99
+ this.runFrida = options.runFrida;
98
100
  this.armIdleTimer();
99
101
  }
100
102
  get isStopped() {
@@ -161,6 +163,12 @@ export class ReaderBroker {
161
163
  activeWatches: this.activeWatchCount,
162
164
  elevation: await Promise.resolve(this.backend.elevationStatus?.() ?? { state: "unknown" })
163
165
  };
166
+ case "frida":
167
+ if (!this.runFrida)
168
+ throw new ReaderProtocolError("FRIDA_NOT_CONFIGURED", "elevated Frida runner is not configured");
169
+ return this.runFrida(request);
170
+ case "modules":
171
+ return this.modules(request);
164
172
  case "open":
165
173
  return this.open(request);
166
174
  case "close":
@@ -240,6 +248,13 @@ export class ReaderBroker {
240
248
  finishedAt: this.now()
241
249
  };
242
250
  }
251
+ async modules(request) {
252
+ const pid = boundedPid(request.pid);
253
+ if (!this.backend.enumerateModules) {
254
+ throw new ReaderProtocolError("MODULE_ENUM_UNAVAILABLE", "native module enumeration is not installed");
255
+ }
256
+ return { pid, modules: await this.backend.enumerateModules(pid) };
257
+ }
243
258
  async watchStart(request) {
244
259
  const pid = boundedPid(request.pid);
245
260
  const address = hexAddress(request.address);
@@ -1 +1 @@
1
- export { ReaderClient, ReaderBrokerPool, ReaderProtocolError, createElevatedLaunchSpec } from "./reader-broker.js";
1
+ export { ReaderClient, ReaderBrokerPool, ReaderProtocolError, createElevatedLaunchSpec } from "./broker.js";
@@ -3,7 +3,7 @@ import { createConnection } from "node:net";
3
3
  import { mkdir, open, readFile, rm } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import koffi from "koffi";
6
- import { ReaderProtocolError } from "./reader-broker.js";
6
+ import { ReaderProtocolError } from "./broker.js";
7
7
  function quoteWindowsArgument(value) {
8
8
  if (value.length > 0 && !/[\s"]/u.test(value))
9
9
  return value;
@@ -94,7 +94,11 @@ function requestPipe(metadata, request, timeoutMs) {
94
94
  else
95
95
  resolveResult(value);
96
96
  };
97
- const timer = setTimeout(() => finish(new Error(`reader broker request timed out after ${timeoutMs}ms`)), timeoutMs);
97
+ const timer = setTimeout(() => {
98
+ const error = new Error(`reader broker request timed out after ${timeoutMs}ms`);
99
+ error.code = "BROKER_REQUEST_TIMEOUT";
100
+ finish(error);
101
+ }, timeoutMs);
98
102
  socket.setEncoding("utf8");
99
103
  socket.once("connect", () => socket.write(`${JSON.stringify({ token: metadata.token, request })}\n`));
100
104
  socket.on("data", chunk => {
@@ -128,10 +132,18 @@ export class WindowsBrokerManager {
128
132
  const request = invocationRequest(invocation);
129
133
  const existing = await this.readMetadata();
130
134
  if (existing) {
135
+ const requestTimeout = invocation.command === "frida"
136
+ ? Math.max(this.options.requestTimeoutMs ?? 30_000, Math.min(600_000, Number(invocation.payload.timeoutMs ?? 120_000) + 10_000))
137
+ : this.options.requestTimeoutMs ?? 30_000;
131
138
  try {
132
- return await requestPipe(existing, request, this.options.requestTimeoutMs ?? 30_000);
139
+ return await requestPipe(existing, request, requestTimeout);
133
140
  }
134
- catch {
141
+ catch (error) {
142
+ const code = error.code;
143
+ // A long-running Frida request is not evidence that the broker died.
144
+ // Keep its metadata so the next command reuses the elevated process.
145
+ if (code === "BROKER_REQUEST_TIMEOUT")
146
+ throw error;
135
147
  await rm(this.metadataFile, { force: true }).catch(() => undefined);
136
148
  }
137
149
  }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "node:net";
3
+ import { spawn } from "node:child_process";
4
+ import { mkdir, rm, writeFile } from "node:fs/promises";
5
+ import { dirname, isAbsolute, resolve } from "node:path";
6
+ import { serveReaderBroker } from "./broker.js";
7
+ import { createWindowsNativeReader } from "./windows.js";
8
+ async function runElevatedFrida(request) {
9
+ const worker = resolve(request.worker);
10
+ if (!isAbsolute(request.worker) || worker !== request.worker && process.platform === "win32") {
11
+ throw new Error("Frida worker path must be absolute");
12
+ }
13
+ const timeoutMs = Math.max(1, Math.min(Number(request.timeoutMs ?? 120_000), 600_000));
14
+ return new Promise(resolveResult => {
15
+ const child = spawn(process.execPath, [worker], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
16
+ let stdout = "";
17
+ let stderr = "";
18
+ let settled = false;
19
+ const finish = (exitCode, timedOut = false) => {
20
+ if (settled)
21
+ return;
22
+ settled = true;
23
+ clearTimeout(timer);
24
+ resolveResult({ exitCode, stdout, stderr, timedOut });
25
+ };
26
+ child.stdout.setEncoding("utf8");
27
+ child.stderr.setEncoding("utf8");
28
+ child.stdout.on("data", value => { stdout += value; });
29
+ child.stderr.on("data", value => { stderr += value; });
30
+ child.once("error", error => { stderr += error.message; finish(1); });
31
+ child.once("exit", code => finish(code ?? 1));
32
+ const timer = setTimeout(() => {
33
+ child.kill();
34
+ finish(1, true);
35
+ }, timeoutMs);
36
+ child.stdin.end(request.input);
37
+ });
38
+ }
39
+ function argument(name) {
40
+ const index = process.argv.indexOf(name);
41
+ return index >= 0 ? process.argv[index + 1] : undefined;
42
+ }
43
+ const listen = argument("--listen");
44
+ const port = Number(argument("--port") ?? "0");
45
+ const token = argument("--token");
46
+ const metadata = argument("--metadata");
47
+ let server;
48
+ const broker = serveReaderBroker({
49
+ attachStdio: !listen,
50
+ backend: process.env.WOWDUMP_USE_FAKE_READER === "1" ? undefined : createWindowsNativeReader(),
51
+ runFrida: runElevatedFrida,
52
+ onShutdown: async () => {
53
+ server?.close();
54
+ if (metadata)
55
+ await rm(metadata, { force: true }).catch(() => undefined);
56
+ }
57
+ });
58
+ if (listen) {
59
+ if (listen !== "127.0.0.1" || !token || !metadata || !Number.isSafeInteger(port) || port < 0 || port > 65535) {
60
+ throw new Error("--listen requires 127.0.0.1, a valid --port, --token and --metadata");
61
+ }
62
+ server = createServer(socket => {
63
+ socket.setEncoding("utf8");
64
+ let buffer = "";
65
+ let pending = Promise.resolve();
66
+ socket.on("data", chunk => {
67
+ buffer += chunk;
68
+ while (buffer.includes("\n")) {
69
+ const newline = buffer.indexOf("\n");
70
+ const line = buffer.slice(0, newline);
71
+ buffer = buffer.slice(newline + 1);
72
+ pending = pending.then(async () => {
73
+ try {
74
+ const envelope = JSON.parse(line);
75
+ if (envelope.token !== token || !envelope.request) {
76
+ socket.write(`${JSON.stringify({ ok: false, command: "auth", timestamp: Date.now(), error: { code: "AUTH_FAILED", message: "invalid reader broker token" } })}\n`);
77
+ return;
78
+ }
79
+ socket.write(`${JSON.stringify(await broker.handle(envelope.request))}\n`);
80
+ }
81
+ catch (error) {
82
+ socket.write(`${JSON.stringify({ ok: false, command: "parse", timestamp: Date.now(), error: { code: "INVALID_JSON", message: error instanceof Error ? error.message : String(error) } })}\n`);
83
+ }
84
+ });
85
+ }
86
+ });
87
+ });
88
+ server.listen(port, listen, async () => {
89
+ await mkdir(dirname(metadata), { recursive: true });
90
+ const address = server?.address();
91
+ if (!address || typeof address === "string")
92
+ throw new Error("reader broker did not acquire a TCP endpoint");
93
+ await writeFile(metadata, `${JSON.stringify({ schema: "wowdump.reader-broker.v1", host: listen, port: address.port, token, pid: process.pid })}\n`, { encoding: "utf8", mode: 0o600 });
94
+ });
95
+ }
96
+ const shutdown = () => {
97
+ void broker.shutdown("request").catch(() => process.exitCode = 1);
98
+ };
99
+ process.once("SIGINT", shutdown);
100
+ process.once("SIGTERM", shutdown);
@@ -0,0 +1 @@
1
+ export {};