wowdump 0.3.4 → 0.3.7

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.
@@ -0,0 +1,412 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawn as nodeSpawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ export class CdbError extends Error {
8
+ code;
9
+ details;
10
+ constructor(code, message, details) {
11
+ super(message);
12
+ this.name = "CdbError";
13
+ this.code = code;
14
+ this.details = details;
15
+ }
16
+ }
17
+ function boundedInteger(value, name, minimum, maximum) {
18
+ const number = Number(value);
19
+ if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
20
+ throw new CdbError("CDB_ARGUMENT_INVALID", `${name} must be an integer between ${minimum} and ${maximum}`);
21
+ }
22
+ return number;
23
+ }
24
+ function address(value, name) {
25
+ const text = String(value ?? "").trim();
26
+ if (!/^0x[0-9a-f]+$/i.test(text))
27
+ throw new CdbError("CDB_ARGUMENT_INVALID", `${name} must be a hexadecimal address`);
28
+ const result = BigInt(text);
29
+ if (result < 0n || result > 0xffffffffffffffffn)
30
+ throw new CdbError("CDB_ARGUMENT_INVALID", `${name} is outside the 64-bit range`);
31
+ return result;
32
+ }
33
+ function hex(value) { return `0x${value.toString(16)}`; }
34
+ function validCommand(value, name) {
35
+ const command = String(value ?? "");
36
+ if (!command || command.includes("\0") || command.length > 64 * 1024)
37
+ throw new CdbError("CDB_ARGUMENT_INVALID", `${name} is empty or too large`);
38
+ return command;
39
+ }
40
+ function validCommandList(values, fallback = []) {
41
+ const source = values ?? fallback;
42
+ if (!Array.isArray(source) || source.length > 256)
43
+ throw new CdbError("CDB_ARGUMENT_INVALID", "CDB command list is too large");
44
+ const commands = source.map((value, index) => validCommand(value, `commands[${index}]`));
45
+ if (commands.join("\n").length > 512 * 1024)
46
+ throw new CdbError("CDB_ARGUMENT_INVALID", "CDB command script is too large");
47
+ return commands;
48
+ }
49
+ function validArgs(values) {
50
+ if (values === undefined)
51
+ return [];
52
+ if (!Array.isArray(values) || values.length > 128)
53
+ throw new CdbError("CDB_ARGUMENT_INVALID", "CDB argument list is too large");
54
+ return values.map((value, index) => {
55
+ const arg = String(value ?? "");
56
+ if (arg.includes("\0") || arg.length > 32 * 1024)
57
+ throw new CdbError("CDB_ARGUMENT_INVALID", `cdbArgs[${index}] is invalid`);
58
+ return arg;
59
+ });
60
+ }
61
+ async function spawnText(executable, args, timeoutMs) {
62
+ return runCdbProcess({ executable, args, cwd: dirname(executable), timeoutMs });
63
+ }
64
+ async function runWhere(name) {
65
+ if (process.platform !== "win32")
66
+ return undefined;
67
+ const result = await runCdbProcess({ executable: "where.exe", args: [name], cwd: process.cwd(), timeoutMs: 2_000 });
68
+ if (result.exitCode !== 0)
69
+ return undefined;
70
+ return result.stdout.split(/\r?\n/).map(value => value.trim()).find(value => value && existsSync(value));
71
+ }
72
+ function candidatePaths(env) {
73
+ const values = [];
74
+ const configured = env.WOWDUMP_CDB?.trim();
75
+ if (configured)
76
+ values.push({ path: configured, source: "environment" });
77
+ const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
78
+ const programFiles = env.ProgramFiles ?? "C:\\Program Files";
79
+ const windir = env.WINDIR ?? "C:\\Windows";
80
+ values.push({ path: join(programFilesX86, "Windows Kits", "10", "Debuggers", "x64", "cdb.exe"), source: "windows-sdk" }, { path: join(programFiles, "Windows Kits", "10", "Debuggers", "x64", "cdb.exe"), source: "windows-sdk" }, { path: join(windir, "System32", "cdb.exe"), source: "windbg" }, { path: join(programFiles, "WindowsApps", "Microsoft.WinDbg_", "cdb.exe"), source: "windbg" }, { path: join(programFiles, "Windows Kits", "10", "Debuggers", "x64", "cdb.exe"), source: "windbg" });
81
+ return values;
82
+ }
83
+ async function discoverWinDbgDirectories(env) {
84
+ const roots = [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA].filter((value) => Boolean(value));
85
+ const results = [];
86
+ for (const root of roots) {
87
+ for (const relative of ["Windows Kits", "WinDbg", "WindowsApps"]) {
88
+ const directory = join(root, relative);
89
+ try {
90
+ const entries = await readdir(directory, { withFileTypes: true });
91
+ for (const entry of entries.filter(item => item.isDirectory()).slice(0, 200)) {
92
+ results.push(join(directory, entry.name, "Debuggers", "x64", "cdb.exe"));
93
+ results.push(join(directory, entry.name, "cdb.exe"));
94
+ }
95
+ }
96
+ catch { /* directory is optional */ }
97
+ }
98
+ }
99
+ return results;
100
+ }
101
+ export async function discoverCdb(env = process.env) {
102
+ if (process.platform !== "win32" && env.WOWDUMP_CDB?.trim() === undefined) {
103
+ return { available: false, code: "CDB_NOT_FOUND", message: "未找到 WinDbg CDB" };
104
+ }
105
+ const candidates = [...candidatePaths(env), ...(await discoverWinDbgDirectories(env)).map(path => ({ path, source: "windbg" }))];
106
+ const seen = new Set();
107
+ for (const candidate of candidates) {
108
+ const path = resolve(candidate.path);
109
+ const key = path.toLowerCase();
110
+ if (seen.has(key) || !existsSync(path))
111
+ continue;
112
+ seen.add(key);
113
+ const version = await spawnText(path, ["-version"], 3_000).catch(() => ({ stdout: "", stderr: "" }));
114
+ return { available: true, executable: path, version: (version.stdout || version.stderr).split(/\r?\n/).map(value => value.trim()).find(Boolean) ?? null, source: candidate.source };
115
+ }
116
+ const found = await runWhere("cdb.exe");
117
+ if (found) {
118
+ const version = await spawnText(found, ["-version"], 3_000).catch(() => ({ stdout: "", stderr: "" }));
119
+ return { available: true, executable: resolve(found), version: (version.stdout || version.stderr).split(/\r?\n/).map(value => value.trim()).find(Boolean) ?? null, source: "where" };
120
+ }
121
+ return { available: false, code: "CDB_NOT_FOUND", message: "未找到 WinDbg CDB" };
122
+ }
123
+ async function terminateProcess(child) {
124
+ if (child.exitCode !== null)
125
+ return;
126
+ try {
127
+ child.kill();
128
+ }
129
+ catch { /* already exited */ }
130
+ if (process.platform === "win32" && child.pid) {
131
+ await new Promise(resolveDone => {
132
+ const killer = nodeSpawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
133
+ killer.once("exit", () => resolveDone());
134
+ killer.once("error", () => resolveDone());
135
+ });
136
+ }
137
+ }
138
+ export function runCdbProcess(options) {
139
+ const spawn = options.spawn ?? nodeSpawn;
140
+ return new Promise(resolveResult => {
141
+ let stdout = "";
142
+ let stderr = "";
143
+ let settled = false;
144
+ let timedOut = false;
145
+ let child;
146
+ let timer;
147
+ const finish = (exitCode, signal, started) => {
148
+ if (settled)
149
+ return;
150
+ settled = true;
151
+ if (timer)
152
+ clearTimeout(timer);
153
+ resolveResult({ exitCode, signal, stdout, stderr, timedOut, started });
154
+ };
155
+ try {
156
+ child = spawn(options.executable, [...options.args], {
157
+ shell: false,
158
+ windowsHide: true,
159
+ cwd: resolve(options.cwd),
160
+ env: options.env ?? process.env,
161
+ stdio: ["ignore", "pipe", "pipe"]
162
+ });
163
+ }
164
+ catch (error) {
165
+ stderr += error instanceof Error ? error.message : String(error);
166
+ finish(1, null, false);
167
+ return;
168
+ }
169
+ child.stdout?.setEncoding("utf8");
170
+ child.stderr?.setEncoding("utf8");
171
+ child.stdout?.on("data", value => { stdout += String(value); });
172
+ child.stderr?.on("data", value => { stderr += String(value); });
173
+ timer = setTimeout(() => {
174
+ timedOut = true;
175
+ void terminateProcess(child).finally(() => finish(1, null, true));
176
+ }, Math.max(1, options.timeoutMs));
177
+ timer.unref?.();
178
+ child.once("error", error => { stderr += error.message; finish(1, null, child.pid !== undefined); });
179
+ child.once("exit", (code, signal) => finish(code ?? 1, signal, true));
180
+ });
181
+ }
182
+ function readU16(buffer, offset) { return buffer.readUInt16LE(offset); }
183
+ function readU32(buffer, offset) { return buffer.readUInt32LE(offset); }
184
+ export async function readPeLayout(file) {
185
+ const input = resolve(file);
186
+ const buffer = await readFile(input);
187
+ if (buffer.length < 0x100 || buffer.toString("ascii", 0, 2) !== "MZ")
188
+ throw new CdbError("PE_INVALID", "Wow.exe has no DOS header");
189
+ const peOffset = readU32(buffer, 0x3c);
190
+ if (peOffset + 24 > buffer.length || buffer.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0")
191
+ throw new CdbError("PE_INVALID", "Wow.exe has no PE header");
192
+ const sections = readU16(buffer, peOffset + 6);
193
+ const optionalSize = readU16(buffer, peOffset + 20);
194
+ const optional = peOffset + 24;
195
+ const magic = readU16(buffer, optional);
196
+ const imageBase = magic === 0x20b ? buffer.readBigUInt64LE(optional + 24) : BigInt(readU32(buffer, optional + 28));
197
+ const sizeOfImage = readU32(buffer, optional + 56);
198
+ const table = optional + optionalSize;
199
+ const values = [];
200
+ for (let index = 0; index < sections; index += 1) {
201
+ const offset = table + index * 40;
202
+ if (offset + 40 > buffer.length)
203
+ break;
204
+ const name = buffer.subarray(offset, offset + 8).toString("ascii").replace(/\0+$/u, "");
205
+ const virtualSize = readU32(buffer, offset + 8);
206
+ const rva = readU32(buffer, offset + 12);
207
+ const rawSize = readU32(buffer, offset + 16);
208
+ const characteristics = readU32(buffer, offset + 36);
209
+ const protection = `${characteristics & 0x40000000 ? "r" : ""}${characteristics & 0x80000000 ? "w" : ""}${characteristics & 0x20000000 ? "x" : ""}`;
210
+ if (name && (virtualSize || rawSize))
211
+ values.push({ name, rva: hex(BigInt(rva)), virtualSize, rawSize, characteristics: hex(BigInt(characteristics)), protection });
212
+ }
213
+ return { imageBase: hex(imageBase), moduleSize: sizeOfImage, sections: values };
214
+ }
215
+ function quoteCdbPath(file) {
216
+ const value = resolve(file);
217
+ if (!isAbsolute(value) || value.includes("\0"))
218
+ throw new CdbError("CDB_ARGUMENT_INVALID", "CDB output path must be absolute");
219
+ return `"${value.replace(/"/gu, "\\\"")}"`;
220
+ }
221
+ function baseCdbCommands(request, generated) {
222
+ const extra = validCommandList(request.commands);
223
+ return [...generated, ...extra, "q"];
224
+ }
225
+ function buildCdbArgs(request, commandFile) {
226
+ const extra = validArgs(request.cdbArgs);
227
+ return ["-p", String(request.pid), "-cf", commandFile, ...extra];
228
+ }
229
+ function parseHexValue(value) {
230
+ const match = value.match(/0x[0-9a-f]+/i);
231
+ return match ? `0x${BigInt(match[0]).toString(16)}` : "0x0";
232
+ }
233
+ export function parseBreakpointHits(output) {
234
+ const hits = [];
235
+ const blocks = output.split("WOWDUMP_HIT_BEGIN").slice(1);
236
+ for (const block of blocks) {
237
+ const end = block.indexOf("WOWDUMP_HIT_END");
238
+ if (end < 0)
239
+ continue;
240
+ const values = {};
241
+ for (const line of block.slice(0, end).split(/\r?\n/)) {
242
+ const match = line.match(/^\s*(rip|rcx|rdx|r8|r9|rsp|returnAddress|threadId)\s*=\s*(\S+)/u);
243
+ if (match)
244
+ values[match[1]] = parseHexValue(match[2]);
245
+ }
246
+ if (values.rip && values.rcx && values.rdx && values.r8 && values.r9 && values.rsp && values.returnAddress && values.threadId) {
247
+ hits.push(values);
248
+ }
249
+ }
250
+ return hits;
251
+ }
252
+ async function writeCdbScript(directory, commands) {
253
+ await mkdir(directory, { recursive: true });
254
+ const file = join(directory, `commands-${Date.now()}-${Math.random().toString(16).slice(2)}.cdb`);
255
+ await writeFile(file, `${commands.join("\n")}\n`, "utf8");
256
+ return file;
257
+ }
258
+ async function sha256(file) {
259
+ const hash = createHash("sha256");
260
+ hash.update(await readFile(file));
261
+ return hash.digest("hex");
262
+ }
263
+ async function dump(request, info) {
264
+ const outputDir = resolve(request.outputDir);
265
+ const maxSectionBytes = boundedInteger(request.maxSectionBytes, "maxSectionBytes", 1, 512 * 1024 * 1024);
266
+ const maxTotalBytes = boundedInteger(request.maxTotalBytes, "maxTotalBytes", 1, 768 * 1024 * 1024);
267
+ const timeoutMs = boundedInteger(request.timeoutMs, "timeoutMs", 1, 15 * 60 * 1000);
268
+ if (!Array.isArray(request.sections) || request.sections.length === 0 || request.sections.length > 64)
269
+ throw new CdbError("CDB_ARGUMENT_INVALID", "sections must contain between 1 and 64 entries");
270
+ let totalRequested = 0;
271
+ const normalizedSections = request.sections.map((section, index) => {
272
+ const readSize = boundedInteger(section.readSize, `sections[${index}].readSize`, 1, Math.min(maxSectionBytes, maxTotalBytes));
273
+ const file = resolve(section.file);
274
+ if (dirname(file).toLowerCase() !== outputDir.toLowerCase())
275
+ throw new CdbError("CDB_ARGUMENT_INVALID", `sections[${index}].file must be inside outputDir`);
276
+ totalRequested += readSize;
277
+ if (totalRequested > maxTotalBytes)
278
+ throw new CdbError("CDB_ARGUMENT_INVALID", "section sizes exceed maxTotalBytes");
279
+ return { ...section, readSize, file };
280
+ });
281
+ await mkdir(outputDir, { recursive: true });
282
+ const generated = [".echo WOWDUMP_DUMP_BEGIN"];
283
+ for (const section of normalizedSections) {
284
+ const start = address(section.runtimeAddress, `${section.name}.runtimeAddress`);
285
+ const end = start + BigInt(section.readSize);
286
+ generated.push(`.writemem ${quoteCdbPath(section.file)} ${hex(start)} ${hex(end)}`);
287
+ generated.push(`.echo WOWDUMP_DUMP_SECTION ${section.name}`);
288
+ }
289
+ const commands = baseCdbCommands(request, generated);
290
+ const commandFile = await writeCdbScript(outputDir, commands);
291
+ const logFile = join(outputDir, "cdb.log");
292
+ try {
293
+ const result = await runCdbProcess({ executable: info.executable, args: buildCdbArgs(request, commandFile), cwd: outputDir, timeoutMs });
294
+ await writeFile(logFile, `${result.stdout}${result.stderr ? `\n[stderr]\n${result.stderr}` : ""}`, "utf8");
295
+ if (!result.started)
296
+ throw new CdbError("CDB_START_FAILED", "CDB 启动失败", { executable: info.executable, stderr: result.stderr });
297
+ if (result.timedOut)
298
+ throw new CdbError("CDB_TIMEOUT", "CDB 调试请求超时", { timeoutMs, logFile });
299
+ if (result.exitCode !== 0)
300
+ throw new CdbError("CDB_EXIT_NONZERO", `CDB exited with code ${result.exitCode}`, { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, logFile });
301
+ const manifestSections = [];
302
+ for (const section of normalizedSections) {
303
+ const bytes = await stat(section.file).then(value => value.size).catch(() => 0);
304
+ manifestSections.push({ ...section, bytes, sha256: bytes > 0 ? await sha256(section.file) : null, shortRead: bytes !== section.readSize });
305
+ }
306
+ const manifest = {
307
+ schema: "wowdump.runtime-dump.v3",
308
+ kind: "dump",
309
+ buildKey: request.buildKey,
310
+ pid: request.pid,
311
+ executableSha256: request.executableSha256 ?? null,
312
+ moduleBase: hex(address(request.moduleBase, "moduleBase")),
313
+ preferredImageBase: request.preferredImageBase ?? null,
314
+ moduleSize: request.moduleSize ?? null,
315
+ sections: manifestSections,
316
+ totalBytes: manifestSections.reduce((sum, value) => sum + Number(value.bytes ?? 0), 0),
317
+ engine: { name: "cdb", path: info.executable, version: info.version ?? null },
318
+ logFile,
319
+ dumpParameters: {
320
+ executableSha256: request.executableSha256 ?? null,
321
+ sections: normalizedSections.map(section => section.name),
322
+ maxSectionBytes,
323
+ maxTotalBytes,
324
+ cdbArgs: validArgs(request.cdbArgs),
325
+ commands: validCommandList(request.commands)
326
+ },
327
+ generatedAt: new Date().toISOString()
328
+ };
329
+ const manifestFile = join(outputDir, "manifest.json");
330
+ await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
331
+ return { ok: true, kind: "dump", manifestFile, outputDirectory: outputDir, manifest, sections: manifestSections.map(value => ({ name: value.name, bytes: value.bytes, sha256: value.sha256 })) };
332
+ }
333
+ finally {
334
+ await rm(commandFile, { force: true }).catch(() => undefined);
335
+ }
336
+ }
337
+ async function breakpoint(request, info, queryPage) {
338
+ const maxHits = boundedInteger(request.maxHits, "maxHits", 1, 1024);
339
+ const durationMs = boundedInteger(request.durationMs, "durationMs", 1, 10 * 60 * 1000);
340
+ const runtimeAddress = address(request.moduleBase, "moduleBase") + address(request.rva, "rva");
341
+ const entryPage = queryPage ? await queryPage(runtimeAddress).catch(error => ({ error: error instanceof Error ? error.message : String(error) })) : null;
342
+ const escaped = `.printf \"WOWDUMP_HIT_BEGIN\\nrip=0x%p\\nrcx=0x%p\\nrdx=0x%p\\nr8=0x%p\\nr9=0x%p\\nrsp=0x%p\\nreturnAddress=0x%p\\nthreadId=0x%p\\nWOWDUMP_HIT_END\\n\", @rip, @rcx, @rdx, @r8, @r9, @rsp, poi(@rsp), @$tid`;
343
+ const generated = [
344
+ "r @$t0 = 0",
345
+ `ba e 1 ${hex(runtimeAddress)} \".if (@$t0 >= ${maxHits}) { bc *; q } .else { r @$t0 = @$t0 + 1; ${escaped}; gc }\"`,
346
+ "g"
347
+ ];
348
+ const commands = baseCdbCommands(request, generated);
349
+ const directory = request.outputFile ? dirname(resolve(request.outputFile)) : await mkdtemp(join(tmpdir(), "wowdump-cdb-breakpoint-"));
350
+ const commandFile = await writeCdbScript(directory, commands);
351
+ try {
352
+ const result = await runCdbProcess({ executable: info.executable, args: buildCdbArgs(request, commandFile), cwd: directory, timeoutMs: durationMs });
353
+ const hits = parseBreakpointHits(`${result.stdout}\n${result.stderr}`).slice(0, maxHits);
354
+ const enriched = await Promise.all(hits.map(async (hit) => ({ ...hit, returnAddressPage: queryPage ? await queryPage(address(hit.returnAddress, "returnAddress")).catch(error => ({ error: error instanceof Error ? error.message : String(error) })) : null })));
355
+ const value = {
356
+ ok: result.started && !result.timedOut && result.exitCode === 0,
357
+ kind: "breakpoint",
358
+ rva: hex(address(request.rva, "rva")),
359
+ runtimeAddress: hex(runtimeAddress),
360
+ maxHits,
361
+ hits: enriched,
362
+ hitCount: enriched.length,
363
+ detached: true,
364
+ timedOut: result.timedOut,
365
+ entryPage,
366
+ engine: { name: "cdb", path: info.executable, version: info.version ?? null },
367
+ stdout: result.stdout.slice(0, 256 * 1024),
368
+ stderr: result.stderr.slice(0, 256 * 1024),
369
+ evidence: [{ source: "windbg", kind: "hardware-execution-breakpoint" }]
370
+ };
371
+ if (request.outputFile)
372
+ await writeFile(resolve(request.outputFile), `${JSON.stringify(value, null, 2)}\n`, "utf8");
373
+ return value;
374
+ }
375
+ finally {
376
+ await rm(commandFile, { force: true }).catch(() => undefined);
377
+ if (!request.outputFile)
378
+ await rm(directory, { recursive: true, force: true }).catch(() => undefined);
379
+ }
380
+ }
381
+ async function command(request, info) {
382
+ const durationMs = boundedInteger(request.durationMs, "durationMs", 1, 15 * 60 * 1000);
383
+ const commands = baseCdbCommands(request, request.commands);
384
+ const directory = request.outputFile ? dirname(resolve(request.outputFile)) : await mkdtemp(join(tmpdir(), "wowdump-cdb-command-"));
385
+ const commandFile = await writeCdbScript(directory, commands);
386
+ try {
387
+ const result = await runCdbProcess({ executable: info.executable, args: buildCdbArgs(request, commandFile), cwd: directory, timeoutMs: durationMs });
388
+ const value = { ok: result.started && !result.timedOut && result.exitCode === 0, kind: "command", pid: request.pid, exitCode: result.exitCode, timedOut: result.timedOut, stdout: result.stdout.slice(0, 512 * 1024), stderr: result.stderr.slice(0, 512 * 1024), engine: { name: "cdb", path: info.executable, version: info.version ?? null }, evidence: [{ source: "windbg", kind: "cdb-command" }] };
389
+ if (request.outputFile)
390
+ await writeFile(resolve(request.outputFile), `${JSON.stringify(value, null, 2)}\n`, "utf8");
391
+ return value;
392
+ }
393
+ finally {
394
+ await rm(commandFile, { force: true }).catch(() => undefined);
395
+ if (!request.outputFile)
396
+ await rm(directory, { recursive: true, force: true }).catch(() => undefined);
397
+ }
398
+ }
399
+ export async function executeCdbDebug(request, queryPage) {
400
+ if (!request || !["dump", "breakpoint", "command"].includes(String(request.kind)))
401
+ throw new CdbError("CDB_ARGUMENT_INVALID", "kind must be dump, breakpoint, or command");
402
+ const info = await discoverCdb();
403
+ if (!info.available || !info.executable)
404
+ throw new CdbError("CDB_NOT_FOUND", info.message ?? "未找到 WinDbg CDB", { discovery: info });
405
+ if (!Number.isSafeInteger(request.pid) || request.pid <= 0)
406
+ throw new CdbError("CDB_ARGUMENT_INVALID", "pid must be positive");
407
+ if (request.kind === "dump")
408
+ return dump(request, info);
409
+ if (request.kind === "breakpoint")
410
+ return breakpoint(request, info, queryPage);
411
+ return command(request, info);
412
+ }
@@ -5,6 +5,14 @@ export const DEFAULT_RIGHTS = Object.freeze([
5
5
  "PROCESS_QUERY_INFORMATION",
6
6
  "PROCESS_VM_READ"
7
7
  ]);
8
+ const MEM_COMMIT = 0x1000;
9
+ // Windows rejects VirtualQueryEx at the null page (Win32 error 87). The
10
+ // lowest user-mode allocation address is 0x10000 on supported Windows hosts.
11
+ const DEFAULT_QUERY_START = 0x10000n;
12
+ // GetSystemInfo().lpMaximumApplicationAddress on supported Windows x64 hosts.
13
+ const MAX_USER_QUERY_ADDRESS = BigInt("0x7ffffffeffff");
14
+ const DEFAULT_QUERY_END = MAX_USER_QUERY_ADDRESS;
15
+ const MAX_QUERY_REGIONS = 100_000;
8
16
  function hexAddress(value) {
9
17
  const normalized = value.trim();
10
18
  if (!/^0x[0-9a-f]+$/i.test(normalized)) {
@@ -30,6 +38,15 @@ function boundedPid(value) {
30
38
  }
31
39
  return pid;
32
40
  }
41
+ function boundedCount(value, name, maximum, fallback) {
42
+ if (value === undefined)
43
+ return fallback;
44
+ const count = Number(value);
45
+ if (!Number.isSafeInteger(count) || count < 1 || count > maximum) {
46
+ throw new ReaderProtocolError("INVALID_COUNT", `${name} must be an integer between 1 and ${maximum}`);
47
+ }
48
+ return count;
49
+ }
33
50
  function toHex(bytes) {
34
51
  return Buffer.from(bytes).toString("hex");
35
52
  }
@@ -85,7 +102,7 @@ export class ReaderBroker {
85
102
  backend;
86
103
  now;
87
104
  onShutdown;
88
- runFrida;
105
+ runDebug;
89
106
  handles = new Map();
90
107
  watches = new Map();
91
108
  idleTimer;
@@ -96,7 +113,7 @@ export class ReaderBroker {
96
113
  this.idleTimeoutMs = Math.max(1, Math.floor(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS));
97
114
  this.now = options.now ?? Date.now;
98
115
  this.onShutdown = options.onShutdown;
99
- this.runFrida = options.runFrida;
116
+ this.runDebug = options.runDebug;
100
117
  this.armIdleTimer();
101
118
  }
102
119
  get isStopped() {
@@ -163,12 +180,14 @@ export class ReaderBroker {
163
180
  activeWatches: this.activeWatchCount,
164
181
  elevation: await Promise.resolve(this.backend.elevationStatus?.() ?? { state: "unknown" })
165
182
  };
166
- case "frida":
167
- if (!this.runFrida)
168
- throw new ReaderProtocolError("FRIDA_NOT_CONFIGURED", "elevated Frida runner is not configured");
169
- return this.runFrida(request);
183
+ case "debug":
184
+ if (!this.runDebug)
185
+ throw new ReaderProtocolError("CDB_NOT_CONFIGURED", "elevated CDB runner is not configured");
186
+ return this.runDebug(request);
170
187
  case "modules":
171
188
  return this.modules(request);
189
+ case "regions":
190
+ return this.regions(request);
172
191
  case "open":
173
192
  return this.open(request);
174
193
  case "close":
@@ -255,6 +274,66 @@ export class ReaderBroker {
255
274
  }
256
275
  return { pid, modules: await this.backend.enumerateModules(pid) };
257
276
  }
277
+ async regions(request) {
278
+ if (!this.backend.virtualQueryEx) {
279
+ throw new ReaderProtocolError("VIRTUAL_QUERY_UNAVAILABLE", "native VirtualQueryEx backend is not installed");
280
+ }
281
+ const pid = boundedPid(request.pid);
282
+ const requestedStart = hexAddress(request.start ?? `0x${DEFAULT_QUERY_START.toString(16)}`);
283
+ const start = requestedStart === 0n ? DEFAULT_QUERY_START : requestedStart;
284
+ const requestedEnd = hexAddress(request.end ?? `0x${DEFAULT_QUERY_END.toString(16)}`);
285
+ const end = requestedEnd > MAX_USER_QUERY_ADDRESS ? MAX_USER_QUERY_ADDRESS : requestedEnd;
286
+ if (end < start)
287
+ throw new ReaderProtocolError("INVALID_RANGE", "end must be greater than or equal to start");
288
+ const maxRegions = boundedCount(request.maxRegions, "maxRegions", MAX_QUERY_REGIONS, 4096);
289
+ const includeFree = request.includeFree === true;
290
+ const handle = await this.getHandle(pid, request.handle);
291
+ const regions = [];
292
+ let cursor = start;
293
+ let truncated = false;
294
+ while (cursor <= end) {
295
+ const raw = await this.backend.virtualQueryEx(handle, cursor);
296
+ const baseAddress = hexAddress(String(raw.baseAddress ?? "0x0"));
297
+ let regionSize;
298
+ try {
299
+ regionSize = BigInt(String(raw.regionSize ?? "0"));
300
+ }
301
+ catch {
302
+ throw new ReaderProtocolError("NATIVE_RESULT", "VirtualQueryEx returned an invalid region size");
303
+ }
304
+ if (regionSize <= 0n)
305
+ throw new ReaderProtocolError("NATIVE_RESULT", "VirtualQueryEx returned a non-positive region size");
306
+ const next = baseAddress + regionSize;
307
+ if (next <= cursor)
308
+ throw new ReaderProtocolError("NATIVE_RESULT", "VirtualQueryEx did not advance the query address");
309
+ const state = Number(raw.state);
310
+ if (includeFree || state === MEM_COMMIT) {
311
+ regions.push({
312
+ ...raw,
313
+ baseAddress: `0x${baseAddress.toString(16)}`,
314
+ regionSize: regionSize.toString(),
315
+ committed: state === MEM_COMMIT
316
+ });
317
+ }
318
+ cursor = next;
319
+ if (regions.length >= maxRegions) {
320
+ truncated = cursor <= end;
321
+ break;
322
+ }
323
+ }
324
+ return {
325
+ pid,
326
+ handle: handle.id,
327
+ start: `0x${start.toString(16)}`,
328
+ end: `0x${end.toString(16)}`,
329
+ endClamped: requestedEnd !== end,
330
+ includeFree,
331
+ maxRegions,
332
+ regions,
333
+ nextAddress: `0x${cursor.toString(16)}`,
334
+ truncated
335
+ };
336
+ }
258
337
  async watchStart(request) {
259
338
  const pid = boundedPid(request.pid);
260
339
  const address = hexAddress(request.address);
@@ -348,9 +427,11 @@ function serializeError(error, backend) {
348
427
  return { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) };
349
428
  }
350
429
  const message = error instanceof Error ? error.message : String(error);
430
+ const typed = error;
351
431
  return {
352
- code: "READER_ERROR",
432
+ code: typeof typed.code === "string" ? typed.code : "READER_ERROR",
353
433
  message,
434
+ ...(typed.details && typeof typed.details === "object" ? { details: typed.details } : {}),
354
435
  elevation: backend.elevationStatus ? backend.elevationStatus() : { state: "unknown" }
355
436
  };
356
437
  }
@@ -130,17 +130,17 @@ export class WindowsBrokerManager {
130
130
  }
131
131
  async request(invocation) {
132
132
  const request = invocationRequest(invocation);
133
+ const requestTimeout = invocation.command === "debug"
134
+ ? Math.max(this.options.requestTimeoutMs ?? 30_000, Math.min(15 * 60_000, Number(invocation.payload.durationMs ?? invocation.payload.timeoutMs ?? 120_000) + 10_000))
135
+ : this.options.requestTimeoutMs ?? 30_000;
133
136
  const existing = await this.readMetadata();
134
137
  if (existing) {
135
- const requestTimeout = invocation.command === "frida"
136
- ? Math.max(this.options.requestTimeoutMs ?? 30_000, Math.min(600_000, Number(invocation.payload.timeoutMs ?? 120_000) + 10_000))
137
- : this.options.requestTimeoutMs ?? 30_000;
138
138
  try {
139
139
  return await requestPipe(existing, request, requestTimeout);
140
140
  }
141
141
  catch (error) {
142
142
  const code = error.code;
143
- // A long-running Frida request is not evidence that the broker died.
143
+ // A long-running debugger request is not evidence that the broker died.
144
144
  // Keep its metadata so the next command reuses the elevated process.
145
145
  if (code === "BROKER_REQUEST_TIMEOUT")
146
146
  throw error;
@@ -148,7 +148,7 @@ export class WindowsBrokerManager {
148
148
  }
149
149
  }
150
150
  const metadata = await this.launchBroker();
151
- return requestPipe(metadata, request, this.options.requestTimeoutMs ?? 30_000);
151
+ return requestPipe(metadata, request, requestTimeout);
152
152
  }
153
153
  async readMetadata() {
154
154
  try {
@@ -1,41 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { createServer } from "node:net";
3
- import { spawn } from "node:child_process";
4
3
  import { mkdir, rm, writeFile } from "node:fs/promises";
5
- import { dirname, isAbsolute, resolve } from "node:path";
4
+ import { dirname } from "node:path";
6
5
  import { serveReaderBroker } from "./broker.js";
6
+ import { executeCdbDebug } from "../debug/cdb.js";
7
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
8
  function argument(name) {
40
9
  const index = process.argv.indexOf(name);
41
10
  return index >= 0 ? process.argv[index + 1] : undefined;
@@ -45,10 +14,24 @@ const port = Number(argument("--port") ?? "0");
45
14
  const token = argument("--token");
46
15
  const metadata = argument("--metadata");
47
16
  let server;
17
+ const backend = process.env.WOWDUMP_USE_FAKE_READER === "1" ? undefined : createWindowsNativeReader();
48
18
  const broker = serveReaderBroker({
49
19
  attachStdio: !listen,
50
- backend: process.env.WOWDUMP_USE_FAKE_READER === "1" ? undefined : createWindowsNativeReader(),
51
- runFrida: runElevatedFrida,
20
+ backend,
21
+ runDebug: async (request) => {
22
+ const debugRequest = request;
23
+ if (!backend)
24
+ return executeCdbDebug(debugRequest);
25
+ const handle = await backend.openProcess(request.pid, ["PROCESS_QUERY_INFORMATION"]);
26
+ try {
27
+ return await executeCdbDebug(debugRequest, address => backend.virtualQueryEx
28
+ ? backend.virtualQueryEx(handle, address)
29
+ : Promise.reject(new Error("VirtualQueryEx is not installed")));
30
+ }
31
+ finally {
32
+ await Promise.resolve(handle.close()).catch(() => undefined);
33
+ }
34
+ },
52
35
  onShutdown: async () => {
53
36
  server?.close();
54
37
  if (metadata)