wowdump 0.2.1 → 0.3.1
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 +21 -21
- package/README.md +56 -118
- package/dist/agent.js +5 -8
- package/dist/cli.js +497 -0
- package/dist/discovery.js +1 -12
- package/dist/dry-run.js +0 -2
- package/dist/focused-session.js +61 -1329
- package/dist/frida-runtime.js +51 -73
- package/dist/frida-worker.js +100 -0
- package/dist/ghidra.js +769 -0
- package/dist/main.js +66 -0
- package/dist/processes.js +2 -5
- package/dist/reader-broker.js +460 -0
- package/dist/reader-client.js +1 -0
- package/dist/reader-main.js +67 -0
- package/dist/session.js +17 -120
- package/dist/toolchain.js +594 -0
- package/dist/windows-launcher.js +207 -0
- package/dist/windows-reader.js +102 -0
- package/dist/wow-analysis.js +211 -236
- package/package.json +18 -35
- package/skills/wowdump/SKILL.md +15 -0
- package/skills/wowdump/commands.md +44 -0
- 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/focus-errors.js +0 -63
- package/dist/focus-service.js +0 -1855
- package/dist/mcp-main.js +0 -51
- package/dist/mcp.js +0 -924
- package/dist/process-log-lock.js +0 -195
- package/dist/runtime-config.js +0 -399
- 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
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
3
|
+
import { constants, createReadStream, createWriteStream, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { access, cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
const execFile = promisify(execFileCallback);
|
|
11
|
+
export const WOWDUMP_HOME_DIRECTORIES = [
|
|
12
|
+
"profiles",
|
|
13
|
+
"logs",
|
|
14
|
+
"runtime",
|
|
15
|
+
"cache",
|
|
16
|
+
"skills",
|
|
17
|
+
"monitors",
|
|
18
|
+
"toolchains"
|
|
19
|
+
];
|
|
20
|
+
export const PINNED_TOOLCHAINS = {
|
|
21
|
+
java: {
|
|
22
|
+
name: "Temurin JDK",
|
|
23
|
+
version: "21.0.8+9",
|
|
24
|
+
directory: "temurin-21.0.8+9",
|
|
25
|
+
windowsUrl: "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.8_9.zip"
|
|
26
|
+
},
|
|
27
|
+
ghidra: {
|
|
28
|
+
name: "Ghidra",
|
|
29
|
+
version: "11.3.2",
|
|
30
|
+
directory: "ghidra-11.3.2",
|
|
31
|
+
windowsUrl: "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.3.2_build/ghidra_11.3.2_PUBLIC_20250415.zip"
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
export const DEFAULT_WOWDUMP_SKILL = `---
|
|
35
|
+
name: wowdump
|
|
36
|
+
description: 使用 wowdump CLI 进行 WoW 原生内存分析和静态取证。
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
# wowdump
|
|
40
|
+
|
|
41
|
+
使用唯一入口 \`wowdump\` 按依赖顺序完成:静态发现候选 RVA;经确认后用 Frida 导出运行时基址和字节验证;验证通过后落盘为 \`reader_ready\` profile,后续只用 reader。
|
|
42
|
+
|
|
43
|
+
所有结论都要保留 build、文件哈希、模块基址、RVA、命令和证据来源。详细命令、参数和输出字段见同目录的 \`commands.md\`。
|
|
44
|
+
`;
|
|
45
|
+
export const DEFAULT_WOWDUMP_COMMANDS = `# wowdump 命令参考
|
|
46
|
+
|
|
47
|
+
## 初始化和工具链
|
|
48
|
+
|
|
49
|
+
\`wowdump init\` 初始化运行目录并探测 JDK/Ghidra。缺少默认依赖时,npm 安装阶段会下载到 \`WOWDUMP_HOME/toolchains\`;也可以用 \`--skip-toolchain-download\` 只探测本机安装。
|
|
50
|
+
|
|
51
|
+
默认目录:运行数据为 \`~/.wowdump\`,Skill 为 \`~/.agents/skills/wowdump\`。可用 \`WOWDUMP_HOME\` 和 \`WOWDUMP_SKILL_HOME\` 覆盖。
|
|
52
|
+
|
|
53
|
+
## 1. 静态发现
|
|
54
|
+
|
|
55
|
+
命令:wowdump analyze static --exe "C:\\path\\Wow.exe" --build "retail@VERSION"
|
|
56
|
+
|
|
57
|
+
输出候选 profile,包含模块 imageBase、段范围、函数/字符串/交叉引用和候选 RVA;结果 confidence 为 candidate。
|
|
58
|
+
|
|
59
|
+
## 2. Frida 运行时导出
|
|
60
|
+
|
|
61
|
+
命令:wowdump analyze runtime-export --pid 1234 --build "retail@VERSION" --static-profile .wowdump\\profiles\\static.json --kind providers --confirm
|
|
62
|
+
|
|
63
|
+
Frida 只负责一次性验证 moduleBase 和静态 profile 指定 RVA 的现场字节/事件,并记录 runtimeAddress = moduleBase + rva;完成后卸载脚本并断开。
|
|
64
|
+
|
|
65
|
+
## 3. 验证并落盘
|
|
66
|
+
|
|
67
|
+
命令:wowdump analyze verify --static-profile .wowdump\\profiles\\static.json --runtime-export .wowdump\\runtime\\export.json --output .wowdump\\profiles\\verified.json
|
|
68
|
+
|
|
69
|
+
只有检查通过的记录才可标为 reader_ready;不匹配项原样保留。
|
|
70
|
+
|
|
71
|
+
## 4. 后续只读 reader
|
|
72
|
+
|
|
73
|
+
命令:wowdump target;wowdump profiles;wowdump memory read --pid 1234 --profile reader-ready;wowdump memory watch start --pid 1234 --profile reader-ready;wowdump memory watch poll --id WATCH_ID;wowdump memory watch stop --id WATCH_ID
|
|
74
|
+
|
|
75
|
+
Windows 首次读取会由 Node 自动请求 UAC 并启动 broker;后续调用复用 broker、句柄和监控状态。broker 仅监听本机 loopback,20 分钟无请求后退出。
|
|
76
|
+
|
|
77
|
+
Frida 导出必须绑定 PID、build、目标、限制和清理期限,并显式传 \`--confirm\`。写操作和失败结果都以结构化 JSON 返回;candidate/unverified 不能当作已确认偏移。
|
|
78
|
+
`;
|
|
79
|
+
function normalizePath(value) {
|
|
80
|
+
return resolve(value.trim().replace(/^"|"$/g, ""));
|
|
81
|
+
}
|
|
82
|
+
function pathExists(value) {
|
|
83
|
+
try {
|
|
84
|
+
return statSync(value).isFile();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function directoryExists(value) {
|
|
91
|
+
try {
|
|
92
|
+
return statSync(value).isDirectory();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function resolveWowdumpHome(env = process.env, userHome = homedir()) {
|
|
99
|
+
return normalizePath(env.WOWDUMP_HOME || join(userHome, ".wowdump"));
|
|
100
|
+
}
|
|
101
|
+
export function resolveWowdumpSkillHome(env = process.env, userHome = homedir()) {
|
|
102
|
+
return normalizePath(env.WOWDUMP_SKILL_HOME || join(userHome, ".agents", "skills"));
|
|
103
|
+
}
|
|
104
|
+
async function createFileIfMissing(file, contents, created, preserved) {
|
|
105
|
+
try {
|
|
106
|
+
await writeFile(file, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
107
|
+
created.push(file);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error.code !== "EEXIST")
|
|
111
|
+
throw error;
|
|
112
|
+
preserved.push(file);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export async function initializeWowdumpHome(options = {}) {
|
|
116
|
+
const env = options.env ?? process.env;
|
|
117
|
+
const home = normalizePath(options.home ?? resolveWowdumpHome(env, options.userHome));
|
|
118
|
+
const created = [];
|
|
119
|
+
const preserved = [];
|
|
120
|
+
await mkdir(home, { recursive: true });
|
|
121
|
+
for (const name of WOWDUMP_HOME_DIRECTORIES) {
|
|
122
|
+
const directory = join(home, name);
|
|
123
|
+
if (directoryExists(directory))
|
|
124
|
+
preserved.push(directory);
|
|
125
|
+
else {
|
|
126
|
+
await mkdir(directory, { recursive: true });
|
|
127
|
+
created.push(directory);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const configFile = join(home, "config.json");
|
|
131
|
+
await createFileIfMissing(configFile, `${JSON.stringify({ schema: "wowdump.config.v1", toolchains: {} }, null, 2)}\n`, created, preserved);
|
|
132
|
+
const skillRoot = normalizePath(options.skillHome ?? resolveWowdumpSkillHome(env, options.userHome));
|
|
133
|
+
const skillDirectory = join(skillRoot, "wowdump");
|
|
134
|
+
if (!directoryExists(skillDirectory)) {
|
|
135
|
+
await mkdir(skillDirectory, { recursive: true });
|
|
136
|
+
created.push(skillDirectory);
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
preserved.push(skillDirectory);
|
|
140
|
+
const skillFile = join(skillDirectory, "SKILL.md");
|
|
141
|
+
const skill = options.skillSource
|
|
142
|
+
? await readFile(normalizePath(options.skillSource), "utf8")
|
|
143
|
+
: DEFAULT_WOWDUMP_SKILL;
|
|
144
|
+
await createFileIfMissing(skillFile, skill.endsWith("\n") ? skill : `${skill}\n`, created, preserved);
|
|
145
|
+
const commandsFile = join(skillDirectory, "commands.md");
|
|
146
|
+
const commands = options.commandsSource
|
|
147
|
+
? await readFile(normalizePath(options.commandsSource), "utf8")
|
|
148
|
+
: DEFAULT_WOWDUMP_COMMANDS;
|
|
149
|
+
await createFileIfMissing(commandsFile, commands.endsWith("\n") ? commands : `${commands}\n`, created, preserved);
|
|
150
|
+
return { home, created, preserved, configFile, skillFile, commandsFile };
|
|
151
|
+
}
|
|
152
|
+
function readConfig(file) {
|
|
153
|
+
try {
|
|
154
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
155
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function configuredTool(config, name) {
|
|
162
|
+
const toolchains = config.toolchains;
|
|
163
|
+
if (!toolchains || typeof toolchains !== "object" || Array.isArray(toolchains))
|
|
164
|
+
return undefined;
|
|
165
|
+
const value = toolchains[name];
|
|
166
|
+
if (typeof value === "string" && value.trim())
|
|
167
|
+
return value;
|
|
168
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
169
|
+
const path = value.path;
|
|
170
|
+
return typeof path === "string" && path.trim() ? path : undefined;
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
function executableNames(base, platform) {
|
|
175
|
+
if (platform !== "win32")
|
|
176
|
+
return [base];
|
|
177
|
+
return [base, `${base}.exe`, `${base}.cmd`, `${base}.bat`];
|
|
178
|
+
}
|
|
179
|
+
function pathCandidates(name, pathValue, platform) {
|
|
180
|
+
if (!pathValue)
|
|
181
|
+
return [];
|
|
182
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
183
|
+
return pathValue.split(separator).filter(Boolean).flatMap(directory => executableNames(name, platform).map(executable => join(directory.replace(/^"|"$/g, ""), executable)));
|
|
184
|
+
}
|
|
185
|
+
function childDirectories(parent, prefix) {
|
|
186
|
+
try {
|
|
187
|
+
return readdirSync(parent, { withFileTypes: true })
|
|
188
|
+
.filter(entry => entry.isDirectory() && prefix.test(entry.name))
|
|
189
|
+
.map(entry => join(parent, entry.name))
|
|
190
|
+
.sort((left, right) => right.localeCompare(left, undefined, { numeric: true }));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function javaExecutable(input, platform) {
|
|
197
|
+
const value = normalizePath(input);
|
|
198
|
+
if (pathExists(value))
|
|
199
|
+
return value;
|
|
200
|
+
for (const name of executableNames("java", platform)) {
|
|
201
|
+
const candidate = join(value, "bin", name);
|
|
202
|
+
if (pathExists(candidate))
|
|
203
|
+
return candidate;
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
function ghidraExecutable(input, platform) {
|
|
208
|
+
const value = normalizePath(input);
|
|
209
|
+
if (pathExists(value) && /^analyzeHeadless(?:\.(?:bat|cmd|sh))?$/i.test(basename(value)))
|
|
210
|
+
return value;
|
|
211
|
+
const names = platform === "win32"
|
|
212
|
+
? ["analyzeHeadless.bat", "analyzeHeadless.cmd", "analyzeHeadless.exe", "analyzeHeadless"]
|
|
213
|
+
: executableNames("analyzeHeadless", platform);
|
|
214
|
+
for (const name of names) {
|
|
215
|
+
for (const candidate of [join(value, "support", name), join(value, name)]) {
|
|
216
|
+
if (pathExists(candidate))
|
|
217
|
+
return candidate;
|
|
218
|
+
}
|
|
219
|
+
for (const child of childDirectories(value, /^ghidra[_-]/i)) {
|
|
220
|
+
const candidate = join(child, "support", name);
|
|
221
|
+
if (pathExists(candidate))
|
|
222
|
+
return candidate;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
function unique(values) {
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
const result = [];
|
|
230
|
+
for (const item of values) {
|
|
231
|
+
if (!item?.trim())
|
|
232
|
+
continue;
|
|
233
|
+
const value = normalizePath(item);
|
|
234
|
+
const key = process.platform === "win32" ? value.toLowerCase() : value;
|
|
235
|
+
if (!seen.has(key)) {
|
|
236
|
+
seen.add(key);
|
|
237
|
+
result.push(value);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
function resolvedTool(candidates, resolver) {
|
|
243
|
+
const searched = [];
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
if (!candidate.value?.trim())
|
|
246
|
+
continue;
|
|
247
|
+
const normalized = normalizePath(candidate.value);
|
|
248
|
+
searched.push(normalized);
|
|
249
|
+
const executable = resolver(normalized);
|
|
250
|
+
if (executable) {
|
|
251
|
+
const root = basename(dirname(executable)).toLowerCase() === "bin" || basename(dirname(executable)).toLowerCase() === "support"
|
|
252
|
+
? dirname(dirname(executable))
|
|
253
|
+
: dirname(executable);
|
|
254
|
+
return { tool: { executable, root, source: candidate.source }, searched: unique(searched) };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { tool: null, searched: unique(searched) };
|
|
258
|
+
}
|
|
259
|
+
function windowsRoots(env) {
|
|
260
|
+
const user = env.USERPROFILE || homedir();
|
|
261
|
+
const programFiles = [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs")].filter((value) => Boolean(value));
|
|
262
|
+
return {
|
|
263
|
+
java: programFiles.flatMap(root => [
|
|
264
|
+
...childDirectories(join(root, "Eclipse Adoptium"), /^(?:jdk|temurin)/i),
|
|
265
|
+
...childDirectories(join(root, "Java"), /^jdk/i),
|
|
266
|
+
...childDirectories(join(root, "Microsoft"), /^jdk/i)
|
|
267
|
+
]),
|
|
268
|
+
ghidra: [
|
|
269
|
+
...programFiles.flatMap(root => childDirectories(root, /^ghidra/i)),
|
|
270
|
+
...childDirectories(user, /^ghidra/i),
|
|
271
|
+
...childDirectories(join(user, "tools"), /^ghidra/i)
|
|
272
|
+
]
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
export function resolveToolchain(options = {}) {
|
|
276
|
+
const env = options.env ?? process.env;
|
|
277
|
+
const platform = options.platform ?? process.platform;
|
|
278
|
+
const home = normalizePath(options.home ?? resolveWowdumpHome(env, options.userHome));
|
|
279
|
+
const config = readConfig(normalizePath(options.configFile ?? join(home, "config.json")));
|
|
280
|
+
const managedRoot = join(home, "toolchains");
|
|
281
|
+
const standard = platform === "win32"
|
|
282
|
+
? windowsRoots(env)
|
|
283
|
+
: {
|
|
284
|
+
java: ["/usr/lib/jvm/default-java", "/usr/lib/jvm/java-21-openjdk", "/opt/java"],
|
|
285
|
+
ghidra: ["/opt/ghidra", "/usr/local/ghidra", join(options.userHome ?? homedir(), "ghidra")]
|
|
286
|
+
};
|
|
287
|
+
const javaPathMatches = pathCandidates("java", options.path ?? env.PATH, platform);
|
|
288
|
+
const ghidraPathMatches = pathCandidates("analyzeHeadless", options.path ?? env.PATH, platform);
|
|
289
|
+
const javaCandidates = [
|
|
290
|
+
{ value: options.java ?? env.WOWDUMP_JAVA, source: "explicit" },
|
|
291
|
+
{ value: configuredTool(config, "java"), source: "config" },
|
|
292
|
+
{ value: env.JAVA_HOME, source: "system" },
|
|
293
|
+
...javaPathMatches.map(value => ({ value, source: "system" })),
|
|
294
|
+
...standard.java.map(value => ({ value, source: "system" })),
|
|
295
|
+
...childDirectories(managedRoot, /^(?:temurin|jdk|java)/i).map(value => ({ value, source: "managed" }))
|
|
296
|
+
];
|
|
297
|
+
const ghidraCandidates = [
|
|
298
|
+
{ value: options.ghidra ?? env.WOWDUMP_GHIDRA, source: "explicit" },
|
|
299
|
+
{ value: configuredTool(config, "ghidra"), source: "config" },
|
|
300
|
+
{ value: env.GHIDRA_HOME ?? env.GHIDRA_INSTALL_DIR, source: "system" },
|
|
301
|
+
...ghidraPathMatches.map(value => ({ value, source: "system" })),
|
|
302
|
+
...standard.ghidra.map(value => ({ value, source: "system" })),
|
|
303
|
+
...childDirectories(managedRoot, /^ghidra/i).map(value => ({ value, source: "managed" }))
|
|
304
|
+
];
|
|
305
|
+
const java = resolvedTool(javaCandidates, value => javaExecutable(value, platform));
|
|
306
|
+
const ghidra = resolvedTool(ghidraCandidates, value => ghidraExecutable(value, platform));
|
|
307
|
+
const missing = [];
|
|
308
|
+
if (!java.tool)
|
|
309
|
+
missing.push("java");
|
|
310
|
+
if (!ghidra.tool)
|
|
311
|
+
missing.push("ghidra");
|
|
312
|
+
return {
|
|
313
|
+
home,
|
|
314
|
+
ready: missing.length === 0,
|
|
315
|
+
java: java.tool,
|
|
316
|
+
ghidra: ghidra.tool,
|
|
317
|
+
missing,
|
|
318
|
+
searched: { java: java.searched, ghidra: ghidra.searched }
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
async function download(url, file) {
|
|
322
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
323
|
+
if (!response.ok || !response.body)
|
|
324
|
+
throw new Error(`download failed with HTTP ${response.status}: ${url}`);
|
|
325
|
+
await pipeline(response.body, createWriteStream(file, { flags: "wx", mode: 0o600 }));
|
|
326
|
+
}
|
|
327
|
+
async function extractZip(archive, directory) {
|
|
328
|
+
if (process.platform === "win32") {
|
|
329
|
+
await execFile("powershell.exe", [
|
|
330
|
+
"-NoProfile",
|
|
331
|
+
"-NonInteractive",
|
|
332
|
+
"-Command",
|
|
333
|
+
"Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force",
|
|
334
|
+
archive,
|
|
335
|
+
directory
|
|
336
|
+
], { windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
await execFile("unzip", ["-q", archive, "-d", directory], { maxBuffer: 4 * 1024 * 1024 });
|
|
340
|
+
}
|
|
341
|
+
function firstDirectory(directory) {
|
|
342
|
+
const entries = readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isDirectory());
|
|
343
|
+
return entries.length === 1 ? join(directory, entries[0].name) : directory;
|
|
344
|
+
}
|
|
345
|
+
export const defaultToolchainInstaller = async (input) => {
|
|
346
|
+
if (process.platform !== "win32") {
|
|
347
|
+
throw new Error(`managed ${input.component} installation currently requires Windows; configure an existing installation`);
|
|
348
|
+
}
|
|
349
|
+
if (directoryExists(input.destination))
|
|
350
|
+
return;
|
|
351
|
+
await mkdir(dirname(input.destination), { recursive: true });
|
|
352
|
+
const staging = await mkdtemp(join(tmpdir(), `wowdump-${input.component}-`));
|
|
353
|
+
const archive = join(staging, `${input.component}.zip`);
|
|
354
|
+
const extracted = join(staging, "extracted");
|
|
355
|
+
try {
|
|
356
|
+
await download(input.url, archive);
|
|
357
|
+
await mkdir(extracted, { recursive: true });
|
|
358
|
+
await extractZip(archive, extracted);
|
|
359
|
+
const source = firstDirectory(extracted);
|
|
360
|
+
try {
|
|
361
|
+
await rename(source, input.destination);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
if (error.code === "EXDEV")
|
|
365
|
+
await cp(source, input.destination, { recursive: true, errorOnExist: true });
|
|
366
|
+
else if (error.code !== "EEXIST")
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
const manifest = join(input.destination, ".wowdump-toolchain.json");
|
|
370
|
+
if (!existsSync(manifest)) {
|
|
371
|
+
await writeFile(manifest, `${JSON.stringify({
|
|
372
|
+
schema: "wowdump.toolchain.v1",
|
|
373
|
+
component: input.component,
|
|
374
|
+
version: input.version,
|
|
375
|
+
source: input.url,
|
|
376
|
+
installedAt: new Date().toISOString()
|
|
377
|
+
}, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
await rm(staging, { recursive: true, force: true });
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
export async function bootstrapToolchain(options = {}) {
|
|
385
|
+
const initialized = await initializeWowdumpHome(options);
|
|
386
|
+
let toolchain = resolveToolchain({ ...options, home: initialized.home });
|
|
387
|
+
const installed = [];
|
|
388
|
+
if (options.installMissing === true && toolchain.missing.length > 0) {
|
|
389
|
+
const installer = options.installer ?? defaultToolchainInstaller;
|
|
390
|
+
for (const component of toolchain.missing) {
|
|
391
|
+
const pinned = PINNED_TOOLCHAINS[component];
|
|
392
|
+
const destination = join(initialized.home, "toolchains", pinned.directory);
|
|
393
|
+
await installer({ component, destination, url: pinned.windowsUrl, version: pinned.version });
|
|
394
|
+
installed.push(component);
|
|
395
|
+
}
|
|
396
|
+
toolchain = resolveToolchain({ ...options, home: initialized.home });
|
|
397
|
+
}
|
|
398
|
+
return { ...initialized, toolchain, installed };
|
|
399
|
+
}
|
|
400
|
+
export function ghidraHeadlessArguments(options) {
|
|
401
|
+
const args = [
|
|
402
|
+
normalizePath(options.projectDirectory),
|
|
403
|
+
options.projectName,
|
|
404
|
+
"-import",
|
|
405
|
+
normalizePath(options.executable),
|
|
406
|
+
"-analysisTimeoutPerFile",
|
|
407
|
+
String(options.timeoutSeconds ?? 1800)
|
|
408
|
+
];
|
|
409
|
+
if (options.overwrite !== false)
|
|
410
|
+
args.push("-overwrite");
|
|
411
|
+
if (options.scriptDirectory)
|
|
412
|
+
args.push("-scriptPath", normalizePath(options.scriptDirectory));
|
|
413
|
+
if (options.exporterScript) {
|
|
414
|
+
args.push("-postScript", options.exporterScript);
|
|
415
|
+
if (options.evidenceFile)
|
|
416
|
+
args.push(normalizePath(options.evidenceFile));
|
|
417
|
+
if (options.buildKey)
|
|
418
|
+
args.push(options.buildKey);
|
|
419
|
+
}
|
|
420
|
+
return args;
|
|
421
|
+
}
|
|
422
|
+
export const defaultExternalCommandRunner = async (command) => {
|
|
423
|
+
try {
|
|
424
|
+
const result = await execFile(command.file, command.args, {
|
|
425
|
+
cwd: command.cwd,
|
|
426
|
+
env: command.env,
|
|
427
|
+
windowsHide: true,
|
|
428
|
+
maxBuffer: 64 * 1024 * 1024
|
|
429
|
+
});
|
|
430
|
+
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
const failure = error;
|
|
434
|
+
return {
|
|
435
|
+
exitCode: typeof failure.code === "number" ? failure.code : 1,
|
|
436
|
+
stdout: failure.stdout ?? "",
|
|
437
|
+
stderr: failure.stderr ?? failure.message
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
export async function sha256File(file) {
|
|
442
|
+
const hash = createHash("sha256");
|
|
443
|
+
await pipeline(createReadStream(file), hash);
|
|
444
|
+
return hash.digest("hex");
|
|
445
|
+
}
|
|
446
|
+
function hex(value) {
|
|
447
|
+
return `0x${BigInt(value).toString(16)}`;
|
|
448
|
+
}
|
|
449
|
+
export function inspectPeSections(buffer) {
|
|
450
|
+
if (buffer.length < 0x40 || buffer.toString("ascii", 0, 2) !== "MZ")
|
|
451
|
+
return [];
|
|
452
|
+
const peOffset = buffer.readUInt32LE(0x3c);
|
|
453
|
+
if (peOffset + 24 > buffer.length || buffer.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0")
|
|
454
|
+
return [];
|
|
455
|
+
const sectionCount = buffer.readUInt16LE(peOffset + 6);
|
|
456
|
+
const optionalSize = buffer.readUInt16LE(peOffset + 20);
|
|
457
|
+
const table = peOffset + 24 + optionalSize;
|
|
458
|
+
const sections = [];
|
|
459
|
+
for (let index = 0; index < sectionCount; index += 1) {
|
|
460
|
+
const offset = table + index * 40;
|
|
461
|
+
if (offset + 40 > buffer.length)
|
|
462
|
+
break;
|
|
463
|
+
const name = buffer.subarray(offset, offset + 8).toString("ascii").replace(/\0+$/, "");
|
|
464
|
+
sections.push({
|
|
465
|
+
name,
|
|
466
|
+
virtualSize: hex(buffer.readUInt32LE(offset + 8)),
|
|
467
|
+
rva: hex(buffer.readUInt32LE(offset + 12)),
|
|
468
|
+
rawSize: hex(buffer.readUInt32LE(offset + 16)),
|
|
469
|
+
rawOffset: hex(buffer.readUInt32LE(offset + 20)),
|
|
470
|
+
characteristics: hex(buffer.readUInt32LE(offset + 36))
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return sections;
|
|
474
|
+
}
|
|
475
|
+
function safeBuildName(buildKey) {
|
|
476
|
+
const value = buildKey.trim().replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
477
|
+
if (!value)
|
|
478
|
+
throw new Error("buildKey must contain a filesystem-safe character");
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
function record(value) {
|
|
482
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
483
|
+
}
|
|
484
|
+
function evidenceArray(evidence, ...keys) {
|
|
485
|
+
for (const key of keys)
|
|
486
|
+
if (Array.isArray(evidence[key]))
|
|
487
|
+
return evidence[key];
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
export async function runGhidraStaticAnalysis(options) {
|
|
491
|
+
const executable = normalizePath(options.executable);
|
|
492
|
+
await access(executable, constants.R_OK);
|
|
493
|
+
if (!options.buildKey.trim())
|
|
494
|
+
throw new Error("buildKey is required");
|
|
495
|
+
const toolchain = resolveToolchain(options);
|
|
496
|
+
if (!toolchain.ghidra || !toolchain.java) {
|
|
497
|
+
throw new Error(`Ghidra toolchain is incomplete: missing ${toolchain.missing.join(", ")}; run npm install or configure --java/--ghidra`);
|
|
498
|
+
}
|
|
499
|
+
const suffix = safeBuildName(options.buildKey);
|
|
500
|
+
const outputDirectory = normalizePath(options.outputDirectory ?? join(toolchain.home, "profiles", suffix));
|
|
501
|
+
const projectDirectory = normalizePath(options.projectDirectory ?? join(toolchain.home, "cache", "ghidra", suffix));
|
|
502
|
+
const projectName = options.projectName ?? `wowdump-${suffix}`;
|
|
503
|
+
const evidenceFile = normalizePath(options.evidenceFile ?? join(outputDirectory, "ghidra-evidence.json"));
|
|
504
|
+
const outputFile = join(outputDirectory, "profile.json");
|
|
505
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
506
|
+
await mkdir(projectDirectory, { recursive: true });
|
|
507
|
+
const args = ghidraHeadlessArguments({
|
|
508
|
+
projectDirectory,
|
|
509
|
+
projectName,
|
|
510
|
+
executable,
|
|
511
|
+
timeoutSeconds: options.timeoutSeconds,
|
|
512
|
+
overwrite: options.overwrite,
|
|
513
|
+
scriptDirectory: options.scriptDirectory,
|
|
514
|
+
exporterScript: options.exporterScript,
|
|
515
|
+
evidenceFile,
|
|
516
|
+
buildKey: options.buildKey
|
|
517
|
+
});
|
|
518
|
+
const command = {
|
|
519
|
+
file: toolchain.ghidra.executable,
|
|
520
|
+
args,
|
|
521
|
+
cwd: outputDirectory,
|
|
522
|
+
env: { ...process.env, JAVA_HOME: toolchain.java.root }
|
|
523
|
+
};
|
|
524
|
+
let processResult;
|
|
525
|
+
if (!options.dryRun) {
|
|
526
|
+
processResult = await (options.runner ?? defaultExternalCommandRunner)(command);
|
|
527
|
+
if (processResult.exitCode !== 0) {
|
|
528
|
+
throw new Error(`Ghidra Headless exited with ${processResult.exitCode}: ${processResult.stderr.trim() || "no diagnostic"}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
let evidence = {};
|
|
532
|
+
if (!options.dryRun && existsSync(evidenceFile))
|
|
533
|
+
evidence = record(JSON.parse(await readFile(evidenceFile, "utf8")));
|
|
534
|
+
const executableSha256 = await sha256File(executable);
|
|
535
|
+
const sections = evidenceArray(evidence, "sections").length > 0
|
|
536
|
+
? evidenceArray(evidence, "sections")
|
|
537
|
+
: inspectPeSections(await readFile(executable));
|
|
538
|
+
const profile = {
|
|
539
|
+
schema: "wowdump.profile.v1",
|
|
540
|
+
buildKey: options.buildKey,
|
|
541
|
+
executable: { path: executable, sha256: executableSha256, module: basename(executable) },
|
|
542
|
+
sections,
|
|
543
|
+
functions: evidenceArray(evidence, "functions"),
|
|
544
|
+
strings: evidenceArray(evidence, "strings"),
|
|
545
|
+
xrefs: evidenceArray(evidence, "xrefs", "crossReferences"),
|
|
546
|
+
providers: evidenceArray(evidence, "providers", "providerMatches"),
|
|
547
|
+
signatures: evidenceArray(evidence, "signatures", "signatureMatches"),
|
|
548
|
+
analysis: {
|
|
549
|
+
engine: "ghidra-headless",
|
|
550
|
+
engineVersion: evidence.ghidraVersion ?? PINNED_TOOLCHAINS.ghidra.version,
|
|
551
|
+
javaVersion: evidence.javaVersion ?? PINNED_TOOLCHAINS.java.version,
|
|
552
|
+
generatedAt: new Date().toISOString(),
|
|
553
|
+
command: { file: command.file, args: command.args },
|
|
554
|
+
evidenceFile
|
|
555
|
+
},
|
|
556
|
+
confidence: "candidate"
|
|
557
|
+
};
|
|
558
|
+
if (!options.dryRun)
|
|
559
|
+
await writeFile(outputFile, `${JSON.stringify(profile, null, 2)}\n`, "utf8");
|
|
560
|
+
return {
|
|
561
|
+
ok: true,
|
|
562
|
+
command: "analyze.static",
|
|
563
|
+
dryRun: options.dryRun === true,
|
|
564
|
+
buildKey: options.buildKey,
|
|
565
|
+
executable,
|
|
566
|
+
executableSha256,
|
|
567
|
+
outputFile,
|
|
568
|
+
commandLine: { file: command.file, args: command.args, cwd: outputDirectory },
|
|
569
|
+
toolchain,
|
|
570
|
+
profile,
|
|
571
|
+
...(processResult ? { process: processResult } : {})
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
async function postinstall() {
|
|
575
|
+
const installMissing = process.env.WOWDUMP_SKIP_TOOLCHAIN_DOWNLOAD !== "1";
|
|
576
|
+
try {
|
|
577
|
+
const result = await bootstrapToolchain({ installMissing });
|
|
578
|
+
process.stdout.write(`${JSON.stringify({
|
|
579
|
+
ok: result.toolchain.ready,
|
|
580
|
+
command: "postinstall",
|
|
581
|
+
home: result.home,
|
|
582
|
+
installed: result.installed,
|
|
583
|
+
missing: result.toolchain.missing,
|
|
584
|
+
skillFile: result.skillFile
|
|
585
|
+
})}\n`);
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
process.stderr.write(`wowdump postinstall: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
589
|
+
process.stderr.write("Set WOWDUMP_SKIP_TOOLCHAIN_DOWNLOAD=1 to initialize without downloading, then configure WOWDUMP_JAVA and WOWDUMP_GHIDRA.\n");
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const invokedFile = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
|
|
593
|
+
if (invokedFile === import.meta.url && process.argv.includes("--postinstall"))
|
|
594
|
+
await postinstall();
|