wowdump 0.3.2 → 0.3.4

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.
package/dist/cli.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { existsSync, realpathSync, readFileSync } from "node:fs";
4
- import { readFile, readdir } from "node:fs/promises";
5
+ import { mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
5
6
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import { Command } from "commander";
8
9
  import { WindowsBrokerManager } from "./reader/launcher.js";
9
- import { initializeWowdumpHome, bootstrapToolchain, resolveToolchain, resolveWowdumpHome } from "./toolchain.js";
10
- import { runDisassembly } from "./analysis/disassemble.js";
10
+ import { initializeWowdumpHome, bootstrapToolchain, resolveToolchain } from "./toolchain.js";
11
+ import { buildPaths, databaseStatus, ensureBuild, findReusableDump, readBuild, resolveWowdumpHome, safeBuildKey, sha256File } from "./core/build-store.js";
11
12
  import { RUNTIME_EXPORT_SCRIPT } from "./analysis/runtime-script.js";
12
13
  import { ProfileEngine } from "./core/profile-engine.js";
13
14
  import { ReaderProfileAdapter } from "./adapters/reader.js";
@@ -85,6 +86,64 @@ async function discoverWowTargets(selectedPid, elevatedModules) {
85
86
  }
86
87
  return targets;
87
88
  }
89
+ async function preferredImageBase(file) {
90
+ let handle;
91
+ try {
92
+ handle = await open(file, "r");
93
+ const header = Buffer.alloc(64 * 1024);
94
+ await handle.read(header, 0, header.length, 0);
95
+ if (header.length < 0x40 || header.readUInt16LE(0) !== 0x5a4d)
96
+ return null;
97
+ const peOffset = header.readUInt32LE(0x3c);
98
+ if (peOffset + 0x58 > header.length || header.readUInt32LE(peOffset) !== 0x00004550)
99
+ return null;
100
+ const optional = peOffset + 24;
101
+ const magic = header.readUInt16LE(optional);
102
+ if (magic === 0x20b)
103
+ return `0x${header.readBigUInt64LE(optional + 24).toString(16)}`;
104
+ if (magic === 0x10b)
105
+ return `0x${BigInt(header.readUInt32LE(optional + 28)).toString(16)}`;
106
+ return null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ finally {
112
+ if (handle)
113
+ await handle.close().catch(() => undefined);
114
+ }
115
+ }
116
+ async function persistDiscoveredBuilds(home, targets) {
117
+ for (const target of targets) {
118
+ const path = typeof target.path === "string" ? target.path : null;
119
+ const buildKey = typeof target.buildKey === "string" ? target.buildKey : null;
120
+ if (!path || !buildKey)
121
+ continue;
122
+ try {
123
+ const executableSha256 = await sha256File(path);
124
+ const imageBase = await preferredImageBase(path);
125
+ const result = await ensureBuild(home, {
126
+ buildKey: safeBuildKey(buildKey),
127
+ executablePath: path,
128
+ executableSha256,
129
+ preferredImageBase: imageBase,
130
+ moduleSize: Number.isSafeInteger(Number(target.moduleSize)) ? Number(target.moduleSize) : null
131
+ });
132
+ target.executableSha256 = executableSha256;
133
+ target.preferredImageBase = imageBase;
134
+ target.database = {
135
+ directory: buildPaths(home, buildKey).database,
136
+ path: buildPaths(home, buildKey).databaseFile,
137
+ status: result.database.status,
138
+ reused: result.reused
139
+ };
140
+ }
141
+ catch (error) {
142
+ target.diagnostics = { ...(target.diagnostics && typeof target.diagnostics === "object" ? target.diagnostics : {}), buildStore: error instanceof Error ? error.message : String(error) };
143
+ }
144
+ }
145
+ return targets;
146
+ }
88
147
  function packageVersion() {
89
148
  const override = process.env.WOWDUMP_VERSION?.trim();
90
149
  if (override)
@@ -248,7 +307,7 @@ function selected(source, keys) {
248
307
  function requireRuntimeConfirmation(options) {
249
308
  if (options.confirm === true)
250
309
  return;
251
- throw new CliError("CONFIRMATION_REQUIRED", "runtime export requires --confirm", {
310
+ throw new CliError("CONFIRMATION_REQUIRED", "runtime analysis requires --confirm", {
252
311
  pid: options.pid,
253
312
  buildKey: options.build,
254
313
  operation: "runtime",
@@ -259,6 +318,37 @@ function requireRuntimeConfirmation(options) {
259
318
  cleanupDeadlineMs: options.cleanupDeadlineMs
260
319
  });
261
320
  }
321
+ function profileDirectoryFor(home, buildKey) {
322
+ if (!buildKey)
323
+ throw new CliError("BUILD_REQUIRED", "a build key is required to resolve the profile directory");
324
+ return buildPaths(home, safeBuildDirectory(buildKey)).profile;
325
+ }
326
+ async function validateProfileIdentity(home, buildKey, profile, file) {
327
+ if (!buildKey)
328
+ return;
329
+ if (typeof profile.buildKey === "string" && profile.buildKey !== buildKey) {
330
+ throw new CliError("BUILD_MISMATCH", `profile build ${profile.buildKey} does not match ${buildKey}`, { profileFile: file });
331
+ }
332
+ const build = await readBuild(home, buildKey);
333
+ if (!build)
334
+ return;
335
+ const profileHash = typeof profile.executableSha256 === "string"
336
+ ? profile.executableSha256
337
+ : profile.executable && typeof profile.executable === "object" && !Array.isArray(profile.executable)
338
+ ? String(profile.executable.sha256 ?? "")
339
+ : "";
340
+ if (profileHash && profileHash !== build.executableSha256) {
341
+ throw new CliError("EXECUTABLE_MISMATCH", "profile executable hash does not match the selected build", { expected: build.executableSha256, actual: profileHash, profileFile: file });
342
+ }
343
+ }
344
+ function safeBuildDirectory(buildKey) {
345
+ try {
346
+ return safeBuildKey(buildKey);
347
+ }
348
+ catch {
349
+ throw new CliError("ARGUMENT_INVALID", "build key contains unsupported path characters");
350
+ }
351
+ }
262
352
  export function createWowdumpCli(dependencies = {}) {
263
353
  const io = dependencies.io ?? { stdout: process.stdout, stderr: process.stderr };
264
354
  const env = dependencies.env ?? process.env;
@@ -288,7 +378,7 @@ export function createWowdumpCli(dependencies = {}) {
288
378
  .option("--pid <pid>", "select one process ID")
289
379
  .action(async (options) => {
290
380
  const selectedPid = options.pid ? positiveInteger(options.pid, "pid") : undefined;
291
- const targets = await discoverWowTargets(selectedPid, async (pid) => broker({ command: "modules", payload: { pid } }));
381
+ const targets = await persistDiscoveredBuilds(home, await discoverWowTargets(selectedPid, async (pid) => broker({ command: "modules", payload: { pid } })));
292
382
  if (selectedPid !== undefined && targets.length === 0)
293
383
  throw new CliError("TARGET_NOT_FOUND", `Wow.exe process ${selectedPid} was not found`);
294
384
  writeJson(io, {
@@ -299,6 +389,14 @@ export function createWowdumpCli(dependencies = {}) {
299
389
  targets
300
390
  });
301
391
  });
392
+ program.command("database")
393
+ .description("Show the persistent static database state for a build")
394
+ .command("status")
395
+ .description("Show database path, lock and freshness")
396
+ .requiredOption("--build <buildKey>", "build key")
397
+ .action(async (options) => {
398
+ writeJson(io, { ...await databaseStatus(home, options.build), command: "database.status" });
399
+ });
302
400
  program.command("target")
303
401
  .description("Show target, reader broker, profile, monitor, and Frida state")
304
402
  .option("--pid <pid>", "target process ID")
@@ -314,9 +412,10 @@ export function createWowdumpCli(dependencies = {}) {
314
412
  program.command("profiles")
315
413
  .description("List profiles or describe one profile")
316
414
  .argument("[id-or-file]", "profile ID or absolute JSON file")
317
- .option("--directory <path>", "profile directory", join(home, "profiles"))
415
+ .option("--build <buildKey>", "build key used for the default profile directory")
416
+ .option("--directory <path>", "profile directory")
318
417
  .action(async (idOrFile, options) => {
319
- const directory = resolve(options.directory);
418
+ const directory = resolve(options.directory ?? profileDirectoryFor(home, options.build));
320
419
  if (idOrFile) {
321
420
  const file = profileFile(directory, idOrFile);
322
421
  const profile = jsonRecord(await readFile(file, "utf8"), file);
@@ -347,13 +446,27 @@ export function createWowdumpCli(dependencies = {}) {
347
446
  ...(options.size ? { size: positiveInteger(options.size, "size") } : {})
348
447
  };
349
448
  const pid = positiveInteger(String(payload.pid ?? options.pid), "pid");
350
- const profileId = typeof payload.profileId === "string" ? payload.profileId : (typeof options.profile === "string" ? options.profile : undefined);
449
+ let profileId = typeof payload.profileId === "string" ? payload.profileId : (typeof options.profile === "string" ? options.profile : undefined);
450
+ const requestedBuild = typeof payload.buildKey === "string" ? payload.buildKey : (typeof options.build === "string" ? options.build : undefined);
451
+ if (!profileId && payload.address === undefined && requestedBuild) {
452
+ const available = await listProfileFiles(profileDirectoryFor(home, requestedBuild));
453
+ if (available.length === 1)
454
+ profileId = basename(available[0], extname(available[0]));
455
+ else if (available.length === 0)
456
+ throw new CliError("PROFILE_NOT_FOUND", `no profile exists for build ${requestedBuild}`, { directory: profileDirectoryFor(home, requestedBuild) });
457
+ else
458
+ throw new CliError("PROFILE_SELECTION_REQUIRED", `more than one profile exists for build ${requestedBuild}`, { profiles: available });
459
+ }
351
460
  if (profileId && payload.address === undefined) {
352
- const profileFilePath = profileFile(join(home, "profiles"), profileId);
461
+ const buildKey = requestedBuild;
462
+ const profileFilePath = profileFile(isAbsolute(profileId) ? dirname(profileId) : profileDirectoryFor(home, buildKey), profileId);
353
463
  const profile = jsonRecord(await readFile(profileFilePath, "utf8"), profileFilePath);
464
+ await validateProfileIdentity(home, buildKey, profile, profileFilePath);
465
+ const buildRecord = buildKey ? await readBuild(home, buildKey) : null;
354
466
  const result = await profileEngine.read({
355
467
  pid,
356
- buildKey: typeof payload.buildKey === "string" ? payload.buildKey : undefined,
468
+ buildKey,
469
+ ...(buildRecord?.executableSha256 ? { executableSha256: buildRecord.executableSha256 } : {}),
357
470
  profile,
358
471
  fields: Array.isArray(payload.fields) ? payload.fields.map(String) : undefined
359
472
  }, profileAdapter);
@@ -401,34 +514,23 @@ export function createWowdumpCli(dependencies = {}) {
401
514
  .description("Stop a monitor and release its resources")
402
515
  .requiredOption("--id <watchId>", "monitor ID")
403
516
  .action(async (options) => writeJson(io, await broker({ command: "watch.stop", payload: { watchId: options.id } })));
404
- const analyze = program.command("analyze").description("Run Frida evidence export and runtime-guided disassembly");
405
- analyze.command("disassemble")
406
- .description("Decode Frida text evidence and produce a Reader profile")
407
- .requiredOption("--exe <path>", "path to Wow.exe")
408
- .requiredOption("--build <buildKey>", "build key")
409
- .requiredOption("--runtime-export <file>", "Frida runtime text JSON")
410
- .option("--ida-evidence <file>", "JSON produced by an IDA Pro MCP analysis")
411
- .option("--output <file>", "profile output JSON")
412
- .option("--max-instructions <count>", "maximum decoded instructions", "256")
413
- .option("--dry-run", "validate inputs without writing a profile")
414
- .action(async (options) => writeJson(io, await runDisassembly({
415
- exe: options.exe,
416
- buildKey: options.build,
417
- runtimeExport: options.runtimeExport,
418
- ...(options.idaEvidence ? { idaEvidence: options.idaEvidence } : {}),
419
- ...(options.output ? { output: options.output } : {}),
420
- maxInstructions: positiveInteger(options.maxInstructions, "max-instructions"),
421
- dryRun: options.dryRun === true
422
- })));
517
+ const analyze = program.command("analyze").description("Run Frida evidence export and bounded runtime operations");
423
518
  analyze.command("runtime")
424
- .description("Read runtime evidence through the elevated Frida broker")
519
+ .description("Dump runtime PE sections or verify profile candidates through the elevated Frida broker")
425
520
  .requiredOption("--pid <pid>", "target process ID")
426
521
  .requiredOption("--build <buildKey>", "build key")
427
522
  .option("--build-key <buildKey>", "alias for --build")
428
- .option("--kind <kind>", "text or providers", "providers")
523
+ .option("--kind <kind>", "dump or verify", "dump")
429
524
  .option("--worker <path>", "Frida worker entry")
430
- .option("--profile <path>", "existing reader profile containing candidate RVAs")
431
- .option("--max-hooks <count>", "maximum hooks", "1")
525
+ .option("--profile <path>", "reader profile containing candidate RVAs (verify only)")
526
+ .option("--output-dir <path>", "directory for dump manifest and section binaries")
527
+ .option("--sections <names...>", "sections to dump (default: .text .rdata .pdata)")
528
+ .option("--no-progress", "disable dump progress on stderr")
529
+ .option("--max-section-bytes <bytes>", "per-section dump limit", "134217728")
530
+ .option("--max-total-bytes <bytes>", "total dump limit", "268435456")
531
+ .option("--chunk-size <bytes>", "dump chunk size", "1048576")
532
+ .option("--max-verify-bytes <bytes>", "maximum bytes read per candidate", "256")
533
+ .option("--max-hooks <count>", "reserved compatibility limit", "1")
432
534
  .option("--duration-ms <ms>", "maximum duration", "5000")
433
535
  .option("--max-events <count>", "maximum events", "100")
434
536
  .option("--cleanup-deadline-ms <ms>", "cleanup deadline", "2000")
@@ -442,40 +544,93 @@ export function createWowdumpCli(dependencies = {}) {
442
544
  durationMs: positiveInteger(options.durationMs, "duration-ms"),
443
545
  maxEvents: positiveInteger(options.maxEvents, "max-events"),
444
546
  cleanupDeadlineMs: positiveInteger(options.cleanupDeadlineMs, "cleanup-deadline-ms"),
547
+ maxSectionBytes: positiveInteger(options.maxSectionBytes, "max-section-bytes"),
548
+ maxTotalBytes: positiveInteger(options.maxTotalBytes, "max-total-bytes"),
549
+ chunkSize: positiveInteger(options.chunkSize, "chunk-size"),
550
+ maxVerifyBytes: positiveInteger(options.maxVerifyBytes, "max-verify-bytes"),
445
551
  confirm: options.confirm === true
446
552
  };
447
- if (!["text", "providers"].includes(normalized.kind))
448
- throw new CliError("ARGUMENT_INVALID", "kind must be text or providers");
553
+ if (!["dump", "verify"].includes(normalized.kind))
554
+ throw new CliError("ARGUMENT_INVALID", "kind must be dump or verify");
449
555
  requireRuntimeConfirmation(normalized);
556
+ if (normalized.kind === "verify" && !options.profile)
557
+ throw new CliError("PROFILE_REQUIRED", "verify requires --profile <file>");
450
558
  const worker = resolve(options.worker ?? env.WOWDUMP_FRIDA_WORKER ?? join(packageDist, "frida-worker.js"));
451
559
  if (!existsSync(worker))
452
560
  throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
453
561
  const profile = options.profile
454
562
  ? jsonRecord(await readFile(resolve(options.profile), "utf8"), options.profile)
455
563
  : undefined;
564
+ const buildRecord = await readBuild(home, normalized.build);
565
+ if (profile)
566
+ await validateProfileIdentity(home, normalized.build, profile, resolve(options.profile));
567
+ const sections = Array.isArray(options.sections) && options.sections.length > 0
568
+ ? options.sections.map((value) => String(value).toLowerCase())
569
+ : [".text", ".rdata", ".pdata"];
570
+ if (normalized.kind === "dump") {
571
+ const reusable = buildRecord?.executableSha256
572
+ ? await findReusableDump(home, normalized.build, {
573
+ executableSha256: buildRecord.executableSha256,
574
+ sections,
575
+ maxSectionBytes: normalized.maxSectionBytes,
576
+ maxTotalBytes: normalized.maxTotalBytes,
577
+ chunkSize: normalized.chunkSize
578
+ })
579
+ : null;
580
+ if (reusable) {
581
+ writeJson(io, { ok: true, command: "analyze.runtime.dump", reused: true, manifestFile: String(reusable.manifestFile ?? ""), manifest: reusable });
582
+ return;
583
+ }
584
+ }
585
+ const sessionId = randomUUID();
586
+ const sessionDirectory = options.outputDir
587
+ ? resolve(options.outputDir, "..")
588
+ : join(buildPaths(home, normalized.build).runtime, sessionId);
589
+ const outputDirectory = resolve(options.outputDir ?? join(sessionDirectory, "dump"));
590
+ await mkdir(sessionDirectory, { recursive: true });
591
+ await writeFile(join(sessionDirectory, "target.json"), `${JSON.stringify({ schema: "wowdump.runtime-target.v1", pid: normalized.pid, buildKey: normalized.build, executableSha256: buildRecord?.executableSha256 ?? null, sessionId, createdAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
456
592
  const request = {
457
- command: "dynamic-script",
593
+ command: normalized.kind === "dump" ? "runtime-dump" : "dynamic-script",
458
594
  pid: normalized.pid,
459
595
  build: normalized.build,
460
596
  source: RUNTIME_EXPORT_SCRIPT,
461
597
  exportName: "collect",
598
+ ...(normalized.kind === "dump" ? { outputDir: outputDirectory, progress: options.progress !== false, executableSha256: buildRecord?.executableSha256 ?? null, preferredImageBase: buildRecord?.preferredImageBase ?? null, moduleSize: buildRecord?.moduleSize ?? null, dumpParameters: { executableSha256: buildRecord?.executableSha256 ?? null, sections, maxSectionBytes: normalized.maxSectionBytes, maxTotalBytes: normalized.maxTotalBytes, chunkSize: normalized.chunkSize } } : {}),
462
599
  args: {
463
600
  kind: normalized.kind,
464
601
  maxHooks: normalized.maxHooks,
465
602
  durationMs: normalized.durationMs,
466
603
  maxEvents: normalized.maxEvents,
467
604
  cleanupDeadlineMs: normalized.cleanupDeadlineMs,
605
+ maxSectionBytes: normalized.maxSectionBytes,
606
+ maxTotalBytes: normalized.maxTotalBytes,
607
+ chunkSize: normalized.chunkSize,
608
+ maxVerifyBytes: normalized.maxVerifyBytes,
609
+ ...(normalized.kind === "dump" ? { sections } : {}),
468
610
  ...(profile ? { profile } : {})
469
611
  },
470
612
  callArgs: [],
471
613
  durationMs: normalized.durationMs
472
614
  };
473
615
  const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
616
+ if (result.stderr && options.progress !== false)
617
+ io.stderr.write(result.stderr);
474
618
  if (result.exitCode !== 0)
475
619
  throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
476
620
  const line = result.stdout.split(/\r?\n/).find(value => value.trim());
477
621
  const value = line ? jsonRecord(line, "runtime result") : { ok: true, stdout: result.stdout };
478
- writeJson(io, { ...value, command: "analyze.runtime" });
622
+ if (normalized.kind === "dump" && value.ok === true) {
623
+ if (typeof value.manifestFile === "string") {
624
+ await writeFile(join(sessionDirectory, "dynamic.json"), `${JSON.stringify(value.manifest ?? value, null, 2)}\n`, "utf8").catch(() => undefined);
625
+ writeJson(io, { ...value, command: "analyze.runtime.dump", sessionDirectory });
626
+ }
627
+ else
628
+ throw new CliError("RUNTIME_DUMP_INVALID", "runtime dump worker returned no manifest");
629
+ }
630
+ else {
631
+ await writeFile(join(sessionDirectory, "verify.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8").catch(() => undefined);
632
+ writeJson(io, { ...value, command: "analyze.runtime.verify" });
633
+ }
479
634
  });
480
635
  analyze.command("dynamic")
481
636
  .description("Run a caller-supplied GumJS script through Frida")
@@ -507,11 +662,17 @@ export function createWowdumpCli(dependencies = {}) {
507
662
  if (!existsSync(worker))
508
663
  throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
509
664
  const request = { command: "dynamic-script", ...selected(normalized, ["pid", "build", "script", "exportName", "args", "callArgs", "durationMs"]) };
665
+ const sessionId = randomUUID();
666
+ const sessionDirectory = join(buildPaths(home, normalized.build).runtime, sessionId);
667
+ await mkdir(sessionDirectory, { recursive: true });
668
+ await writeFile(join(sessionDirectory, "target.json"), `${JSON.stringify({ schema: "wowdump.runtime-target.v1", pid: normalized.pid, buildKey: normalized.build, sessionId, createdAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
510
669
  const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
511
670
  if (result.exitCode !== 0)
512
671
  throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
513
672
  const line = result.stdout.split(/\r?\n/).find(value => value.trim());
514
- writeJson(io, line ? JSON.parse(line) : { ok: true, command: "analyze.dynamic", stdout: result.stdout });
673
+ const value = line ? JSON.parse(line) : { ok: true, command: "analyze.dynamic", stdout: result.stdout };
674
+ await writeFile(join(sessionDirectory, "dynamic.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8");
675
+ writeJson(io, { ...value, command: "analyze.dynamic", sessionDirectory });
515
676
  });
516
677
  program.command("init")
517
678
  .description("Initialize WOWDUMP_HOME without overwriting existing files")
@@ -0,0 +1,166 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join, normalize, resolve } from "node:path";
6
+ export function resolveWowdumpHome(env = process.env, userHome = homedir()) {
7
+ return normalize(resolve(env.WOWDUMP_HOME || join(userHome, ".wowdump")));
8
+ }
9
+ export function safeBuildKey(buildKey) {
10
+ const value = buildKey.trim();
11
+ if (!value || value === "." || value === ".." || !/^[A-Za-z0-9_.@-]+$/.test(value))
12
+ throw new Error("build key contains unsupported path characters");
13
+ return value;
14
+ }
15
+ export function buildDirectory(home, buildKey) {
16
+ return join(resolve(home), safeBuildKey(buildKey));
17
+ }
18
+ export function buildPaths(home, buildKey) {
19
+ const build = buildDirectory(home, buildKey);
20
+ const database = join(build, "database");
21
+ return {
22
+ root: resolve(home),
23
+ build,
24
+ database,
25
+ databaseFile: join(database, "database.json"),
26
+ databaseLock: join(database, "database.lock"),
27
+ ida: join(database, "ida"),
28
+ profile: join(build, "profile"),
29
+ runtime: join(build, "runtime")
30
+ };
31
+ }
32
+ export async function sha256File(file) {
33
+ return new Promise((resolveHash, reject) => {
34
+ const hash = createHash("sha256");
35
+ const stream = createReadStream(file);
36
+ stream.on("data", chunk => hash.update(chunk));
37
+ stream.once("error", reject);
38
+ stream.once("end", () => resolveHash(hash.digest("hex")));
39
+ });
40
+ }
41
+ async function readJson(file) {
42
+ try {
43
+ return JSON.parse(await readFile(file, "utf8"));
44
+ }
45
+ catch (error) {
46
+ if (error.code === "ENOENT")
47
+ return null;
48
+ throw error;
49
+ }
50
+ }
51
+ async function writeJson(file, value) {
52
+ await mkdir(resolve(file, ".."), { recursive: true });
53
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
54
+ }
55
+ export async function readBuild(home, buildKey) {
56
+ return readJson(join(buildDirectory(home, buildKey), "build.json"));
57
+ }
58
+ export async function readDatabase(home, buildKey) {
59
+ return readJson(buildPaths(home, buildKey).databaseFile);
60
+ }
61
+ async function ensureBuildUnlocked(home, input) {
62
+ const paths = buildPaths(home, input.buildKey);
63
+ await mkdir(paths.profile, { recursive: true });
64
+ await mkdir(paths.ida, { recursive: true });
65
+ await mkdir(paths.runtime, { recursive: true });
66
+ const now = new Date().toISOString();
67
+ const oldBuild = await readJson(join(paths.build, "build.json"));
68
+ const oldDatabase = await readJson(paths.databaseFile);
69
+ const sameIdentity = Boolean(oldBuild && oldDatabase
70
+ && oldBuild.executableSha256 === input.executableSha256
71
+ && oldBuild.preferredImageBase === input.preferredImageBase
72
+ && oldBuild.moduleSize === input.moduleSize
73
+ && oldDatabase.executableSha256 === input.executableSha256
74
+ && oldDatabase.preferredImageBase === input.preferredImageBase
75
+ && oldDatabase.moduleSize === input.moduleSize);
76
+ const build = {
77
+ schema: "wowdump.build.v1",
78
+ ...input,
79
+ createdAt: oldBuild?.createdAt ?? now,
80
+ updatedAt: now
81
+ };
82
+ const database = {
83
+ schema: "wowdump.database.v1",
84
+ buildKey: input.buildKey,
85
+ executableSha256: input.executableSha256,
86
+ preferredImageBase: input.preferredImageBase,
87
+ moduleSize: input.moduleSize,
88
+ idaDatabase: join(paths.ida, "Wow.i64"),
89
+ idaVersion: oldDatabase?.idaVersion ?? null,
90
+ status: sameIdentity ? (oldDatabase?.status ?? "creating") : oldDatabase ? "stale" : "creating",
91
+ createdAt: oldDatabase?.createdAt ?? now,
92
+ updatedAt: now,
93
+ lastAnalysisAt: oldDatabase?.lastAnalysisAt ?? null
94
+ };
95
+ await writeJson(join(paths.build, "build.json"), build);
96
+ await writeJson(paths.databaseFile, database);
97
+ return { build, database, reused: sameIdentity && database.status === "ready" };
98
+ }
99
+ export async function ensureBuild(home, input) {
100
+ const release = await acquireDatabaseLock(home, input.buildKey);
101
+ try {
102
+ return await ensureBuildUnlocked(home, input);
103
+ }
104
+ finally {
105
+ await release();
106
+ }
107
+ }
108
+ export async function markDatabaseReady(home, buildKey, patch = {}) {
109
+ const current = await readDatabase(home, buildKey);
110
+ if (!current)
111
+ throw new Error(`database for ${buildKey} does not exist`);
112
+ const database = { ...current, ...patch, status: "ready", updatedAt: new Date().toISOString(), lastAnalysisAt: new Date().toISOString() };
113
+ await writeJson(buildPaths(home, buildKey).databaseFile, database);
114
+ return database;
115
+ }
116
+ export async function acquireDatabaseLock(home, buildKey) {
117
+ const paths = buildPaths(home, buildKey);
118
+ await mkdir(paths.database, { recursive: true });
119
+ let handle;
120
+ try {
121
+ handle = await open(paths.databaseLock, "wx");
122
+ }
123
+ catch (error) {
124
+ throw new Error(`database is locked: ${paths.databaseLock}`, { cause: error });
125
+ }
126
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
127
+ return async () => {
128
+ await handle.close().catch(() => undefined);
129
+ await rm(paths.databaseLock, { force: true }).catch(() => undefined);
130
+ };
131
+ }
132
+ export async function databaseStatus(home, buildKey) {
133
+ const paths = buildPaths(home, buildKey);
134
+ const [build, loadedDatabase, lock] = await Promise.all([
135
+ readJson(join(paths.build, "build.json")),
136
+ readJson(paths.databaseFile),
137
+ stat(paths.databaseLock).then(() => true).catch(() => false)
138
+ ]);
139
+ let database = loadedDatabase;
140
+ if (database && database.status === "creating" && await stat(database.idaDatabase).then(() => true).catch(() => false)) {
141
+ database = { ...database, status: "ready", updatedAt: new Date().toISOString(), lastAnalysisAt: database.lastAnalysisAt ?? new Date().toISOString() };
142
+ if (!lock)
143
+ await writeJson(paths.databaseFile, database);
144
+ }
145
+ return { ok: true, buildKey: safeBuildKey(buildKey), buildDirectory: paths.build, databasePath: paths.databaseFile, exists: Boolean(database), locked: lock, build, database };
146
+ }
147
+ export async function findReusableDump(home, buildKey, criteria) {
148
+ const runtime = buildPaths(home, buildKey).runtime;
149
+ let sessions;
150
+ try {
151
+ sessions = await readdir(runtime, { withFileTypes: true });
152
+ }
153
+ catch (error) {
154
+ if (error.code === "ENOENT")
155
+ return null;
156
+ throw error;
157
+ }
158
+ for (const session of sessions.filter(item => item.isDirectory())) {
159
+ const manifestFile = join(runtime, session.name, "dump", "manifest.json");
160
+ const manifest = await readJson(manifestFile);
161
+ if (!manifest || typeof manifest.moduleBase !== "string" || JSON.stringify(manifest.dumpParameters ?? null) !== JSON.stringify(criteria))
162
+ continue;
163
+ return { ...manifest, manifestFile };
164
+ }
165
+ return null;
166
+ }
@@ -143,6 +143,15 @@ export class ProfileEngine {
143
143
  if (request.buildKey && request.buildKey !== buildKey) {
144
144
  throw new ProfileEngineError("BUILD_MISMATCH", `profile build ${buildKey} does not match ${request.buildKey}`);
145
145
  }
146
+ if (request.executableSha256) {
147
+ const executable = profile.executable && typeof profile.executable === "object" && !Array.isArray(profile.executable)
148
+ ? profile.executable
149
+ : undefined;
150
+ const profileHash = typeof profile.executableSha256 === "string" ? profile.executableSha256 : String(executable?.sha256 ?? "");
151
+ if (profileHash && profileHash !== request.executableSha256) {
152
+ throw new ProfileEngineError("EXECUTABLE_MISMATCH", "profile executable hash does not match the target build", { expected: request.executableSha256, actual: profileHash });
153
+ }
154
+ }
146
155
  const status = String(profile.readerStatus ?? profile.status ?? (profile.confidence === "reader_ready" ? "reader_ready" : ""));
147
156
  if (status !== "reader_ready")
148
157
  throw new ProfileEngineError("PROFILE_NOT_READY", "profile is not reader_ready", { status });
@@ -1,6 +1,8 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, writeFile } from "node:fs/promises";
2
2
  import { createInterface } from "node:readline";
3
3
  import { FridaCommandRuntime } from "./analysis/frida-runtime.js";
4
+ import { RuntimeDumpWriter } from "./analysis/runtime-dump.js";
5
+ import { isAbsolute, resolve } from "node:path";
4
6
  function record(value) {
5
7
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
6
8
  }
@@ -15,7 +17,7 @@ function positive(value, name) {
15
17
  * GumJS source; this process only attaches, loads, calls, and cleans it up.
16
18
  */
17
19
  async function run(input) {
18
- if (input.command !== "dynamic-script")
20
+ if (input.command !== "dynamic-script" && input.command !== "runtime-dump")
19
21
  throw new Error("unsupported worker command");
20
22
  const pid = positive(input.pid, "pid");
21
23
  const buildKey = String(input.build ?? input.buildKey ?? "").trim();
@@ -29,24 +31,74 @@ async function run(input) {
29
31
  const runtime = new FridaCommandRuntime({ artifactDir: process.env.WOW_ANALYZE_DIR });
30
32
  let sessionId;
31
33
  let loaded = false;
34
+ const isDump = input.command === "runtime-dump";
35
+ const outputDirectory = isDump ? resolve(String(input.outputDir ?? "")) : undefined;
36
+ const outputDirectoryInput = isDump ? String(input.outputDir ?? "") : "";
37
+ const writer = isDump ? new RuntimeDumpWriter({
38
+ outputDirectory: outputDirectory,
39
+ buildKey,
40
+ pid,
41
+ executableSha256: typeof input.executableSha256 === "string" ? input.executableSha256 : null,
42
+ preferredImageBase: typeof input.preferredImageBase === "string" ? input.preferredImageBase : null,
43
+ moduleSize: Number.isSafeInteger(Number(input.moduleSize)) ? Number(input.moduleSize) : null,
44
+ dumpParameters: input.dumpParameters && typeof input.dumpParameters === "object" ? input.dumpParameters : undefined
45
+ }) : undefined;
46
+ let finalized = false;
32
47
  try {
48
+ if (isDump) {
49
+ if (!outputDirectoryInput || !isAbsolute(outputDirectoryInput))
50
+ throw new Error("runtime dump outputDir must be an absolute path");
51
+ await writer.initialize();
52
+ }
33
53
  const attached = await runtime.execute({ operation: "attach", pid, buildKey, allowUnmatched: true });
34
54
  sessionId = String(attached.sessionId);
35
55
  const modules = await runtime.execute({ operation: "modules", sessionId, pid, buildKey });
36
56
  const module = Array.isArray(modules.modules)
37
57
  ? modules.modules.find(item => String(item.name ?? "").toLowerCase() === "wow.exe")
38
58
  : undefined;
59
+ const scriptSource = `globalThis.__WOWDUMP_INPUT__ = Object.freeze(${JSON.stringify(input.args ?? {})});\n${source}`;
60
+ let value;
61
+ let messages = [];
62
+ if (isDump) {
63
+ await writeFile(resolve(outputDirectory, "..", "target.json"), `${JSON.stringify({ schema: "wowdump.runtime-target.v1", pid, buildKey, module, executableSha256: input.executableSha256 ?? null, createdAt: new Date().toISOString() }, null, 2)}\n`, "utf8").catch(() => undefined);
64
+ let progressBytes = 0;
65
+ const streamed = await runtime.streamScriptCall({
66
+ operation: "script_load",
67
+ sessionId,
68
+ pid,
69
+ buildKey,
70
+ source: scriptSource,
71
+ exportName: typeof input.exportName === "string" ? input.exportName : "collect"
72
+ }, undefined, async (message, data) => {
73
+ const envelope = message && typeof message === "object" ? message : {};
74
+ if (envelope.type !== "send" || !data)
75
+ return;
76
+ const payload = envelope.payload && typeof envelope.payload === "object" ? envelope.payload : {};
77
+ if (payload.type !== "wowdump.dump.chunk")
78
+ return;
79
+ await writer.writeChunk(payload, data);
80
+ progressBytes += data.byteLength;
81
+ if (input.progress !== false)
82
+ process.stderr.write(`wowdump dump ${progressBytes} bytes\\n`);
83
+ });
84
+ value = streamed.value;
85
+ const result = value && typeof value === "object" ? value : {};
86
+ if (result.ok !== true)
87
+ throw new Error(String(result.error ?? "runtime dump failed"));
88
+ const persisted = await writer.finalize({ ...result, module: module ?? result.module });
89
+ finalized = true;
90
+ await writeFile(resolve(outputDirectory, "..", "dynamic.json"), `${JSON.stringify({ ...result, manifestFile: persisted.manifestFile, generatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8").catch(() => undefined);
91
+ return { ok: true, command: "analyze.runtime.dump", pid, buildKey, manifestFile: persisted.manifestFile, outputDirectory, manifest: persisted.manifest, sections: persisted.sections };
92
+ }
39
93
  await runtime.execute({
40
94
  operation: "script_load",
41
95
  sessionId,
42
96
  pid,
43
97
  buildKey,
44
- source: `globalThis.__WOWDUMP_INPUT__ = Object.freeze(${JSON.stringify(input.args ?? {})});\n${source}`,
98
+ source: scriptSource,
45
99
  scriptId: "dynamic"
46
100
  });
47
101
  loaded = true;
48
- let value;
49
- let messages = [];
50
102
  if (typeof input.exportName === "string" && input.exportName.trim()) {
51
103
  const called = await runtime.execute({
52
104
  operation: "script_call",
@@ -79,6 +131,8 @@ async function run(input) {
79
131
  finally {
80
132
  if (sessionId && loaded)
81
133
  await runtime.execute({ operation: "script_unload", sessionId, scriptId: "dynamic" }).catch(() => undefined);
134
+ if (isDump && writer && !finalized)
135
+ await writer.abort().catch(() => undefined);
82
136
  await runtime.close();
83
137
  }
84
138
  }