wowdump 0.3.3 → 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/README.md CHANGED
@@ -14,19 +14,23 @@ 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
25
29
  ```
26
30
 
27
31
  Windows reader 首次使用时由 Node 请求 UAC 启动 broker,后续调用复用同一 broker,空闲 20 分钟后退出。Frida 运行时导出需要显式 `--confirm`;高开销 Stalker 默认关闭。
28
32
 
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`。
33
+ `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
34
 
31
35
  ## 开发验证
32
36
 
@@ -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);
@@ -458,25 +514,7 @@ export function createWowdumpCli(dependencies = {}) {
458
514
  .description("Stop a monitor and release its resources")
459
515
  .requiredOption("--id <watchId>", "monitor ID")
460
516
  .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
- })));
517
+ const analyze = program.command("analyze").description("Run Frida evidence export and bounded runtime operations");
480
518
  analyze.command("runtime")
481
519
  .description("Dump runtime PE sections or verify profile candidates through the elevated Frida broker")
482
520
  .requiredOption("--pid <pid>", "target process ID")
@@ -486,6 +524,8 @@ export function createWowdumpCli(dependencies = {}) {
486
524
  .option("--worker <path>", "Frida worker entry")
487
525
  .option("--profile <path>", "reader profile containing candidate RVAs (verify only)")
488
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")
489
529
  .option("--max-section-bytes <bytes>", "per-section dump limit", "134217728")
490
530
  .option("--max-total-bytes <bytes>", "total dump limit", "268435456")
491
531
  .option("--chunk-size <bytes>", "dump chunk size", "1048576")
@@ -521,12 +561,41 @@ export function createWowdumpCli(dependencies = {}) {
521
561
  const profile = options.profile
522
562
  ? jsonRecord(await readFile(resolve(options.profile), "utf8"), options.profile)
523
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");
524
592
  const request = {
525
- command: "dynamic-script",
593
+ command: normalized.kind === "dump" ? "runtime-dump" : "dynamic-script",
526
594
  pid: normalized.pid,
527
595
  build: normalized.build,
528
596
  source: RUNTIME_EXPORT_SCRIPT,
529
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 } } : {}),
530
599
  args: {
531
600
  kind: normalized.kind,
532
601
  maxHooks: normalized.maxHooks,
@@ -537,22 +606,29 @@ export function createWowdumpCli(dependencies = {}) {
537
606
  maxTotalBytes: normalized.maxTotalBytes,
538
607
  chunkSize: normalized.chunkSize,
539
608
  maxVerifyBytes: normalized.maxVerifyBytes,
609
+ ...(normalized.kind === "dump" ? { sections } : {}),
540
610
  ...(profile ? { profile } : {})
541
611
  },
542
612
  callArgs: [],
543
613
  durationMs: normalized.durationMs
544
614
  };
545
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);
546
618
  if (result.exitCode !== 0)
547
619
  throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
548
620
  const line = result.stdout.split(/\r?\n/).find(value => value.trim());
549
621
  const value = line ? jsonRecord(line, "runtime result") : { ok: true, stdout: result.stdout };
550
622
  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" });
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");
554
629
  }
555
630
  else {
631
+ await writeFile(join(sessionDirectory, "verify.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8").catch(() => undefined);
556
632
  writeJson(io, { ...value, command: "analyze.runtime.verify" });
557
633
  }
558
634
  });
@@ -586,11 +662,17 @@ export function createWowdumpCli(dependencies = {}) {
586
662
  if (!existsSync(worker))
587
663
  throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
588
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");
589
669
  const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
590
670
  if (result.exitCode !== 0)
591
671
  throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
592
672
  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 });
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 });
594
676
  });
595
677
  program.command("init")
596
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
  }
package/dist/toolchain.js CHANGED
@@ -1,29 +1,28 @@
1
- import { createHash } from "node:crypto";
2
- import { createReadStream, readdirSync, statSync } from "node:fs";
1
+ import { readdirSync, statSync } from "node:fs";
3
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
3
  import { homedir } from "node:os";
5
4
  import { dirname, extname, join, resolve } from "node:path";
6
5
  import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { pipeline } from "node:stream/promises";
8
- export const WOWDUMP_HOME_DIRECTORIES = ["profiles", "logs", "runtime", "cache", "skills", "monitors", "toolchains"];
6
+ import { resolveWowdumpHome } from "./core/build-store.js";
9
7
  export const DEFAULT_WOWDUMP_SKILL = `---
10
8
  name: wowdump
11
- description: 使用 Frida 运行时证据和 IDA/iced-x86 定位 WoW 字段,生成 Reader 可用 profile。
9
+ description: Frida 运行时证据和 IDA Pro MCP 或 iced-x86 定位 WoW 字段,生成并验证 Reader profile。
12
10
  ---
13
11
 
14
12
  # wowdump
15
13
 
16
- 查字段时先读取 targets,再用显式 GumJS 导出运行时线索。静态定位优先交给可用的 IDA Pro MCP;没有时运行 \`wowdump analyze disassemble\`,由 Agent 根据 iced-x86 输出分析并补齐字段定义。两条路径都必须最终生成 \`wowdump.profile.v1\`,且只有地址、类型和证据齐全时才标记 \`reader_ready\`。详情见 references/workflow.md、references/dynamic.md、references/disassemble.md 和 references/profiles.md。
14
+ 先运行 \`wowdump targets\`,再复用 \`~/.wowdump/<buildKey>/database/\`。动态脚本必须由 Agent 显式提供;runtime dump 保存当前 session 的二进制证据,Reader 只读取 build profile。详情见 references/workflow.md、references/commands.md、references/dynamic.md、references/disassemble.md 和 references/profiles.md。
17
15
  `;
18
16
  export const DEFAULT_WOWDUMP_COMMANDS = `# wowdump 命令参考
19
17
 
20
18
  1. \`wowdump targets\`
21
- 2. \`wowdump analyze runtime --pid <pid> --build <buildKey> --kind dump --confirm\`
22
- 3. \`wowdump analyze dynamic --pid <pid> --build <buildKey> --script <GumJS> --export collect --confirm\`
23
- 4. \`wowdump analyze disassemble --exe <Wow.exe> --build <buildKey> --runtime-export <runtime.json> --output <profile.json>\`
24
- 5. \`wowdump memory read --pid <pid> --build <buildKey> --profile <profile.json> --field <name>\`
19
+ 2. \`wowdump database status --build <buildKey>\`
20
+ 3. \`wowdump analyze runtime --pid <pid> --build <buildKey> --kind dump --confirm\`
21
+ 4. \`wowdump analyze dynamic --pid <pid> --build <buildKey> --script <GumJS> --export collect --confirm\`
22
+ 5. \`wowdump analyze runtime --pid <pid> --build <buildKey> --kind verify --profile <profile.json> --confirm\`
23
+ 6. \`wowdump memory read --pid <pid> --build <buildKey> --profile <profile.json> --field <name>\`
25
24
 
26
- \`dump\` 会导出供 IDA/iced-x86 分析的 PE 段并生成 manifest;\`verify\` 需要 \`--profile\`,只验证已有候选 RVA。若 Agent 能调用 IDA Pro MCP,先用 MCP 分析 dump manifest 指向的段,再把结果保存为 JSON 并通过 \`--ida-evidence\` 交给 disassemble 命令。未确认的候选 profile 放在 runtime 目录;确认后才复制到当前 build 的 profile 目录。
25
+ \`dump\` 默认流式导出供 IDA/iced-x86 分析的 PE 段并生成 manifest;\`verify\` 只验证已有候选 RVA。未确认的候选 profile 放在 runtime session 目录;确认后才复制到当前 build 的 profile 目录。
27
26
  `;
28
27
  function normalizePath(value) { return resolve(value.trim().replace(/^"|"$/g, "")); }
29
28
  function directoryExists(value) { try {
@@ -48,7 +47,7 @@ catch (error) {
48
47
  else
49
48
  throw error;
50
49
  } }
51
- export function resolveWowdumpHome(env = process.env, userHome = homedir()) { return normalizePath(env.WOWDUMP_HOME || join(userHome, ".wowdump")); }
50
+ export { resolveWowdumpHome, sha256File } from "./core/build-store.js";
52
51
  export function resolveWowdumpSkillHome(env = process.env, userHome = homedir()) { return normalizePath(env.WOWDUMP_SKILL_HOME || join(userHome, ".agents", "skills")); }
53
52
  export async function initializeWowdumpHome(options = {}) {
54
53
  const env = options.env ?? process.env;
@@ -56,15 +55,6 @@ export async function initializeWowdumpHome(options = {}) {
56
55
  const created = [];
57
56
  const preserved = [];
58
57
  await mkdir(home, { recursive: true });
59
- for (const name of WOWDUMP_HOME_DIRECTORIES) {
60
- const dir = join(home, name);
61
- if (directoryExists(dir))
62
- preserved.push(dir);
63
- else {
64
- await mkdir(dir, { recursive: true });
65
- created.push(dir);
66
- }
67
- }
68
58
  const configFile = join(home, "config.json");
69
59
  await createFileIfMissing(configFile, `${JSON.stringify({ schema: "wowdump.config.v1" }, null, 2)}\n`, created, preserved);
70
60
  const skillDirectory = join(normalizePath(options.skillHome ?? resolveWowdumpSkillHome(env, options.userHome)), "wowdump");
@@ -108,7 +98,6 @@ export async function initializeWowdumpHome(options = {}) {
108
98
  }
109
99
  export async function bootstrapToolchain(options = {}) { const home = await initializeWowdumpHome(options); return { ...home, toolchain: { ready: true, disassembler: "iced-x86" }, installed: [] }; }
110
100
  export function resolveToolchain(options = {}) { return { home: normalizePath(options.home ?? resolveWowdumpHome(options.env ?? process.env, options.userHome)), ready: true, disassembler: "iced-x86" }; }
111
- export async function sha256File(file) { const hash = createHash("sha256"); await pipeline(createReadStream(file), hash); return hash.digest("hex"); }
112
101
  export function inspectPeSections(buffer) { if (buffer.length < 0x40 || buffer.toString("ascii", 0, 2) !== "MZ")
113
102
  return []; const pe = buffer.readUInt32LE(0x3c); if (pe + 24 > buffer.length || buffer.toString("ascii", pe, pe + 4) !== "PE\0\0")
114
103
  return []; const count = buffer.readUInt16LE(pe + 6); const optional = buffer.readUInt16LE(pe + 20); const table = pe + 24 + optional; const out = []; for (let i = 0; i < count; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wowdump",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "WoW native memory analysis CLI with Frida evidence, IDA/iced-x86 disassembly, and an elevated reader broker",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -5,20 +5,21 @@ description: 以 Frida 运行时探测为主,按证据需要交替使用 IDA P
5
5
 
6
6
  # wowdump
7
7
 
8
- 用于读取角色属性、Buff/Debuff、技能冷却、附近单位等原生字段。优先从当前进程取得真实值;需要长期复用时,再把已验证的线索固化为带证据的 `wowdump.profile.v1`。
8
+ 用于读取角色属性、Buff/Debuff、技能冷却、附近单位等原生字段。每个 build 有一个静态数据库,运行时证据按 session 保存,Reader 只读取已验证的 profile
9
9
 
10
10
  ## 默认策略
11
11
 
12
- 1. 先运行 `wowdump targets`,确认 PID、Wow.exe 路径和 buildKey;多个目标时让用户选择。
13
- 2. `~/.wowdump/<buildKey>/runtime/<session>/` 保存临时证据,在 `~/.wowdump/<buildKey>/profile/` 只保存用户确认的持久化 profile。
14
- 3. 默认编写并显式传入 GumJS,使用 `wowdump analyze dynamic --script <GumJS> --confirm` 做运行时发现、原生 Hook、快照或事件采集。
15
- 4. 若已有 `reader_ready` profile,先用 reader 读取;若动态结果已足够回答问题,直接返回,不强行做静态分析。
16
- 5. 动态结果需要函数 RVA、对象布局或字段类型时,先用 `wowdump analyze runtime --kind dump` 导出 `.text`、`.rdata`、`.data`、`.pdata` manifest,再调用 IDA Pro MCP;没有 IDA 时才用 iced-x86 输出。静态和动态可以交替执行,每轮只扩大当前字段所需的证据范围。
17
- 6. 将确认的 RVA、指针链、类型、边界和证据写入候选 profile。字段证据不足时保持 `candidate`,回到 dynamic 补采样或回到 IDA/iced 缩小候选。
18
- 7. `wowdump analyze runtime --kind verify --profile <profile> --confirm` 或 `wowdump memory read --profile <profile> --field <name>` 做 broker-backed 验证;只有地址、类型和实际读取都成功后才标记 `reader_ready`。经用户确认后再保存到该 build 的 `profile/`。
12
+ 1. 先运行 `wowdump targets`,确认 PID、Wow.exe 路径、哈希和 buildKey;多个目标时让用户选择。该命令会创建或复用 `~/.wowdump/<buildKey>/database/`。
13
+ 2. 先检查 `wowdump database status --build <buildKey>`。同一可执行文件哈希、首选 image base 和模块大小时复用已有 IDA 数据库;哈希变化时数据库为 `stale`。
14
+ 3. `reader_ready` profile 时先用 `wowdump memory read --pid <pid> --build <buildKey> --profile <profile> --field <name>`;Reader 不读取 IDA 数据库、dump 二进制或 runtime JSON。
15
+ 4. 没有可用 profile 时,由 Agent 编写并显式传入 GumJS:`wowdump analyze dynamic --pid <pid> --build <buildKey> --script <GumJS> --confirm`。脚本可做快照、有限 Hook 和事件采集。
16
+ 5. 动态线索需要静态证据时运行 `wowdump analyze runtime --kind dump`。dump 只写当前 session 的 `.text/.rdata/.pdata`,`.data` 通过 `--sections` 按需加入;不创建新的静态数据库。
17
+ 6. 优先让 IDA Pro MCP 打开该 build 的 `database/ida/Wow.i64`,按 manifest runtime overlay 映射到原始 Wow.exe,输出函数 RVA、字符串 RVA、XREF、结构偏移和字段类型。没有 IDA MCP 时,用 iced-x86 对相关范围局部反汇编。
18
+ 7. 将静态结果和动态证据合并为 runtime session 下的 `candidate.profile.json`,只保存 RVA/相对偏移,不保存绝对地址。
19
+ 8. 用 `wowdump analyze runtime --kind verify --profile <candidate> --confirm` 做 broker 实读;确认 build/hash、地址、类型、边界和读取值一致后,才把 profile 复制到 `~/.wowdump/<buildKey>/profile/`。
19
20
 
20
21
  不要把 dynamic、IDA 或 iced 固定成一次性线性流水线。选择下一步的依据是当前字段缺少什么证据:值用 dynamic,函数/布局用 IDA/iced,稳定读取用 reader。
21
22
 
22
- 具体的交替决策见 [references/workflow.md](references/workflow.md);动态 Hook 规则见 [references/dynamic.md](references/dynamic.md);IDA/iced 的选择和交替策略见 [references/disassemble.md](references/disassemble.md);请求字段和 profile 生命周期见 [references/request-schema.md](references/request-schema.md) 与 [references/profiles.md](references/profiles.md)。
23
+ 具体决策见 [references/workflow.md](references/workflow.md);命令参数见 [references/commands.md](references/commands.md);动态 Hook 规则见 [references/dynamic.md](references/dynamic.md);IDA/iced 规则见 [references/disassemble.md](references/disassemble.md);请求字段和 profile 生命周期见 [references/request-schema.md](references/request-schema.md) 与 [references/profiles.md](references/profiles.md)。
23
24
 
24
25
  IDA MCP 使用长 TTL 会话(默认 `idle_ttl_sec: 3600`)。每次查询前先做 health probe;出现 `worker not reachable` 时按 `references/disassemble.md` 重新打开并只重试一次。IDA worker 与 Windows broker 独立,broker 的 UAC 复用不会保持 IDA worker 存活。
@@ -21,21 +21,29 @@ wowdump analyze dynamic `
21
21
  --export collect `
22
22
  --args '{"fields":["playerAuras","nearbyEnemies","cooldowns"]}' `
23
23
  --duration-ms 5000 `
24
- --confirm > $HOME\.wowdump\runtime\<session-id>\state.json
24
+ --confirm
25
25
  ```
26
26
 
27
- 导出供静态分析的运行时模块段:
27
+ CLI 会把结果保存到 `~/.wowdump/<buildKey>/runtime/<session-id>/dynamic.json`,并在 stdout 返回 session 目录。
28
+
29
+ 导出供静态分析的运行时模块段。默认只导出 `.text/.rdata/.pdata`;需要运行时全局状态时显式加入 `.data`:
28
30
 
29
31
  ```powershell
30
32
  wowdump analyze runtime `
31
33
  --pid 33976 `
32
34
  --build "retail@12.1.0.69587" `
33
35
  --kind dump `
34
- --output-dir "$HOME\.wowdump\retail@12.1.0.69587\runtime\dump-01" `
36
+ --sections .text .rdata .pdata `
35
37
  --confirm
36
38
  ```
37
39
 
38
- 输出目录中包含 `manifest.json` `.text`、`.rdata`、`.data`、`.pdata` 二进制段。stdout 只返回 manifest 路径和摘要,不打印整段十六进制。
40
+ 输出写入 `~/.wowdump/<buildKey>/runtime/<session-id>/dump/`,包含 `manifest.json` 和二进制段文件。Frida 按块发送,worker 直接写盘;stdout 只返回 manifest 路径、段摘要、字节数和 SHA-256,不打印 Base64 或整段十六进制。相同 build 哈希、段选择和限制参数会复用已有 manifest。
41
+
42
+ 查看静态数据库状态:
43
+
44
+ ```powershell
45
+ wowdump database status --build "retail@12.1.0.69587"
46
+ ```
39
47
 
40
48
  验证已有候选 profile:
41
49
 
@@ -50,16 +58,12 @@ wowdump analyze runtime `
50
58
 
51
59
  `verify` 不负责发现地址;没有 profile 时会直接提示缺少验证目标。
52
60
 
53
- ## 需要时做静态定位
61
+ ## 静态定位
54
62
 
55
- 只有 dynamic 结果缺少函数 RVA、对象布局或字段类型时,才调用 IDA Pro MCP;没有 IDA 时使用 iced-x86 兜底:
63
+ 只有 dynamic 结果缺少函数 RVA、对象布局或字段类型时,才调用 IDA Pro MCP。优先复用 `~/.wowdump/<buildKey>/database/ida/Wow.i64`;runtime manifest 只作为 overlay 证据,不作为独立程序导入。没有 IDA MCP 时使用 iced-x86 对 manifest 指向的局部范围兜底。静态结果保存到当前 session 的 `ida-evidence.json`,候选 profile 保存到 `candidate.profile.json`。
56
64
 
57
65
  ```powershell
58
- wowdump analyze disassemble `
59
- --exe "D:\Game\World of Warcraft\_retail_\Wow.exe" `
60
- --build "retail@12.1.0.69587" `
61
- --runtime-export "$HOME\.wowdump\runtime\<session-id>\state.json" `
62
- --output "$HOME\.wowdump\runtime\<session-id>\retail-12.1.0.69587.profile.json"
66
+ IDA MCP iced-x86 的输出至少包含:函数 RVA、字符串 RVA、XREF、结构偏移、字段类型、调用关系和证据来源。
63
67
  ```
64
68
 
65
69
  ## 读取
@@ -1,6 +1,6 @@
1
1
  # 反汇编与 IDA Pro 分支
2
2
 
3
- 静态分析是按需使用的证据放大器,不是每个查询的必经步骤。需要静态证据时,先用 `wowdump analyze runtime --kind dump` 导出运行时 `.text`、`.rdata`、`.data`、`.pdata` 以及 manifest;运行时字节样本、Hook 调用记录或返回值也可以作为输入。先检查当前 Agent 是否能调用本机 IDA Pro MCP:
3
+ 静态分析是按需使用的证据放大器,不是每个查询的必经步骤。需要静态证据时,先用 `wowdump analyze runtime --kind dump` 导出运行时 `.text`、`.rdata`、`.pdata` 以及 manifest;`.data` 只有在字段需要运行时全局状态时才通过 `--sections` 加入。原始 Wow.exe 始终是 IDA 的主输入,runtime 二进制只是 overlay 证据。先检查当前 Agent 是否能调用本机 IDA Pro MCP:
4
4
 
5
5
  ## IDA Pro MCP 下载与安装
6
6
 
@@ -47,16 +47,6 @@ IDA 的 headless worker 不是常驻进程。`idb_list` 中仍有 session 记录
47
47
  4. 第二次仍失联时停止扩大分析范围,保存已取得的 JSON 证据并转用 iced-x86 或请求用户稍后重试。
48
48
 
49
49
  不要把 `session_id`、IDA 临时 worker PID 或 `0x140000000` 这类 IDA image base 写入持久化 profile。profile 只保存相对模块的 RVA、字段类型和可复核证据。IDA MCP 会话和 wowdump 的 Windows broker 是两条独立链路:前者负责反汇编,后者负责管理员内存读取;broker 复用不会延长 IDA worker 生命周期。
50
- - IDA 不可用时执行:
51
-
52
- ```powershell
53
- wowdump analyze disassemble `
54
- --exe "C:\Games\World of Warcraft\_retail_\Wow.exe" `
55
- --build "retail@VERSION" `
56
- --runtime-export "$HOME\.wowdump\retail@VERSION\runtime\SESSION\state.json" `
57
- --output "$HOME\.wowdump\runtime\SESSION\candidate.profile.json"
58
- ```
59
-
60
- CLI 使用 `iced-x86` 解码 runtime 样本,返回模块基址、RVA、指令文本、PE sections 和证据。Agent 负责把这些线索与运行时字段对应起来,再用 dynamic 验证 `root`、`pointerChain`、`layout/type`、计数上限和停止条件。不要把一次运行的绝对地址写进持久化 profile。
50
+ - IDA 不可用时,Agent 使用 npm 内置的 `iced-x86` 对 manifest 指向的局部 RVA 反汇编,输出模块 RVA、指令文本、访问偏移和证据。Agent 负责把这些线索与运行时字段对应起来,再用 dynamic 验证 `root`、`pointerChain`、`layout/type`、计数上限和停止条件。不要把一次运行的绝对地址写进持久化 profile。
61
51
 
62
52
  输出中的 `readerStatus: "reader_ready"` 只适用于每个字段都有可验证 RVA/地址、类型和边界,并且 reader 实际读成功的情况;否则保持 `candidate`,回到 dynamic 或继续局部静态分析。只做一次性查询时可以不生成持久化 profile。
@@ -6,9 +6,20 @@
6
6
 
7
7
  ```text
8
8
  ~/.wowdump/<buildKey>/
9
- └── profile/ # 只放用户确认保存的 profile
10
- ├── player-state.json
11
- └── combat-state.json
9
+ ├── build.json
10
+ ├── database/
11
+ │ ├── database.json
12
+ │ └── ida/Wow.i64
13
+ ├── profile/ # 只放用户确认保存的 profile
14
+ │ ├── player-state.json
15
+ │ └── combat-state.json
16
+ └── runtime/<session-id>/ # 当前任务证据
17
+ ├── target.json
18
+ ├── dynamic.json
19
+ ├── dump/manifest.json
20
+ ├── ida-evidence.json
21
+ ├── candidate.profile.json
22
+ └── verify.json
12
23
  ```
13
24
 
14
25
  buildKey 只能作为单层目录名使用。保留 `@`、点和连字符;拒绝路径分隔符、`..` 和其他会改变目录层级的字符。不同 build 必须使用不同目录。
@@ -18,7 +29,7 @@ buildKey 只能作为单层目录名使用。保留 `@`、点和连字符;拒
18
29
  动态导出、反汇编候选和未确认的 profile 放到本次任务的临时目录,例如:
19
30
 
20
31
  ```text
21
- ~/.wowdump/runtime/<session-id>/
32
+ ~/.wowdump/<buildKey>/runtime/<session-id>/
22
33
  ```
23
34
 
24
35
  它们不是持久化 profile,任务结束后可以清理。不要把候选文件直接写入 `<buildKey>/profile/`。
@@ -29,6 +40,6 @@ buildKey 只能作为单层目录名使用。保留 `@`、点和连字符;拒
29
40
  2. 向用户展示候选 profile 的路径、字段和验证结果,询问是否保存为该 build 的持久化 profile。
30
41
  3. 用户确认后,将候选复制到 `~/.wowdump/<buildKey>/profile/<profile-id>.json`,保留 `buildKey`、来源和验证证据。
31
42
  4. 目标文件已存在时先询问覆盖,或使用新的 profile-id;不要静默覆盖。
32
- 5. 后续读取优先使用这个绝对路径,或用 `wowdump profiles --directory ~/.wowdump/<buildKey>/profile` 列出它。
43
+ 5. 后续读取优先使用这个绝对路径,或用 `wowdump profiles --build <buildKey>` 列出它。Reader 只读取 profile,不读取 `database/ida` 和 `runtime/dump/*.bin`。
33
44
 
34
45
  profile 只对记录的 buildKey 和模块布局有效。发现 buildKey 不匹配时停止读取并重新走分析流程,不修改旧 profile。
@@ -1,6 +1,6 @@
1
1
  # 字段请求格式
2
2
 
3
- 先把用户要查的内容写成一个请求文件。字段名是本次任务的目标,不是地址;地址由 dynamic disassemble 阶段产生。
3
+ 先把用户要查的内容写成一个请求文件。字段名是本次任务的目标,不是地址;地址由 dynamic 证据和 IDA Pro MCP/iced-x86 局部分析产生。
4
4
 
5
5
  ```json
6
6
  {
@@ -11,7 +11,7 @@
11
11
  | --- | --- | --- |
12
12
  | 不知道模块/进程 | `wowdump targets` | PID、路径、buildKey、模块基址明确 |
13
13
  | 不知道对象或函数是否被调用 | Dynamic GumJS 枚举、有限扫描或 Hook | 得到对象地址、候选函数或调用事件 |
14
- | 需要给 IDA/iced 完整模块证据 | `wowdump analyze runtime --kind dump` | 得到 `.text`、`.rdata`、`.data`、`.pdata` 和 manifest |
14
+ | 需要给 IDA/iced 模块证据 | `wowdump analyze runtime --kind dump` | 得到默认 `.text`、`.rdata`、`.pdata` 和 manifest;`.data` 仅按需加入 |
15
15
  | 有候选函数但不知道其语义 | Dynamic Hook,配合用户触发一次相关动作 | `this`、参数、返回值与目标字段相关 |
16
16
  | 有调用但不知道字段偏移/类型 | IDA Pro MCP 局部反汇编;无 IDA 时 iced-x86 | 指令访问行为能解释对象和字段 |
17
17
  | 静态候选需要运行时确认 | 回到 Dynamic Hook 或快照 | 候选 RVA 在当前模块命中且数据变化符合预期 |
@@ -21,8 +21,8 @@
21
21
 
22
22
  1. 先检查是否已有同 build 的 `reader_ready` profile;有则直接 Reader,失败再回到 Dynamic。
23
23
  2. 没有 profile 时优先执行 Agent 编写的 GumJS。脚本可以直接 Hook 已知 RVA,也可以在限定范围内扫描候选。
24
- 3. Dynamic 只拿到线索时,先用 `analyze runtime --kind dump` 导出相关 PE 段,再让 IDA Pro MCP 分析对应函数、字符串和交叉引用。不要把 dump 当成 profile。
25
- 4. 没有 IDA 时使用 `analyze disassemble`/iced-x86 作为局部解码工具,得到候选后仍回到 Dynamic 验证。
24
+ 3. Dynamic 只拿到线索时,先用 `analyze runtime --kind dump` 导出当前 session 的相关 PE 段,再让 IDA Pro MCP 复用 build database 分析对应函数、字符串和交叉引用。不要把 dump 当成 profile。
25
+ 4. 没有 IDA MCP 时直接使用 iced-x86 manifest 指向的局部范围解码,得到候选后仍回到 Dynamic 验证。
26
26
  5. 每轮最多扩大一个维度:函数数量、扫描范围、读取字节数或采样时长。连续两轮没有新增证据时,停止扩大并请求用户触发相关游戏动作或确认字段范围。
27
27
 
28
28
  ## 证据门槛
@@ -1,77 +0,0 @@
1
- import { readFile, writeFile } from "node:fs/promises";
2
- import { access } from "node:fs/promises";
3
- import { constants } from "node:fs";
4
- import { resolve } from "node:path";
5
- import { Decoder, DecoderOptions, Formatter, FormatterSyntax } from "iced-x86";
6
- import { inspectPeSections, sha256File } from "../toolchain.js";
7
- function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
8
- function array(value) { return Array.isArray(value) ? value.filter(item => item && typeof item === "object" && !Array.isArray(item)) : []; }
9
- function hex(value) { if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
10
- return BigInt(value); if (typeof value === "string" && /^0x[0-9a-f]+$/i.test(value.trim()))
11
- return BigInt(value); return undefined; }
12
- function hexText(value) { return `0x${value.toString(16)}`; }
13
- function moduleInfo(runtime) { const module = record(runtime.module); const base = hex(module.moduleBase ?? module.base ?? runtime.moduleBase) ?? 0n; const size = Number(module.moduleSize ?? module.size ?? runtime.moduleSize ?? 0); return { base, size: Number.isFinite(size) ? size : 0, name: String(module.name ?? "Wow.exe") }; }
14
- function sampleBytes(item) { const value = item.bytesHex ?? item.dataHex; if (typeof value !== "string" || !/^[0-9a-f]*$/i.test(value) || value.length % 2 !== 0)
15
- return undefined; return Buffer.from(value, "hex"); }
16
- function sampleRva(item) { return hex(item.rva) ?? (hex(item.address) !== undefined ? hex(item.address) : undefined); }
17
- function decodeSamples(runtime, maxInstructions) {
18
- const info = moduleInfo(runtime);
19
- const samples = [...array(runtime.samples), ...array(runtime.records)];
20
- const formatter = new Formatter(FormatterSyntax.Masm);
21
- const output = [];
22
- let remaining = maxInstructions;
23
- for (const sample of samples) {
24
- if (remaining <= 0)
25
- break;
26
- const bytes = sampleBytes(sample);
27
- const raw = sampleRva(sample);
28
- if (!bytes || raw === undefined)
29
- continue;
30
- const rva = raw >= info.base && info.base > 0n ? raw - info.base : raw;
31
- const ip = info.base + rva;
32
- const decoder = new Decoder(64, bytes, DecoderOptions.None);
33
- decoder.ip = ip;
34
- const instructions = [];
35
- while (remaining > 0 && decoder.canDecode) {
36
- const instruction = decoder.decode();
37
- if (instruction.code === 0)
38
- break;
39
- instructions.push(formatter.format(instruction));
40
- remaining--;
41
- }
42
- output.push({ rva: hexText(rva), address: hexText(ip), bytesHex: bytes.toString("hex"), instructions, source: "frida-text" });
43
- }
44
- return output;
45
- }
46
- function fieldCandidates(runtime, ida) {
47
- const candidates = {};
48
- const source = { ...record(runtime.fields), ...record(ida.fields) };
49
- for (const [name, value] of Object.entries(source)) {
50
- const item = record(value);
51
- const rva = item.rva ?? record(item.root).rva;
52
- const type = item.type ?? (record(item.layout).type);
53
- const layout = item.layout;
54
- if (rva !== undefined && (typeof type === "string" || (layout && typeof layout === "object" && !Array.isArray(layout))))
55
- candidates[name] = { ...item, root: item.root ?? { rva }, ...(type ? { type } : {}), evidence: [...array(item.evidence), { source: ida && Object.keys(ida).length ? "ida-pro-mcp" : "iced-x86", kind: "runtime-guided" }] };
56
- }
57
- return candidates;
58
- }
59
- export async function runDisassembly(options) {
60
- const executable = resolve(options.exe);
61
- await access(executable, constants.R_OK);
62
- if (!options.buildKey?.trim())
63
- throw new Error("buildKey is required");
64
- const runtime = record(JSON.parse(await readFile(resolve(options.runtimeExport), "utf8")));
65
- const ida = options.idaEvidence ? record(JSON.parse(await readFile(resolve(options.idaEvidence), "utf8"))) : {};
66
- const info = moduleInfo(runtime);
67
- const decoded = decodeSamples(runtime, Math.min(Math.max(options.maxInstructions ?? 256, 1), 10_000));
68
- const sections = inspectPeSections(await readFile(executable));
69
- const fields = fieldCandidates(runtime, ida);
70
- const ready = Object.keys(fields).length > 0 && Object.values(fields).every(field => { const value = record(field); return value.root !== undefined && (value.type !== undefined || value.layout !== undefined); });
71
- const outputFile = resolve(options.output ?? `${executable}.${options.buildKey.replace(/[^A-Za-z0-9_.@-]+/g, "-")}.profile.json`);
72
- const profile = { schema: "wowdump.profile.v1", id: `${options.buildKey}-runtime`, buildKey: options.buildKey, readerStatus: ready ? "reader_ready" : "candidate", confidence: ready ? "confirmed" : "candidate", module: { name: info.name, rvaBase: "0x0" }, executable: { path: executable, sha256: await sha256File(executable) }, sections, fields, evidence: [{ source: "frida", kind: "runtime-text", samples: decoded.length }, { source: options.idaEvidence ? "ida-pro-mcp" : "iced-x86", kind: "disassembly", instructions: decoded.length }], analysis: { engine: options.idaEvidence ? "ida-pro-mcp+iced-x86" : "iced-x86", generatedAt: new Date().toISOString() } };
73
- const disassembly = { module: { name: info.name, moduleBase: hexText(info.base), moduleSize: info.size }, sections, instructions: decoded, idaEvidence: options.idaEvidence ?? null };
74
- if (!options.dryRun)
75
- await writeFile(outputFile, `${JSON.stringify(profile, null, 2)}\n`, "utf8");
76
- return { ok: true, command: "analyze.disassemble", engine: options.idaEvidence ? "ida-pro-mcp" : "iced-x86", buildKey: options.buildKey, executable, outputFile, profile, disassembly };
77
- }