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