wowdump 0.3.3 → 0.3.5

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/README.md CHANGED
@@ -14,19 +14,26 @@ wowdump init
14
14
  ## 基本流程
15
15
 
16
16
  ```powershell
17
- # 1. 导出运行时模块段
17
+ # 1. 发现目标并创建/复用 build 数据库
18
+ wowdump targets
19
+ wowdump database status --build "retail@VERSION"
20
+
21
+ # 2. 导出运行时模块段
18
22
  wowdump analyze runtime --pid 1234 --build "retail@VERSION" --kind dump --confirm
19
23
 
20
- # 2. 用 IDA Pro MCP(若可用)或 iced-x86 分析 dump-<id>\manifest.json,保存字段候选和证据
21
- # 3. 将候选证据交给 profile 生成步骤,再用 reader 验证
24
+ # 3. 用 IDA Pro MCP(若可用)或 iced-x86 分析 session dump manifest,保存字段候选和证据
25
+ # 4. 将候选证据交给 verify,再用 Reader 验证
22
26
 
23
- # 4. 用户确认后,把 profile 保存到当前 build 的 profile 目录,再读取
27
+ # 5. 用户确认后,把 profile 保存到当前 build 的 profile 目录,再读取
24
28
  wowdump memory read --pid 1234 --profile "$HOME\.wowdump\retail@VERSION\profile\player-state.json" --field playerAuras
29
+
30
+ # 枚举已提交虚拟内存区(复用同一个管理员 broker)
31
+ wowdump memory regions --pid 1234 --start 0x15000000000 --end 0x15400000000 --max-regions 20000
25
32
  ```
26
33
 
27
34
  Windows reader 首次使用时由 Node 请求 UAC 启动 broker,后续调用复用同一 broker,空闲 20 分钟后退出。Frida 运行时导出需要显式 `--confirm`;高开销 Stalker 默认关闭。
28
35
 
29
- `analyze runtime --kind dump` 会把 `.text`、`.rdata`、`.data` `.pdata` 导出为二进制文件,并写入 `manifest.json`;manifest 供 IDA Pro MCP 或 iced-x86 使用。`analyze runtime --kind verify --profile <file>` 只验证已有 profile 的 RVA。当前 build 的持久化 profile 放在 `~/.wowdump/<buildKey>/profile/`;动态导出、静态候选和验证报告放在 runtime 会话临时目录。完整字段和 JSON 约定见 `~/.agents/skills/wowdump/references/commands.md`、`references/request-schema.md` 与 `references/profiles.md`;短时 Hook 示例见 `~/.agents/skills/wowdump/scripts/dynamic-session.js`。
36
+ `targets` 会写入 `~/.wowdump/<buildKey>/build.json` 和 `database/database.json`;同一 build 的 IDA 数据库只创建一份。`analyze runtime --kind dump` 默认把 `.text`、`.rdata`、`.pdata` 流式写入当前 session,`.data` 需通过 `--sections` 显式加入。`analyze runtime --kind verify --profile <file>` 只验证已有 profile 的 RVA。Reader 只读取 `~/.wowdump/<buildKey>/profile/`,不读取 IDA 数据库和 dump 二进制。完整字段和 JSON 约定见 `~/.agents/skills/wowdump/references/commands.md`、`references/request-schema.md` 与 `references/profiles.md`;短时 Hook 示例见 `~/.agents/skills/wowdump/scripts/dynamic-session.js`。
30
37
 
31
38
  ## 开发验证
32
39
 
@@ -281,6 +281,32 @@ export class FridaCommandRuntime {
281
281
  await this.detachSession(sessionId).catch(() => undefined);
282
282
  }
283
283
  }
284
+ /**
285
+ * Run a caller-supplied export while forwarding Frida binary messages to a
286
+ * consumer. Runtime dump uses this path so ArrayBuffers never become JSON.
287
+ */
288
+ async streamScriptCall(request, context, handler, callArgs = []) {
289
+ const record = await this.session(request, context);
290
+ const source = this.source(request.source);
291
+ const script = await record.session.createScript(buildPrelude(context ?? record.context) + "\n" + source, request.options);
292
+ let pending = Promise.resolve();
293
+ script.message?.connect((message, data) => {
294
+ pending = pending.then(() => handler(message, data));
295
+ });
296
+ await script.load();
297
+ try {
298
+ const name = requireString(request.exportName ?? request.method, "exportName");
299
+ const value = await exportFunction(script, name)(...callArgs.map(item => decodeHostArg(item)));
300
+ await pending;
301
+ return { sessionId: record.id, value: normalize(value), ...sessionContext(record) };
302
+ }
303
+ finally {
304
+ try {
305
+ await script.unload();
306
+ }
307
+ catch { /* cleanup is best effort */ }
308
+ }
309
+ }
284
310
  async api() {
285
311
  if (this.apiOverride)
286
312
  return this.apiOverride;
@@ -0,0 +1,93 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, open, rm, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ function safeName(name) {
5
+ const value = String(name ?? "section");
6
+ return /^[A-Za-z0-9_.-]+$/.test(value) ? value : "section";
7
+ }
8
+ /** Streams Frida ArrayBuffer messages into section files without Base64 JSON. */
9
+ export class RuntimeDumpWriter {
10
+ outputDirectory;
11
+ options;
12
+ sections = new Map();
13
+ aborted = false;
14
+ constructor(options) {
15
+ this.options = options;
16
+ this.outputDirectory = resolve(options.outputDirectory);
17
+ }
18
+ async initialize() {
19
+ await mkdir(this.outputDirectory, { recursive: true });
20
+ }
21
+ async writeChunk(meta, data) {
22
+ if (this.aborted)
23
+ throw new Error("runtime dump writer is aborted");
24
+ if (!data)
25
+ throw new Error("runtime dump chunk has no binary payload");
26
+ const name = safeName(meta.section);
27
+ const offset = Number(meta.offset);
28
+ if (!Number.isSafeInteger(offset) || offset < 0)
29
+ throw new Error(`invalid ${name} chunk offset`);
30
+ let state = this.sections.get(name);
31
+ if (!state) {
32
+ const file = join(this.outputDirectory, `${name}.bin`);
33
+ state = { name, file, handle: await open(file, "w"), hash: createHash("sha256"), offset: 0, bytes: 0 };
34
+ this.sections.set(name, state);
35
+ }
36
+ if (offset !== state.offset)
37
+ throw new Error(`${name} chunk offset ${offset} does not follow ${state.offset}`);
38
+ const bytes = Buffer.from(data);
39
+ await state.handle.write(bytes, 0, bytes.length, state.offset);
40
+ state.hash.update(bytes);
41
+ state.offset += bytes.length;
42
+ state.bytes += bytes.length;
43
+ }
44
+ async finalize(value) {
45
+ if (this.aborted)
46
+ throw new Error("runtime dump writer is aborted");
47
+ for (const state of this.sections.values())
48
+ await state.handle.close();
49
+ const rawSections = Array.isArray(value.sections) ? value.sections : [];
50
+ for (const section of rawSections) {
51
+ const name = safeName(section.name);
52
+ if (!this.sections.has(name))
53
+ await writeFile(join(this.outputDirectory, `${name}.bin`), Buffer.alloc(0));
54
+ }
55
+ const sections = rawSections.map((section) => {
56
+ const name = safeName(section.name);
57
+ const state = this.sections.get(name);
58
+ return {
59
+ ...section,
60
+ ...(state ? { file: state.file, bytes: state.bytes, sha256: state.hash.digest("hex") } : { file: join(this.outputDirectory, `${name}.bin`), bytes: 0, sha256: null })
61
+ };
62
+ });
63
+ const manifest = {
64
+ schema: "wowdump.runtime-dump.v2",
65
+ kind: "dump",
66
+ buildKey: this.options.buildKey,
67
+ pid: this.options.pid,
68
+ executableSha256: this.options.executableSha256 ?? null,
69
+ preferredImageBase: this.options.preferredImageBase ?? null,
70
+ moduleBase: typeof value.module?.moduleBase === "string"
71
+ ? value.module.moduleBase
72
+ : typeof value.module?.base === "string" ? value.module.base : null,
73
+ moduleSize: this.options.moduleSize ?? (typeof value.module?.moduleSize === "number" ? value.module.moduleSize : null),
74
+ module: value.module ?? null,
75
+ totalBytes: sections.reduce((sum, section) => sum + Number(section.bytes ?? 0), 0),
76
+ truncated: value.truncated === true || sections.some(section => section.truncated === true),
77
+ limits: value.limits ?? null,
78
+ dumpParameters: this.options.dumpParameters ?? null,
79
+ sections,
80
+ evidence: value.evidence ?? [],
81
+ generatedAt: new Date().toISOString()
82
+ };
83
+ const manifestFile = join(this.outputDirectory, "manifest.json");
84
+ await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
85
+ return { ok: true, kind: "dump", manifestFile, outputDirectory: this.outputDirectory, manifest, sections: sections.map(section => ({ name: section.name, bytes: section.bytes, sha256: section.sha256 })) };
86
+ }
87
+ async abort() {
88
+ this.aborted = true;
89
+ for (const state of this.sections.values())
90
+ await state.handle.close().catch(() => undefined);
91
+ await rm(this.outputDirectory, { recursive: true, force: true }).catch(() => undefined);
92
+ }
93
+ }
@@ -16,7 +16,6 @@ const maxSectionBytes = Math.min(Math.max(Number(input.maxSectionBytes || 128 *
16
16
  const maxTotalBytes = Math.min(Math.max(Number(input.maxTotalBytes || 256 * 1024 * 1024), 1), 768 * 1024 * 1024);
17
17
  const chunkSize = Math.min(Math.max(Number(input.chunkSize || 1024 * 1024), 4096), 4 * 1024 * 1024);
18
18
  const maxVerifyBytes = Math.min(Math.max(Number(input.maxVerifyBytes || 256), 1), 4096);
19
- const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
20
19
 
21
20
  function readBytes(address, size) {
22
21
  const value = address.readByteArray(size);
@@ -31,21 +30,6 @@ function bytesHex(address, size) {
31
30
  return output;
32
31
  }
33
32
 
34
- function base64(bytes) {
35
- let output = "";
36
- for (let index = 0; index < bytes.length; index += 3) {
37
- const first = bytes[index];
38
- const second = index + 1 < bytes.length ? bytes[index + 1] : 0;
39
- const third = index + 2 < bytes.length ? bytes[index + 2] : 0;
40
- const value = (first << 16) | (second << 8) | third;
41
- output += BASE64[(value >>> 18) & 63];
42
- output += BASE64[(value >>> 12) & 63];
43
- output += index + 1 < bytes.length ? BASE64[(value >>> 6) & 63] : "=";
44
- output += index + 2 < bytes.length ? BASE64[value & 63] : "=";
45
- }
46
- return output;
47
- }
48
-
49
33
  function ascii(address, size) {
50
34
  const bytes = readBytes(address, size);
51
35
  if (!bytes) return "";
@@ -111,18 +95,17 @@ function dumpSection(section, totalState) {
111
95
  const requestedSize = Math.min(section.size, maxSectionBytes);
112
96
  const available = Math.max(0, maxTotalBytes - totalState.bytes);
113
97
  const readSize = Math.min(requestedSize, available);
114
- const chunks = [];
115
98
  let offset = 0;
116
99
  while (offset < readSize) {
117
100
  const size = Math.min(chunkSize, readSize - offset);
118
101
  const bytes = readBytes(module.base.add(parseInt(section.rva, 16)).add(offset), size);
119
102
  if (!bytes) break;
120
- chunks.push({ offset, size: bytes.length, dataBase64: base64(bytes) });
103
+ send({ type: "wowdump.dump.chunk", section: section.name, offset, size: bytes.length }, bytes.buffer);
121
104
  offset += bytes.length;
122
105
  if (bytes.length !== size) break;
123
106
  }
124
107
  totalState.bytes += offset;
125
- return { ...section, requestedSize, readSize: offset, truncated: offset < section.size, chunks };
108
+ return { ...section, requestedSize, readSize: offset, truncated: offset < section.size };
126
109
  }
127
110
 
128
111
  function candidateEntries(profile) {
@@ -146,7 +129,10 @@ function collect() {
146
129
  if (input.kind === "dump") {
147
130
  const parsed = parseSections();
148
131
  if (parsed.error) return { ok: false, kind: "dump", module: { name: module.name, moduleBase: module.base.toString(), moduleSize: module.size }, sections: [], records: [], evidence: [{ source: "frida", kind: "pe-parse-failed", error: parsed.error }] };
149
- const selected = parsed.sections.filter(section => [".text", ".rdata", ".data", ".pdata"].includes(section.name.toLowerCase()));
132
+ const requestedSections = Array.isArray(input.sections) && input.sections.length > 0
133
+ ? input.sections.map(value => String(value).toLowerCase())
134
+ : [".text", ".rdata", ".pdata"];
135
+ const selected = parsed.sections.filter(section => requestedSections.includes(section.name.toLowerCase()));
150
136
  const totalState = { bytes: 0 };
151
137
  const sections = selected.map(section => dumpSection(section, totalState));
152
138
  return { ok: true, kind: "dump", module: { name: module.name, moduleBase: module.base.toString(), moduleSize: module.size, path: module.path }, sections, totalBytes: totalState.bytes, limits: { maxSectionBytes, maxTotalBytes, chunkSize }, truncated: sections.some(section => section.truncated), evidence: [{ source: "frida", kind: "frida-runtime-pe-sections", sections: sections.map(section => section.name), totalBytes: totalState.bytes }] };
package/dist/cli.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { createHash } from "node:crypto";
3
+ import { randomUUID } from "node:crypto";
4
4
  import { existsSync, realpathSync, readFileSync } from "node:fs";
5
- import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
+ import { mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
6
6
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { Command } from "commander";
9
9
  import { WindowsBrokerManager } from "./reader/launcher.js";
10
- import { initializeWowdumpHome, bootstrapToolchain, resolveToolchain, resolveWowdumpHome } from "./toolchain.js";
11
- 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";
12
12
  import { RUNTIME_EXPORT_SCRIPT } from "./analysis/runtime-script.js";
13
13
  import { ProfileEngine } from "./core/profile-engine.js";
14
14
  import { ReaderProfileAdapter } from "./adapters/reader.js";
@@ -86,6 +86,64 @@ async function discoverWowTargets(selectedPid, elevatedModules) {
86
86
  }
87
87
  return targets;
88
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
+ }
89
147
  function packageVersion() {
90
148
  const override = process.env.WOWDUMP_VERSION?.trim();
91
149
  if (override)
@@ -260,61 +318,36 @@ function requireRuntimeConfirmation(options) {
260
318
  cleanupDeadlineMs: options.cleanupDeadlineMs
261
319
  });
262
320
  }
263
- function safeBuildDirectory(buildKey) {
264
- const value = buildKey.trim();
265
- if (!value || !/^[A-Za-z0-9_.@-]+$/.test(value))
266
- throw new CliError("ARGUMENT_INVALID", "build key contains unsupported path characters");
267
- return value;
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;
268
325
  }
269
- function decodeBase64(value, expectedSize, label) {
270
- if (typeof value !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))
271
- throw new CliError("RUNTIME_DUMP_INVALID", `${label} is not valid base64`);
272
- const data = Buffer.from(value, "base64");
273
- if (data.length !== expectedSize)
274
- throw new CliError("RUNTIME_DUMP_INVALID", `${label} size does not match metadata`);
275
- return data;
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
+ }
276
343
  }
277
- export async function persistRuntimeDump(value, outputDirectory) {
278
- const sections = Array.isArray(value.sections) ? value.sections : [];
279
- if (value.ok !== true || sections.length === 0)
280
- return value;
281
- const directory = resolve(outputDirectory);
282
- await mkdir(directory, { recursive: true });
283
- const summaries = [];
284
- for (const raw of sections) {
285
- const section = jsonRecord(JSON.stringify(raw), "runtime section");
286
- const name = typeof section.name === "string" && /^[A-Za-z0-9_.-]+$/.test(section.name) ? section.name : "section";
287
- const file = join(directory, `${name}.bin`);
288
- const chunks = Array.isArray(section.chunks) ? section.chunks : [];
289
- const buffers = chunks.map((chunk, index) => {
290
- const item = jsonRecord(JSON.stringify(chunk), `runtime section ${name} chunk ${index}`);
291
- return decodeBase64(item.dataBase64, Number(item.size), `${name} chunk ${index}`);
292
- });
293
- const data = Buffer.concat(buffers);
294
- await writeFile(file, data);
295
- summaries.push({
296
- ...selected(section, ["name", "rva", "runtimeAddress", "virtualSize", "rawSize", "characteristics", "protection", "requestedSize", "readSize", "truncated"]),
297
- file,
298
- bytes: data.length,
299
- sha256: createHash("sha256").update(data).digest("hex")
300
- });
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");
301
350
  }
302
- const manifest = {
303
- schema: "wowdump.runtime-dump.v1",
304
- kind: "dump",
305
- buildKey: value.buildKey ?? null,
306
- pid: value.pid ?? null,
307
- module: value.module ?? null,
308
- totalBytes: value.totalBytes ?? summaries.reduce((total, item) => total + Number(item.bytes ?? 0), 0),
309
- truncated: value.truncated === true || summaries.some(item => item.truncated === true),
310
- limits: value.limits ?? null,
311
- sections: summaries,
312
- evidence: value.evidence ?? [],
313
- generatedAt: new Date().toISOString()
314
- };
315
- const manifestFile = join(directory, "manifest.json");
316
- await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
317
- return { ...value, sections: summaries, outputDirectory: directory, manifestFile, manifest };
318
351
  }
319
352
  export function createWowdumpCli(dependencies = {}) {
320
353
  const io = dependencies.io ?? { stdout: process.stdout, stderr: process.stderr };
@@ -345,7 +378,7 @@ export function createWowdumpCli(dependencies = {}) {
345
378
  .option("--pid <pid>", "select one process ID")
346
379
  .action(async (options) => {
347
380
  const selectedPid = options.pid ? positiveInteger(options.pid, "pid") : undefined;
348
- 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 } })));
349
382
  if (selectedPid !== undefined && targets.length === 0)
350
383
  throw new CliError("TARGET_NOT_FOUND", `Wow.exe process ${selectedPid} was not found`);
351
384
  writeJson(io, {
@@ -356,6 +389,14 @@ export function createWowdumpCli(dependencies = {}) {
356
389
  targets
357
390
  });
358
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
+ });
359
400
  program.command("target")
360
401
  .description("Show target, reader broker, profile, monitor, and Frida state")
361
402
  .option("--pid <pid>", "target process ID")
@@ -371,9 +412,10 @@ export function createWowdumpCli(dependencies = {}) {
371
412
  program.command("profiles")
372
413
  .description("List profiles or describe one profile")
373
414
  .argument("[id-or-file]", "profile ID or absolute JSON file")
374
- .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")
375
417
  .action(async (idOrFile, options) => {
376
- const directory = resolve(options.directory);
418
+ const directory = resolve(options.directory ?? profileDirectoryFor(home, options.build));
377
419
  if (idOrFile) {
378
420
  const file = profileFile(directory, idOrFile);
379
421
  const profile = jsonRecord(await readFile(file, "utf8"), file);
@@ -404,13 +446,27 @@ export function createWowdumpCli(dependencies = {}) {
404
446
  ...(options.size ? { size: positiveInteger(options.size, "size") } : {})
405
447
  };
406
448
  const pid = positiveInteger(String(payload.pid ?? options.pid), "pid");
407
- 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
+ }
408
460
  if (profileId && payload.address === undefined) {
409
- const profileFilePath = profileFile(join(home, "profiles"), profileId);
461
+ const buildKey = requestedBuild;
462
+ const profileFilePath = profileFile(isAbsolute(profileId) ? dirname(profileId) : profileDirectoryFor(home, buildKey), profileId);
410
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;
411
466
  const result = await profileEngine.read({
412
467
  pid,
413
- buildKey: typeof payload.buildKey === "string" ? payload.buildKey : undefined,
468
+ buildKey,
469
+ ...(buildRecord?.executableSha256 ? { executableSha256: buildRecord.executableSha256 } : {}),
414
470
  profile,
415
471
  fields: Array.isArray(payload.fields) ? payload.fields.map(String) : undefined
416
472
  }, profileAdapter);
@@ -419,6 +475,20 @@ export function createWowdumpCli(dependencies = {}) {
419
475
  }
420
476
  writeJson(io, await broker({ command: "read", payload }));
421
477
  });
478
+ memory.command("regions")
479
+ .description("Enumerate committed virtual memory regions through VirtualQueryEx")
480
+ .requiredOption("--pid <pid>", "target process ID")
481
+ .option("--start <hex>", "query start address", "0x0")
482
+ .option("--end <hex>", "query end address")
483
+ .option("--max-regions <count>", "maximum returned regions", "4096")
484
+ .option("--include-free", "include free and reserved regions")
485
+ .action(async (options) => writeJson(io, await broker({ command: "regions", payload: {
486
+ pid: positiveInteger(options.pid, "pid"),
487
+ start: options.start,
488
+ ...(options.end ? { end: options.end } : {}),
489
+ maxRegions: positiveInteger(options.maxRegions, "max-regions"),
490
+ includeFree: options.includeFree === true
491
+ } })));
422
492
  const watch = memory.command("watch").description("Manage broker-owned memory monitors");
423
493
  watch.command("start")
424
494
  .description("Start a monitor")
@@ -458,25 +528,7 @@ export function createWowdumpCli(dependencies = {}) {
458
528
  .description("Stop a monitor and release its resources")
459
529
  .requiredOption("--id <watchId>", "monitor ID")
460
530
  .action(async (options) => writeJson(io, await broker({ command: "watch.stop", payload: { watchId: options.id } })));
461
- const analyze = program.command("analyze").description("Run Frida evidence export and runtime-guided disassembly");
462
- analyze.command("disassemble")
463
- .description("Decode Frida text evidence and produce a Reader profile")
464
- .requiredOption("--exe <path>", "path to Wow.exe")
465
- .requiredOption("--build <buildKey>", "build key")
466
- .requiredOption("--runtime-export <file>", "runtime evidence JSON")
467
- .option("--ida-evidence <file>", "JSON produced by an IDA Pro MCP analysis")
468
- .option("--output <file>", "profile output JSON")
469
- .option("--max-instructions <count>", "maximum decoded instructions", "256")
470
- .option("--dry-run", "validate inputs without writing a profile")
471
- .action(async (options) => writeJson(io, await runDisassembly({
472
- exe: options.exe,
473
- buildKey: options.build,
474
- runtimeExport: options.runtimeExport,
475
- ...(options.idaEvidence ? { idaEvidence: options.idaEvidence } : {}),
476
- ...(options.output ? { output: options.output } : {}),
477
- maxInstructions: positiveInteger(options.maxInstructions, "max-instructions"),
478
- dryRun: options.dryRun === true
479
- })));
531
+ const analyze = program.command("analyze").description("Run Frida evidence export and bounded runtime operations");
480
532
  analyze.command("runtime")
481
533
  .description("Dump runtime PE sections or verify profile candidates through the elevated Frida broker")
482
534
  .requiredOption("--pid <pid>", "target process ID")
@@ -486,6 +538,8 @@ export function createWowdumpCli(dependencies = {}) {
486
538
  .option("--worker <path>", "Frida worker entry")
487
539
  .option("--profile <path>", "reader profile containing candidate RVAs (verify only)")
488
540
  .option("--output-dir <path>", "directory for dump manifest and section binaries")
541
+ .option("--sections <names...>", "sections to dump (default: .text .rdata .pdata)")
542
+ .option("--no-progress", "disable dump progress on stderr")
489
543
  .option("--max-section-bytes <bytes>", "per-section dump limit", "134217728")
490
544
  .option("--max-total-bytes <bytes>", "total dump limit", "268435456")
491
545
  .option("--chunk-size <bytes>", "dump chunk size", "1048576")
@@ -521,12 +575,41 @@ export function createWowdumpCli(dependencies = {}) {
521
575
  const profile = options.profile
522
576
  ? jsonRecord(await readFile(resolve(options.profile), "utf8"), options.profile)
523
577
  : undefined;
578
+ const buildRecord = await readBuild(home, normalized.build);
579
+ if (profile)
580
+ await validateProfileIdentity(home, normalized.build, profile, resolve(options.profile));
581
+ const sections = Array.isArray(options.sections) && options.sections.length > 0
582
+ ? options.sections.map((value) => String(value).toLowerCase())
583
+ : [".text", ".rdata", ".pdata"];
584
+ if (normalized.kind === "dump") {
585
+ const reusable = buildRecord?.executableSha256
586
+ ? await findReusableDump(home, normalized.build, {
587
+ executableSha256: buildRecord.executableSha256,
588
+ sections,
589
+ maxSectionBytes: normalized.maxSectionBytes,
590
+ maxTotalBytes: normalized.maxTotalBytes,
591
+ chunkSize: normalized.chunkSize
592
+ })
593
+ : null;
594
+ if (reusable) {
595
+ writeJson(io, { ok: true, command: "analyze.runtime.dump", reused: true, manifestFile: String(reusable.manifestFile ?? ""), manifest: reusable });
596
+ return;
597
+ }
598
+ }
599
+ const sessionId = randomUUID();
600
+ const sessionDirectory = options.outputDir
601
+ ? resolve(options.outputDir, "..")
602
+ : join(buildPaths(home, normalized.build).runtime, sessionId);
603
+ const outputDirectory = resolve(options.outputDir ?? join(sessionDirectory, "dump"));
604
+ await mkdir(sessionDirectory, { recursive: true });
605
+ 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");
524
606
  const request = {
525
- command: "dynamic-script",
607
+ command: normalized.kind === "dump" ? "runtime-dump" : "dynamic-script",
526
608
  pid: normalized.pid,
527
609
  build: normalized.build,
528
610
  source: RUNTIME_EXPORT_SCRIPT,
529
611
  exportName: "collect",
612
+ ...(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 } } : {}),
530
613
  args: {
531
614
  kind: normalized.kind,
532
615
  maxHooks: normalized.maxHooks,
@@ -537,22 +620,29 @@ export function createWowdumpCli(dependencies = {}) {
537
620
  maxTotalBytes: normalized.maxTotalBytes,
538
621
  chunkSize: normalized.chunkSize,
539
622
  maxVerifyBytes: normalized.maxVerifyBytes,
623
+ ...(normalized.kind === "dump" ? { sections } : {}),
540
624
  ...(profile ? { profile } : {})
541
625
  },
542
626
  callArgs: [],
543
627
  durationMs: normalized.durationMs
544
628
  };
545
629
  const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
630
+ if (result.stderr && options.progress !== false)
631
+ io.stderr.write(result.stderr);
546
632
  if (result.exitCode !== 0)
547
633
  throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
548
634
  const line = result.stdout.split(/\r?\n/).find(value => value.trim());
549
635
  const value = line ? jsonRecord(line, "runtime result") : { ok: true, stdout: result.stdout };
550
636
  if (normalized.kind === "dump" && value.ok === true) {
551
- const defaultDirectory = join(home, safeBuildDirectory(normalized.build), "runtime", `dump-${Date.now()}`);
552
- const persisted = await persistRuntimeDump(value, options.outputDir ?? defaultDirectory);
553
- writeJson(io, { ...persisted, command: "analyze.runtime.dump" });
637
+ if (typeof value.manifestFile === "string") {
638
+ await writeFile(join(sessionDirectory, "dynamic.json"), `${JSON.stringify(value.manifest ?? value, null, 2)}\n`, "utf8").catch(() => undefined);
639
+ writeJson(io, { ...value, command: "analyze.runtime.dump", sessionDirectory });
640
+ }
641
+ else
642
+ throw new CliError("RUNTIME_DUMP_INVALID", "runtime dump worker returned no manifest");
554
643
  }
555
644
  else {
645
+ await writeFile(join(sessionDirectory, "verify.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8").catch(() => undefined);
556
646
  writeJson(io, { ...value, command: "analyze.runtime.verify" });
557
647
  }
558
648
  });
@@ -586,11 +676,17 @@ export function createWowdumpCli(dependencies = {}) {
586
676
  if (!existsSync(worker))
587
677
  throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
588
678
  const request = { command: "dynamic-script", ...selected(normalized, ["pid", "build", "script", "exportName", "args", "callArgs", "durationMs"]) };
679
+ const sessionId = randomUUID();
680
+ const sessionDirectory = join(buildPaths(home, normalized.build).runtime, sessionId);
681
+ await mkdir(sessionDirectory, { recursive: true });
682
+ 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");
589
683
  const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
590
684
  if (result.exitCode !== 0)
591
685
  throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
592
686
  const line = result.stdout.split(/\r?\n/).find(value => value.trim());
593
- writeJson(io, line ? JSON.parse(line) : { ok: true, command: "analyze.dynamic", stdout: result.stdout });
687
+ const value = line ? JSON.parse(line) : { ok: true, command: "analyze.dynamic", stdout: result.stdout };
688
+ await writeFile(join(sessionDirectory, "dynamic.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8");
689
+ writeJson(io, { ...value, command: "analyze.dynamic", sessionDirectory });
594
690
  });
595
691
  program.command("init")
596
692
  .description("Initialize WOWDUMP_HOME without overwriting existing files")