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 +9 -5
- package/dist/analysis/frida-runtime.js +26 -0
- package/dist/analysis/runtime-dump.js +93 -0
- package/dist/analysis/runtime-script.js +6 -20
- package/dist/cli.js +168 -86
- package/dist/core/build-store.js +166 -0
- package/dist/core/profile-engine.js +9 -0
- package/dist/frida-worker.js +59 -5
- package/dist/toolchain.js +11 -22
- package/package.json +1 -1
- package/skills/wowdump/SKILL.md +10 -9
- package/skills/wowdump/references/commands.md +15 -11
- package/skills/wowdump/references/disassemble.md +2 -12
- package/skills/wowdump/references/profiles.md +16 -5
- package/skills/wowdump/references/request-schema.md +1 -1
- package/skills/wowdump/references/workflow.md +3 -3
- package/dist/analysis/disassemble.js +0 -77
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
|
-
#
|
|
21
|
-
#
|
|
24
|
+
# 3. 用 IDA Pro MCP(若可用)或 iced-x86 分析 session dump manifest,保存字段候选和证据
|
|
25
|
+
# 4. 将候选证据交给 verify,再用 Reader 验证
|
|
22
26
|
|
|
23
|
-
#
|
|
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`
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
11
|
-
import {
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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("--
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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 });
|
package/dist/frida-worker.js
CHANGED
|
@@ -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:
|
|
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 {
|
|
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 {
|
|
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:
|
|
9
|
+
description: 用 Frida 运行时证据和 IDA Pro MCP 或 iced-x86 定位 WoW 字段,生成并验证 Reader profile。
|
|
12
10
|
---
|
|
13
11
|
|
|
14
12
|
# wowdump
|
|
15
13
|
|
|
16
|
-
|
|
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
|
|
22
|
-
3. \`wowdump analyze
|
|
23
|
-
4. \`wowdump analyze
|
|
24
|
-
5. \`wowdump
|
|
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\`
|
|
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
|
|
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
package/skills/wowdump/SKILL.md
CHANGED
|
@@ -5,20 +5,21 @@ description: 以 Frida 运行时探测为主,按证据需要交替使用 IDA P
|
|
|
5
5
|
|
|
6
6
|
# wowdump
|
|
7
7
|
|
|
8
|
-
用于读取角色属性、Buff/Debuff
|
|
8
|
+
用于读取角色属性、Buff/Debuff、技能冷却、附近单位等原生字段。每个 build 有一个静态数据库,运行时证据按 session 保存,Reader 只读取已验证的 profile。
|
|
9
9
|
|
|
10
10
|
## 默认策略
|
|
11
11
|
|
|
12
|
-
1. 先运行 `wowdump targets`,确认 PID、Wow.exe
|
|
13
|
-
2.
|
|
14
|
-
3.
|
|
15
|
-
4.
|
|
16
|
-
5.
|
|
17
|
-
6.
|
|
18
|
-
7.
|
|
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
|
-
|
|
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
|
|
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
|
-
--
|
|
36
|
+
--sections .text .rdata .pdata `
|
|
35
37
|
--confirm
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
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`、`.
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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 --
|
|
43
|
+
5. 后续读取优先使用这个绝对路径,或用 `wowdump profiles --build <buildKey>` 列出它。Reader 只读取 profile,不读取 `database/ida` 和 `runtime/dump/*.bin`。
|
|
33
44
|
|
|
34
45
|
profile 只对记录的 buildKey 和模块布局有效。发现 buildKey 不匹配时停止读取并重新走分析流程,不修改旧 profile。
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
| --- | --- | --- |
|
|
12
12
|
| 不知道模块/进程 | `wowdump targets` | PID、路径、buildKey、模块基址明确 |
|
|
13
13
|
| 不知道对象或函数是否被调用 | Dynamic GumJS 枚举、有限扫描或 Hook | 得到对象地址、候选函数或调用事件 |
|
|
14
|
-
| 需要给 IDA/iced
|
|
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`
|
|
25
|
-
4. 没有 IDA
|
|
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
|
-
}
|