wowdump 0.3.2 → 0.3.3

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,19 @@ wowdump init
14
14
  ## 基本流程
15
15
 
16
16
  ```powershell
17
- # 1. 导出运行时模块或 provider
18
- wowdump analyze runtime --pid 1234 --build "retail@VERSION" --kind text --duration-ms 5000 --max-events 100 --confirm > runtime.json
17
+ # 1. 导出运行时模块段
18
+ wowdump analyze runtime --pid 1234 --build "retail@VERSION" --kind dump --confirm
19
19
 
20
- # 2. 通过 IDA Pro MCP(若可用)或 iced-x86 局部反汇编生成 profile
21
- wowdump analyze disassemble --exe "C:\Games\World of Warcraft\_retail_\Wow.exe" --build "retail@VERSION" --runtime-export "$HOME\.wowdump\runtime\<session-id>\text.json" --output "$HOME\.wowdump\runtime\<session-id>\candidate.profile.json"
20
+ # 2. IDA Pro MCP(若可用)或 iced-x86 分析 dump-<id>\manifest.json,保存字段候选和证据
21
+ # 3. 将候选证据交给 profile 生成步骤,再用 reader 验证
22
22
 
23
- # 3. 用户确认后,把 profile 保存到当前 build 的 profile 目录,再读取
23
+ # 4. 用户确认后,把 profile 保存到当前 build 的 profile 目录,再读取
24
24
  wowdump memory read --pid 1234 --profile "$HOME\.wowdump\retail@VERSION\profile\player-state.json" --field playerAuras
25
25
  ```
26
26
 
27
27
  Windows reader 首次使用时由 Node 请求 UAC 启动 broker,后续调用复用同一 broker,空闲 20 分钟后退出。Frida 运行时导出需要显式 `--confirm`;高开销 Stalker 默认关闭。
28
28
 
29
- 当前 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`。
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`。
30
30
 
31
31
  ## 开发验证
32
32
 
@@ -1,36 +1,170 @@
1
- /** Built-in bounded sampler used by `analyze runtime`; dynamic stays caller-owned. */
1
+ /**
2
+ * Built-in runtime evidence operations used by `analyze runtime`.
3
+ *
4
+ * The caller-supplied `analyze dynamic` path remains the escape hatch for
5
+ * field-specific GumJS. This script only provides two bounded primitives:
6
+ * `dump` exports PE sections for static analysis and `verify` reads profile
7
+ * candidates at runtime.
8
+ */
2
9
  export const RUNTIME_EXPORT_SCRIPT = String.raw `
3
10
  "use strict";
4
11
  const input = globalThis.__WOWDUMP_INPUT__ || {};
5
12
  const moduleName = typeof input.module === "string" && input.module ? input.module : "Wow.exe";
6
13
  const module = Process.findModuleByName(moduleName);
7
- const limit = Math.min(Math.max(Number(input.maxEvents || 16), 1), 256);
8
- const sampleBytes = Math.min(Math.max(Number(input.sampleBytes || 64), 1), 256);
9
- function bytes(address, size) { const value = address.readByteArray(size); return value ? Array.from(new Uint8Array(value), byte => byte.toString(16).padStart(2, "0")).join("") : ""; }
10
- function collect() {
11
- if (!module) return { ok: false, module: { name: moduleName }, records: [], evidence: [{ source: "frida", kind: "module-missing" }] };
12
- if (input.kind === "text") {
13
- const ranges = Process.enumerateRanges("r-x").filter(range => range.base.compare(module.base) >= 0 && range.base.compare(module.base.add(module.size)) < 0);
14
- const textRanges = ranges.map(range => ({ base: range.base.toString(), rva: range.base.sub(module.base).toString(), size: range.size, protection: range.protection }));
15
- const records = [];
16
- for (const range of ranges) {
17
- if (records.length >= limit) break;
18
- const rva = range.base.sub(module.base);
19
- const offset = rva.isNull() && range.size > 0x1000 ? 0x1000 : 0;
20
- const address = range.base.add(offset);
21
- records.push({ address: address.toString(), rva: address.sub(module.base).toString(), size: Math.min(sampleBytes, range.size - offset), bytesHex: bytes(address, Math.min(sampleBytes, range.size - offset)) });
14
+ const maxCandidates = Math.min(Math.max(Number(input.maxCandidates || input.maxEvents || 256), 1), 4096);
15
+ const maxSectionBytes = Math.min(Math.max(Number(input.maxSectionBytes || 128 * 1024 * 1024), 1), 512 * 1024 * 1024);
16
+ const maxTotalBytes = Math.min(Math.max(Number(input.maxTotalBytes || 256 * 1024 * 1024), 1), 768 * 1024 * 1024);
17
+ const chunkSize = Math.min(Math.max(Number(input.chunkSize || 1024 * 1024), 4096), 4 * 1024 * 1024);
18
+ const maxVerifyBytes = Math.min(Math.max(Number(input.maxVerifyBytes || 256), 1), 4096);
19
+ const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
20
+
21
+ function readBytes(address, size) {
22
+ const value = address.readByteArray(size);
23
+ return value ? new Uint8Array(value) : null;
24
+ }
25
+
26
+ function bytesHex(address, size) {
27
+ const bytes = readBytes(address, size);
28
+ if (!bytes) return "";
29
+ let output = "";
30
+ for (let index = 0; index < bytes.length; index++) output += bytes[index].toString(16).padStart(2, "0");
31
+ return output;
32
+ }
33
+
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
+ function ascii(address, size) {
50
+ const bytes = readBytes(address, size);
51
+ if (!bytes) return "";
52
+ let output = "";
53
+ for (let index = 0; index < bytes.length; index++) {
54
+ const value = bytes[index];
55
+ output += value >= 0x20 && value <= 0x7e ? String.fromCharCode(value) : "";
56
+ }
57
+ return output;
58
+ }
59
+
60
+ function u16(address) { return address.readU16(); }
61
+ function u32(address) { return address.readU32(); }
62
+ function sectionName(address) {
63
+ return ascii(address, 8).replace(/\0/g, "").trim();
64
+ }
65
+
66
+ function parseSections() {
67
+ if (!module) return { error: "module-missing", sections: [] };
68
+ try {
69
+ const base = module.base;
70
+ if (u16(base) !== 0x5a4d) return { error: "invalid-dos-signature", sections: [] };
71
+ const peOffset = u32(base.add(0x3c));
72
+ if (peOffset < 0x40 || peOffset > module.size - 24) return { error: "invalid-pe-offset", sections: [] };
73
+ const pe = base.add(peOffset);
74
+ if (u32(pe) !== 0x00004550) return { error: "invalid-pe-signature", sections: [] };
75
+ const count = u16(pe.add(6));
76
+ const optionalSize = u16(pe.add(20));
77
+ if (count < 1 || count > 96 || optionalSize < 0x60) return { error: "invalid-section-table", sections: [] };
78
+ const table = pe.add(24 + optionalSize);
79
+ const sections = [];
80
+ for (let index = 0; index < count; index++) {
81
+ const header = table.add(index * 40);
82
+ const name = sectionName(header);
83
+ const virtualSize = u32(header.add(8));
84
+ const rva = u32(header.add(12));
85
+ const rawSize = u32(header.add(16));
86
+ const characteristics = u32(header.add(36));
87
+ const size = Math.max(virtualSize, rawSize);
88
+ if (!name || !size || rva >= module.size) continue;
89
+ const boundedSize = Math.min(size, module.size - rva);
90
+ const executable = (characteristics & 0x20000000) !== 0;
91
+ const writable = (characteristics & 0x80000000) !== 0;
92
+ const readable = (characteristics & 0x40000000) !== 0;
93
+ sections.push({
94
+ name,
95
+ rva: "0x" + rva.toString(16),
96
+ runtimeAddress: base.add(rva).toString(),
97
+ virtualSize,
98
+ rawSize,
99
+ characteristics: "0x" + characteristics.toString(16),
100
+ protection: (readable ? "r" : "") + (writable ? "w" : "") + (executable ? "x" : ""),
101
+ size: boundedSize
102
+ });
22
103
  }
23
- return { ok: true, kind: "text", module: { name: module.name, moduleBase: module.base.toString(), moduleSize: module.size }, textRanges, samples: records, records, evidence: [{ source: "frida", kind: "frida-runtime-text", samples: records.length }] };
104
+ return { error: null, sections };
105
+ } catch (error) {
106
+ return { error: String(error), sections: [] };
107
+ }
108
+ }
109
+
110
+ function dumpSection(section, totalState) {
111
+ const requestedSize = Math.min(section.size, maxSectionBytes);
112
+ const available = Math.max(0, maxTotalBytes - totalState.bytes);
113
+ const readSize = Math.min(requestedSize, available);
114
+ const chunks = [];
115
+ let offset = 0;
116
+ while (offset < readSize) {
117
+ const size = Math.min(chunkSize, readSize - offset);
118
+ const bytes = readBytes(module.base.add(parseInt(section.rva, 16)).add(offset), size);
119
+ if (!bytes) break;
120
+ chunks.push({ offset, size: bytes.length, dataBase64: base64(bytes) });
121
+ offset += bytes.length;
122
+ if (bytes.length !== size) break;
123
+ }
124
+ totalState.bytes += offset;
125
+ return { ...section, requestedSize, readSize: offset, truncated: offset < section.size, chunks };
126
+ }
127
+
128
+ function candidateEntries(profile) {
129
+ const entries = [];
130
+ const fields = profile && typeof profile.fields === "object" && !Array.isArray(profile.fields) ? profile.fields : {};
131
+ for (const [name, value] of Object.entries(fields)) entries.push({ name, value });
132
+ return entries.slice(0, maxCandidates);
133
+ }
134
+
135
+ function candidateRva(value) {
136
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
137
+ const item = value;
138
+ const root = item.root && typeof item.root === "object" && !Array.isArray(item.root) ? item.root : {};
139
+ const rva = item.rva ?? root.rva;
140
+ if (typeof rva !== "string" && typeof rva !== "number") return null;
141
+ try { return ptr(String(rva)).toString(); } catch (_) { return null; }
142
+ }
143
+
144
+ function collect() {
145
+ if (!module) return { ok: false, kind: input.kind || "dump", module: { name: moduleName }, sections: [], records: [], evidence: [{ source: "frida", kind: "module-missing" }] };
146
+ if (input.kind === "dump") {
147
+ const parsed = parseSections();
148
+ 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()));
150
+ const totalState = { bytes: 0 };
151
+ const sections = selected.map(section => dumpSection(section, totalState));
152
+ 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 }] };
24
153
  }
25
- const profile = input.profile && typeof input.profile === "object" ? input.profile : {};
26
- const candidates = Array.isArray(profile.providers) ? profile.providers : [];
154
+ if (input.kind !== "verify") return { ok: false, kind: input.kind || null, module: { name: module.name }, records: [], evidence: [{ source: "frida", kind: "unsupported-runtime-kind" }] };
155
+ const profile = input.profile && typeof input.profile === "object" ? input.profile : null;
156
+ if (!profile) return { ok: false, kind: "verify", module: { name: module.name, moduleBase: module.base.toString() }, records: [], evidence: [{ source: "frida", kind: "profile-required" }] };
27
157
  const records = [];
28
- for (const candidate of candidates.slice(0, limit)) {
29
- if (!candidate || typeof candidate.rva !== "string") continue;
30
- const address = module.base.add(ptr(candidate.rva));
31
- records.push({ name: candidate.name || "candidate", rva: candidate.rva, runtimeAddress: address.toString(), bytesHex: bytes(address, Math.min(Number(candidate.size || 16), 256)), source: "frida" });
158
+ for (const entry of candidateEntries(profile)) {
159
+ const rva = candidateRva(entry.value);
160
+ if (rva === null) { records.push({ name: entry.name, ok: false, reason: "missing-rva", source: "frida" }); continue; }
161
+ const address = module.base.add(ptr(rva));
162
+ try {
163
+ const bytes = bytesHex(address, Math.min(Number(entry.value.size || entry.value.byteSize || maxVerifyBytes), maxVerifyBytes));
164
+ records.push({ name: entry.name, rva, runtimeAddress: address.toString(), bytesHex: bytes, bytesRead: bytes.length / 2, complete: bytes.length > 0, ok: bytes.length > 0, source: "frida" });
165
+ } catch (error) { records.push({ name: entry.name, rva, runtimeAddress: address.toString(), ok: false, reason: String(error), source: "frida" }); }
32
166
  }
33
- return { ok: true, module: { name: module.name, moduleBase: module.base.toString(), moduleSize: module.size }, records, evidence: [{ source: "frida", kind: "frida-runtime-read", count: records.length }] };
167
+ return { ok: true, kind: "verify", module: { name: module.name, moduleBase: module.base.toString(), moduleSize: module.size, path: module.path }, records, evidence: [{ source: "frida", kind: "frida-runtime-verify", candidates: records.length, passed: records.filter(item => item.ok).length }] };
34
168
  }
35
169
  rpc.exports = { collect };
36
170
  `;
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
3
4
  import { existsSync, realpathSync, readFileSync } from "node:fs";
4
- import { readFile, readdir } from "node:fs/promises";
5
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
6
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import { Command } from "commander";
@@ -248,7 +249,7 @@ function selected(source, keys) {
248
249
  function requireRuntimeConfirmation(options) {
249
250
  if (options.confirm === true)
250
251
  return;
251
- throw new CliError("CONFIRMATION_REQUIRED", "runtime export requires --confirm", {
252
+ throw new CliError("CONFIRMATION_REQUIRED", "runtime analysis requires --confirm", {
252
253
  pid: options.pid,
253
254
  buildKey: options.build,
254
255
  operation: "runtime",
@@ -259,6 +260,62 @@ function requireRuntimeConfirmation(options) {
259
260
  cleanupDeadlineMs: options.cleanupDeadlineMs
260
261
  });
261
262
  }
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;
268
+ }
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;
276
+ }
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
+ });
301
+ }
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
+ }
262
319
  export function createWowdumpCli(dependencies = {}) {
263
320
  const io = dependencies.io ?? { stdout: process.stdout, stderr: process.stderr };
264
321
  const env = dependencies.env ?? process.env;
@@ -406,7 +463,7 @@ export function createWowdumpCli(dependencies = {}) {
406
463
  .description("Decode Frida text evidence and produce a Reader profile")
407
464
  .requiredOption("--exe <path>", "path to Wow.exe")
408
465
  .requiredOption("--build <buildKey>", "build key")
409
- .requiredOption("--runtime-export <file>", "Frida runtime text JSON")
466
+ .requiredOption("--runtime-export <file>", "runtime evidence JSON")
410
467
  .option("--ida-evidence <file>", "JSON produced by an IDA Pro MCP analysis")
411
468
  .option("--output <file>", "profile output JSON")
412
469
  .option("--max-instructions <count>", "maximum decoded instructions", "256")
@@ -421,14 +478,19 @@ export function createWowdumpCli(dependencies = {}) {
421
478
  dryRun: options.dryRun === true
422
479
  })));
423
480
  analyze.command("runtime")
424
- .description("Read runtime evidence through the elevated Frida broker")
481
+ .description("Dump runtime PE sections or verify profile candidates through the elevated Frida broker")
425
482
  .requiredOption("--pid <pid>", "target process ID")
426
483
  .requiredOption("--build <buildKey>", "build key")
427
484
  .option("--build-key <buildKey>", "alias for --build")
428
- .option("--kind <kind>", "text or providers", "providers")
485
+ .option("--kind <kind>", "dump or verify", "dump")
429
486
  .option("--worker <path>", "Frida worker entry")
430
- .option("--profile <path>", "existing reader profile containing candidate RVAs")
431
- .option("--max-hooks <count>", "maximum hooks", "1")
487
+ .option("--profile <path>", "reader profile containing candidate RVAs (verify only)")
488
+ .option("--output-dir <path>", "directory for dump manifest and section binaries")
489
+ .option("--max-section-bytes <bytes>", "per-section dump limit", "134217728")
490
+ .option("--max-total-bytes <bytes>", "total dump limit", "268435456")
491
+ .option("--chunk-size <bytes>", "dump chunk size", "1048576")
492
+ .option("--max-verify-bytes <bytes>", "maximum bytes read per candidate", "256")
493
+ .option("--max-hooks <count>", "reserved compatibility limit", "1")
432
494
  .option("--duration-ms <ms>", "maximum duration", "5000")
433
495
  .option("--max-events <count>", "maximum events", "100")
434
496
  .option("--cleanup-deadline-ms <ms>", "cleanup deadline", "2000")
@@ -442,11 +504,17 @@ export function createWowdumpCli(dependencies = {}) {
442
504
  durationMs: positiveInteger(options.durationMs, "duration-ms"),
443
505
  maxEvents: positiveInteger(options.maxEvents, "max-events"),
444
506
  cleanupDeadlineMs: positiveInteger(options.cleanupDeadlineMs, "cleanup-deadline-ms"),
507
+ maxSectionBytes: positiveInteger(options.maxSectionBytes, "max-section-bytes"),
508
+ maxTotalBytes: positiveInteger(options.maxTotalBytes, "max-total-bytes"),
509
+ chunkSize: positiveInteger(options.chunkSize, "chunk-size"),
510
+ maxVerifyBytes: positiveInteger(options.maxVerifyBytes, "max-verify-bytes"),
445
511
  confirm: options.confirm === true
446
512
  };
447
- if (!["text", "providers"].includes(normalized.kind))
448
- throw new CliError("ARGUMENT_INVALID", "kind must be text or providers");
513
+ if (!["dump", "verify"].includes(normalized.kind))
514
+ throw new CliError("ARGUMENT_INVALID", "kind must be dump or verify");
449
515
  requireRuntimeConfirmation(normalized);
516
+ if (normalized.kind === "verify" && !options.profile)
517
+ throw new CliError("PROFILE_REQUIRED", "verify requires --profile <file>");
450
518
  const worker = resolve(options.worker ?? env.WOWDUMP_FRIDA_WORKER ?? join(packageDist, "frida-worker.js"));
451
519
  if (!existsSync(worker))
452
520
  throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
@@ -465,6 +533,10 @@ export function createWowdumpCli(dependencies = {}) {
465
533
  durationMs: normalized.durationMs,
466
534
  maxEvents: normalized.maxEvents,
467
535
  cleanupDeadlineMs: normalized.cleanupDeadlineMs,
536
+ maxSectionBytes: normalized.maxSectionBytes,
537
+ maxTotalBytes: normalized.maxTotalBytes,
538
+ chunkSize: normalized.chunkSize,
539
+ maxVerifyBytes: normalized.maxVerifyBytes,
468
540
  ...(profile ? { profile } : {})
469
541
  },
470
542
  callArgs: [],
@@ -475,7 +547,14 @@ export function createWowdumpCli(dependencies = {}) {
475
547
  throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
476
548
  const line = result.stdout.split(/\r?\n/).find(value => value.trim());
477
549
  const value = line ? jsonRecord(line, "runtime result") : { ok: true, stdout: result.stdout };
478
- writeJson(io, { ...value, command: "analyze.runtime" });
550
+ 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" });
554
+ }
555
+ else {
556
+ writeJson(io, { ...value, command: "analyze.runtime.verify" });
557
+ }
479
558
  });
480
559
  analyze.command("dynamic")
481
560
  .description("Run a caller-supplied GumJS script through Frida")
package/dist/toolchain.js CHANGED
@@ -18,12 +18,12 @@ description: 使用 Frida 运行时证据和 IDA/iced-x86 定位 WoW 字段,
18
18
  export const DEFAULT_WOWDUMP_COMMANDS = `# wowdump 命令参考
19
19
 
20
20
  1. \`wowdump targets\`
21
- 2. \`wowdump analyze runtime --pid <pid> --build <buildKey> --kind text --confirm\`
21
+ 2. \`wowdump analyze runtime --pid <pid> --build <buildKey> --kind dump --confirm\`
22
22
  3. \`wowdump analyze dynamic --pid <pid> --build <buildKey> --script <GumJS> --export collect --confirm\`
23
23
  4. \`wowdump analyze disassemble --exe <Wow.exe> --build <buildKey> --runtime-export <runtime.json> --output <profile.json>\`
24
24
  5. \`wowdump memory read --pid <pid> --build <buildKey> --profile <profile.json> --field <name>\`
25
25
 
26
- Agent 能调用 IDA Pro MCP,先用 MCP 分析 runtime text,再把结果保存为 JSON 并通过 \`--ida-evidence\` 交给 disassemble 命令。未确认的候选 profile 放在 runtime 目录;确认后才复制到当前 build 的 profile 目录。
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 目录。
27
27
  `;
28
28
  function normalizePath(value) { return resolve(value.trim().replace(/^"|"$/g, "")); }
29
29
  function directoryExists(value) { try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wowdump",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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": {
@@ -42,5 +42,9 @@
42
42
  "devDependencies": {
43
43
  "@types/node": "^22.13.4",
44
44
  "typescript": "^5.7.3"
45
+ },
46
+ "allowScripts": {
47
+ "frida@17.17.0": true,
48
+ "koffi@2.16.3": true
45
49
  }
46
50
  }
@@ -13,10 +13,12 @@ description: 以 Frida 运行时探测为主,按证据需要交替使用 IDA P
13
13
  2. 在 `~/.wowdump/<buildKey>/runtime/<session>/` 保存临时证据,在 `~/.wowdump/<buildKey>/profile/` 只保存用户确认的持久化 profile。
14
14
  3. 默认编写并显式传入 GumJS,使用 `wowdump analyze dynamic --script <GumJS> --confirm` 做运行时发现、原生 Hook、快照或事件采集。
15
15
  4. 若已有 `reader_ready` profile,先用 reader 读取;若动态结果已足够回答问题,直接返回,不强行做静态分析。
16
- 5. 动态结果需要函数 RVA、对象布局或字段类型时,再对相关样本调用 IDA Pro MCP;没有 IDA 时才用 `wowdump analyze disassemble` 的 iced-x86 输出。静态和动态可以交替执行,每轮只扩大当前字段所需的证据范围。
16
+ 5. 动态结果需要函数 RVA、对象布局或字段类型时,先用 `wowdump analyze runtime --kind dump` 导出 `.text`、`.rdata`、`.data`、`.pdata` 和 manifest,再调用 IDA Pro MCP;没有 IDA 时才用 iced-x86 输出。静态和动态可以交替执行,每轮只扩大当前字段所需的证据范围。
17
17
  6. 将确认的 RVA、指针链、类型、边界和证据写入候选 profile。字段证据不足时保持 `candidate`,回到 dynamic 补采样或回到 IDA/iced 缩小候选。
18
- 7. 用 `wowdump memory read --profile <profile> --field <name>` 做 broker-backed 验证;只有地址、类型和实际读取都成功后才标记 `reader_ready`。经用户确认后再保存到该 build 的 `profile/`。
18
+ 7. 用 `wowdump analyze runtime --kind verify --profile <profile> --confirm` 或 `wowdump memory read --profile <profile> --field <name>` 做 broker-backed 验证;只有地址、类型和实际读取都成功后才标记 `reader_ready`。经用户确认后再保存到该 build 的 `profile/`。
19
19
 
20
20
  不要把 dynamic、IDA 或 iced 固定成一次性线性流水线。选择下一步的依据是当前字段缺少什么证据:值用 dynamic,函数/布局用 IDA/iced,稳定读取用 reader。
21
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
+
24
+ IDA MCP 使用长 TTL 会话(默认 `idle_ttl_sec: 3600`)。每次查询前先做 health probe;出现 `worker not reachable` 时按 `references/disassemble.md` 重新打开并只重试一次。IDA worker 与 Windows broker 独立,broker 的 UAC 复用不会保持 IDA worker 存活。
@@ -24,18 +24,32 @@ wowdump analyze dynamic `
24
24
  --confirm > $HOME\.wowdump\runtime\<session-id>\state.json
25
25
  ```
26
26
 
27
- 固定采样:
27
+ 导出供静态分析的运行时模块段:
28
28
 
29
29
  ```powershell
30
30
  wowdump analyze runtime `
31
31
  --pid 33976 `
32
32
  --build "retail@12.1.0.69587" `
33
- --kind text `
34
- --duration-ms 5000 `
35
- --max-events 100 `
36
- --confirm > $HOME\.wowdump\runtime\<session-id>\text.json
33
+ --kind dump `
34
+ --output-dir "$HOME\.wowdump\retail@12.1.0.69587\runtime\dump-01" `
35
+ --confirm
36
+ ```
37
+
38
+ 输出目录中包含 `manifest.json` 和 `.text`、`.rdata`、`.data`、`.pdata` 二进制段。stdout 只返回 manifest 路径和摘要,不打印整段十六进制。
39
+
40
+ 验证已有候选 profile:
41
+
42
+ ```powershell
43
+ wowdump analyze runtime `
44
+ --pid 33976 `
45
+ --build "retail@12.1.0.69587" `
46
+ --kind verify `
47
+ --profile "$HOME\.wowdump\retail@12.1.0.69587\runtime\candidate.profile.json" `
48
+ --confirm
37
49
  ```
38
50
 
51
+ `verify` 不负责发现地址;没有 profile 时会直接提示缺少验证目标。
52
+
39
53
  ## 需要时做静态定位
40
54
 
41
55
  只有 dynamic 结果缺少函数 RVA、对象布局或字段类型时,才调用 IDA Pro MCP;没有 IDA 时使用 iced-x86 兜底:
@@ -1,15 +1,59 @@
1
1
  # 反汇编与 IDA Pro 分支
2
2
 
3
- 静态分析是按需使用的证据放大器,不是每个查询的必经步骤。运行时 text、字节样本、Hook 调用记录或返回值都可以作为输入。先检查当前 Agent 是否能调用本机 IDA Pro MCP:
3
+ 静态分析是按需使用的证据放大器,不是每个查询的必经步骤。需要静态证据时,先用 `wowdump analyze runtime --kind dump` 导出运行时 `.text`、`.rdata`、`.data`、`.pdata` 以及 manifest;运行时字节样本、Hook 调用记录或返回值也可以作为输入。先检查当前 Agent 是否能调用本机 IDA Pro MCP:
4
+
5
+ ## IDA Pro MCP 下载与安装
6
+
7
+ 官方来源:
8
+
9
+ - GitHub:https://github.com/mrexodia/ida-pro-mcp
10
+ - Hex-Rays 插件页:https://plugins.hex-rays.com/mrexodia/ida-pro-mcp
11
+
12
+ 使用 Codex 时,官方仓库提供插件市场安装方式:
13
+
14
+ ```text
15
+ codex plugin marketplace add mrexodia/codex-marketplace
16
+ codex plugin add ida-pro-mcp@mrexodia
17
+ ```
18
+
19
+ IDA Pro MCP 需要 **IDA Pro 8.3+**(不支持 IDA Free)、Python 3.11+,headless `idalib` 路径还需要 `uv`。安装后重启 IDA 和 Codex,使 MCP 工具生效。若插件市场不可用,按官方仓库 README 的 GUI 或 `idalib-mcp` 安装说明操作;不要在项目中复制或维护 MCP 的内部 Python/IDA 代码。
4
20
 
5
21
  - 能调用时,让 MCP 只分析当前字段相关样本附近的函数、字符串、调用关系和结构,把结果保存为 JSON。随后回到 dynamic Hook 验证候选函数,不要直接把静态候选当成可读字段。
6
- - 不能调用时执行:
22
+
23
+ ## IDA MCP 会话保活与重连
24
+
25
+ IDA 的 headless worker 不是常驻进程。`idb_list` 中仍有 session 记录,不代表对应 worker 仍然可用;查询返回 `Worker for session ... is not reachable` 时,不能继续复用该 session。
26
+
27
+ 打开数据库时使用较长的空闲 TTL,并开启自动分析和缓存:
28
+
29
+ ```json
30
+ {
31
+ "input_path": "C:\\Games\\World of Warcraft\\_retail_\\Wow.exe",
32
+ "mode": "prefer_headless",
33
+ "run_auto_analysis": true,
34
+ "build_caches": true,
35
+ "init_hexrays": true,
36
+ "idle_ttl_sec": 3600
37
+ }
38
+ ```
39
+
40
+ 每次查询前先调用 `server_health({ database: sessionId })`,确认 `status: "ok"`、`auto_analysis_ready: true`。长查询之间也要做一次 health probe,避免 worker 被回收后才发现会话失效。
41
+
42
+ 失联时只做一次恢复:
43
+
44
+ 1. 丢弃失联的 session ID;如果服务仍列出它,调用 `idb_close`。
45
+ 2. 用相同 `input_path` 和 `idle_ttl_sec: 3600` 重新调用 `idb_open`。
46
+ 3. 对新 session 做 `server_health`,成功后重试原查询一次。
47
+ 4. 第二次仍失联时停止扩大分析范围,保存已取得的 JSON 证据并转用 iced-x86 或请求用户稍后重试。
48
+
49
+ 不要把 `session_id`、IDA 临时 worker PID 或 `0x140000000` 这类 IDA image base 写入持久化 profile。profile 只保存相对模块的 RVA、字段类型和可复核证据。IDA MCP 会话和 wowdump 的 Windows broker 是两条独立链路:前者负责反汇编,后者负责管理员内存读取;broker 复用不会延长 IDA worker 生命周期。
50
+ - IDA 不可用时执行:
7
51
 
8
52
  ```powershell
9
53
  wowdump analyze disassemble `
10
54
  --exe "C:\Games\World of Warcraft\_retail_\Wow.exe" `
11
55
  --build "retail@VERSION" `
12
- --runtime-export "$HOME\.wowdump\runtime\SESSION\text.json" `
56
+ --runtime-export "$HOME\.wowdump\retail@VERSION\runtime\SESSION\state.json" `
13
57
  --output "$HOME\.wowdump\runtime\SESSION\candidate.profile.json"
14
58
  ```
15
59
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  ## Disassemble(按需使用)
18
18
 
19
- `--runtime-export` 指向 dynamic JSON。只有 dynamic 线索不足以确认函数、布局或类型时才调用 IDA Pro MCP;没有时再用 `analyze disassemble` 的 iced-x86 解码。分析后回到 dynamic 验证。输出 profile 时,每个字段至少给出:
19
+ `analyze runtime --kind dump` 的 `manifest.json` 和段文件是静态分析输入;`--runtime-export` 仍可指向 dynamic JSON。只有 dynamic 线索不足以确认函数、布局或类型时才调用 IDA Pro MCP;没有时再用 iced-x86 解码。分析后回到 dynamic 或 `analyze runtime --kind verify --profile <file>` 验证。输出 profile 时,每个字段至少给出:
20
20
 
21
21
  ```json
22
22
  {
@@ -11,6 +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
15
  | 有候选函数但不知道其语义 | Dynamic Hook,配合用户触发一次相关动作 | `this`、参数、返回值与目标字段相关 |
15
16
  | 有调用但不知道字段偏移/类型 | IDA Pro MCP 局部反汇编;无 IDA 时 iced-x86 | 指令访问行为能解释对象和字段 |
16
17
  | 静态候选需要运行时确认 | 回到 Dynamic Hook 或快照 | 候选 RVA 在当前模块命中且数据变化符合预期 |
@@ -20,7 +21,7 @@
20
21
 
21
22
  1. 先检查是否已有同 build 的 `reader_ready` profile;有则直接 Reader,失败再回到 Dynamic。
22
23
  2. 没有 profile 时优先执行 Agent 编写的 GumJS。脚本可以直接 Hook 已知 RVA,也可以在限定范围内扫描候选。
23
- 3. Dynamic 只拿到线索时,再让 IDA Pro MCP 分析对应函数或字节附近区域。不要对整个模块做无目标导出。
24
+ 3. Dynamic 只拿到线索时,先用 `analyze runtime --kind dump` 导出相关 PE 段,再让 IDA Pro MCP 分析对应函数、字符串和交叉引用。不要把 dump 当成 profile。
24
25
  4. 没有 IDA 时使用 `analyze disassemble`/iced-x86 作为局部解码工具,得到候选后仍回到 Dynamic 验证。
25
26
  5. 每轮最多扩大一个维度:函数数量、扫描范围、读取字节数或采样时长。连续两轮没有新增证据时,停止扩大并请求用户触发相关游戏动作或确认字段范围。
26
27