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.
- package/README.md +12 -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 +182 -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/reader/broker.js +71 -0
- package/dist/toolchain.js +11 -22
- package/package.json +1 -1
- package/skills/wowdump/SKILL.md +11 -9
- package/skills/wowdump/references/commands.md +27 -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,26 @@ 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
|
|
29
|
+
|
|
30
|
+
# 枚举已提交虚拟内存区(复用同一个管理员 broker)
|
|
31
|
+
wowdump memory regions --pid 1234 --start 0x15000000000 --end 0x15400000000 --max-regions 20000
|
|
25
32
|
```
|
|
26
33
|
|
|
27
34
|
Windows reader 首次使用时由 Node 请求 UAC 启动 broker,后续调用复用同一 broker,空闲 20 分钟后退出。Frida 运行时导出需要显式 `--confirm`;高开销 Stalker 默认关闭。
|
|
28
35
|
|
|
29
|
-
`analyze runtime --kind dump`
|
|
36
|
+
`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
37
|
|
|
31
38
|
## 开发验证
|
|
32
39
|
|
|
@@ -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);
|
|
@@ -419,6 +475,20 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
419
475
|
}
|
|
420
476
|
writeJson(io, await broker({ command: "read", payload }));
|
|
421
477
|
});
|
|
478
|
+
memory.command("regions")
|
|
479
|
+
.description("Enumerate committed virtual memory regions through VirtualQueryEx")
|
|
480
|
+
.requiredOption("--pid <pid>", "target process ID")
|
|
481
|
+
.option("--start <hex>", "query start address", "0x0")
|
|
482
|
+
.option("--end <hex>", "query end address")
|
|
483
|
+
.option("--max-regions <count>", "maximum returned regions", "4096")
|
|
484
|
+
.option("--include-free", "include free and reserved regions")
|
|
485
|
+
.action(async (options) => writeJson(io, await broker({ command: "regions", payload: {
|
|
486
|
+
pid: positiveInteger(options.pid, "pid"),
|
|
487
|
+
start: options.start,
|
|
488
|
+
...(options.end ? { end: options.end } : {}),
|
|
489
|
+
maxRegions: positiveInteger(options.maxRegions, "max-regions"),
|
|
490
|
+
includeFree: options.includeFree === true
|
|
491
|
+
} })));
|
|
422
492
|
const watch = memory.command("watch").description("Manage broker-owned memory monitors");
|
|
423
493
|
watch.command("start")
|
|
424
494
|
.description("Start a monitor")
|
|
@@ -458,25 +528,7 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
458
528
|
.description("Stop a monitor and release its resources")
|
|
459
529
|
.requiredOption("--id <watchId>", "monitor ID")
|
|
460
530
|
.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
|
-
})));
|
|
531
|
+
const analyze = program.command("analyze").description("Run Frida evidence export and bounded runtime operations");
|
|
480
532
|
analyze.command("runtime")
|
|
481
533
|
.description("Dump runtime PE sections or verify profile candidates through the elevated Frida broker")
|
|
482
534
|
.requiredOption("--pid <pid>", "target process ID")
|
|
@@ -486,6 +538,8 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
486
538
|
.option("--worker <path>", "Frida worker entry")
|
|
487
539
|
.option("--profile <path>", "reader profile containing candidate RVAs (verify only)")
|
|
488
540
|
.option("--output-dir <path>", "directory for dump manifest and section binaries")
|
|
541
|
+
.option("--sections <names...>", "sections to dump (default: .text .rdata .pdata)")
|
|
542
|
+
.option("--no-progress", "disable dump progress on stderr")
|
|
489
543
|
.option("--max-section-bytes <bytes>", "per-section dump limit", "134217728")
|
|
490
544
|
.option("--max-total-bytes <bytes>", "total dump limit", "268435456")
|
|
491
545
|
.option("--chunk-size <bytes>", "dump chunk size", "1048576")
|
|
@@ -521,12 +575,41 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
521
575
|
const profile = options.profile
|
|
522
576
|
? jsonRecord(await readFile(resolve(options.profile), "utf8"), options.profile)
|
|
523
577
|
: undefined;
|
|
578
|
+
const buildRecord = await readBuild(home, normalized.build);
|
|
579
|
+
if (profile)
|
|
580
|
+
await validateProfileIdentity(home, normalized.build, profile, resolve(options.profile));
|
|
581
|
+
const sections = Array.isArray(options.sections) && options.sections.length > 0
|
|
582
|
+
? options.sections.map((value) => String(value).toLowerCase())
|
|
583
|
+
: [".text", ".rdata", ".pdata"];
|
|
584
|
+
if (normalized.kind === "dump") {
|
|
585
|
+
const reusable = buildRecord?.executableSha256
|
|
586
|
+
? await findReusableDump(home, normalized.build, {
|
|
587
|
+
executableSha256: buildRecord.executableSha256,
|
|
588
|
+
sections,
|
|
589
|
+
maxSectionBytes: normalized.maxSectionBytes,
|
|
590
|
+
maxTotalBytes: normalized.maxTotalBytes,
|
|
591
|
+
chunkSize: normalized.chunkSize
|
|
592
|
+
})
|
|
593
|
+
: null;
|
|
594
|
+
if (reusable) {
|
|
595
|
+
writeJson(io, { ok: true, command: "analyze.runtime.dump", reused: true, manifestFile: String(reusable.manifestFile ?? ""), manifest: reusable });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const sessionId = randomUUID();
|
|
600
|
+
const sessionDirectory = options.outputDir
|
|
601
|
+
? resolve(options.outputDir, "..")
|
|
602
|
+
: join(buildPaths(home, normalized.build).runtime, sessionId);
|
|
603
|
+
const outputDirectory = resolve(options.outputDir ?? join(sessionDirectory, "dump"));
|
|
604
|
+
await mkdir(sessionDirectory, { recursive: true });
|
|
605
|
+
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
606
|
const request = {
|
|
525
|
-
command: "dynamic-script",
|
|
607
|
+
command: normalized.kind === "dump" ? "runtime-dump" : "dynamic-script",
|
|
526
608
|
pid: normalized.pid,
|
|
527
609
|
build: normalized.build,
|
|
528
610
|
source: RUNTIME_EXPORT_SCRIPT,
|
|
529
611
|
exportName: "collect",
|
|
612
|
+
...(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
613
|
args: {
|
|
531
614
|
kind: normalized.kind,
|
|
532
615
|
maxHooks: normalized.maxHooks,
|
|
@@ -537,22 +620,29 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
537
620
|
maxTotalBytes: normalized.maxTotalBytes,
|
|
538
621
|
chunkSize: normalized.chunkSize,
|
|
539
622
|
maxVerifyBytes: normalized.maxVerifyBytes,
|
|
623
|
+
...(normalized.kind === "dump" ? { sections } : {}),
|
|
540
624
|
...(profile ? { profile } : {})
|
|
541
625
|
},
|
|
542
626
|
callArgs: [],
|
|
543
627
|
durationMs: normalized.durationMs
|
|
544
628
|
};
|
|
545
629
|
const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
|
|
630
|
+
if (result.stderr && options.progress !== false)
|
|
631
|
+
io.stderr.write(result.stderr);
|
|
546
632
|
if (result.exitCode !== 0)
|
|
547
633
|
throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
|
|
548
634
|
const line = result.stdout.split(/\r?\n/).find(value => value.trim());
|
|
549
635
|
const value = line ? jsonRecord(line, "runtime result") : { ok: true, stdout: result.stdout };
|
|
550
636
|
if (normalized.kind === "dump" && value.ok === true) {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
637
|
+
if (typeof value.manifestFile === "string") {
|
|
638
|
+
await writeFile(join(sessionDirectory, "dynamic.json"), `${JSON.stringify(value.manifest ?? value, null, 2)}\n`, "utf8").catch(() => undefined);
|
|
639
|
+
writeJson(io, { ...value, command: "analyze.runtime.dump", sessionDirectory });
|
|
640
|
+
}
|
|
641
|
+
else
|
|
642
|
+
throw new CliError("RUNTIME_DUMP_INVALID", "runtime dump worker returned no manifest");
|
|
554
643
|
}
|
|
555
644
|
else {
|
|
645
|
+
await writeFile(join(sessionDirectory, "verify.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8").catch(() => undefined);
|
|
556
646
|
writeJson(io, { ...value, command: "analyze.runtime.verify" });
|
|
557
647
|
}
|
|
558
648
|
});
|
|
@@ -586,11 +676,17 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
586
676
|
if (!existsSync(worker))
|
|
587
677
|
throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
|
|
588
678
|
const request = { command: "dynamic-script", ...selected(normalized, ["pid", "build", "script", "exportName", "args", "callArgs", "durationMs"]) };
|
|
679
|
+
const sessionId = randomUUID();
|
|
680
|
+
const sessionDirectory = join(buildPaths(home, normalized.build).runtime, sessionId);
|
|
681
|
+
await mkdir(sessionDirectory, { recursive: true });
|
|
682
|
+
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
683
|
const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
|
|
590
684
|
if (result.exitCode !== 0)
|
|
591
685
|
throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
|
|
592
686
|
const line = result.stdout.split(/\r?\n/).find(value => value.trim());
|
|
593
|
-
|
|
687
|
+
const value = line ? JSON.parse(line) : { ok: true, command: "analyze.dynamic", stdout: result.stdout };
|
|
688
|
+
await writeFile(join(sessionDirectory, "dynamic.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
689
|
+
writeJson(io, { ...value, command: "analyze.dynamic", sessionDirectory });
|
|
594
690
|
});
|
|
595
691
|
program.command("init")
|
|
596
692
|
.description("Initialize WOWDUMP_HOME without overwriting existing files")
|