wowdump 0.2.1 → 0.3.2
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/LICENSE +1 -1
- package/README.md +17 -111
- package/dist/adapters/reader.js +33 -0
- package/dist/analysis/disassemble.js +77 -0
- package/dist/{frida-runtime.js → analysis/frida-runtime.js} +3 -25
- package/dist/analysis/runtime-script.js +36 -0
- package/dist/cli.js +563 -0
- package/dist/core/profile-engine.js +238 -0
- package/dist/frida-worker.js +99 -0
- package/dist/reader/broker.js +475 -0
- package/dist/reader/client.js +1 -0
- package/dist/reader/launcher.js +219 -0
- package/dist/reader/main.js +100 -0
- package/dist/reader/protocol.js +1 -0
- package/dist/reader/windows.js +242 -0
- package/dist/reader-main.js +2 -0
- package/dist/toolchain.js +123 -0
- package/package.json +19 -37
- package/skills/wowdump/SKILL.md +22 -0
- package/skills/wowdump/references/commands.md +63 -0
- package/skills/wowdump/references/disassemble.md +18 -0
- package/skills/wowdump/references/dynamic.md +54 -0
- package/skills/wowdump/references/evidence-workflow.md +41 -0
- package/skills/wowdump/references/profiles.md +34 -0
- package/skills/wowdump/references/request-schema.md +28 -0
- package/skills/wowdump/references/workflow.md +44 -0
- package/skills/wowdump/scripts/dynamic-session.js +133 -0
- package/dist/agent.js +0 -1335
- package/dist/analysis-path.js +0 -38
- package/dist/analysis-process-log.js +0 -146
- package/dist/broker-client.js +0 -411
- package/dist/broker-codec.js +0 -148
- package/dist/broker-core.js +0 -1045
- package/dist/broker-gateway.js +0 -447
- package/dist/broker-ledger.js +0 -196
- package/dist/broker-main.js +0 -291
- package/dist/broker-protocol.js +0 -119
- package/dist/broker-runtime.js +0 -1283
- package/dist/broker-server.js +0 -466
- package/dist/build-bundle-loader.js +0 -183
- package/dist/build-bundle.js +0 -11
- package/dist/discovery.js +0 -59
- package/dist/dry-run.js +0 -38
- package/dist/error-log.js +0 -71
- package/dist/focus-errors.js +0 -63
- package/dist/focus-service.js +0 -1855
- package/dist/focused-session.js +0 -1357
- package/dist/mcp-main.js +0 -51
- package/dist/mcp.js +0 -924
- package/dist/observability.js +0 -41
- package/dist/process-log-lock.js +0 -195
- package/dist/processes.js +0 -47
- package/dist/runtime-config.js +0 -399
- package/dist/session.js +0 -145
- package/dist/storage.js +0 -12
- package/dist/wow-analysis.js +0 -1430
- package/resources/builds/retail/12.0.7.68974/build-profile.json +0 -290
- package/resources/builds/retail/12.0.7.68974/data-sources.json +0 -1633
- package/resources/builds/retail/12.0.7.68974/lua-targets.jsonl +0 -5130
- package/resources/builds/retail/12.0.7.68974/manifest.json +0 -63
- package/resources/builds/retail/12.0.7.68974/signatures.json +0 -260
- /package/dist/{adapters.js → core/build-adapters.js} +0 -0
- /package/dist/{types.js → core/types.js} +0 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
6
|
+
import { serveReaderBroker } from "./broker.js";
|
|
7
|
+
import { createWindowsNativeReader } from "./windows.js";
|
|
8
|
+
async function runElevatedFrida(request) {
|
|
9
|
+
const worker = resolve(request.worker);
|
|
10
|
+
if (!isAbsolute(request.worker) || worker !== request.worker && process.platform === "win32") {
|
|
11
|
+
throw new Error("Frida worker path must be absolute");
|
|
12
|
+
}
|
|
13
|
+
const timeoutMs = Math.max(1, Math.min(Number(request.timeoutMs ?? 120_000), 600_000));
|
|
14
|
+
return new Promise(resolveResult => {
|
|
15
|
+
const child = spawn(process.execPath, [worker], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
16
|
+
let stdout = "";
|
|
17
|
+
let stderr = "";
|
|
18
|
+
let settled = false;
|
|
19
|
+
const finish = (exitCode, timedOut = false) => {
|
|
20
|
+
if (settled)
|
|
21
|
+
return;
|
|
22
|
+
settled = true;
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
resolveResult({ exitCode, stdout, stderr, timedOut });
|
|
25
|
+
};
|
|
26
|
+
child.stdout.setEncoding("utf8");
|
|
27
|
+
child.stderr.setEncoding("utf8");
|
|
28
|
+
child.stdout.on("data", value => { stdout += value; });
|
|
29
|
+
child.stderr.on("data", value => { stderr += value; });
|
|
30
|
+
child.once("error", error => { stderr += error.message; finish(1); });
|
|
31
|
+
child.once("exit", code => finish(code ?? 1));
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
child.kill();
|
|
34
|
+
finish(1, true);
|
|
35
|
+
}, timeoutMs);
|
|
36
|
+
child.stdin.end(request.input);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function argument(name) {
|
|
40
|
+
const index = process.argv.indexOf(name);
|
|
41
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
42
|
+
}
|
|
43
|
+
const listen = argument("--listen");
|
|
44
|
+
const port = Number(argument("--port") ?? "0");
|
|
45
|
+
const token = argument("--token");
|
|
46
|
+
const metadata = argument("--metadata");
|
|
47
|
+
let server;
|
|
48
|
+
const broker = serveReaderBroker({
|
|
49
|
+
attachStdio: !listen,
|
|
50
|
+
backend: process.env.WOWDUMP_USE_FAKE_READER === "1" ? undefined : createWindowsNativeReader(),
|
|
51
|
+
runFrida: runElevatedFrida,
|
|
52
|
+
onShutdown: async () => {
|
|
53
|
+
server?.close();
|
|
54
|
+
if (metadata)
|
|
55
|
+
await rm(metadata, { force: true }).catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
if (listen) {
|
|
59
|
+
if (listen !== "127.0.0.1" || !token || !metadata || !Number.isSafeInteger(port) || port < 0 || port > 65535) {
|
|
60
|
+
throw new Error("--listen requires 127.0.0.1, a valid --port, --token and --metadata");
|
|
61
|
+
}
|
|
62
|
+
server = createServer(socket => {
|
|
63
|
+
socket.setEncoding("utf8");
|
|
64
|
+
let buffer = "";
|
|
65
|
+
let pending = Promise.resolve();
|
|
66
|
+
socket.on("data", chunk => {
|
|
67
|
+
buffer += chunk;
|
|
68
|
+
while (buffer.includes("\n")) {
|
|
69
|
+
const newline = buffer.indexOf("\n");
|
|
70
|
+
const line = buffer.slice(0, newline);
|
|
71
|
+
buffer = buffer.slice(newline + 1);
|
|
72
|
+
pending = pending.then(async () => {
|
|
73
|
+
try {
|
|
74
|
+
const envelope = JSON.parse(line);
|
|
75
|
+
if (envelope.token !== token || !envelope.request) {
|
|
76
|
+
socket.write(`${JSON.stringify({ ok: false, command: "auth", timestamp: Date.now(), error: { code: "AUTH_FAILED", message: "invalid reader broker token" } })}\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
socket.write(`${JSON.stringify(await broker.handle(envelope.request))}\n`);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
socket.write(`${JSON.stringify({ ok: false, command: "parse", timestamp: Date.now(), error: { code: "INVALID_JSON", message: error instanceof Error ? error.message : String(error) } })}\n`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
server.listen(port, listen, async () => {
|
|
89
|
+
await mkdir(dirname(metadata), { recursive: true });
|
|
90
|
+
const address = server?.address();
|
|
91
|
+
if (!address || typeof address === "string")
|
|
92
|
+
throw new Error("reader broker did not acquire a TCP endpoint");
|
|
93
|
+
await writeFile(metadata, `${JSON.stringify({ schema: "wowdump.reader-broker.v1", host: listen, port: address.port, token, pid: process.pid })}\n`, { encoding: "utf8", mode: 0o600 });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const shutdown = () => {
|
|
97
|
+
void broker.shutdown("request").catch(() => process.exitCode = 1);
|
|
98
|
+
};
|
|
99
|
+
process.once("SIGINT", shutdown);
|
|
100
|
+
process.once("SIGTERM", shutdown);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import koffi from "koffi";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
const PROCESS_QUERY_INFORMATION = 0x0400;
|
|
4
|
+
const PROCESS_VM_READ = 0x0010;
|
|
5
|
+
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
|
6
|
+
const HANDLE = koffi.pointer("WOWDUMP_HANDLE", koffi.opaque());
|
|
7
|
+
const BYTE_POINTER = koffi.pointer("WOWDUMP_BYTE", koffi.opaque());
|
|
8
|
+
const SIZE_T = process.arch === "x64" ? "uint64_t" : "uint32_t";
|
|
9
|
+
const kernel32 = koffi.load("kernel32.dll");
|
|
10
|
+
const shell32 = koffi.load("shell32.dll");
|
|
11
|
+
const OpenProcess = kernel32.func("OpenProcess", HANDLE, ["uint32_t", "bool", "uint32_t"]);
|
|
12
|
+
const ReadProcessMemory = kernel32.func("ReadProcessMemory", "bool", [HANDLE, "uint64_t", koffi.pointer("uint8_t"), SIZE_T, koffi.out(koffi.pointer(SIZE_T))]);
|
|
13
|
+
const VirtualQueryEx = kernel32.func("VirtualQueryEx", SIZE_T, [HANDLE, "uint64_t", koffi.pointer("uint8_t"), SIZE_T]);
|
|
14
|
+
const CloseHandle = kernel32.func("CloseHandle", "bool", [HANDLE]);
|
|
15
|
+
const GetLastError = kernel32.func("GetLastError", "uint32_t", []);
|
|
16
|
+
const QueryFullProcessImageNameW = kernel32.func("QueryFullProcessImageNameW", "bool", [HANDLE, "uint32_t", koffi.pointer("char16_t"), koffi.inout(koffi.pointer("uint32_t"))]);
|
|
17
|
+
const IsUserAnAdmin = shell32.func("IsUserAnAdmin", "bool", []);
|
|
18
|
+
const MODULEENTRY32W = koffi.struct("WOWDUMP_MODULEENTRY32W", {
|
|
19
|
+
dwSize: "uint32_t",
|
|
20
|
+
th32ModuleID: "uint32_t",
|
|
21
|
+
th32ProcessID: "uint32_t",
|
|
22
|
+
GlblcntUsage: "uint32_t",
|
|
23
|
+
ProccntUsage: "uint32_t",
|
|
24
|
+
modBaseAddr: BYTE_POINTER,
|
|
25
|
+
modBaseSize: "uint32_t",
|
|
26
|
+
hModule: HANDLE,
|
|
27
|
+
szModule: koffi.array("char16_t", 256, "String"),
|
|
28
|
+
szExePath: koffi.array("char16_t", 260, "String")
|
|
29
|
+
});
|
|
30
|
+
const PROCESSENTRY32W = koffi.struct("WOWDUMP_PROCESSENTRY32W", {
|
|
31
|
+
dwSize: "uint32_t",
|
|
32
|
+
cntUsage: "uint32_t",
|
|
33
|
+
th32ProcessID: "uint32_t",
|
|
34
|
+
th32DefaultHeapID: "uint64_t",
|
|
35
|
+
th32ModuleID: "uint32_t",
|
|
36
|
+
cntThreads: "uint32_t",
|
|
37
|
+
th32ParentProcessID: "uint32_t",
|
|
38
|
+
pcPriClassBase: "int32_t",
|
|
39
|
+
dwFlags: "uint32_t",
|
|
40
|
+
szExeFile: koffi.array("char16_t", 260, "String")
|
|
41
|
+
});
|
|
42
|
+
const CreateToolhelp32Snapshot = kernel32.func("CreateToolhelp32Snapshot", HANDLE, ["uint32_t", "uint32_t"]);
|
|
43
|
+
const Module32FirstW = kernel32.func("Module32FirstW", "bool", [HANDLE, koffi.inout(koffi.pointer(MODULEENTRY32W))]);
|
|
44
|
+
const Module32NextW = kernel32.func("Module32NextW", "bool", [HANDLE, koffi.inout(koffi.pointer(MODULEENTRY32W))]);
|
|
45
|
+
const Process32FirstW = kernel32.func("Process32FirstW", "bool", [HANDLE, koffi.inout(koffi.pointer(PROCESSENTRY32W))]);
|
|
46
|
+
const Process32NextW = kernel32.func("Process32NextW", "bool", [HANDLE, koffi.inout(koffi.pointer(PROCESSENTRY32W))]);
|
|
47
|
+
const TH32CS_SNAPMODULE = 0x00000008;
|
|
48
|
+
const TH32CS_SNAPMODULE32 = 0x00000010;
|
|
49
|
+
const TH32CS_SNAPPROCESS = 0x00000002;
|
|
50
|
+
function addressOf(handle) {
|
|
51
|
+
return `0x${koffi.address(handle).toString(16)}`;
|
|
52
|
+
}
|
|
53
|
+
function win32Error(operation) {
|
|
54
|
+
const win32 = Number(GetLastError());
|
|
55
|
+
const error = new Error(`${operation} failed with Win32 error ${win32}`);
|
|
56
|
+
error.code = "WIN32_ERROR";
|
|
57
|
+
error.win32 = win32;
|
|
58
|
+
return error;
|
|
59
|
+
}
|
|
60
|
+
class WindowsHandle {
|
|
61
|
+
raw;
|
|
62
|
+
pid;
|
|
63
|
+
id;
|
|
64
|
+
constructor(raw, pid) {
|
|
65
|
+
this.raw = raw;
|
|
66
|
+
this.pid = pid;
|
|
67
|
+
this.id = addressOf(raw);
|
|
68
|
+
}
|
|
69
|
+
close() {
|
|
70
|
+
if (this.raw !== null) {
|
|
71
|
+
CloseHandle(this.raw);
|
|
72
|
+
this.raw = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export class WindowsNativeReader {
|
|
77
|
+
async enumerateModules(pid) {
|
|
78
|
+
const snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid);
|
|
79
|
+
if (!snapshot)
|
|
80
|
+
throw win32Error(`CreateToolhelp32Snapshot(${pid})`);
|
|
81
|
+
const modules = [];
|
|
82
|
+
try {
|
|
83
|
+
const entry = {
|
|
84
|
+
dwSize: koffi.sizeof(MODULEENTRY32W),
|
|
85
|
+
th32ModuleID: 0,
|
|
86
|
+
th32ProcessID: 0,
|
|
87
|
+
GlblcntUsage: 0,
|
|
88
|
+
ProccntUsage: 0,
|
|
89
|
+
modBaseAddr: null,
|
|
90
|
+
modBaseSize: 0,
|
|
91
|
+
hModule: null,
|
|
92
|
+
szModule: "",
|
|
93
|
+
szExePath: ""
|
|
94
|
+
};
|
|
95
|
+
let first = Module32FirstW(snapshot, entry);
|
|
96
|
+
while (first) {
|
|
97
|
+
const base = entry.modBaseAddr ? `0x${koffi.address(entry.modBaseAddr).toString(16)}` : "0x0";
|
|
98
|
+
modules.push({ name: String(entry.szModule), base, size: Number(entry.modBaseSize), path: String(entry.szExePath) });
|
|
99
|
+
first = Module32NextW(snapshot, entry);
|
|
100
|
+
}
|
|
101
|
+
if (modules.some(module => module.name.length > 0 || Boolean(module.path)))
|
|
102
|
+
return modules;
|
|
103
|
+
const path = await this.queryProcessPath(pid);
|
|
104
|
+
if (path)
|
|
105
|
+
return [{ name: basename(path), base: "0x0", size: 0, path }];
|
|
106
|
+
throw win32Error(`QueryFullProcessImageNameW(${pid})`);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
CloseHandle(snapshot);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async queryProcessPath(pid) {
|
|
113
|
+
const handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_QUERY_INFORMATION, false, pid);
|
|
114
|
+
if (!handle)
|
|
115
|
+
return undefined;
|
|
116
|
+
try {
|
|
117
|
+
const capacity = 32768;
|
|
118
|
+
const buffer = Buffer.alloc(capacity * 2);
|
|
119
|
+
const length = [capacity];
|
|
120
|
+
if (!QueryFullProcessImageNameW(handle, 0, buffer, length))
|
|
121
|
+
return undefined;
|
|
122
|
+
const count = Math.max(0, Math.min(Number(length[0]), capacity));
|
|
123
|
+
return buffer.toString("utf16le", 0, count * 2) || undefined;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
CloseHandle(handle);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async openProcess(pid, rights) {
|
|
130
|
+
const requested = rights.reduce((mask, right) => {
|
|
131
|
+
if (right === "PROCESS_QUERY_INFORMATION")
|
|
132
|
+
return mask | PROCESS_QUERY_INFORMATION;
|
|
133
|
+
if (right === "PROCESS_VM_READ")
|
|
134
|
+
return mask | PROCESS_VM_READ;
|
|
135
|
+
return mask;
|
|
136
|
+
}, 0);
|
|
137
|
+
const handle = OpenProcess(requested || PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
|
|
138
|
+
if (!handle)
|
|
139
|
+
throw win32Error(`OpenProcess(${pid})`);
|
|
140
|
+
return new WindowsHandle(handle, pid);
|
|
141
|
+
}
|
|
142
|
+
async readProcessMemory(handle, address, size) {
|
|
143
|
+
const native = handle;
|
|
144
|
+
const bytes = Buffer.allocUnsafe(size);
|
|
145
|
+
const read = [0n];
|
|
146
|
+
const ok = ReadProcessMemory(native.raw, address, bytes, size, read);
|
|
147
|
+
const bytesRead = Number(read[0]);
|
|
148
|
+
if (!ok && bytesRead === 0)
|
|
149
|
+
throw win32Error(`ReadProcessMemory(${native.id}, ${`0x${address.toString(16)}`})`);
|
|
150
|
+
return { bytes: bytes.subarray(0, bytesRead), bytesRead };
|
|
151
|
+
}
|
|
152
|
+
async virtualQueryEx(handle, address) {
|
|
153
|
+
const native = handle;
|
|
154
|
+
const buffer = Buffer.alloc(48);
|
|
155
|
+
const result = Number(VirtualQueryEx(native.raw, address, buffer, buffer.length));
|
|
156
|
+
if (result === 0)
|
|
157
|
+
throw win32Error(`VirtualQueryEx(${native.id})`);
|
|
158
|
+
return {
|
|
159
|
+
baseAddress: `0x${buffer.readBigUInt64LE(0).toString(16)}`,
|
|
160
|
+
allocationBase: `0x${buffer.readBigUInt64LE(8).toString(16)}`,
|
|
161
|
+
allocationProtect: buffer.readUInt32LE(16),
|
|
162
|
+
regionSize: buffer.readBigUInt64LE(24).toString(),
|
|
163
|
+
state: buffer.readUInt32LE(32),
|
|
164
|
+
protect: buffer.readUInt32LE(36),
|
|
165
|
+
type: buffer.readUInt32LE(40),
|
|
166
|
+
bytesReturned: result
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async isProcessAlive(pid) {
|
|
170
|
+
try {
|
|
171
|
+
const handle = await this.openProcess(pid, ["PROCESS_QUERY_INFORMATION"]);
|
|
172
|
+
handle.close();
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
elevationStatus() {
|
|
180
|
+
return {
|
|
181
|
+
elevated: Boolean(IsUserAnAdmin()),
|
|
182
|
+
processArchitecture: process.arch,
|
|
183
|
+
backend: "koffi/kernel32",
|
|
184
|
+
requestedRights: ["PROCESS_QUERY_INFORMATION", "PROCESS_VM_READ"]
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Enumerate processes without spawning a shell or depending on Frida. */
|
|
189
|
+
export function enumerateWindowsProcesses() {
|
|
190
|
+
if (process.platform !== "win32")
|
|
191
|
+
return [];
|
|
192
|
+
const snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
|
193
|
+
if (!snapshot)
|
|
194
|
+
throw win32Error("CreateToolhelp32Snapshot(processes)");
|
|
195
|
+
const rows = [];
|
|
196
|
+
try {
|
|
197
|
+
const entry = {
|
|
198
|
+
dwSize: koffi.sizeof(PROCESSENTRY32W),
|
|
199
|
+
cntUsage: 0,
|
|
200
|
+
th32ProcessID: 0,
|
|
201
|
+
th32DefaultHeapID: 0n,
|
|
202
|
+
th32ModuleID: 0,
|
|
203
|
+
cntThreads: 0,
|
|
204
|
+
th32ParentProcessID: 0,
|
|
205
|
+
pcPriClassBase: 0,
|
|
206
|
+
dwFlags: 0,
|
|
207
|
+
szExeFile: ""
|
|
208
|
+
};
|
|
209
|
+
let found = Process32FirstW(snapshot, entry);
|
|
210
|
+
while (found) {
|
|
211
|
+
const pid = Number(entry.th32ProcessID);
|
|
212
|
+
const name = String(entry.szExeFile);
|
|
213
|
+
let path = null;
|
|
214
|
+
if (pid > 0) {
|
|
215
|
+
const handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_QUERY_INFORMATION, false, pid);
|
|
216
|
+
if (handle) {
|
|
217
|
+
try {
|
|
218
|
+
const capacity = 32768;
|
|
219
|
+
const buffer = Buffer.alloc(capacity * 2);
|
|
220
|
+
const length = [capacity];
|
|
221
|
+
if (QueryFullProcessImageNameW(handle, 0, buffer, length)) {
|
|
222
|
+
const count = Math.max(0, Math.min(Number(length[0]), capacity));
|
|
223
|
+
path = buffer.toString("utf16le", 0, count * 2) || null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
CloseHandle(handle);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
rows.push({ pid, name, path });
|
|
232
|
+
found = Process32NextW(snapshot, entry);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
CloseHandle(snapshot);
|
|
237
|
+
}
|
|
238
|
+
return rows;
|
|
239
|
+
}
|
|
240
|
+
export function createWindowsNativeReader() {
|
|
241
|
+
return new WindowsNativeReader();
|
|
242
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
export const WOWDUMP_HOME_DIRECTORIES = ["profiles", "logs", "runtime", "cache", "skills", "monitors", "toolchains"];
|
|
9
|
+
export const DEFAULT_WOWDUMP_SKILL = `---
|
|
10
|
+
name: wowdump
|
|
11
|
+
description: 使用 Frida 运行时证据和 IDA/iced-x86 定位 WoW 字段,生成 Reader 可用 profile。
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# wowdump
|
|
15
|
+
|
|
16
|
+
查字段时先读取 targets,再用显式 GumJS 导出运行时线索。静态定位优先交给可用的 IDA Pro MCP;没有时运行 \`wowdump analyze disassemble\`,由 Agent 根据 iced-x86 输出分析并补齐字段定义。两条路径都必须最终生成 \`wowdump.profile.v1\`,且只有地址、类型和证据齐全时才标记 \`reader_ready\`。详情见 references/workflow.md、references/dynamic.md、references/disassemble.md 和 references/profiles.md。
|
|
17
|
+
`;
|
|
18
|
+
export const DEFAULT_WOWDUMP_COMMANDS = `# wowdump 命令参考
|
|
19
|
+
|
|
20
|
+
1. \`wowdump targets\`
|
|
21
|
+
2. \`wowdump analyze runtime --pid <pid> --build <buildKey> --kind text --confirm\`
|
|
22
|
+
3. \`wowdump analyze dynamic --pid <pid> --build <buildKey> --script <GumJS> --export collect --confirm\`
|
|
23
|
+
4. \`wowdump analyze disassemble --exe <Wow.exe> --build <buildKey> --runtime-export <runtime.json> --output <profile.json>\`
|
|
24
|
+
5. \`wowdump memory read --pid <pid> --build <buildKey> --profile <profile.json> --field <name>\`
|
|
25
|
+
|
|
26
|
+
若 Agent 能调用 IDA Pro MCP,先用 MCP 分析 runtime text,再把结果保存为 JSON 并通过 \`--ida-evidence\` 交给 disassemble 命令。未确认的候选 profile 放在 runtime 目录;确认后才复制到当前 build 的 profile 目录。
|
|
27
|
+
`;
|
|
28
|
+
function normalizePath(value) { return resolve(value.trim().replace(/^"|"$/g, "")); }
|
|
29
|
+
function directoryExists(value) { try {
|
|
30
|
+
return statSync(value).isDirectory();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
} }
|
|
35
|
+
function fileExists(value) { try {
|
|
36
|
+
return statSync(value).isFile();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
} }
|
|
41
|
+
async function createFileIfMissing(file, contents, created, preserved) { try {
|
|
42
|
+
await writeFile(file, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
43
|
+
created.push(file);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "EEXIST")
|
|
47
|
+
preserved.push(file);
|
|
48
|
+
else
|
|
49
|
+
throw error;
|
|
50
|
+
} }
|
|
51
|
+
export function resolveWowdumpHome(env = process.env, userHome = homedir()) { return normalizePath(env.WOWDUMP_HOME || join(userHome, ".wowdump")); }
|
|
52
|
+
export function resolveWowdumpSkillHome(env = process.env, userHome = homedir()) { return normalizePath(env.WOWDUMP_SKILL_HOME || join(userHome, ".agents", "skills")); }
|
|
53
|
+
export async function initializeWowdumpHome(options = {}) {
|
|
54
|
+
const env = options.env ?? process.env;
|
|
55
|
+
const home = normalizePath(options.home ?? resolveWowdumpHome(env, options.userHome));
|
|
56
|
+
const created = [];
|
|
57
|
+
const preserved = [];
|
|
58
|
+
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
|
+
const configFile = join(home, "config.json");
|
|
69
|
+
await createFileIfMissing(configFile, `${JSON.stringify({ schema: "wowdump.config.v1" }, null, 2)}\n`, created, preserved);
|
|
70
|
+
const skillDirectory = join(normalizePath(options.skillHome ?? resolveWowdumpSkillHome(env, options.userHome)), "wowdump");
|
|
71
|
+
if (directoryExists(skillDirectory))
|
|
72
|
+
preserved.push(skillDirectory);
|
|
73
|
+
else {
|
|
74
|
+
await mkdir(skillDirectory, { recursive: true });
|
|
75
|
+
created.push(skillDirectory);
|
|
76
|
+
}
|
|
77
|
+
const bundledSkill = resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", "wowdump", "SKILL.md");
|
|
78
|
+
const skillSource = options.skillSource ? normalizePath(options.skillSource) : fileExists(bundledSkill) ? bundledSkill : undefined;
|
|
79
|
+
const skillFile = join(skillDirectory, "SKILL.md");
|
|
80
|
+
await createFileIfMissing(skillFile, `${(skillSource ? await readFile(skillSource, "utf8") : DEFAULT_WOWDUMP_SKILL).replace(/\n?$/, "\n")}`, created, preserved);
|
|
81
|
+
const referencesDirectory = join(skillDirectory, "references");
|
|
82
|
+
if (directoryExists(referencesDirectory))
|
|
83
|
+
preserved.push(referencesDirectory);
|
|
84
|
+
else {
|
|
85
|
+
await mkdir(referencesDirectory, { recursive: true });
|
|
86
|
+
created.push(referencesDirectory);
|
|
87
|
+
}
|
|
88
|
+
const commandsFile = join(referencesDirectory, "commands.md");
|
|
89
|
+
await createFileIfMissing(commandsFile, `${(options.commandsSource ? await readFile(normalizePath(options.commandsSource), "utf8") : DEFAULT_WOWDUMP_COMMANDS).replace(/\n?$/, "\n")}`, created, preserved);
|
|
90
|
+
const bundledReferences = resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", "wowdump", "references");
|
|
91
|
+
if (directoryExists(bundledReferences))
|
|
92
|
+
for (const entry of readdirSync(bundledReferences, { withFileTypes: true }))
|
|
93
|
+
if (entry.isFile() && extname(entry.name).toLowerCase() === ".md")
|
|
94
|
+
await createFileIfMissing(join(referencesDirectory, entry.name), await readFile(join(bundledReferences, entry.name), "utf8"), created, preserved);
|
|
95
|
+
const scriptsDirectory = join(skillDirectory, "scripts");
|
|
96
|
+
if (directoryExists(scriptsDirectory))
|
|
97
|
+
preserved.push(scriptsDirectory);
|
|
98
|
+
else {
|
|
99
|
+
await mkdir(scriptsDirectory, { recursive: true });
|
|
100
|
+
created.push(scriptsDirectory);
|
|
101
|
+
}
|
|
102
|
+
const bundledScripts = resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", "wowdump", "scripts");
|
|
103
|
+
if (directoryExists(bundledScripts))
|
|
104
|
+
for (const entry of readdirSync(bundledScripts, { withFileTypes: true }))
|
|
105
|
+
if (entry.isFile() && extname(entry.name).toLowerCase() === ".js")
|
|
106
|
+
await createFileIfMissing(join(scriptsDirectory, entry.name), await readFile(join(bundledScripts, entry.name), "utf8"), created, preserved);
|
|
107
|
+
return { home, created, preserved, configFile, skillFile, commandsFile, scriptsDirectory };
|
|
108
|
+
}
|
|
109
|
+
export async function bootstrapToolchain(options = {}) { const home = await initializeWowdumpHome(options); return { ...home, toolchain: { ready: true, disassembler: "iced-x86" }, installed: [] }; }
|
|
110
|
+
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
|
+
export function inspectPeSections(buffer) { if (buffer.length < 0x40 || buffer.toString("ascii", 0, 2) !== "MZ")
|
|
113
|
+
return []; const pe = buffer.readUInt32LE(0x3c); if (pe + 24 > buffer.length || buffer.toString("ascii", pe, pe + 4) !== "PE\0\0")
|
|
114
|
+
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++) {
|
|
115
|
+
const off = table + i * 40;
|
|
116
|
+
if (off + 40 > buffer.length)
|
|
117
|
+
break;
|
|
118
|
+
out.push({ name: buffer.subarray(off, off + 8).toString("ascii").replace(/\0+$/, ""), virtualSize: `0x${buffer.readUInt32LE(off + 8).toString(16)}`, rva: `0x${buffer.readUInt32LE(off + 12).toString(16)}`, rawSize: `0x${buffer.readUInt32LE(off + 16).toString(16)}`, rawOffset: `0x${buffer.readUInt32LE(off + 20).toString(16)}` });
|
|
119
|
+
} return out; }
|
|
120
|
+
const invokedFile = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
|
|
121
|
+
if (invokedFile === import.meta.url && process.argv.includes("--postinstall")) {
|
|
122
|
+
await bootstrapToolchain();
|
|
123
|
+
}
|
package/package.json
CHANGED
|
@@ -1,64 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wowdump",
|
|
3
|
-
"version": "0.2
|
|
4
|
-
"description": "
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"wowdump": "dist/mcp-main.js"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"dist/**",
|
|
11
|
-
"!dist/main.js",
|
|
12
|
-
"resources/**",
|
|
13
|
-
"README.md",
|
|
14
|
-
"LICENSE"
|
|
15
|
-
],
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "WoW native memory analysis CLI with Frida evidence, IDA/iced-x86 disassembly, and an elevated reader broker",
|
|
16
5
|
"license": "MIT",
|
|
17
|
-
"author": "Follenfang",
|
|
18
6
|
"repository": {
|
|
19
7
|
"type": "git",
|
|
20
8
|
"url": "git+https://github.com/Follen/wowdump.git"
|
|
21
9
|
},
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"mcp",
|
|
28
|
-
"frida",
|
|
29
|
-
"world-of-warcraft",
|
|
30
|
-
"wow",
|
|
31
|
-
"runtime-analysis"
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"skills",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
32
15
|
],
|
|
33
|
-
"
|
|
34
|
-
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": {
|
|
18
|
+
"wowdump": "dist/cli.js"
|
|
35
19
|
},
|
|
36
20
|
"engines": {
|
|
37
21
|
"node": ">=22"
|
|
38
22
|
},
|
|
39
23
|
"scripts": {
|
|
40
|
-
"
|
|
24
|
+
"clean": "node scripts/clean-dist.mjs",
|
|
25
|
+
"build": "npm run clean && npm run build:host",
|
|
41
26
|
"build:host": "tsc -p tsconfig.json",
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
27
|
+
"wowdump": "node dist/cli.js",
|
|
28
|
+
"postinstall": "node -e \"const fs=require('node:fs');if(fs.existsSync('dist/toolchain.js'))import('./dist/toolchain.js').then(m=>m.bootstrapToolchain())\"",
|
|
29
|
+
"prepack": "npm run build",
|
|
45
30
|
"package:verify": "node scripts/verify-package.mjs",
|
|
46
31
|
"release:verify": "node scripts/verify-release-workflow.mjs verify",
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
50
|
-
"prepack": "npm run build && npm run bundle:verify"
|
|
32
|
+
"test": "npm run build:host && node --test tests/*.test.mjs",
|
|
33
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
51
34
|
},
|
|
52
35
|
"dependencies": {
|
|
53
|
-
"@modelcontextprotocol/server": "2.0.0",
|
|
54
36
|
"commander": "^13.1.0",
|
|
55
37
|
"frida": "^17.2.0",
|
|
56
|
-
"
|
|
38
|
+
"iced-x86": "^1.21.0",
|
|
39
|
+
"koffi": "^2.16.3",
|
|
57
40
|
"zod": "^3.24.2"
|
|
58
41
|
},
|
|
59
42
|
"devDependencies": {
|
|
60
43
|
"@types/node": "^22.13.4",
|
|
61
|
-
"frida-compile": "^16.4.2",
|
|
62
44
|
"typescript": "^5.7.3"
|
|
63
45
|
}
|
|
64
46
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wowdump
|
|
3
|
+
description: 以 Frida 运行时探测为主,按证据需要交替使用 IDA Pro MCP 或 iced-x86,生成并验证 WoW 原生内存 Reader profile。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# wowdump
|
|
7
|
+
|
|
8
|
+
用于读取角色属性、Buff/Debuff、技能冷却、附近单位等原生字段。优先从当前进程取得真实值;需要长期复用时,再把已验证的线索固化为带证据的 `wowdump.profile.v1`。
|
|
9
|
+
|
|
10
|
+
## 默认策略
|
|
11
|
+
|
|
12
|
+
1. 先运行 `wowdump targets`,确认 PID、Wow.exe 路径和 buildKey;多个目标时让用户选择。
|
|
13
|
+
2. 在 `~/.wowdump/<buildKey>/runtime/<session>/` 保存临时证据,在 `~/.wowdump/<buildKey>/profile/` 只保存用户确认的持久化 profile。
|
|
14
|
+
3. 默认编写并显式传入 GumJS,使用 `wowdump analyze dynamic --script <GumJS> --confirm` 做运行时发现、原生 Hook、快照或事件采集。
|
|
15
|
+
4. 若已有 `reader_ready` profile,先用 reader 读取;若动态结果已足够回答问题,直接返回,不强行做静态分析。
|
|
16
|
+
5. 动态结果需要函数 RVA、对象布局或字段类型时,再对相关样本调用 IDA Pro MCP;没有 IDA 时才用 `wowdump analyze disassemble` 的 iced-x86 输出。静态和动态可以交替执行,每轮只扩大当前字段所需的证据范围。
|
|
17
|
+
6. 将确认的 RVA、指针链、类型、边界和证据写入候选 profile。字段证据不足时保持 `candidate`,回到 dynamic 补采样或回到 IDA/iced 缩小候选。
|
|
18
|
+
7. 用 `wowdump memory read --profile <profile> --field <name>` 做 broker-backed 验证;只有地址、类型和实际读取都成功后才标记 `reader_ready`。经用户确认后再保存到该 build 的 `profile/`。
|
|
19
|
+
|
|
20
|
+
不要把 dynamic、IDA 或 iced 固定成一次性线性流水线。选择下一步的依据是当前字段缺少什么证据:值用 dynamic,函数/布局用 IDA/iced,稳定读取用 reader。
|
|
21
|
+
|
|
22
|
+
具体的交替决策见 [references/workflow.md](references/workflow.md);动态 Hook 规则见 [references/dynamic.md](references/dynamic.md);IDA/iced 的选择和交替策略见 [references/disassemble.md](references/disassemble.md);请求字段和 profile 生命周期见 [references/request-schema.md](references/request-schema.md) 与 [references/profiles.md](references/profiles.md)。
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# wowdump 命令参考
|
|
2
|
+
|
|
3
|
+
Windows 下需要内存或 Frida 权限时,CLI 会连接同一个管理员 broker;首次建立 broker 才需要 UAC,默认空闲 20 分钟后退出。
|
|
4
|
+
|
|
5
|
+
## 发现目标
|
|
6
|
+
|
|
7
|
+
```powershell
|
|
8
|
+
wowdump targets
|
|
9
|
+
wowdump targets --pid 33976
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
使用输出里的 `pid`、`path` 和 `buildKey`。`buildKey` 来自目标安装目录的 `.build.info`;不要写 `SIM` 或手填版本。
|
|
13
|
+
|
|
14
|
+
## 运行时动态分析
|
|
15
|
+
|
|
16
|
+
```powershell
|
|
17
|
+
wowdump analyze dynamic `
|
|
18
|
+
--pid 33976 `
|
|
19
|
+
--build "retail@12.1.0.69587" `
|
|
20
|
+
--script "C:\work\discover-state.js" `
|
|
21
|
+
--export collect `
|
|
22
|
+
--args '{"fields":["playerAuras","nearbyEnemies","cooldowns"]}' `
|
|
23
|
+
--duration-ms 5000 `
|
|
24
|
+
--confirm > $HOME\.wowdump\runtime\<session-id>\state.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
固定采样:
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
wowdump analyze runtime `
|
|
31
|
+
--pid 33976 `
|
|
32
|
+
--build "retail@12.1.0.69587" `
|
|
33
|
+
--kind text `
|
|
34
|
+
--duration-ms 5000 `
|
|
35
|
+
--max-events 100 `
|
|
36
|
+
--confirm > $HOME\.wowdump\runtime\<session-id>\text.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 需要时做静态定位
|
|
40
|
+
|
|
41
|
+
只有 dynamic 结果缺少函数 RVA、对象布局或字段类型时,才调用 IDA Pro MCP;没有 IDA 时使用 iced-x86 兜底:
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
wowdump analyze disassemble `
|
|
45
|
+
--exe "D:\Game\World of Warcraft\_retail_\Wow.exe" `
|
|
46
|
+
--build "retail@12.1.0.69587" `
|
|
47
|
+
--runtime-export "$HOME\.wowdump\runtime\<session-id>\state.json" `
|
|
48
|
+
--output "$HOME\.wowdump\runtime\<session-id>\retail-12.1.0.69587.profile.json"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 读取
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
wowdump memory read `
|
|
55
|
+
--pid 33976 `
|
|
56
|
+
--build "retail@12.1.0.69587" `
|
|
57
|
+
--profile "$HOME\.wowdump\retail@12.1.0.69587\profile\player-state.json" `
|
|
58
|
+
--field playerAuras nearbyEnemies cooldowns
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
需要连续观察时使用 `memory watch start/poll/stop`。字段未定义、模块不匹配、指针为空或发生短读时,保留 JSON 证据并停止该字段,不改写 profile。
|
|
62
|
+
|
|
63
|
+
静态分析完成后回到 `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)。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# 反汇编与 IDA Pro 分支
|
|
2
|
+
|
|
3
|
+
静态分析是按需使用的证据放大器,不是每个查询的必经步骤。运行时 text、字节样本、Hook 调用记录或返回值都可以作为输入。先检查当前 Agent 是否能调用本机 IDA Pro MCP:
|
|
4
|
+
|
|
5
|
+
- 能调用时,让 MCP 只分析当前字段相关样本附近的函数、字符串、调用关系和结构,把结果保存为 JSON。随后回到 dynamic Hook 验证候选函数,不要直接把静态候选当成可读字段。
|
|
6
|
+
- 不能调用时执行:
|
|
7
|
+
|
|
8
|
+
```powershell
|
|
9
|
+
wowdump analyze disassemble `
|
|
10
|
+
--exe "C:\Games\World of Warcraft\_retail_\Wow.exe" `
|
|
11
|
+
--build "retail@VERSION" `
|
|
12
|
+
--runtime-export "$HOME\.wowdump\runtime\SESSION\text.json" `
|
|
13
|
+
--output "$HOME\.wowdump\runtime\SESSION\candidate.profile.json"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
CLI 使用 `iced-x86` 解码 runtime 样本,返回模块基址、RVA、指令文本、PE sections 和证据。Agent 负责把这些线索与运行时字段对应起来,再用 dynamic 验证 `root`、`pointerChain`、`layout/type`、计数上限和停止条件。不要把一次运行的绝对地址写进持久化 profile。
|
|
17
|
+
|
|
18
|
+
输出中的 `readerStatus: "reader_ready"` 只适用于每个字段都有可验证 RVA/地址、类型和边界,并且 reader 实际读成功的情况;否则保持 `candidate`,回到 dynamic 或继续局部静态分析。只做一次性查询时可以不生成持久化 profile。
|