wowdump 0.2.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 (63) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +17 -111
  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} +3 -25
  6. package/dist/analysis/runtime-script.js +36 -0
  7. package/dist/cli.js +563 -0
  8. package/dist/core/profile-engine.js +238 -0
  9. package/dist/frida-worker.js +99 -0
  10. package/dist/reader/broker.js +475 -0
  11. package/dist/reader/client.js +1 -0
  12. package/dist/reader/launcher.js +219 -0
  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 +2 -0
  17. package/dist/toolchain.js +123 -0
  18. package/package.json +19 -37
  19. package/skills/wowdump/SKILL.md +22 -0
  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 -1335
  29. package/dist/analysis-path.js +0 -38
  30. package/dist/analysis-process-log.js +0 -146
  31. package/dist/broker-client.js +0 -411
  32. package/dist/broker-codec.js +0 -148
  33. package/dist/broker-core.js +0 -1045
  34. package/dist/broker-gateway.js +0 -447
  35. package/dist/broker-ledger.js +0 -196
  36. package/dist/broker-main.js +0 -291
  37. package/dist/broker-protocol.js +0 -119
  38. package/dist/broker-runtime.js +0 -1283
  39. package/dist/broker-server.js +0 -466
  40. package/dist/build-bundle-loader.js +0 -183
  41. package/dist/build-bundle.js +0 -11
  42. package/dist/discovery.js +0 -59
  43. package/dist/dry-run.js +0 -38
  44. package/dist/error-log.js +0 -71
  45. package/dist/focus-errors.js +0 -63
  46. package/dist/focus-service.js +0 -1855
  47. package/dist/focused-session.js +0 -1357
  48. package/dist/mcp-main.js +0 -51
  49. package/dist/mcp.js +0 -924
  50. package/dist/observability.js +0 -41
  51. package/dist/process-log-lock.js +0 -195
  52. package/dist/processes.js +0 -47
  53. package/dist/runtime-config.js +0 -399
  54. package/dist/session.js +0 -145
  55. package/dist/storage.js +0 -12
  56. package/dist/wow-analysis.js +0 -1430
  57. package/resources/builds/retail/12.0.7.68974/build-profile.json +0 -290
  58. package/resources/builds/retail/12.0.7.68974/data-sources.json +0 -1633
  59. package/resources/builds/retail/12.0.7.68974/lua-targets.jsonl +0 -5130
  60. package/resources/builds/retail/12.0.7.68974/manifest.json +0 -63
  61. package/resources/builds/retail/12.0.7.68974/signatures.json +0 -260
  62. /package/dist/{adapters.js → core/build-adapters.js} +0 -0
  63. /package/dist/{types.js → core/types.js} +0 -0
@@ -0,0 +1,475 @@
1
+ import { createInterface } from "node:readline";
2
+ import { stdin, stdout } from "node:process";
3
+ export const DEFAULT_IDLE_TIMEOUT_MS = 20 * 60 * 1000;
4
+ export const DEFAULT_RIGHTS = Object.freeze([
5
+ "PROCESS_QUERY_INFORMATION",
6
+ "PROCESS_VM_READ"
7
+ ]);
8
+ function hexAddress(value) {
9
+ const normalized = value.trim();
10
+ if (!/^0x[0-9a-f]+$/i.test(normalized)) {
11
+ throw new ReaderProtocolError("INVALID_ADDRESS", "address must be a hexadecimal 0x-prefixed string");
12
+ }
13
+ const address = BigInt(normalized);
14
+ if (address < 0n || address > 0xffffffffffffffffn) {
15
+ throw new ReaderProtocolError("INVALID_ADDRESS", "address is outside the 64-bit range");
16
+ }
17
+ return address;
18
+ }
19
+ function boundedSize(value, name, maximum = 16 * 1024 * 1024) {
20
+ const size = Number(value);
21
+ if (!Number.isSafeInteger(size) || size <= 0 || size > maximum) {
22
+ throw new ReaderProtocolError("INVALID_SIZE", `${name} must be an integer between 1 and ${maximum}`);
23
+ }
24
+ return size;
25
+ }
26
+ function boundedPid(value) {
27
+ const pid = Number(value);
28
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fffffff) {
29
+ throw new ReaderProtocolError("INVALID_PID", "pid must be a positive Windows process id");
30
+ }
31
+ return pid;
32
+ }
33
+ function toHex(bytes) {
34
+ return Buffer.from(bytes).toString("hex");
35
+ }
36
+ function bytesFromResult(result) {
37
+ if (!result || !(result.bytes instanceof Uint8Array)) {
38
+ throw new ReaderProtocolError("NATIVE_RESULT", "native reader returned no byte buffer");
39
+ }
40
+ const bytesRead = result.bytesRead === undefined ? result.bytes.byteLength : Number(result.bytesRead);
41
+ if (!Number.isSafeInteger(bytesRead) || bytesRead < 0 || bytesRead > result.bytes.byteLength) {
42
+ throw new ReaderProtocolError("NATIVE_RESULT", "native reader returned an invalid bytesRead value");
43
+ }
44
+ return { bytes: result.bytes.subarray(0, bytesRead), bytesRead };
45
+ }
46
+ export class ReaderProtocolError extends Error {
47
+ code;
48
+ details;
49
+ constructor(code, message, details) {
50
+ super(message);
51
+ this.name = "ReaderProtocolError";
52
+ this.code = code;
53
+ this.details = details;
54
+ }
55
+ }
56
+ /**
57
+ * Placeholder backend used until the platform adapter is installed. Keeping
58
+ * this explicit makes an unavailable native layer observable to the caller.
59
+ */
60
+ export class UnavailableNativeReader {
61
+ diagnostic() {
62
+ throw new ReaderProtocolError("NATIVE_BACKEND_UNAVAILABLE", "Windows native reader backend is not installed", {
63
+ platform: process.platform,
64
+ requestedRights: [...DEFAULT_RIGHTS],
65
+ elevation: this.elevationStatus()
66
+ });
67
+ }
68
+ openProcess(_pid, _rights) {
69
+ return Promise.reject(this.diagnostic());
70
+ }
71
+ readProcessMemory(_handle, _address, _size) {
72
+ return Promise.reject(this.diagnostic());
73
+ }
74
+ elevationStatus() {
75
+ return {
76
+ isWindows: process.platform === "win32",
77
+ elevated: false,
78
+ state: "unknown",
79
+ reason: "native elevation probe is not installed"
80
+ };
81
+ }
82
+ }
83
+ export class ReaderBroker {
84
+ idleTimeoutMs;
85
+ backend;
86
+ now;
87
+ onShutdown;
88
+ runFrida;
89
+ handles = new Map();
90
+ watches = new Map();
91
+ idleTimer;
92
+ watchCounter = 0;
93
+ stopped = false;
94
+ constructor(options = {}) {
95
+ this.backend = options.backend ?? new UnavailableNativeReader();
96
+ this.idleTimeoutMs = Math.max(1, Math.floor(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS));
97
+ this.now = options.now ?? Date.now;
98
+ this.onShutdown = options.onShutdown;
99
+ this.runFrida = options.runFrida;
100
+ this.armIdleTimer();
101
+ }
102
+ get isStopped() {
103
+ return this.stopped;
104
+ }
105
+ get activeHandleCount() {
106
+ return this.handles.size;
107
+ }
108
+ get activeWatchCount() {
109
+ return [...this.watches.values()].filter(watch => !watch.stopped).length;
110
+ }
111
+ /** Handle one already-decoded request. Suitable for a CLI client or tests. */
112
+ async handle(request) {
113
+ const command = typeof request?.command === "string" ? request.command : "unknown";
114
+ const base = { id: request?.id, ok: true, command, timestamp: this.now() };
115
+ if (this.stopped) {
116
+ return { ...base, ok: false, error: { code: "BROKER_STOPPED", message: "reader broker is stopped" } };
117
+ }
118
+ try {
119
+ const result = await this.dispatch(request);
120
+ this.armIdleTimer();
121
+ return { ...base, ...result, ok: true };
122
+ }
123
+ catch (error) {
124
+ this.armIdleTimer();
125
+ return { ...base, ok: false, error: serializeError(error, this.backend) };
126
+ }
127
+ }
128
+ async shutdown(reason = "request") {
129
+ if (this.stopped)
130
+ return;
131
+ this.stopped = true;
132
+ if (this.idleTimer)
133
+ clearTimeout(this.idleTimer);
134
+ for (const watch of this.watches.values())
135
+ watch.stopped = true;
136
+ this.watches.clear();
137
+ const handles = [...this.handles.values()];
138
+ this.handles.clear();
139
+ for (const handle of handles) {
140
+ await Promise.resolve(handle.close()).catch(() => undefined);
141
+ }
142
+ await Promise.resolve(this.backend.close?.()).catch(() => undefined);
143
+ await Promise.resolve(this.onShutdown?.(reason)).catch(() => undefined);
144
+ }
145
+ armIdleTimer() {
146
+ if (this.stopped)
147
+ return;
148
+ if (this.idleTimer)
149
+ clearTimeout(this.idleTimer);
150
+ this.idleTimer = setTimeout(() => {
151
+ void this.shutdown("idle");
152
+ }, this.idleTimeoutMs);
153
+ // A broker should not keep an otherwise idle CLI event loop alive in tests.
154
+ this.idleTimer.unref?.();
155
+ }
156
+ async dispatch(request) {
157
+ switch (request.command) {
158
+ case "status":
159
+ return {
160
+ pid: process.pid,
161
+ idleTimeoutMs: this.idleTimeoutMs,
162
+ activeHandles: this.handles.size,
163
+ activeWatches: this.activeWatchCount,
164
+ elevation: await Promise.resolve(this.backend.elevationStatus?.() ?? { state: "unknown" })
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);
172
+ case "open":
173
+ return this.open(request);
174
+ case "close":
175
+ return this.close(request);
176
+ case "read":
177
+ return this.read(request);
178
+ case "watch.start":
179
+ return this.watchStart(request);
180
+ case "watch.poll":
181
+ return this.watchPoll(request);
182
+ case "watch.stop":
183
+ return this.watchStop(request);
184
+ case "shutdown":
185
+ await this.shutdown("request");
186
+ return { stopped: true };
187
+ default:
188
+ throw new ReaderProtocolError("UNKNOWN_COMMAND", `unsupported reader command: ${String(request?.command)}`);
189
+ }
190
+ }
191
+ async getHandle(pid, requested) {
192
+ const existing = this.handles.get(pid);
193
+ if (existing && (!requested || requested === existing.id))
194
+ return existing;
195
+ if (requested && existing?.id !== requested) {
196
+ throw new ReaderProtocolError("HANDLE_MISMATCH", `handle ${requested} is not open for pid ${pid}`);
197
+ }
198
+ const handle = await this.backend.openProcess(pid, DEFAULT_RIGHTS);
199
+ this.handles.set(pid, handle);
200
+ return handle;
201
+ }
202
+ async open(request) {
203
+ const pid = boundedPid(request.pid);
204
+ const rights = request.rights?.length ? [...request.rights] : [...DEFAULT_RIGHTS];
205
+ const handle = await this.backend.openProcess(pid, rights);
206
+ const previous = this.handles.get(pid);
207
+ if (previous && previous !== handle)
208
+ await Promise.resolve(previous.close()).catch(() => undefined);
209
+ this.handles.set(pid, handle);
210
+ return { pid, handle: handle.id, rights };
211
+ }
212
+ async close(request) {
213
+ const pid = request.pid === undefined ? undefined : boundedPid(request.pid);
214
+ const handle = pid === undefined
215
+ ? [...this.handles.values()].find(candidate => candidate.id === request.handle)
216
+ : this.handles.get(pid);
217
+ if (!handle)
218
+ return { closed: false, pid, handle: request.handle };
219
+ await Promise.resolve(handle.close());
220
+ if (pid !== undefined)
221
+ this.handles.delete(pid);
222
+ else
223
+ for (const [key, candidate] of this.handles)
224
+ if (candidate === handle)
225
+ this.handles.delete(key);
226
+ for (const watch of this.watches.values()) {
227
+ if (watch.handle === handle)
228
+ watch.stopped = true;
229
+ }
230
+ return { closed: true, pid, handle: handle.id };
231
+ }
232
+ async read(request) {
233
+ const pid = boundedPid(request.pid);
234
+ const address = hexAddress(request.address);
235
+ const size = boundedSize(request.size, "size");
236
+ const handle = await this.getHandle(pid, request.handle);
237
+ const startedAt = this.now();
238
+ const native = bytesFromResult(await this.backend.readProcessMemory(handle, address, size));
239
+ return {
240
+ pid,
241
+ handle: handle.id,
242
+ address: `0x${address.toString(16)}`,
243
+ requestedSize: size,
244
+ bytesRead: native.bytesRead,
245
+ dataHex: toHex(native.bytes),
246
+ complete: native.bytesRead === size,
247
+ startedAt,
248
+ finishedAt: this.now()
249
+ };
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
+ }
258
+ async watchStart(request) {
259
+ const pid = boundedPid(request.pid);
260
+ const address = hexAddress(request.address);
261
+ const size = boundedSize(request.size, "size", 1024 * 1024);
262
+ const intervalMs = Math.min(60 * 60 * 1000, Math.max(10, Math.floor(Number(request.intervalMs ?? 1000))));
263
+ const maxSamples = Math.min(100000, Math.max(1, Math.floor(Number(request.maxSamples ?? 0) || 100000)));
264
+ const handle = await this.getHandle(pid, request.handle);
265
+ const id = `watch-${++this.watchCounter}`;
266
+ this.watches.set(id, {
267
+ id,
268
+ pid,
269
+ address,
270
+ size,
271
+ intervalMs,
272
+ changeOnly: request.changeOnly === true,
273
+ maxSamples,
274
+ handle,
275
+ sequence: 0,
276
+ nextDue: this.now(),
277
+ stopped: false
278
+ });
279
+ return { watchId: id, pid, handle: handle.id, address: `0x${address.toString(16)}`, size, intervalMs, changeOnly: request.changeOnly === true, maxSamples };
280
+ }
281
+ async watchPoll(request) {
282
+ if (!request.watchId || typeof request.watchId !== "string")
283
+ throw new ReaderProtocolError("INVALID_WATCH", "watchId is required");
284
+ const watch = this.watches.get(request.watchId);
285
+ if (!watch || watch.stopped)
286
+ return { watchId: request.watchId, stopped: true, events: [] };
287
+ const now = this.now();
288
+ if (now < watch.nextDue)
289
+ return { watchId: watch.id, stopped: false, events: [], nextDue: watch.nextDue };
290
+ watch.nextDue = now + watch.intervalMs;
291
+ if (watch.sequence >= watch.maxSamples) {
292
+ watch.stopped = true;
293
+ return { watchId: watch.id, stopped: true, reason: "max_samples", events: [] };
294
+ }
295
+ if (this.backend.isProcessAlive && !(await this.backend.isProcessAlive(watch.pid))) {
296
+ watch.stopped = true;
297
+ return { watchId: watch.id, stopped: true, reason: "target_exited", events: [{ type: "target_exited", pid: watch.pid, sequence: watch.sequence + 1, timestamp: now }] };
298
+ }
299
+ try {
300
+ const native = bytesFromResult(await this.backend.readProcessMemory(watch.handle, watch.address, watch.size));
301
+ const changed = !watch.lastBytes || !equalBytes(watch.lastBytes, native.bytes);
302
+ watch.sequence += 1;
303
+ const event = {
304
+ type: changed ? "snapshot" : "unchanged",
305
+ watchId: watch.id,
306
+ pid: watch.pid,
307
+ sequence: watch.sequence,
308
+ timestamp: this.now(),
309
+ address: `0x${watch.address.toString(16)}`,
310
+ requestedSize: watch.size,
311
+ bytesRead: native.bytesRead,
312
+ complete: native.bytesRead === watch.size,
313
+ dataHex: toHex(native.bytes),
314
+ changed
315
+ };
316
+ const previous = watch.lastBytes;
317
+ watch.lastBytes = new Uint8Array(native.bytes);
318
+ if (watch.changeOnly && !changed)
319
+ return { watchId: watch.id, stopped: false, events: [], sequence: watch.sequence };
320
+ return { watchId: watch.id, stopped: false, events: [event], previousHex: previous ? toHex(previous) : undefined, sequence: watch.sequence };
321
+ }
322
+ catch (error) {
323
+ watch.stopped = true;
324
+ return { watchId: watch.id, stopped: true, reason: "reader_error", events: [{ type: "reader_error", watchId: watch.id, pid: watch.pid, sequence: watch.sequence + 1, timestamp: this.now(), error: serializeError(error, this.backend) }] };
325
+ }
326
+ }
327
+ async watchStop(request) {
328
+ if (!request.watchId || typeof request.watchId !== "string")
329
+ throw new ReaderProtocolError("INVALID_WATCH", "watchId is required");
330
+ const watch = this.watches.get(request.watchId);
331
+ if (!watch)
332
+ return { watchId: request.watchId, stopped: false };
333
+ watch.stopped = true;
334
+ this.watches.delete(request.watchId);
335
+ return { watchId: request.watchId, stopped: true, sequence: watch.sequence };
336
+ }
337
+ }
338
+ function equalBytes(left, right) {
339
+ if (left.byteLength !== right.byteLength)
340
+ return false;
341
+ for (let index = 0; index < left.byteLength; index += 1)
342
+ if (left[index] !== right[index])
343
+ return false;
344
+ return true;
345
+ }
346
+ function serializeError(error, backend) {
347
+ if (error instanceof ReaderProtocolError) {
348
+ return { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) };
349
+ }
350
+ const message = error instanceof Error ? error.message : String(error);
351
+ return {
352
+ code: "READER_ERROR",
353
+ message,
354
+ elevation: backend.elevationStatus ? backend.elevationStatus() : { state: "unknown" }
355
+ };
356
+ }
357
+ /** Start a JSONL broker process over stdio. */
358
+ export function serveReaderBroker(options = {}) {
359
+ const broker = new ReaderBroker({ ...options, attachStdio: false });
360
+ if (options.attachStdio !== false) {
361
+ const input = options.input ?? stdin;
362
+ const output = options.output ?? stdout;
363
+ const rl = createInterface({ input, crlfDelay: Infinity });
364
+ // Preserve JSONL request order so open/read/close sequences cannot race.
365
+ let pending = Promise.resolve();
366
+ rl.on("line", line => {
367
+ pending = pending.then(async () => {
368
+ if (!line.trim())
369
+ return;
370
+ let request;
371
+ try {
372
+ request = JSON.parse(line);
373
+ }
374
+ catch (error) {
375
+ const response = {
376
+ ok: false,
377
+ command: "parse",
378
+ timestamp: Date.now(),
379
+ error: { code: "INVALID_JSON", message: error instanceof Error ? error.message : String(error) }
380
+ };
381
+ output.write(`${JSON.stringify(response)}\n`);
382
+ return;
383
+ }
384
+ const response = await broker.handle(request);
385
+ output.write(`${JSON.stringify(response)}\n`);
386
+ if (request.command === "shutdown")
387
+ rl.close();
388
+ });
389
+ });
390
+ const close = () => { void broker.shutdown("request"); };
391
+ input.once("close", close);
392
+ input.once("error", close);
393
+ }
394
+ return broker;
395
+ }
396
+ /** Small typed client used by the CLI; transport owns process/UAC reuse. */
397
+ export class ReaderClient {
398
+ transport;
399
+ requestTimeoutMs;
400
+ counter = 0;
401
+ constructor(options) {
402
+ this.transport = options.transport;
403
+ this.requestTimeoutMs = Math.max(1, Math.floor(options.requestTimeoutMs ?? 30_000));
404
+ }
405
+ async request(request) {
406
+ const id = request.id ?? `req-${++this.counter}`;
407
+ const line = await withTimeout(this.transport.request(JSON.stringify({ ...request, id })), this.requestTimeoutMs);
408
+ const response = JSON.parse(line);
409
+ if (!response || typeof response !== "object" || typeof response.ok !== "boolean") {
410
+ throw new ReaderProtocolError("INVALID_RESPONSE", "reader broker returned an invalid JSONL response");
411
+ }
412
+ return response;
413
+ }
414
+ close() {
415
+ return Promise.resolve(this.transport.close?.());
416
+ }
417
+ }
418
+ function withTimeout(promise, timeoutMs) {
419
+ return new Promise((resolve, reject) => {
420
+ const timer = setTimeout(() => reject(new ReaderProtocolError("REQUEST_TIMEOUT", `reader request timed out after ${timeoutMs}ms`)), timeoutMs);
421
+ timer.unref?.();
422
+ promise.then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
423
+ });
424
+ }
425
+ export function createElevatedLaunchSpec(file, args = []) {
426
+ if (!file.trim())
427
+ throw new TypeError("elevated broker file must be non-empty");
428
+ return Object.freeze({ file, args: [...args], verb: "runas", windowsHide: false });
429
+ }
430
+ /** Reuse one broker client and invoke the launcher only after the first miss. */
431
+ export class ReaderBrokerPool {
432
+ client;
433
+ launching;
434
+ launcher;
435
+ launchSpec;
436
+ requestTimeoutMs;
437
+ constructor(launcher, launchSpec, requestTimeoutMs) {
438
+ this.launcher = launcher;
439
+ this.launchSpec = launchSpec;
440
+ this.requestTimeoutMs = requestTimeoutMs;
441
+ }
442
+ async request(request) {
443
+ const client = await this.getClient();
444
+ try {
445
+ return await client.request(request);
446
+ }
447
+ catch (error) {
448
+ // A dead broker is discarded; the next request gets a fresh UAC launch.
449
+ this.client = undefined;
450
+ throw error;
451
+ }
452
+ }
453
+ async close() {
454
+ const client = this.client;
455
+ this.client = undefined;
456
+ await client?.close();
457
+ }
458
+ async getClient() {
459
+ if (this.client)
460
+ return this.client;
461
+ if (!this.launching) {
462
+ this.launching = this.launcher.launch(this.launchSpec).then(result => {
463
+ this.client = new ReaderClient({ transport: result.transport, requestTimeoutMs: this.requestTimeoutMs });
464
+ return this.client;
465
+ }).catch(error => {
466
+ throw new ReaderProtocolError("ELEVATION_FAILED", error instanceof Error ? error.message : String(error), {
467
+ verb: this.launchSpec.verb,
468
+ requestedRights: [...DEFAULT_RIGHTS],
469
+ elevation: "launch rejected or cancelled"
470
+ });
471
+ }).finally(() => { this.launching = undefined; });
472
+ }
473
+ return this.launching;
474
+ }
475
+ }
@@ -0,0 +1 @@
1
+ export { ReaderClient, ReaderBrokerPool, ReaderProtocolError, createElevatedLaunchSpec } from "./broker.js";
@@ -0,0 +1,219 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createConnection } from "node:net";
3
+ import { mkdir, open, readFile, rm } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import koffi from "koffi";
6
+ import { ReaderProtocolError } from "./broker.js";
7
+ function quoteWindowsArgument(value) {
8
+ if (value.length > 0 && !/[\s"]/u.test(value))
9
+ return value;
10
+ let result = '"';
11
+ let slashes = 0;
12
+ for (const character of value) {
13
+ if (character === "\\") {
14
+ slashes += 1;
15
+ continue;
16
+ }
17
+ if (character === '"') {
18
+ result += "\\".repeat(slashes * 2 + 1) + '"';
19
+ slashes = 0;
20
+ continue;
21
+ }
22
+ result += "\\".repeat(slashes) + character;
23
+ slashes = 0;
24
+ }
25
+ return result + "\\".repeat(slashes * 2) + '"';
26
+ }
27
+ export function buildWindowsCommandLine(args) {
28
+ return args.map(quoteWindowsArgument).join(" ");
29
+ }
30
+ export function shellExecuteRunAs(file, args, cwd) {
31
+ if (process.platform !== "win32")
32
+ throw new Error("ShellExecuteEx runas is only available on Windows");
33
+ const shell32 = koffi.load("shell32.dll");
34
+ const kernel32 = koffi.load("kernel32.dll");
35
+ const SHELLEXECUTEINFOW = koffi.struct("WOWDUMP_SHELLEXECUTEINFOW", {
36
+ cbSize: "uint32_t",
37
+ fMask: "uint32_t",
38
+ hwnd: "void *",
39
+ lpVerb: "str16",
40
+ lpFile: "str16",
41
+ lpParameters: "str16",
42
+ lpDirectory: "str16",
43
+ nShow: "int32_t",
44
+ hInstApp: "void *",
45
+ lpIDList: "void *",
46
+ lpClass: "str16",
47
+ hkeyClass: "void *",
48
+ dwHotKey: "uint32_t",
49
+ hIcon: "void *",
50
+ hProcess: "void *"
51
+ });
52
+ const ShellExecuteExW = shell32.func("bool __stdcall ShellExecuteExW(_Inout_ WOWDUMP_SHELLEXECUTEINFOW *info)");
53
+ const GetLastError = kernel32.func("uint32_t __stdcall GetLastError()");
54
+ const info = {
55
+ cbSize: koffi.sizeof(SHELLEXECUTEINFOW),
56
+ fMask: 0,
57
+ hwnd: null,
58
+ lpVerb: "runas",
59
+ lpFile: resolve(file),
60
+ lpParameters: buildWindowsCommandLine(args),
61
+ lpDirectory: resolve(cwd),
62
+ nShow: 0,
63
+ hInstApp: null,
64
+ lpIDList: null,
65
+ lpClass: null,
66
+ hkeyClass: null,
67
+ dwHotKey: 0,
68
+ hIcon: null,
69
+ hProcess: null
70
+ };
71
+ if (!ShellExecuteExW(info)) {
72
+ const win32 = Number(GetLastError());
73
+ const error = new Error(`ShellExecuteExW(runas) failed with Win32 error ${win32}`);
74
+ error.win32 = win32;
75
+ throw error;
76
+ }
77
+ }
78
+ function invocationRequest(invocation) {
79
+ return { command: invocation.command, ...invocation.payload };
80
+ }
81
+ function requestPipe(metadata, request, timeoutMs) {
82
+ return new Promise((resolveResult, reject) => {
83
+ const socket = createConnection({ host: metadata.host, port: metadata.port });
84
+ let buffer = "";
85
+ let settled = false;
86
+ const finish = (error, value) => {
87
+ if (settled)
88
+ return;
89
+ settled = true;
90
+ clearTimeout(timer);
91
+ socket.destroy();
92
+ if (error)
93
+ reject(error);
94
+ else
95
+ resolveResult(value);
96
+ };
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);
102
+ socket.setEncoding("utf8");
103
+ socket.once("connect", () => socket.write(`${JSON.stringify({ token: metadata.token, request })}\n`));
104
+ socket.on("data", chunk => {
105
+ buffer += chunk;
106
+ const newline = buffer.indexOf("\n");
107
+ if (newline < 0)
108
+ return;
109
+ try {
110
+ finish(undefined, JSON.parse(buffer.slice(0, newline)));
111
+ }
112
+ catch (error) {
113
+ finish(error instanceof Error ? error : new Error(String(error)));
114
+ }
115
+ });
116
+ socket.once("error", error => finish(error));
117
+ socket.once("end", () => { if (!settled)
118
+ finish(new Error("reader broker closed without a response")); });
119
+ });
120
+ }
121
+ async function pause(milliseconds) {
122
+ await new Promise(resolvePause => setTimeout(resolvePause, milliseconds));
123
+ }
124
+ export class WindowsBrokerManager {
125
+ metadataFile;
126
+ options;
127
+ constructor(options) {
128
+ this.options = { ...options, home: resolve(options.home), readerEntry: resolve(options.readerEntry) };
129
+ this.metadataFile = join(this.options.home, "runtime", "reader-broker.json");
130
+ }
131
+ async request(invocation) {
132
+ const request = invocationRequest(invocation);
133
+ const existing = await this.readMetadata();
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;
138
+ try {
139
+ return await requestPipe(existing, request, requestTimeout);
140
+ }
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;
147
+ await rm(this.metadataFile, { force: true }).catch(() => undefined);
148
+ }
149
+ }
150
+ const metadata = await this.launchBroker();
151
+ return requestPipe(metadata, request, this.options.requestTimeoutMs ?? 30_000);
152
+ }
153
+ async readMetadata() {
154
+ try {
155
+ const value = JSON.parse(await readFile(this.metadataFile, "utf8"));
156
+ return value?.schema === "wowdump.reader-broker.v1"
157
+ && value.host === "127.0.0.1"
158
+ && Number.isSafeInteger(value.port)
159
+ && value.port > 0
160
+ && value.port <= 65535
161
+ && typeof value.token === "string"
162
+ ? value
163
+ : undefined;
164
+ }
165
+ catch {
166
+ return undefined;
167
+ }
168
+ }
169
+ async launchBroker() {
170
+ await mkdir(dirname(this.metadataFile), { recursive: true });
171
+ const lockFile = `${this.metadataFile}.lock`;
172
+ let lock;
173
+ try {
174
+ lock = await open(lockFile, "wx", 0o600);
175
+ }
176
+ catch (error) {
177
+ if (error.code !== "EEXIST")
178
+ throw error;
179
+ const deadline = Date.now() + (this.options.launchTimeoutMs ?? 30_000);
180
+ while (Date.now() < deadline) {
181
+ const metadata = await this.readMetadata();
182
+ if (metadata)
183
+ return metadata;
184
+ await pause(100);
185
+ }
186
+ await rm(lockFile, { force: true }).catch(() => undefined);
187
+ return this.launchBroker();
188
+ }
189
+ const token = randomBytes(32).toString("hex");
190
+ const args = [this.options.readerEntry, "--listen", "127.0.0.1", "--port", "0", "--token", token, "--metadata", this.metadataFile];
191
+ const launch = this.options.launch ?? shellExecuteRunAs;
192
+ try {
193
+ try {
194
+ launch(this.options.nodeExecutable ?? process.execPath, args, dirname(this.options.readerEntry));
195
+ }
196
+ catch (error) {
197
+ const native = error;
198
+ throw new ReaderProtocolError("ELEVATION_FAILED", native.message, {
199
+ verb: "runas",
200
+ win32: native.win32 ?? null,
201
+ requestedRights: ["PROCESS_QUERY_INFORMATION", "PROCESS_VM_READ"],
202
+ readerEntry: this.options.readerEntry
203
+ });
204
+ }
205
+ const deadline = Date.now() + (this.options.launchTimeoutMs ?? 30_000);
206
+ while (Date.now() < deadline) {
207
+ const metadata = await this.readMetadata();
208
+ if (metadata?.token === token)
209
+ return metadata;
210
+ await pause(100);
211
+ }
212
+ throw new Error(`elevated reader broker did not become ready within ${this.options.launchTimeoutMs ?? 30_000}ms`);
213
+ }
214
+ finally {
215
+ await lock.close().catch(() => undefined);
216
+ await rm(lockFile, { force: true }).catch(() => undefined);
217
+ }
218
+ }
219
+ }