wowdump 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,19 +14,23 @@ 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. 发现目标并创建/复用 build 数据库
18
+ wowdump targets
19
+ wowdump database status --build "retail@VERSION"
19
20
 
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"
21
+ # 2. 导出运行时模块段
22
+ wowdump analyze runtime --pid 1234 --build "retail@VERSION" --kind dump --confirm
22
23
 
23
- # 3. 用户确认后,把 profile 保存到当前 build profile 目录,再读取
24
+ # 3. IDA Pro MCP(若可用)或 iced-x86 分析 session dump manifest,保存字段候选和证据
25
+ # 4. 将候选证据交给 verify,再用 Reader 验证
26
+
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
- 当前 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
+ }
@@ -1,36 +1,156 @@
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
+
20
+ function readBytes(address, size) {
21
+ const value = address.readByteArray(size);
22
+ return value ? new Uint8Array(value) : null;
23
+ }
24
+
25
+ function bytesHex(address, size) {
26
+ const bytes = readBytes(address, size);
27
+ if (!bytes) return "";
28
+ let output = "";
29
+ for (let index = 0; index < bytes.length; index++) output += bytes[index].toString(16).padStart(2, "0");
30
+ return output;
31
+ }
32
+
33
+ function ascii(address, size) {
34
+ const bytes = readBytes(address, size);
35
+ if (!bytes) return "";
36
+ let output = "";
37
+ for (let index = 0; index < bytes.length; index++) {
38
+ const value = bytes[index];
39
+ output += value >= 0x20 && value <= 0x7e ? String.fromCharCode(value) : "";
40
+ }
41
+ return output;
42
+ }
43
+
44
+ function u16(address) { return address.readU16(); }
45
+ function u32(address) { return address.readU32(); }
46
+ function sectionName(address) {
47
+ return ascii(address, 8).replace(/\0/g, "").trim();
48
+ }
49
+
50
+ function parseSections() {
51
+ if (!module) return { error: "module-missing", sections: [] };
52
+ try {
53
+ const base = module.base;
54
+ if (u16(base) !== 0x5a4d) return { error: "invalid-dos-signature", sections: [] };
55
+ const peOffset = u32(base.add(0x3c));
56
+ if (peOffset < 0x40 || peOffset > module.size - 24) return { error: "invalid-pe-offset", sections: [] };
57
+ const pe = base.add(peOffset);
58
+ if (u32(pe) !== 0x00004550) return { error: "invalid-pe-signature", sections: [] };
59
+ const count = u16(pe.add(6));
60
+ const optionalSize = u16(pe.add(20));
61
+ if (count < 1 || count > 96 || optionalSize < 0x60) return { error: "invalid-section-table", sections: [] };
62
+ const table = pe.add(24 + optionalSize);
63
+ const sections = [];
64
+ for (let index = 0; index < count; index++) {
65
+ const header = table.add(index * 40);
66
+ const name = sectionName(header);
67
+ const virtualSize = u32(header.add(8));
68
+ const rva = u32(header.add(12));
69
+ const rawSize = u32(header.add(16));
70
+ const characteristics = u32(header.add(36));
71
+ const size = Math.max(virtualSize, rawSize);
72
+ if (!name || !size || rva >= module.size) continue;
73
+ const boundedSize = Math.min(size, module.size - rva);
74
+ const executable = (characteristics & 0x20000000) !== 0;
75
+ const writable = (characteristics & 0x80000000) !== 0;
76
+ const readable = (characteristics & 0x40000000) !== 0;
77
+ sections.push({
78
+ name,
79
+ rva: "0x" + rva.toString(16),
80
+ runtimeAddress: base.add(rva).toString(),
81
+ virtualSize,
82
+ rawSize,
83
+ characteristics: "0x" + characteristics.toString(16),
84
+ protection: (readable ? "r" : "") + (writable ? "w" : "") + (executable ? "x" : ""),
85
+ size: boundedSize
86
+ });
22
87
  }
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 }] };
88
+ return { error: null, sections };
89
+ } catch (error) {
90
+ return { error: String(error), sections: [] };
91
+ }
92
+ }
93
+
94
+ function dumpSection(section, totalState) {
95
+ const requestedSize = Math.min(section.size, maxSectionBytes);
96
+ const available = Math.max(0, maxTotalBytes - totalState.bytes);
97
+ const readSize = Math.min(requestedSize, available);
98
+ let offset = 0;
99
+ while (offset < readSize) {
100
+ const size = Math.min(chunkSize, readSize - offset);
101
+ const bytes = readBytes(module.base.add(parseInt(section.rva, 16)).add(offset), size);
102
+ if (!bytes) break;
103
+ send({ type: "wowdump.dump.chunk", section: section.name, offset, size: bytes.length }, bytes.buffer);
104
+ offset += bytes.length;
105
+ if (bytes.length !== size) break;
106
+ }
107
+ totalState.bytes += offset;
108
+ return { ...section, requestedSize, readSize: offset, truncated: offset < section.size };
109
+ }
110
+
111
+ function candidateEntries(profile) {
112
+ const entries = [];
113
+ const fields = profile && typeof profile.fields === "object" && !Array.isArray(profile.fields) ? profile.fields : {};
114
+ for (const [name, value] of Object.entries(fields)) entries.push({ name, value });
115
+ return entries.slice(0, maxCandidates);
116
+ }
117
+
118
+ function candidateRva(value) {
119
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
120
+ const item = value;
121
+ const root = item.root && typeof item.root === "object" && !Array.isArray(item.root) ? item.root : {};
122
+ const rva = item.rva ?? root.rva;
123
+ if (typeof rva !== "string" && typeof rva !== "number") return null;
124
+ try { return ptr(String(rva)).toString(); } catch (_) { return null; }
125
+ }
126
+
127
+ function collect() {
128
+ if (!module) return { ok: false, kind: input.kind || "dump", module: { name: moduleName }, sections: [], records: [], evidence: [{ source: "frida", kind: "module-missing" }] };
129
+ if (input.kind === "dump") {
130
+ const parsed = parseSections();
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 }] };
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()));
136
+ const totalState = { bytes: 0 };
137
+ const sections = selected.map(section => dumpSection(section, totalState));
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 }] };
24
139
  }
25
- const profile = input.profile && typeof input.profile === "object" ? input.profile : {};
26
- const candidates = Array.isArray(profile.providers) ? profile.providers : [];
140
+ if (input.kind !== "verify") return { ok: false, kind: input.kind || null, module: { name: module.name }, records: [], evidence: [{ source: "frida", kind: "unsupported-runtime-kind" }] };
141
+ const profile = input.profile && typeof input.profile === "object" ? input.profile : null;
142
+ if (!profile) return { ok: false, kind: "verify", module: { name: module.name, moduleBase: module.base.toString() }, records: [], evidence: [{ source: "frida", kind: "profile-required" }] };
27
143
  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" });
144
+ for (const entry of candidateEntries(profile)) {
145
+ const rva = candidateRva(entry.value);
146
+ if (rva === null) { records.push({ name: entry.name, ok: false, reason: "missing-rva", source: "frida" }); continue; }
147
+ const address = module.base.add(ptr(rva));
148
+ try {
149
+ const bytes = bytesHex(address, Math.min(Number(entry.value.size || entry.value.byteSize || maxVerifyBytes), maxVerifyBytes));
150
+ 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" });
151
+ } catch (error) { records.push({ name: entry.name, rva, runtimeAddress: address.toString(), ok: false, reason: String(error), source: "frida" }); }
32
152
  }
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 }] };
153
+ 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
154
  }
35
155
  rpc.exports = { collect };
36
156
  `;