wowdump 0.2.0 → 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 -181
- 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,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wowdump
|
|
3
|
+
description: "使用 wowdump Node CLI 进行只读的 WoW 原生内存分析、Ghidra 静态取证、受限 profile 读取,以及经过明确确认的运行时验证。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# wowdump
|
|
7
|
+
|
|
8
|
+
`wowdump` 是唯一用户入口。按依赖顺序完成:
|
|
9
|
+
|
|
10
|
+
1. 用 Ghidra 静态分析发现段、函数、签名和候选 RVA。
|
|
11
|
+
2. 用经过确认的 Frida 短时导出运行中模块的基址和对应字节,验证这些 RVA。
|
|
12
|
+
3. 把通过验证的 RVA、字段、约束和证据落盘为 `reader_ready` profile。
|
|
13
|
+
4. 后续只用 broker-backed reader 读取和监控,不再启动 Frida。
|
|
14
|
+
|
|
15
|
+
每一步都保留 build、文件哈希、模块基址、RVA、命令和证据来源。详细命令和字段格式见同目录的 `commands.md`。
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# wowdump 命令参考
|
|
2
|
+
|
|
3
|
+
## 1. 静态发现
|
|
4
|
+
|
|
5
|
+
```powershell
|
|
6
|
+
wowdump analyze static --exe "C:\path with spaces\Wow.exe" --build "retail@VERSION"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
输出是候选 profile:包含 `module.imageBase`、段范围、函数/字符串/交叉引用,以及由 `address - imageBase` 得到的候选 RVA。此阶段只读磁盘文件,不连接游戏;结果 `confidence` 为 `candidate`。
|
|
10
|
+
|
|
11
|
+
## 2. Frida 运行时导出
|
|
12
|
+
|
|
13
|
+
运行时导出必须先确认准确的 PID、build、目标操作、Hook 数、持续时间、事件上限和清理期限:
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
wowdump analyze runtime-export --pid 1234 --build "retail@VERSION" --static-profile .wowdump\profiles\static.json --kind providers --confirm
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Frida 只负责一次性验证:获取 `Wow.exe` 的 `moduleBase`/`moduleSize`,读取静态 profile 指定 RVA 的现场字节或运行时事件,并把 `runtimeAddress = moduleBase + rva` 写入导出证据。完成后卸载脚本并断开连接。
|
|
20
|
+
|
|
21
|
+
## 3. 验证并落盘
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
wowdump analyze verify --static-profile .wowdump\profiles\static.json --runtime-export .wowdump\runtime\export.json --output .wowdump\profiles\verified.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
验证 build key、可执行文件 SHA256、模块基址、段边界、RVA 入口字节和字段约束。只有检查全部通过的记录才可标为 `reader_ready`;不匹配项原样保留,不能静默升级置信度。
|
|
28
|
+
|
|
29
|
+
## 4. 后续只读 reader
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
wowdump target
|
|
33
|
+
wowdump profiles reader-ready
|
|
34
|
+
wowdump memory read --pid 1234 --profile reader-ready
|
|
35
|
+
wowdump memory watch start --pid 1234 --profile reader-ready
|
|
36
|
+
wowdump memory watch poll --id WATCH_ID
|
|
37
|
+
wowdump memory watch stop --id WATCH_ID
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
reader 从 profile 取 `moduleName`、`root.rva`、指针链、字段偏移和校验规则,先计算 `moduleBase + rva`,再执行只读 `ReadProcessMemory`。没有 `reader_ready`、build 不匹配或身份变化时返回结构化错误,不猜测地址。
|
|
41
|
+
|
|
42
|
+
## 5. 工具链和 broker
|
|
43
|
+
|
|
44
|
+
`npm install`/`wowdump init` 会探测 JDK/Ghidra;缺失依赖下载到 `WOWDUMP_HOME/toolchains`。Skill 默认安装到 `~/.agents/skills/wowdump`,可用 `WOWDUMP_SKILL_HOME` 覆盖。Windows 首次 reader 调用由 Node 触发 UAC,broker 复用句柄和监控状态,空闲 20 分钟后退出。
|
package/dist/analysis-path.js
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
function worktreeFormalRoot(start) {
|
|
5
|
-
const normalized = resolve(start).replaceAll("\\", "/");
|
|
6
|
-
const marker = "/.worktrees/";
|
|
7
|
-
const index = normalized.toLowerCase().lastIndexOf(marker);
|
|
8
|
-
return index >= 0 ? join(normalized.slice(0, index), "analyze", "vm") : undefined;
|
|
9
|
-
}
|
|
10
|
-
function existingAnalysisRoot(start) {
|
|
11
|
-
let current = resolve(start);
|
|
12
|
-
while (true) {
|
|
13
|
-
const candidate = join(current, "analyze", "vm");
|
|
14
|
-
if (existsSync(candidate))
|
|
15
|
-
return candidate;
|
|
16
|
-
const parent = dirname(current);
|
|
17
|
-
if (parent === current)
|
|
18
|
-
return undefined;
|
|
19
|
-
current = parent;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
/** Resolve the one formal evidence root even when the process runs in a Comet worktree. */
|
|
23
|
-
export function resolveAnalysisDir(configured = process.env.WOW_ANALYZE_DIR, cwd = process.cwd(), moduleUrl = import.meta.url) {
|
|
24
|
-
if (configured)
|
|
25
|
-
return resolve(cwd, configured);
|
|
26
|
-
const anchors = [resolve(cwd), dirname(fileURLToPath(moduleUrl))];
|
|
27
|
-
for (const anchor of anchors) {
|
|
28
|
-
const formalRoot = worktreeFormalRoot(anchor);
|
|
29
|
-
if (formalRoot)
|
|
30
|
-
return formalRoot;
|
|
31
|
-
}
|
|
32
|
-
for (const anchor of anchors) {
|
|
33
|
-
const formalRoot = existingAnalysisRoot(anchor);
|
|
34
|
-
if (formalRoot)
|
|
35
|
-
return formalRoot;
|
|
36
|
-
}
|
|
37
|
-
return join(anchors[0], "analyze", "vm");
|
|
38
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, open, readFile, truncate } from "node:fs/promises";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
4
|
-
import { acquireProcessLogLock } from "./process-log-lock.js";
|
|
5
|
-
export class AnalysisProcessLog {
|
|
6
|
-
fileName;
|
|
7
|
-
tail = Promise.resolve();
|
|
8
|
-
root;
|
|
9
|
-
constructor(root, fileName = "wow-analysis-process-68974.jsonl") {
|
|
10
|
-
this.fileName = fileName;
|
|
11
|
-
this.root = resolve(root);
|
|
12
|
-
}
|
|
13
|
-
append(input) {
|
|
14
|
-
const operation = this.tail.then(() => this.appendOwned(input));
|
|
15
|
-
this.tail = operation.catch(() => undefined);
|
|
16
|
-
return operation;
|
|
17
|
-
}
|
|
18
|
-
async flush() { await this.tail; }
|
|
19
|
-
async appendOwned(input) {
|
|
20
|
-
await mkdir(this.root, { recursive: true });
|
|
21
|
-
const release = await acquireProcessLogLock(join(this.root, ".wow-analysis-process.lock"));
|
|
22
|
-
try {
|
|
23
|
-
const file = join(this.root, this.fileName);
|
|
24
|
-
const seq = await nextSequence(file);
|
|
25
|
-
const event = {
|
|
26
|
-
seq,
|
|
27
|
-
timestamp: new Date().toISOString(),
|
|
28
|
-
buildKey: input.buildKey ?? "retail@12.0.7.68974",
|
|
29
|
-
phase: input.phase ?? "verify",
|
|
30
|
-
action: input.action,
|
|
31
|
-
tool: input.tool ?? "frida-mcp",
|
|
32
|
-
target: input.target ?? { name: "Wow.exe", va: null, rva: null, imageBase: "0x140000000", moduleBase: null, runtimeAddress: null },
|
|
33
|
-
command: input.command ?? null,
|
|
34
|
-
inputs: input.inputs ?? [],
|
|
35
|
-
inputHashes: input.inputHashes ?? {},
|
|
36
|
-
result: input.result ?? {},
|
|
37
|
-
status: input.status,
|
|
38
|
-
durationMs: input.durationMs ?? 0,
|
|
39
|
-
artifacts: input.artifacts ?? [],
|
|
40
|
-
error: input.error ?? null,
|
|
41
|
-
nextAction: input.nextAction ?? null
|
|
42
|
-
};
|
|
43
|
-
const handle = await open(file, "a", 0o600);
|
|
44
|
-
try {
|
|
45
|
-
await handle.write(`${JSON.stringify(event)}\n`, null, "utf8");
|
|
46
|
-
await handle.sync();
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
await handle.close();
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
finally {
|
|
53
|
-
await release();
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
async function nextSequence(file) {
|
|
58
|
-
try {
|
|
59
|
-
await recoverIncompleteTail(file);
|
|
60
|
-
const text = await readFile(file, "utf8");
|
|
61
|
-
let highest = 0;
|
|
62
|
-
for (const line of text.split(/\r?\n/)) {
|
|
63
|
-
if (!line)
|
|
64
|
-
continue;
|
|
65
|
-
const value = JSON.parse(line);
|
|
66
|
-
if (Number.isSafeInteger(value.seq))
|
|
67
|
-
highest = Math.max(highest, Number(value.seq));
|
|
68
|
-
}
|
|
69
|
-
return highest + 1;
|
|
70
|
-
}
|
|
71
|
-
catch (error) {
|
|
72
|
-
if (error.code === "ENOENT")
|
|
73
|
-
return 1;
|
|
74
|
-
throw error;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
async function recoverIncompleteTail(file) {
|
|
78
|
-
const content = await readFile(file).catch(error => {
|
|
79
|
-
if (error.code === "ENOENT")
|
|
80
|
-
return Buffer.alloc(0);
|
|
81
|
-
throw error;
|
|
82
|
-
});
|
|
83
|
-
if (content.length === 0 || content.at(-1) === 0x0a)
|
|
84
|
-
return;
|
|
85
|
-
const offset = content.lastIndexOf(0x0a) + 1;
|
|
86
|
-
const tail = content.subarray(offset);
|
|
87
|
-
if (!isIncompleteJsonTail(tail))
|
|
88
|
-
return;
|
|
89
|
-
const sha256 = createHash("sha256").update(tail).digest("hex");
|
|
90
|
-
const quarantine = `${file}.tail-${sha256}.quarantine.json`;
|
|
91
|
-
const evidence = Buffer.from(`${JSON.stringify({ schemaVersion: 1, kind: "analysis-process-incomplete-tail", sourcePath: resolve(file), byteOffset: offset, bytes: tail.length, sha256, encoding: "base64", data: tail.toString("base64") })}\n`, "utf8");
|
|
92
|
-
try {
|
|
93
|
-
const handle = await open(quarantine, "wx", 0o600);
|
|
94
|
-
try {
|
|
95
|
-
await handle.write(evidence);
|
|
96
|
-
await handle.sync();
|
|
97
|
-
}
|
|
98
|
-
finally {
|
|
99
|
-
await handle.close();
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
catch (error) {
|
|
103
|
-
if (error.code !== "EEXIST")
|
|
104
|
-
throw error;
|
|
105
|
-
const prior = await readFile(quarantine);
|
|
106
|
-
if (!prior.equals(evidence))
|
|
107
|
-
throw new Error(`process log quarantine conflict: ${quarantine}`);
|
|
108
|
-
}
|
|
109
|
-
await truncate(file, offset);
|
|
110
|
-
const handle = await open(file, "r+");
|
|
111
|
-
try {
|
|
112
|
-
await handle.sync();
|
|
113
|
-
}
|
|
114
|
-
finally {
|
|
115
|
-
await handle.close();
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
function isIncompleteJsonTail(tail) {
|
|
119
|
-
const text = tail.toString("utf8").trimStart();
|
|
120
|
-
if (!text.startsWith("{"))
|
|
121
|
-
return false;
|
|
122
|
-
const stack = [];
|
|
123
|
-
let inString = false;
|
|
124
|
-
let escaped = false;
|
|
125
|
-
for (const character of text) {
|
|
126
|
-
if (inString) {
|
|
127
|
-
if (escaped)
|
|
128
|
-
escaped = false;
|
|
129
|
-
else if (character === "\\")
|
|
130
|
-
escaped = true;
|
|
131
|
-
else if (character === '"')
|
|
132
|
-
inString = false;
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (character === '"')
|
|
136
|
-
inString = true;
|
|
137
|
-
else if (character === "{" || character === "[")
|
|
138
|
-
stack.push(character);
|
|
139
|
-
else if (character === "}" || character === "]") {
|
|
140
|
-
const expected = character === "}" ? "{" : "[";
|
|
141
|
-
if (stack.pop() !== expected)
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return inString || escaped || stack.length > 0;
|
|
146
|
-
}
|
package/dist/broker-client.js
DELETED
|
@@ -1,411 +0,0 @@
|
|
|
1
|
-
import { connect } from "node:net";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { isAbsolute } from "node:path";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { homedir, tmpdir } from "node:os";
|
|
7
|
-
import { open, readFile, stat, unlink } from "node:fs/promises";
|
|
8
|
-
import { BROKER_DEFAULT_TIMEOUT_MS, BROKER_PROTOCOL_VERSION, FrameDecoder, BrokerProtocolError, canonicalArtifactRoot, encodeFrame, pipeNameForSid, sidHash } from "./broker-protocol.js";
|
|
9
|
-
export class BrokerBootstrapCoordinator {
|
|
10
|
-
platform;
|
|
11
|
-
constructor(platform) {
|
|
12
|
-
this.platform = platform;
|
|
13
|
-
}
|
|
14
|
-
async start(options) {
|
|
15
|
-
if (!isAbsolute(options.executable) || !isAbsolute(options.workdir))
|
|
16
|
-
throw new BrokerProtocolError("BROKER_EXECUTABLE_INVALID", "Broker executable and workdir must be absolute configured paths");
|
|
17
|
-
const hash = sidHash(options.sid);
|
|
18
|
-
const pipeName = options.pipeName ?? pipeNameForSid(options.sid);
|
|
19
|
-
const bootstrapNonce = options.nonce?.() ?? randomUUID();
|
|
20
|
-
const artifactRoot = canonicalArtifactRoot(options.artifactRoot ?? process.env.WOWDUMP_RUNTIME_DIR ?? defaultRuntimeRoot());
|
|
21
|
-
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
22
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
23
|
-
const beforeDeadline = (operation, task) => runBootstrapBeforeDeadline(deadline, operation, task);
|
|
24
|
-
const mutex = await beforeDeadline("acquire bootstrap mutex", () => this.platform.acquireBootstrapMutex(`wowdump-frida-bootstrap-${hash}`));
|
|
25
|
-
if (!mutex) {
|
|
26
|
-
let ready;
|
|
27
|
-
try {
|
|
28
|
-
ready = await beforeDeadline("wait for existing Broker readiness", () => this.platform.waitReady(pipeName, remainingBootstrapMs(deadline)));
|
|
29
|
-
}
|
|
30
|
-
catch (error) {
|
|
31
|
-
throw new BrokerProtocolError("BOOTSTRAP_TIMEOUT", error instanceof Error ? error.message : "Broker ready timeout", "Inspect the active Broker bootstrap and retry.");
|
|
32
|
-
}
|
|
33
|
-
this.validateReady(ready, { hash, pipeName, artifactRoot });
|
|
34
|
-
return ready;
|
|
35
|
-
}
|
|
36
|
-
try {
|
|
37
|
-
const input = { pipeName, sidHash: hash, bootstrapNonce, artifactRoot, executable: options.executable, workdir: options.workdir, argv: options.argv ?? [] };
|
|
38
|
-
let elevation;
|
|
39
|
-
try {
|
|
40
|
-
if (await beforeDeadline("check process elevation", () => this.platform.isElevated()))
|
|
41
|
-
await beforeDeadline("launch Broker directly", () => this.platform.launchDirect(input));
|
|
42
|
-
else {
|
|
43
|
-
elevation = normalizeElevationResult(await beforeDeadline("launch elevated Broker", () => this.platform.launchElevated(input)));
|
|
44
|
-
if (elevation.status === "cancelled")
|
|
45
|
-
throw elevationError("UAC_CANCELLED", "Broker elevation was cancelled", elevation, "Start the Broker again and approve UAC.");
|
|
46
|
-
if (elevation.status === "failed")
|
|
47
|
-
throw elevationError("UAC_FAILED", "Broker elevation failed", elevation, "Inspect Windows elevation logs and retry.");
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
if (error instanceof BrokerProtocolError)
|
|
52
|
-
throw error;
|
|
53
|
-
throw new BrokerProtocolError("BOOTSTRAP_SPAWN_FAILED", error instanceof Error ? error.message : String(error), "Verify Broker executable configuration and retry.");
|
|
54
|
-
}
|
|
55
|
-
let ready;
|
|
56
|
-
try {
|
|
57
|
-
ready = await beforeDeadline("wait for Broker readiness", () => this.platform.waitReady(pipeName, remainingBootstrapMs(deadline)));
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
const detail = elevation ? ` (${elevationDiagnostic(elevation)})` : "";
|
|
61
|
-
throw new BrokerProtocolError("BOOTSTRAP_TIMEOUT", `${error instanceof Error ? error.message : "Broker ready timeout"}${detail}`, "Inspect Broker startup logs and retry.");
|
|
62
|
-
}
|
|
63
|
-
this.validateReady(ready, { hash, pipeName, bootstrapNonce, artifactRoot });
|
|
64
|
-
return ready;
|
|
65
|
-
}
|
|
66
|
-
finally {
|
|
67
|
-
const release = Promise.resolve().then(() => mutex.release());
|
|
68
|
-
try {
|
|
69
|
-
await beforeDeadline("release bootstrap mutex", () => release);
|
|
70
|
-
}
|
|
71
|
-
catch { /* release is best-effort and must not mask the bootstrap result */ }
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
validateReady(ready, expected) {
|
|
75
|
-
let readyArtifactRoot = "";
|
|
76
|
-
try {
|
|
77
|
-
readyArtifactRoot = canonicalArtifactRoot(ready.artifactRoot);
|
|
78
|
-
}
|
|
79
|
-
catch { /* normalize all identity failures below */ }
|
|
80
|
-
if (ready.protocolVersion !== BROKER_PROTOCOL_VERSION || ready.sidHash !== expected.hash || ready.pipeName !== expected.pipeName ||
|
|
81
|
-
(expected.bootstrapNonce !== undefined && ready.bootstrapNonce !== expected.bootstrapNonce) || !ready.instanceId || !ready.elevated ||
|
|
82
|
-
readyArtifactRoot !== expected.artifactRoot) {
|
|
83
|
-
throw new BrokerProtocolError("READY_IDENTITY_MISMATCH", "Broker ready identity did not match bootstrap inputs", "Terminate the untrusted child and restart Broker.");
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
function remainingBootstrapMs(deadline) { return Math.max(0, deadline - Date.now()); }
|
|
88
|
-
function runBootstrapBeforeDeadline(deadline, operation, task) {
|
|
89
|
-
const remaining = remainingBootstrapMs(deadline);
|
|
90
|
-
if (remaining <= 0)
|
|
91
|
-
return Promise.reject(bootstrapTimeout(operation));
|
|
92
|
-
return new Promise((resolve, reject) => {
|
|
93
|
-
let settled = false;
|
|
94
|
-
const timer = setTimeout(() => {
|
|
95
|
-
if (settled)
|
|
96
|
-
return;
|
|
97
|
-
settled = true;
|
|
98
|
-
reject(bootstrapTimeout(operation));
|
|
99
|
-
}, remaining);
|
|
100
|
-
Promise.resolve().then(task).then(value => { if (!settled) {
|
|
101
|
-
settled = true;
|
|
102
|
-
clearTimeout(timer);
|
|
103
|
-
resolve(value);
|
|
104
|
-
} }, error => { if (!settled) {
|
|
105
|
-
settled = true;
|
|
106
|
-
clearTimeout(timer);
|
|
107
|
-
reject(error);
|
|
108
|
-
} });
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
function bootstrapTimeout(operation) {
|
|
112
|
-
return new BrokerProtocolError("BOOTSTRAP_TIMEOUT", `Broker bootstrap deadline expired during ${operation}`, "Inspect Broker startup logs and retry.");
|
|
113
|
-
}
|
|
114
|
-
export class WindowsBrokerBootstrapPlatform {
|
|
115
|
-
options;
|
|
116
|
-
constructor(options = {}) {
|
|
117
|
-
this.options = options;
|
|
118
|
-
}
|
|
119
|
-
async acquireBootstrapMutex(name) {
|
|
120
|
-
const path = join(this.options.directory ?? tmpdir(), `${name}.lock`);
|
|
121
|
-
const pipeName = bootstrapPipeName(name);
|
|
122
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
123
|
-
try {
|
|
124
|
-
const handle = await open(path, "wx");
|
|
125
|
-
try {
|
|
126
|
-
await handle.writeFile(JSON.stringify({ pid: process.pid, pipeName, acquiredAt: new Date().toISOString() }), "utf8");
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
await handle.close().catch(() => undefined);
|
|
130
|
-
await unlink(path).catch(() => undefined);
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
return { release: async () => { await handle.close().catch(() => undefined); await unlink(path).catch(() => undefined); } };
|
|
134
|
-
}
|
|
135
|
-
catch (error) {
|
|
136
|
-
if (error?.code !== "EEXIST" || attempt !== 0)
|
|
137
|
-
return null;
|
|
138
|
-
if (!await staleBootstrapLock(path, pipeName, this.options))
|
|
139
|
-
return null;
|
|
140
|
-
await unlink(path).catch(() => undefined);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
async isElevated() { return isWindowsProcessElevated(); }
|
|
146
|
-
async launchDirect(input) {
|
|
147
|
-
const child = spawn(input.executable, brokerChildArguments(input), { cwd: input.workdir, detached: true, windowsHide: true, stdio: "ignore", env: brokerLaunchEnvironment({ WOW_BROKER_SID_HASH: input.sidHash, WOW_BROKER_ARTIFACT_ROOT: input.artifactRoot }) });
|
|
148
|
-
child.unref();
|
|
149
|
-
}
|
|
150
|
-
async launchElevated(input) {
|
|
151
|
-
const args = brokerChildArguments(input);
|
|
152
|
-
const environment = brokerLaunchEnvironment({ WOW_BROKER_LAUNCH_FILE: input.executable, WOW_BROKER_LAUNCH_ARGS: serializeWindowsArgumentList(args), WOW_BROKER_LAUNCH_CWD: input.workdir, WOW_BROKER_SID_HASH: input.sidHash, WOW_BROKER_ARTIFACT_ROOT: input.artifactRoot });
|
|
153
|
-
const result = await runProcess("powershell.exe", brokerElevatedPowerShellArguments(), environment);
|
|
154
|
-
return brokerElevationResultFromProcess(result);
|
|
155
|
-
}
|
|
156
|
-
async waitReady(pipeName, timeoutMs) {
|
|
157
|
-
const deadline = Date.now() + timeoutMs;
|
|
158
|
-
while (Date.now() < deadline) {
|
|
159
|
-
const transport = new NamedPipeClientTransport(pipeName);
|
|
160
|
-
try {
|
|
161
|
-
const client = new BrokerClient(transport, `bootstrap-${process.pid}`);
|
|
162
|
-
const result = await client.request("broker_status", { timeoutMs: Math.min(500, Math.max(1, deadline - Date.now())) });
|
|
163
|
-
if (result && typeof result === "object" && !Array.isArray(result))
|
|
164
|
-
return result;
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
await delay(25);
|
|
168
|
-
}
|
|
169
|
-
finally {
|
|
170
|
-
await transport.close();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
throw new Error("Broker ready deadline expired");
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
export function brokerChildArguments(input) {
|
|
177
|
-
return [
|
|
178
|
-
...input.argv,
|
|
179
|
-
"--pipe", input.pipeName,
|
|
180
|
-
"--bootstrap-nonce", input.bootstrapNonce,
|
|
181
|
-
"--sid-hash", input.sidHash,
|
|
182
|
-
"--artifact-root", input.artifactRoot
|
|
183
|
-
];
|
|
184
|
-
}
|
|
185
|
-
export function brokerElevatedPowerShellArguments() {
|
|
186
|
-
const command = [
|
|
187
|
-
"try {",
|
|
188
|
-
"$startInfo = [System.Diagnostics.ProcessStartInfo]::new();",
|
|
189
|
-
"$startInfo.FileName = $env:WOW_BROKER_LAUNCH_FILE;",
|
|
190
|
-
"$startInfo.Arguments = $env:WOW_BROKER_LAUNCH_ARGS;",
|
|
191
|
-
"$startInfo.WorkingDirectory = $env:WOW_BROKER_LAUNCH_CWD;",
|
|
192
|
-
"$startInfo.UseShellExecute = $true;",
|
|
193
|
-
"$startInfo.Verb = 'runas';",
|
|
194
|
-
"$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden;",
|
|
195
|
-
"$child = [System.Diagnostics.Process]::Start($startInfo); [Console]::Error.WriteLine('__WOW_BROKER_ELEVATION_STARTED__ pid=' + $child.Id); exit 0",
|
|
196
|
-
"} catch {",
|
|
197
|
-
"$root = $_.Exception; $cursor = $root; $native = $null;",
|
|
198
|
-
"while ($null -ne $cursor) { if ($null -ne $cursor.PSObject.Properties['NativeErrorCode']) { $native = $cursor.NativeErrorCode; break }; $cursor = $cursor.InnerException };",
|
|
199
|
-
"$nativeText = if ($null -eq $native) { 'null' } else { [string][int]$native };",
|
|
200
|
-
"$hresultText = if ($null -eq $root) { 'null' } else { [string][int]$root.HResult };",
|
|
201
|
-
"$typeText = if ($null -eq $root) { 'unknown' } else { $root.GetType().FullName -replace '[^A-Za-z0-9_.+]', '_' };",
|
|
202
|
-
"[Console]::Error.WriteLine('__WOW_BROKER_ELEVATION_ERROR__ nativeErrorCode=' + $nativeText + ' hresult=' + $hresultText + ' type=' + $typeText); exit 1",
|
|
203
|
-
"}"
|
|
204
|
-
].join(" ");
|
|
205
|
-
return ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", command];
|
|
206
|
-
}
|
|
207
|
-
const ELEVATION_ERROR_MARKER = /__WOW_BROKER_ELEVATION_ERROR__\s+nativeErrorCode=(null|-?\d+)\s+hresult=(?:null|-?\d+)\s+type=[A-Za-z0-9_.+]+/;
|
|
208
|
-
const ELEVATION_STARTED_MARKER = /__WOW_BROKER_ELEVATION_STARTED__\s+pid=(\d+)/;
|
|
209
|
-
export function brokerElevationResultFromProcess(result) {
|
|
210
|
-
const marker = ELEVATION_ERROR_MARKER.exec(result.stderr);
|
|
211
|
-
const started = ELEVATION_STARTED_MARKER.exec(result.stderr);
|
|
212
|
-
const nativeErrorCode = marker && marker[1] !== "null" ? Number.parseInt(marker[1], 10) : null;
|
|
213
|
-
return {
|
|
214
|
-
status: result.code === 0 ? "started" : nativeErrorCode === 1223 ? "cancelled" : "failed",
|
|
215
|
-
exitCode: result.code,
|
|
216
|
-
stderr: sanitizeDiagnostic(result.stderr),
|
|
217
|
-
cause: result.cause ? sanitizeDiagnostic(result.cause) : null,
|
|
218
|
-
nativeErrorCode,
|
|
219
|
-
childPid: started ? Number.parseInt(started[1], 10) : null
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
/** Serialize argv as one Windows command line for ProcessStartInfo.Arguments. */
|
|
223
|
-
export function serializeWindowsArgumentList(args) {
|
|
224
|
-
return args.map(quoteWindowsArgument).join(" ");
|
|
225
|
-
}
|
|
226
|
-
function quoteWindowsArgument(value) {
|
|
227
|
-
if (value.length === 0)
|
|
228
|
-
return '""';
|
|
229
|
-
let result = '"';
|
|
230
|
-
let slashes = 0;
|
|
231
|
-
for (const character of value) {
|
|
232
|
-
if (character === "\\") {
|
|
233
|
-
slashes += 1;
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
if (character === '"') {
|
|
237
|
-
result += "\\".repeat(slashes * 2 + 1) + '"';
|
|
238
|
-
slashes = 0;
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
result += "\\".repeat(slashes) + character;
|
|
242
|
-
slashes = 0;
|
|
243
|
-
}
|
|
244
|
-
result += "\\".repeat(slashes * 2) + '"';
|
|
245
|
-
return result;
|
|
246
|
-
}
|
|
247
|
-
const BROKER_ENV_ALLOWLIST = ["SystemRoot", "WINDIR", "ComSpec", "PATH", "PATHEXT", "TEMP", "TMP", "USERPROFILE", "LOCALAPPDATA", "APPDATA", "ProgramData", "WOWDUMP_RUNTIME_DIR", "WOWDUMP_PROFILE_DIR", "WOWDUMP_GAME_ROOTS", "WOWDUMP_BROKER_IDLE_MS", "WOW_ANALYZE_DIR"];
|
|
248
|
-
export function brokerLaunchEnvironment(required, source = process.env) {
|
|
249
|
-
const environment = {};
|
|
250
|
-
for (const key of BROKER_ENV_ALLOWLIST)
|
|
251
|
-
if (source[key] !== undefined)
|
|
252
|
-
environment[key] = source[key];
|
|
253
|
-
for (const [key, value] of Object.entries(required)) {
|
|
254
|
-
if (!key.startsWith("WOW_BROKER_") || key === "WOW_BROKER_ELEVATED" || value === undefined)
|
|
255
|
-
continue;
|
|
256
|
-
environment[key] = value;
|
|
257
|
-
}
|
|
258
|
-
return environment;
|
|
259
|
-
}
|
|
260
|
-
function bootstrapPipeName(lockName) {
|
|
261
|
-
const match = /^wowdump-frida-bootstrap-([a-f0-9]{24})$/i.exec(lockName);
|
|
262
|
-
return match ? `\\\\.\\pipe\\wowdump-frida-${match[1].toLowerCase()}` : undefined;
|
|
263
|
-
}
|
|
264
|
-
async function staleBootstrapLock(lockPath, pipeName, options) {
|
|
265
|
-
let metadata;
|
|
266
|
-
try {
|
|
267
|
-
const raw = await readFile(lockPath, "utf8");
|
|
268
|
-
const parsed = JSON.parse(raw);
|
|
269
|
-
if (parsed && typeof parsed === "object")
|
|
270
|
-
metadata = parsed;
|
|
271
|
-
}
|
|
272
|
-
catch { /* an empty/corrupt lock is handled by the age and pipe checks below */ }
|
|
273
|
-
const pid = typeof metadata?.pid === "number" && Number.isInteger(metadata.pid) ? metadata.pid : undefined;
|
|
274
|
-
if (pid !== undefined && (options.processProbe ?? processAlive)(pid))
|
|
275
|
-
return false;
|
|
276
|
-
if (pipeName && await (options.pipeProbe ?? pipeReachable)(pipeName))
|
|
277
|
-
return false;
|
|
278
|
-
try {
|
|
279
|
-
const ageMs = Date.now() - (await stat(lockPath)).mtimeMs;
|
|
280
|
-
return ageMs >= (options.staleAfterMs ?? 2_000);
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
return false;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
function processAlive(pid) {
|
|
287
|
-
if (pid <= 0 || pid === process.pid)
|
|
288
|
-
return pid === process.pid;
|
|
289
|
-
try {
|
|
290
|
-
process.kill(pid, 0);
|
|
291
|
-
return true;
|
|
292
|
-
}
|
|
293
|
-
catch (error) {
|
|
294
|
-
return error?.code === "EPERM";
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
function pipeReachable(pipeName) {
|
|
298
|
-
return new Promise(resolve => {
|
|
299
|
-
const socket = connect(pipeName);
|
|
300
|
-
const finish = (value) => { socket.destroy(); resolve(value); };
|
|
301
|
-
const timer = setTimeout(() => finish(false), 100);
|
|
302
|
-
socket.once("connect", () => { clearTimeout(timer); finish(true); });
|
|
303
|
-
socket.once("error", () => { clearTimeout(timer); finish(false); });
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
export async function isWindowsProcessElevated(options = {}) {
|
|
307
|
-
if ((options.platform ?? process.platform) !== "win32")
|
|
308
|
-
return false;
|
|
309
|
-
const result = await (options.queryGroups ?? (() => runProcess("whoami.exe", ["/groups"], process.env)))();
|
|
310
|
-
return result.code === 0 && /\bS-1-16-12288\b/.test(result.stdout);
|
|
311
|
-
}
|
|
312
|
-
async function runProcess(file, args, env) {
|
|
313
|
-
return new Promise(resolve => {
|
|
314
|
-
const child = spawn(file, args, { env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
315
|
-
let stdout = "";
|
|
316
|
-
let stderr = "";
|
|
317
|
-
child.stdout.setEncoding("utf8");
|
|
318
|
-
child.stderr.setEncoding("utf8");
|
|
319
|
-
child.stdout.on("data", chunk => { stdout += chunk; });
|
|
320
|
-
child.stderr.on("data", chunk => { stderr += chunk; });
|
|
321
|
-
child.on("error", error => resolve({ code: null, stdout, stderr, cause: sanitizeDiagnostic(error.message) || error.name }));
|
|
322
|
-
child.on("exit", code => resolve({ code, stdout, stderr, cause: null }));
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
function normalizeElevationResult(result) {
|
|
326
|
-
return typeof result === "string" ? { status: result, exitCode: null, stderr: "", cause: null, nativeErrorCode: null, childPid: null } : result;
|
|
327
|
-
}
|
|
328
|
-
function elevationDiagnostic(result) {
|
|
329
|
-
const fields = [`status=${result.status}`, `exitCode=${result.exitCode === null ? "null" : result.exitCode}`];
|
|
330
|
-
if (result.childPid !== undefined && result.childPid !== null)
|
|
331
|
-
fields.push(`childPid=${result.childPid}`);
|
|
332
|
-
if (result.nativeErrorCode !== null)
|
|
333
|
-
fields.push(`nativeErrorCode=${result.nativeErrorCode}`);
|
|
334
|
-
if (result.stderr)
|
|
335
|
-
fields.push(`stderr=${JSON.stringify(sanitizeDiagnostic(result.stderr, 512))}`);
|
|
336
|
-
if (result.cause)
|
|
337
|
-
fields.push(`cause=${JSON.stringify(sanitizeDiagnostic(result.cause, 512))}`);
|
|
338
|
-
return fields.join(", ");
|
|
339
|
-
}
|
|
340
|
-
function elevationError(code, summary, result, nextAction) {
|
|
341
|
-
const details = [`exitCode=${result.exitCode === null ? "null" : result.exitCode}`];
|
|
342
|
-
if (result.nativeErrorCode !== null)
|
|
343
|
-
details.push(`nativeErrorCode=${result.nativeErrorCode}`);
|
|
344
|
-
if (result.stderr)
|
|
345
|
-
details.push(`stderr=${JSON.stringify(sanitizeDiagnostic(result.stderr))}`);
|
|
346
|
-
if (result.cause)
|
|
347
|
-
details.push(`cause=${JSON.stringify(sanitizeDiagnostic(result.cause))}`);
|
|
348
|
-
const error = new BrokerProtocolError(code, `${summary} (${details.join(", ")})`, nextAction);
|
|
349
|
-
error.exitCode = result.exitCode;
|
|
350
|
-
error.stderr = sanitizeDiagnostic(result.stderr);
|
|
351
|
-
error.cause = result.cause ? sanitizeDiagnostic(result.cause) : null;
|
|
352
|
-
error.nativeErrorCode = result.nativeErrorCode;
|
|
353
|
-
return error;
|
|
354
|
-
}
|
|
355
|
-
function sanitizeDiagnostic(value, maxLength = 2_048) {
|
|
356
|
-
const normalized = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim();
|
|
357
|
-
const suffix = "...[truncated]";
|
|
358
|
-
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, Math.max(0, maxLength - suffix.length))}${suffix}`;
|
|
359
|
-
}
|
|
360
|
-
function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
|
|
361
|
-
function defaultRuntimeRoot() { return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "wowdump"); }
|
|
362
|
-
export class NamedPipeClientTransport {
|
|
363
|
-
pipeName;
|
|
364
|
-
constructor(pipeName) {
|
|
365
|
-
this.pipeName = pipeName;
|
|
366
|
-
}
|
|
367
|
-
request(frame, timeoutMs) {
|
|
368
|
-
return new Promise((resolve, reject) => {
|
|
369
|
-
const socket = connect(this.pipeName);
|
|
370
|
-
const chunks = [];
|
|
371
|
-
const decoder = new FrameDecoder();
|
|
372
|
-
const timer = setTimeout(() => { socket.destroy(); reject(new BrokerProtocolError("REQUEST_TIMEOUT", "Broker request timed out", "Query request_status before retrying mutating work.")); }, timeoutMs);
|
|
373
|
-
socket.on("connect", () => socket.write(frame));
|
|
374
|
-
socket.on("data", chunk => { chunks.push(chunk); try {
|
|
375
|
-
if (decoder.push(chunk).length) {
|
|
376
|
-
clearTimeout(timer);
|
|
377
|
-
socket.end();
|
|
378
|
-
resolve(Buffer.concat(chunks));
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
catch (error) {
|
|
382
|
-
clearTimeout(timer);
|
|
383
|
-
socket.destroy();
|
|
384
|
-
reject(error);
|
|
385
|
-
} });
|
|
386
|
-
socket.on("error", error => { clearTimeout(timer); reject(error); });
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
async close() { }
|
|
390
|
-
}
|
|
391
|
-
export class BrokerClient {
|
|
392
|
-
transport;
|
|
393
|
-
clientId;
|
|
394
|
-
constructor(transport, clientId = randomUUID()) {
|
|
395
|
-
this.transport = transport;
|
|
396
|
-
this.clientId = clientId;
|
|
397
|
-
}
|
|
398
|
-
async request(operation, options = {}) {
|
|
399
|
-
const request = { schemaVersion: 1, protocolVersion: BROKER_PROTOCOL_VERSION, requestId: options.requestId ?? randomUUID(), clientId: this.clientId, operation, pid: options.pid ?? null, buildKey: options.buildKey ?? null, payload: options.payload ?? {} };
|
|
400
|
-
const raw = await this.transport.request(encodeFrame(request), options.timeoutMs ?? BROKER_DEFAULT_TIMEOUT_MS);
|
|
401
|
-
const decoder = new FrameDecoder();
|
|
402
|
-
const values = decoder.push(raw);
|
|
403
|
-
if (values.length !== 1)
|
|
404
|
-
throw new BrokerProtocolError("FRAME_INVALID", "expected one Broker response frame");
|
|
405
|
-
const response = values[0];
|
|
406
|
-
if (response.ok === false)
|
|
407
|
-
throw new BrokerProtocolError(response.error.code, response.error.message, response.error.nextAction);
|
|
408
|
-
return response.result;
|
|
409
|
-
}
|
|
410
|
-
close() { return this.transport.close(); }
|
|
411
|
-
}
|