wowdump 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -5,6 +5,9 @@ export const DEFAULT_RIGHTS = Object.freeze([
5
5
  "PROCESS_QUERY_INFORMATION",
6
6
  "PROCESS_VM_READ"
7
7
  ]);
8
+ const MEM_COMMIT = 0x1000;
9
+ const DEFAULT_QUERY_END = BigInt("0x7fffffffffff");
10
+ const MAX_QUERY_REGIONS = 100_000;
8
11
  function hexAddress(value) {
9
12
  const normalized = value.trim();
10
13
  if (!/^0x[0-9a-f]+$/i.test(normalized)) {
@@ -30,6 +33,15 @@ function boundedPid(value) {
30
33
  }
31
34
  return pid;
32
35
  }
36
+ function boundedCount(value, name, maximum, fallback) {
37
+ if (value === undefined)
38
+ return fallback;
39
+ const count = Number(value);
40
+ if (!Number.isSafeInteger(count) || count < 1 || count > maximum) {
41
+ throw new ReaderProtocolError("INVALID_COUNT", `${name} must be an integer between 1 and ${maximum}`);
42
+ }
43
+ return count;
44
+ }
33
45
  function toHex(bytes) {
34
46
  return Buffer.from(bytes).toString("hex");
35
47
  }
@@ -169,6 +181,8 @@ export class ReaderBroker {
169
181
  return this.runFrida(request);
170
182
  case "modules":
171
183
  return this.modules(request);
184
+ case "regions":
185
+ return this.regions(request);
172
186
  case "open":
173
187
  return this.open(request);
174
188
  case "close":
@@ -255,6 +269,63 @@ export class ReaderBroker {
255
269
  }
256
270
  return { pid, modules: await this.backend.enumerateModules(pid) };
257
271
  }
272
+ async regions(request) {
273
+ if (!this.backend.virtualQueryEx) {
274
+ throw new ReaderProtocolError("VIRTUAL_QUERY_UNAVAILABLE", "native VirtualQueryEx backend is not installed");
275
+ }
276
+ const pid = boundedPid(request.pid);
277
+ const start = hexAddress(request.start ?? "0x0");
278
+ const end = hexAddress(request.end ?? `0x${DEFAULT_QUERY_END.toString(16)}`);
279
+ if (end < start)
280
+ throw new ReaderProtocolError("INVALID_RANGE", "end must be greater than or equal to start");
281
+ const maxRegions = boundedCount(request.maxRegions, "maxRegions", MAX_QUERY_REGIONS, 4096);
282
+ const includeFree = request.includeFree === true;
283
+ const handle = await this.getHandle(pid, request.handle);
284
+ const regions = [];
285
+ let cursor = start;
286
+ let truncated = false;
287
+ while (cursor <= end) {
288
+ const raw = await this.backend.virtualQueryEx(handle, cursor);
289
+ const baseAddress = hexAddress(String(raw.baseAddress ?? "0x0"));
290
+ let regionSize;
291
+ try {
292
+ regionSize = BigInt(String(raw.regionSize ?? "0"));
293
+ }
294
+ catch {
295
+ throw new ReaderProtocolError("NATIVE_RESULT", "VirtualQueryEx returned an invalid region size");
296
+ }
297
+ if (regionSize <= 0n)
298
+ throw new ReaderProtocolError("NATIVE_RESULT", "VirtualQueryEx returned a non-positive region size");
299
+ const next = baseAddress + regionSize;
300
+ if (next <= cursor)
301
+ throw new ReaderProtocolError("NATIVE_RESULT", "VirtualQueryEx did not advance the query address");
302
+ const state = Number(raw.state);
303
+ if (includeFree || state === MEM_COMMIT) {
304
+ regions.push({
305
+ ...raw,
306
+ baseAddress: `0x${baseAddress.toString(16)}`,
307
+ regionSize: regionSize.toString(),
308
+ committed: state === MEM_COMMIT
309
+ });
310
+ }
311
+ cursor = next;
312
+ if (regions.length >= maxRegions) {
313
+ truncated = cursor <= end;
314
+ break;
315
+ }
316
+ }
317
+ return {
318
+ pid,
319
+ handle: handle.id,
320
+ start: `0x${start.toString(16)}`,
321
+ end: `0x${end.toString(16)}`,
322
+ includeFree,
323
+ maxRegions,
324
+ regions,
325
+ nextAddress: `0x${cursor.toString(16)}`,
326
+ truncated
327
+ };
328
+ }
258
329
  async watchStart(request) {
259
330
  const pid = boundedPid(request.pid);
260
331
  const address = hexAddress(request.address);
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.5",
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,22 @@ 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
+ 需要定位堆或确认可读范围时,先用 `wowdump memory regions --pid <pid> --start <hex> --end <hex>` 枚举 `VirtualQueryEx` 返回的已提交区,再用同一 broker 做有界 `memory read`;默认不包含 free/reserved 区。
16
+ 4. 没有可用 profile 时,由 Agent 编写并显式传入 GumJS:`wowdump analyze dynamic --pid <pid> --build <buildKey> --script <GumJS> --confirm`。脚本可做快照、有限 Hook 和事件采集。
17
+ 5. 动态线索需要静态证据时运行 `wowdump analyze runtime --kind dump`。dump 只写当前 session 的 `.text/.rdata/.pdata`,`.data` 通过 `--sections` 按需加入;不创建新的静态数据库。
18
+ 6. 优先让 IDA Pro MCP 打开该 build `database/ida/Wow.i64`,按 manifest runtime overlay 映射到原始 Wow.exe,输出函数 RVA、字符串 RVA、XREF、结构偏移和字段类型。没有 IDA MCP 时,用 iced-x86 对相关范围局部反汇编。
19
+ 7. 将静态结果和动态证据合并为 runtime session 下的 `candidate.profile.json`,只保存 RVA/相对偏移,不保存绝对地址。
20
+ 8. 用 `wowdump analyze runtime --kind verify --profile <candidate> --confirm` 做 broker 实读;确认 build/hash、地址、类型、边界和读取值一致后,才把 profile 复制到 `~/.wowdump/<buildKey>/profile/`。
19
21
 
20
22
  不要把 dynamic、IDA 或 iced 固定成一次性线性流水线。选择下一步的依据是当前字段缺少什么证据:值用 dynamic,函数/布局用 IDA/iced,稳定读取用 reader。
21
23
 
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)。
24
+ 具体决策见 [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
25
 
24
26
  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
  ## 读取
@@ -74,4 +78,16 @@ wowdump memory read `
74
78
 
75
79
  需要连续观察时使用 `memory watch start/poll/stop`。字段未定义、模块不匹配、指针为空或发生短读时,保留 JSON 证据并停止该字段,不改写 profile。
76
80
 
81
+ 需要枚举目标进程的虚拟内存布局时使用 `memory regions`。它通过同一个管理员 broker 调用 Windows `VirtualQueryEx`,默认只返回 `MEM_COMMIT` 区域;查询有地址范围和数量上限,不会把整片地址空间读入内存:
82
+
83
+ ```powershell
84
+ wowdump memory regions `
85
+ --pid 33976 `
86
+ --start 0x15000000000 `
87
+ --end 0x15400000000 `
88
+ --max-regions 20000
89
+ ```
90
+
91
+ 需要诊断空闲/保留区时显式加入 `--include-free`。输出包含 `baseAddress`、`regionSize`、`state`、`protect`、`type` 和 `committed`,可将已提交的 heap 区域交给后续只读扫描。
92
+
77
93
  静态分析完成后回到 `analyze dynamic` Hook 验证候选,再生成 profile。短时 Hook/事件采集可以复制 [scripts/dynamic-session.js](../scripts/dynamic-session.js) 后按请求修改;它不会被 CLI 自动执行。详细脚本规则见 [dynamic.md](dynamic.md) 和 [disassemble.md](disassemble.md)。字段请求格式见 [request-schema.md](request-schema.md),profile 生命周期见 [profiles.md](profiles.md)。
@@ -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。