wowdump 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/dist/analysis/frida-runtime.js +26 -0
- package/dist/analysis/runtime-dump.js +93 -0
- package/dist/analysis/runtime-script.js +144 -24
- package/dist/cli.js +199 -38
- package/dist/core/build-store.js +166 -0
- package/dist/core/profile-engine.js +9 -0
- package/dist/frida-worker.js +59 -5
- package/dist/toolchain.js +11 -22
- package/package.json +5 -1
- package/skills/wowdump/SKILL.md +12 -9
- package/skills/wowdump/references/commands.md +30 -12
- package/skills/wowdump/references/disassemble.md +44 -10
- package/skills/wowdump/references/evidence-workflow.md +1 -1
- package/skills/wowdump/references/profiles.md +16 -5
- package/skills/wowdump/references/request-schema.md +1 -1
- package/skills/wowdump/references/workflow.md +3 -2
- package/dist/analysis/disassemble.js +0 -77
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { existsSync, realpathSync, readFileSync } from "node:fs";
|
|
4
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
5
|
+
import { mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
6
|
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
8
|
import { Command } from "commander";
|
|
8
9
|
import { WindowsBrokerManager } from "./reader/launcher.js";
|
|
9
|
-
import { initializeWowdumpHome, bootstrapToolchain, resolveToolchain
|
|
10
|
-
import {
|
|
10
|
+
import { initializeWowdumpHome, bootstrapToolchain, resolveToolchain } from "./toolchain.js";
|
|
11
|
+
import { buildPaths, databaseStatus, ensureBuild, findReusableDump, readBuild, resolveWowdumpHome, safeBuildKey, sha256File } from "./core/build-store.js";
|
|
11
12
|
import { RUNTIME_EXPORT_SCRIPT } from "./analysis/runtime-script.js";
|
|
12
13
|
import { ProfileEngine } from "./core/profile-engine.js";
|
|
13
14
|
import { ReaderProfileAdapter } from "./adapters/reader.js";
|
|
@@ -85,6 +86,64 @@ async function discoverWowTargets(selectedPid, elevatedModules) {
|
|
|
85
86
|
}
|
|
86
87
|
return targets;
|
|
87
88
|
}
|
|
89
|
+
async function preferredImageBase(file) {
|
|
90
|
+
let handle;
|
|
91
|
+
try {
|
|
92
|
+
handle = await open(file, "r");
|
|
93
|
+
const header = Buffer.alloc(64 * 1024);
|
|
94
|
+
await handle.read(header, 0, header.length, 0);
|
|
95
|
+
if (header.length < 0x40 || header.readUInt16LE(0) !== 0x5a4d)
|
|
96
|
+
return null;
|
|
97
|
+
const peOffset = header.readUInt32LE(0x3c);
|
|
98
|
+
if (peOffset + 0x58 > header.length || header.readUInt32LE(peOffset) !== 0x00004550)
|
|
99
|
+
return null;
|
|
100
|
+
const optional = peOffset + 24;
|
|
101
|
+
const magic = header.readUInt16LE(optional);
|
|
102
|
+
if (magic === 0x20b)
|
|
103
|
+
return `0x${header.readBigUInt64LE(optional + 24).toString(16)}`;
|
|
104
|
+
if (magic === 0x10b)
|
|
105
|
+
return `0x${BigInt(header.readUInt32LE(optional + 28)).toString(16)}`;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
if (handle)
|
|
113
|
+
await handle.close().catch(() => undefined);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function persistDiscoveredBuilds(home, targets) {
|
|
117
|
+
for (const target of targets) {
|
|
118
|
+
const path = typeof target.path === "string" ? target.path : null;
|
|
119
|
+
const buildKey = typeof target.buildKey === "string" ? target.buildKey : null;
|
|
120
|
+
if (!path || !buildKey)
|
|
121
|
+
continue;
|
|
122
|
+
try {
|
|
123
|
+
const executableSha256 = await sha256File(path);
|
|
124
|
+
const imageBase = await preferredImageBase(path);
|
|
125
|
+
const result = await ensureBuild(home, {
|
|
126
|
+
buildKey: safeBuildKey(buildKey),
|
|
127
|
+
executablePath: path,
|
|
128
|
+
executableSha256,
|
|
129
|
+
preferredImageBase: imageBase,
|
|
130
|
+
moduleSize: Number.isSafeInteger(Number(target.moduleSize)) ? Number(target.moduleSize) : null
|
|
131
|
+
});
|
|
132
|
+
target.executableSha256 = executableSha256;
|
|
133
|
+
target.preferredImageBase = imageBase;
|
|
134
|
+
target.database = {
|
|
135
|
+
directory: buildPaths(home, buildKey).database,
|
|
136
|
+
path: buildPaths(home, buildKey).databaseFile,
|
|
137
|
+
status: result.database.status,
|
|
138
|
+
reused: result.reused
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
target.diagnostics = { ...(target.diagnostics && typeof target.diagnostics === "object" ? target.diagnostics : {}), buildStore: error instanceof Error ? error.message : String(error) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return targets;
|
|
146
|
+
}
|
|
88
147
|
function packageVersion() {
|
|
89
148
|
const override = process.env.WOWDUMP_VERSION?.trim();
|
|
90
149
|
if (override)
|
|
@@ -248,7 +307,7 @@ function selected(source, keys) {
|
|
|
248
307
|
function requireRuntimeConfirmation(options) {
|
|
249
308
|
if (options.confirm === true)
|
|
250
309
|
return;
|
|
251
|
-
throw new CliError("CONFIRMATION_REQUIRED", "runtime
|
|
310
|
+
throw new CliError("CONFIRMATION_REQUIRED", "runtime analysis requires --confirm", {
|
|
252
311
|
pid: options.pid,
|
|
253
312
|
buildKey: options.build,
|
|
254
313
|
operation: "runtime",
|
|
@@ -259,6 +318,37 @@ function requireRuntimeConfirmation(options) {
|
|
|
259
318
|
cleanupDeadlineMs: options.cleanupDeadlineMs
|
|
260
319
|
});
|
|
261
320
|
}
|
|
321
|
+
function profileDirectoryFor(home, buildKey) {
|
|
322
|
+
if (!buildKey)
|
|
323
|
+
throw new CliError("BUILD_REQUIRED", "a build key is required to resolve the profile directory");
|
|
324
|
+
return buildPaths(home, safeBuildDirectory(buildKey)).profile;
|
|
325
|
+
}
|
|
326
|
+
async function validateProfileIdentity(home, buildKey, profile, file) {
|
|
327
|
+
if (!buildKey)
|
|
328
|
+
return;
|
|
329
|
+
if (typeof profile.buildKey === "string" && profile.buildKey !== buildKey) {
|
|
330
|
+
throw new CliError("BUILD_MISMATCH", `profile build ${profile.buildKey} does not match ${buildKey}`, { profileFile: file });
|
|
331
|
+
}
|
|
332
|
+
const build = await readBuild(home, buildKey);
|
|
333
|
+
if (!build)
|
|
334
|
+
return;
|
|
335
|
+
const profileHash = typeof profile.executableSha256 === "string"
|
|
336
|
+
? profile.executableSha256
|
|
337
|
+
: profile.executable && typeof profile.executable === "object" && !Array.isArray(profile.executable)
|
|
338
|
+
? String(profile.executable.sha256 ?? "")
|
|
339
|
+
: "";
|
|
340
|
+
if (profileHash && profileHash !== build.executableSha256) {
|
|
341
|
+
throw new CliError("EXECUTABLE_MISMATCH", "profile executable hash does not match the selected build", { expected: build.executableSha256, actual: profileHash, profileFile: file });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function safeBuildDirectory(buildKey) {
|
|
345
|
+
try {
|
|
346
|
+
return safeBuildKey(buildKey);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
throw new CliError("ARGUMENT_INVALID", "build key contains unsupported path characters");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
262
352
|
export function createWowdumpCli(dependencies = {}) {
|
|
263
353
|
const io = dependencies.io ?? { stdout: process.stdout, stderr: process.stderr };
|
|
264
354
|
const env = dependencies.env ?? process.env;
|
|
@@ -288,7 +378,7 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
288
378
|
.option("--pid <pid>", "select one process ID")
|
|
289
379
|
.action(async (options) => {
|
|
290
380
|
const selectedPid = options.pid ? positiveInteger(options.pid, "pid") : undefined;
|
|
291
|
-
const targets = await discoverWowTargets(selectedPid, async (pid) => broker({ command: "modules", payload: { pid } }));
|
|
381
|
+
const targets = await persistDiscoveredBuilds(home, await discoverWowTargets(selectedPid, async (pid) => broker({ command: "modules", payload: { pid } })));
|
|
292
382
|
if (selectedPid !== undefined && targets.length === 0)
|
|
293
383
|
throw new CliError("TARGET_NOT_FOUND", `Wow.exe process ${selectedPid} was not found`);
|
|
294
384
|
writeJson(io, {
|
|
@@ -299,6 +389,14 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
299
389
|
targets
|
|
300
390
|
});
|
|
301
391
|
});
|
|
392
|
+
program.command("database")
|
|
393
|
+
.description("Show the persistent static database state for a build")
|
|
394
|
+
.command("status")
|
|
395
|
+
.description("Show database path, lock and freshness")
|
|
396
|
+
.requiredOption("--build <buildKey>", "build key")
|
|
397
|
+
.action(async (options) => {
|
|
398
|
+
writeJson(io, { ...await databaseStatus(home, options.build), command: "database.status" });
|
|
399
|
+
});
|
|
302
400
|
program.command("target")
|
|
303
401
|
.description("Show target, reader broker, profile, monitor, and Frida state")
|
|
304
402
|
.option("--pid <pid>", "target process ID")
|
|
@@ -314,9 +412,10 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
314
412
|
program.command("profiles")
|
|
315
413
|
.description("List profiles or describe one profile")
|
|
316
414
|
.argument("[id-or-file]", "profile ID or absolute JSON file")
|
|
317
|
-
.option("--
|
|
415
|
+
.option("--build <buildKey>", "build key used for the default profile directory")
|
|
416
|
+
.option("--directory <path>", "profile directory")
|
|
318
417
|
.action(async (idOrFile, options) => {
|
|
319
|
-
const directory = resolve(options.directory);
|
|
418
|
+
const directory = resolve(options.directory ?? profileDirectoryFor(home, options.build));
|
|
320
419
|
if (idOrFile) {
|
|
321
420
|
const file = profileFile(directory, idOrFile);
|
|
322
421
|
const profile = jsonRecord(await readFile(file, "utf8"), file);
|
|
@@ -347,13 +446,27 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
347
446
|
...(options.size ? { size: positiveInteger(options.size, "size") } : {})
|
|
348
447
|
};
|
|
349
448
|
const pid = positiveInteger(String(payload.pid ?? options.pid), "pid");
|
|
350
|
-
|
|
449
|
+
let profileId = typeof payload.profileId === "string" ? payload.profileId : (typeof options.profile === "string" ? options.profile : undefined);
|
|
450
|
+
const requestedBuild = typeof payload.buildKey === "string" ? payload.buildKey : (typeof options.build === "string" ? options.build : undefined);
|
|
451
|
+
if (!profileId && payload.address === undefined && requestedBuild) {
|
|
452
|
+
const available = await listProfileFiles(profileDirectoryFor(home, requestedBuild));
|
|
453
|
+
if (available.length === 1)
|
|
454
|
+
profileId = basename(available[0], extname(available[0]));
|
|
455
|
+
else if (available.length === 0)
|
|
456
|
+
throw new CliError("PROFILE_NOT_FOUND", `no profile exists for build ${requestedBuild}`, { directory: profileDirectoryFor(home, requestedBuild) });
|
|
457
|
+
else
|
|
458
|
+
throw new CliError("PROFILE_SELECTION_REQUIRED", `more than one profile exists for build ${requestedBuild}`, { profiles: available });
|
|
459
|
+
}
|
|
351
460
|
if (profileId && payload.address === undefined) {
|
|
352
|
-
const
|
|
461
|
+
const buildKey = requestedBuild;
|
|
462
|
+
const profileFilePath = profileFile(isAbsolute(profileId) ? dirname(profileId) : profileDirectoryFor(home, buildKey), profileId);
|
|
353
463
|
const profile = jsonRecord(await readFile(profileFilePath, "utf8"), profileFilePath);
|
|
464
|
+
await validateProfileIdentity(home, buildKey, profile, profileFilePath);
|
|
465
|
+
const buildRecord = buildKey ? await readBuild(home, buildKey) : null;
|
|
354
466
|
const result = await profileEngine.read({
|
|
355
467
|
pid,
|
|
356
|
-
buildKey
|
|
468
|
+
buildKey,
|
|
469
|
+
...(buildRecord?.executableSha256 ? { executableSha256: buildRecord.executableSha256 } : {}),
|
|
357
470
|
profile,
|
|
358
471
|
fields: Array.isArray(payload.fields) ? payload.fields.map(String) : undefined
|
|
359
472
|
}, profileAdapter);
|
|
@@ -401,34 +514,23 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
401
514
|
.description("Stop a monitor and release its resources")
|
|
402
515
|
.requiredOption("--id <watchId>", "monitor ID")
|
|
403
516
|
.action(async (options) => writeJson(io, await broker({ command: "watch.stop", payload: { watchId: options.id } })));
|
|
404
|
-
const analyze = program.command("analyze").description("Run Frida evidence export and runtime
|
|
405
|
-
analyze.command("disassemble")
|
|
406
|
-
.description("Decode Frida text evidence and produce a Reader profile")
|
|
407
|
-
.requiredOption("--exe <path>", "path to Wow.exe")
|
|
408
|
-
.requiredOption("--build <buildKey>", "build key")
|
|
409
|
-
.requiredOption("--runtime-export <file>", "Frida runtime text JSON")
|
|
410
|
-
.option("--ida-evidence <file>", "JSON produced by an IDA Pro MCP analysis")
|
|
411
|
-
.option("--output <file>", "profile output JSON")
|
|
412
|
-
.option("--max-instructions <count>", "maximum decoded instructions", "256")
|
|
413
|
-
.option("--dry-run", "validate inputs without writing a profile")
|
|
414
|
-
.action(async (options) => writeJson(io, await runDisassembly({
|
|
415
|
-
exe: options.exe,
|
|
416
|
-
buildKey: options.build,
|
|
417
|
-
runtimeExport: options.runtimeExport,
|
|
418
|
-
...(options.idaEvidence ? { idaEvidence: options.idaEvidence } : {}),
|
|
419
|
-
...(options.output ? { output: options.output } : {}),
|
|
420
|
-
maxInstructions: positiveInteger(options.maxInstructions, "max-instructions"),
|
|
421
|
-
dryRun: options.dryRun === true
|
|
422
|
-
})));
|
|
517
|
+
const analyze = program.command("analyze").description("Run Frida evidence export and bounded runtime operations");
|
|
423
518
|
analyze.command("runtime")
|
|
424
|
-
.description("
|
|
519
|
+
.description("Dump runtime PE sections or verify profile candidates through the elevated Frida broker")
|
|
425
520
|
.requiredOption("--pid <pid>", "target process ID")
|
|
426
521
|
.requiredOption("--build <buildKey>", "build key")
|
|
427
522
|
.option("--build-key <buildKey>", "alias for --build")
|
|
428
|
-
.option("--kind <kind>", "
|
|
523
|
+
.option("--kind <kind>", "dump or verify", "dump")
|
|
429
524
|
.option("--worker <path>", "Frida worker entry")
|
|
430
|
-
.option("--profile <path>", "
|
|
431
|
-
.option("--
|
|
525
|
+
.option("--profile <path>", "reader profile containing candidate RVAs (verify only)")
|
|
526
|
+
.option("--output-dir <path>", "directory for dump manifest and section binaries")
|
|
527
|
+
.option("--sections <names...>", "sections to dump (default: .text .rdata .pdata)")
|
|
528
|
+
.option("--no-progress", "disable dump progress on stderr")
|
|
529
|
+
.option("--max-section-bytes <bytes>", "per-section dump limit", "134217728")
|
|
530
|
+
.option("--max-total-bytes <bytes>", "total dump limit", "268435456")
|
|
531
|
+
.option("--chunk-size <bytes>", "dump chunk size", "1048576")
|
|
532
|
+
.option("--max-verify-bytes <bytes>", "maximum bytes read per candidate", "256")
|
|
533
|
+
.option("--max-hooks <count>", "reserved compatibility limit", "1")
|
|
432
534
|
.option("--duration-ms <ms>", "maximum duration", "5000")
|
|
433
535
|
.option("--max-events <count>", "maximum events", "100")
|
|
434
536
|
.option("--cleanup-deadline-ms <ms>", "cleanup deadline", "2000")
|
|
@@ -442,40 +544,93 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
442
544
|
durationMs: positiveInteger(options.durationMs, "duration-ms"),
|
|
443
545
|
maxEvents: positiveInteger(options.maxEvents, "max-events"),
|
|
444
546
|
cleanupDeadlineMs: positiveInteger(options.cleanupDeadlineMs, "cleanup-deadline-ms"),
|
|
547
|
+
maxSectionBytes: positiveInteger(options.maxSectionBytes, "max-section-bytes"),
|
|
548
|
+
maxTotalBytes: positiveInteger(options.maxTotalBytes, "max-total-bytes"),
|
|
549
|
+
chunkSize: positiveInteger(options.chunkSize, "chunk-size"),
|
|
550
|
+
maxVerifyBytes: positiveInteger(options.maxVerifyBytes, "max-verify-bytes"),
|
|
445
551
|
confirm: options.confirm === true
|
|
446
552
|
};
|
|
447
|
-
if (!["
|
|
448
|
-
throw new CliError("ARGUMENT_INVALID", "kind must be
|
|
553
|
+
if (!["dump", "verify"].includes(normalized.kind))
|
|
554
|
+
throw new CliError("ARGUMENT_INVALID", "kind must be dump or verify");
|
|
449
555
|
requireRuntimeConfirmation(normalized);
|
|
556
|
+
if (normalized.kind === "verify" && !options.profile)
|
|
557
|
+
throw new CliError("PROFILE_REQUIRED", "verify requires --profile <file>");
|
|
450
558
|
const worker = resolve(options.worker ?? env.WOWDUMP_FRIDA_WORKER ?? join(packageDist, "frida-worker.js"));
|
|
451
559
|
if (!existsSync(worker))
|
|
452
560
|
throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
|
|
453
561
|
const profile = options.profile
|
|
454
562
|
? jsonRecord(await readFile(resolve(options.profile), "utf8"), options.profile)
|
|
455
563
|
: undefined;
|
|
564
|
+
const buildRecord = await readBuild(home, normalized.build);
|
|
565
|
+
if (profile)
|
|
566
|
+
await validateProfileIdentity(home, normalized.build, profile, resolve(options.profile));
|
|
567
|
+
const sections = Array.isArray(options.sections) && options.sections.length > 0
|
|
568
|
+
? options.sections.map((value) => String(value).toLowerCase())
|
|
569
|
+
: [".text", ".rdata", ".pdata"];
|
|
570
|
+
if (normalized.kind === "dump") {
|
|
571
|
+
const reusable = buildRecord?.executableSha256
|
|
572
|
+
? await findReusableDump(home, normalized.build, {
|
|
573
|
+
executableSha256: buildRecord.executableSha256,
|
|
574
|
+
sections,
|
|
575
|
+
maxSectionBytes: normalized.maxSectionBytes,
|
|
576
|
+
maxTotalBytes: normalized.maxTotalBytes,
|
|
577
|
+
chunkSize: normalized.chunkSize
|
|
578
|
+
})
|
|
579
|
+
: null;
|
|
580
|
+
if (reusable) {
|
|
581
|
+
writeJson(io, { ok: true, command: "analyze.runtime.dump", reused: true, manifestFile: String(reusable.manifestFile ?? ""), manifest: reusable });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const sessionId = randomUUID();
|
|
586
|
+
const sessionDirectory = options.outputDir
|
|
587
|
+
? resolve(options.outputDir, "..")
|
|
588
|
+
: join(buildPaths(home, normalized.build).runtime, sessionId);
|
|
589
|
+
const outputDirectory = resolve(options.outputDir ?? join(sessionDirectory, "dump"));
|
|
590
|
+
await mkdir(sessionDirectory, { recursive: true });
|
|
591
|
+
await writeFile(join(sessionDirectory, "target.json"), `${JSON.stringify({ schema: "wowdump.runtime-target.v1", pid: normalized.pid, buildKey: normalized.build, executableSha256: buildRecord?.executableSha256 ?? null, sessionId, createdAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
456
592
|
const request = {
|
|
457
|
-
command: "dynamic-script",
|
|
593
|
+
command: normalized.kind === "dump" ? "runtime-dump" : "dynamic-script",
|
|
458
594
|
pid: normalized.pid,
|
|
459
595
|
build: normalized.build,
|
|
460
596
|
source: RUNTIME_EXPORT_SCRIPT,
|
|
461
597
|
exportName: "collect",
|
|
598
|
+
...(normalized.kind === "dump" ? { outputDir: outputDirectory, progress: options.progress !== false, executableSha256: buildRecord?.executableSha256 ?? null, preferredImageBase: buildRecord?.preferredImageBase ?? null, moduleSize: buildRecord?.moduleSize ?? null, dumpParameters: { executableSha256: buildRecord?.executableSha256 ?? null, sections, maxSectionBytes: normalized.maxSectionBytes, maxTotalBytes: normalized.maxTotalBytes, chunkSize: normalized.chunkSize } } : {}),
|
|
462
599
|
args: {
|
|
463
600
|
kind: normalized.kind,
|
|
464
601
|
maxHooks: normalized.maxHooks,
|
|
465
602
|
durationMs: normalized.durationMs,
|
|
466
603
|
maxEvents: normalized.maxEvents,
|
|
467
604
|
cleanupDeadlineMs: normalized.cleanupDeadlineMs,
|
|
605
|
+
maxSectionBytes: normalized.maxSectionBytes,
|
|
606
|
+
maxTotalBytes: normalized.maxTotalBytes,
|
|
607
|
+
chunkSize: normalized.chunkSize,
|
|
608
|
+
maxVerifyBytes: normalized.maxVerifyBytes,
|
|
609
|
+
...(normalized.kind === "dump" ? { sections } : {}),
|
|
468
610
|
...(profile ? { profile } : {})
|
|
469
611
|
},
|
|
470
612
|
callArgs: [],
|
|
471
613
|
durationMs: normalized.durationMs
|
|
472
614
|
};
|
|
473
615
|
const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
|
|
616
|
+
if (result.stderr && options.progress !== false)
|
|
617
|
+
io.stderr.write(result.stderr);
|
|
474
618
|
if (result.exitCode !== 0)
|
|
475
619
|
throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
|
|
476
620
|
const line = result.stdout.split(/\r?\n/).find(value => value.trim());
|
|
477
621
|
const value = line ? jsonRecord(line, "runtime result") : { ok: true, stdout: result.stdout };
|
|
478
|
-
|
|
622
|
+
if (normalized.kind === "dump" && value.ok === true) {
|
|
623
|
+
if (typeof value.manifestFile === "string") {
|
|
624
|
+
await writeFile(join(sessionDirectory, "dynamic.json"), `${JSON.stringify(value.manifest ?? value, null, 2)}\n`, "utf8").catch(() => undefined);
|
|
625
|
+
writeJson(io, { ...value, command: "analyze.runtime.dump", sessionDirectory });
|
|
626
|
+
}
|
|
627
|
+
else
|
|
628
|
+
throw new CliError("RUNTIME_DUMP_INVALID", "runtime dump worker returned no manifest");
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
await writeFile(join(sessionDirectory, "verify.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8").catch(() => undefined);
|
|
632
|
+
writeJson(io, { ...value, command: "analyze.runtime.verify" });
|
|
633
|
+
}
|
|
479
634
|
});
|
|
480
635
|
analyze.command("dynamic")
|
|
481
636
|
.description("Run a caller-supplied GumJS script through Frida")
|
|
@@ -507,11 +662,17 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
507
662
|
if (!existsSync(worker))
|
|
508
663
|
throw new CliError("FRIDA_WORKER_NOT_FOUND", `Frida worker was not found: ${worker}`);
|
|
509
664
|
const request = { command: "dynamic-script", ...selected(normalized, ["pid", "build", "script", "exportName", "args", "callArgs", "durationMs"]) };
|
|
665
|
+
const sessionId = randomUUID();
|
|
666
|
+
const sessionDirectory = join(buildPaths(home, normalized.build).runtime, sessionId);
|
|
667
|
+
await mkdir(sessionDirectory, { recursive: true });
|
|
668
|
+
await writeFile(join(sessionDirectory, "target.json"), `${JSON.stringify({ schema: "wowdump.runtime-target.v1", pid: normalized.pid, buildKey: normalized.build, sessionId, createdAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
510
669
|
const result = await sidecar(process.execPath, [worker], `${JSON.stringify(request)}\n`);
|
|
511
670
|
if (result.exitCode !== 0)
|
|
512
671
|
throw new CliError("FRIDA_WORKER_FAILED", result.stderr.trim() || `Frida worker exited with ${result.exitCode}`);
|
|
513
672
|
const line = result.stdout.split(/\r?\n/).find(value => value.trim());
|
|
514
|
-
|
|
673
|
+
const value = line ? JSON.parse(line) : { ok: true, command: "analyze.dynamic", stdout: result.stdout };
|
|
674
|
+
await writeFile(join(sessionDirectory, "dynamic.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
675
|
+
writeJson(io, { ...value, command: "analyze.dynamic", sessionDirectory });
|
|
515
676
|
});
|
|
516
677
|
program.command("init")
|
|
517
678
|
.description("Initialize WOWDUMP_HOME without overwriting existing files")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, normalize, resolve } from "node:path";
|
|
6
|
+
export function resolveWowdumpHome(env = process.env, userHome = homedir()) {
|
|
7
|
+
return normalize(resolve(env.WOWDUMP_HOME || join(userHome, ".wowdump")));
|
|
8
|
+
}
|
|
9
|
+
export function safeBuildKey(buildKey) {
|
|
10
|
+
const value = buildKey.trim();
|
|
11
|
+
if (!value || value === "." || value === ".." || !/^[A-Za-z0-9_.@-]+$/.test(value))
|
|
12
|
+
throw new Error("build key contains unsupported path characters");
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
export function buildDirectory(home, buildKey) {
|
|
16
|
+
return join(resolve(home), safeBuildKey(buildKey));
|
|
17
|
+
}
|
|
18
|
+
export function buildPaths(home, buildKey) {
|
|
19
|
+
const build = buildDirectory(home, buildKey);
|
|
20
|
+
const database = join(build, "database");
|
|
21
|
+
return {
|
|
22
|
+
root: resolve(home),
|
|
23
|
+
build,
|
|
24
|
+
database,
|
|
25
|
+
databaseFile: join(database, "database.json"),
|
|
26
|
+
databaseLock: join(database, "database.lock"),
|
|
27
|
+
ida: join(database, "ida"),
|
|
28
|
+
profile: join(build, "profile"),
|
|
29
|
+
runtime: join(build, "runtime")
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export async function sha256File(file) {
|
|
33
|
+
return new Promise((resolveHash, reject) => {
|
|
34
|
+
const hash = createHash("sha256");
|
|
35
|
+
const stream = createReadStream(file);
|
|
36
|
+
stream.on("data", chunk => hash.update(chunk));
|
|
37
|
+
stream.once("error", reject);
|
|
38
|
+
stream.once("end", () => resolveHash(hash.digest("hex")));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function readJson(file) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "ENOENT")
|
|
47
|
+
return null;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function writeJson(file, value) {
|
|
52
|
+
await mkdir(resolve(file, ".."), { recursive: true });
|
|
53
|
+
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
54
|
+
}
|
|
55
|
+
export async function readBuild(home, buildKey) {
|
|
56
|
+
return readJson(join(buildDirectory(home, buildKey), "build.json"));
|
|
57
|
+
}
|
|
58
|
+
export async function readDatabase(home, buildKey) {
|
|
59
|
+
return readJson(buildPaths(home, buildKey).databaseFile);
|
|
60
|
+
}
|
|
61
|
+
async function ensureBuildUnlocked(home, input) {
|
|
62
|
+
const paths = buildPaths(home, input.buildKey);
|
|
63
|
+
await mkdir(paths.profile, { recursive: true });
|
|
64
|
+
await mkdir(paths.ida, { recursive: true });
|
|
65
|
+
await mkdir(paths.runtime, { recursive: true });
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
const oldBuild = await readJson(join(paths.build, "build.json"));
|
|
68
|
+
const oldDatabase = await readJson(paths.databaseFile);
|
|
69
|
+
const sameIdentity = Boolean(oldBuild && oldDatabase
|
|
70
|
+
&& oldBuild.executableSha256 === input.executableSha256
|
|
71
|
+
&& oldBuild.preferredImageBase === input.preferredImageBase
|
|
72
|
+
&& oldBuild.moduleSize === input.moduleSize
|
|
73
|
+
&& oldDatabase.executableSha256 === input.executableSha256
|
|
74
|
+
&& oldDatabase.preferredImageBase === input.preferredImageBase
|
|
75
|
+
&& oldDatabase.moduleSize === input.moduleSize);
|
|
76
|
+
const build = {
|
|
77
|
+
schema: "wowdump.build.v1",
|
|
78
|
+
...input,
|
|
79
|
+
createdAt: oldBuild?.createdAt ?? now,
|
|
80
|
+
updatedAt: now
|
|
81
|
+
};
|
|
82
|
+
const database = {
|
|
83
|
+
schema: "wowdump.database.v1",
|
|
84
|
+
buildKey: input.buildKey,
|
|
85
|
+
executableSha256: input.executableSha256,
|
|
86
|
+
preferredImageBase: input.preferredImageBase,
|
|
87
|
+
moduleSize: input.moduleSize,
|
|
88
|
+
idaDatabase: join(paths.ida, "Wow.i64"),
|
|
89
|
+
idaVersion: oldDatabase?.idaVersion ?? null,
|
|
90
|
+
status: sameIdentity ? (oldDatabase?.status ?? "creating") : oldDatabase ? "stale" : "creating",
|
|
91
|
+
createdAt: oldDatabase?.createdAt ?? now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
lastAnalysisAt: oldDatabase?.lastAnalysisAt ?? null
|
|
94
|
+
};
|
|
95
|
+
await writeJson(join(paths.build, "build.json"), build);
|
|
96
|
+
await writeJson(paths.databaseFile, database);
|
|
97
|
+
return { build, database, reused: sameIdentity && database.status === "ready" };
|
|
98
|
+
}
|
|
99
|
+
export async function ensureBuild(home, input) {
|
|
100
|
+
const release = await acquireDatabaseLock(home, input.buildKey);
|
|
101
|
+
try {
|
|
102
|
+
return await ensureBuildUnlocked(home, input);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await release();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export async function markDatabaseReady(home, buildKey, patch = {}) {
|
|
109
|
+
const current = await readDatabase(home, buildKey);
|
|
110
|
+
if (!current)
|
|
111
|
+
throw new Error(`database for ${buildKey} does not exist`);
|
|
112
|
+
const database = { ...current, ...patch, status: "ready", updatedAt: new Date().toISOString(), lastAnalysisAt: new Date().toISOString() };
|
|
113
|
+
await writeJson(buildPaths(home, buildKey).databaseFile, database);
|
|
114
|
+
return database;
|
|
115
|
+
}
|
|
116
|
+
export async function acquireDatabaseLock(home, buildKey) {
|
|
117
|
+
const paths = buildPaths(home, buildKey);
|
|
118
|
+
await mkdir(paths.database, { recursive: true });
|
|
119
|
+
let handle;
|
|
120
|
+
try {
|
|
121
|
+
handle = await open(paths.databaseLock, "wx");
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw new Error(`database is locked: ${paths.databaseLock}`, { cause: error });
|
|
125
|
+
}
|
|
126
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
|
|
127
|
+
return async () => {
|
|
128
|
+
await handle.close().catch(() => undefined);
|
|
129
|
+
await rm(paths.databaseLock, { force: true }).catch(() => undefined);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export async function databaseStatus(home, buildKey) {
|
|
133
|
+
const paths = buildPaths(home, buildKey);
|
|
134
|
+
const [build, loadedDatabase, lock] = await Promise.all([
|
|
135
|
+
readJson(join(paths.build, "build.json")),
|
|
136
|
+
readJson(paths.databaseFile),
|
|
137
|
+
stat(paths.databaseLock).then(() => true).catch(() => false)
|
|
138
|
+
]);
|
|
139
|
+
let database = loadedDatabase;
|
|
140
|
+
if (database && database.status === "creating" && await stat(database.idaDatabase).then(() => true).catch(() => false)) {
|
|
141
|
+
database = { ...database, status: "ready", updatedAt: new Date().toISOString(), lastAnalysisAt: database.lastAnalysisAt ?? new Date().toISOString() };
|
|
142
|
+
if (!lock)
|
|
143
|
+
await writeJson(paths.databaseFile, database);
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, buildKey: safeBuildKey(buildKey), buildDirectory: paths.build, databasePath: paths.databaseFile, exists: Boolean(database), locked: lock, build, database };
|
|
146
|
+
}
|
|
147
|
+
export async function findReusableDump(home, buildKey, criteria) {
|
|
148
|
+
const runtime = buildPaths(home, buildKey).runtime;
|
|
149
|
+
let sessions;
|
|
150
|
+
try {
|
|
151
|
+
sessions = await readdir(runtime, { withFileTypes: true });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error.code === "ENOENT")
|
|
155
|
+
return null;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
for (const session of sessions.filter(item => item.isDirectory())) {
|
|
159
|
+
const manifestFile = join(runtime, session.name, "dump", "manifest.json");
|
|
160
|
+
const manifest = await readJson(manifestFile);
|
|
161
|
+
if (!manifest || typeof manifest.moduleBase !== "string" || JSON.stringify(manifest.dumpParameters ?? null) !== JSON.stringify(criteria))
|
|
162
|
+
continue;
|
|
163
|
+
return { ...manifest, manifestFile };
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
@@ -143,6 +143,15 @@ export class ProfileEngine {
|
|
|
143
143
|
if (request.buildKey && request.buildKey !== buildKey) {
|
|
144
144
|
throw new ProfileEngineError("BUILD_MISMATCH", `profile build ${buildKey} does not match ${request.buildKey}`);
|
|
145
145
|
}
|
|
146
|
+
if (request.executableSha256) {
|
|
147
|
+
const executable = profile.executable && typeof profile.executable === "object" && !Array.isArray(profile.executable)
|
|
148
|
+
? profile.executable
|
|
149
|
+
: undefined;
|
|
150
|
+
const profileHash = typeof profile.executableSha256 === "string" ? profile.executableSha256 : String(executable?.sha256 ?? "");
|
|
151
|
+
if (profileHash && profileHash !== request.executableSha256) {
|
|
152
|
+
throw new ProfileEngineError("EXECUTABLE_MISMATCH", "profile executable hash does not match the target build", { expected: request.executableSha256, actual: profileHash });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
146
155
|
const status = String(profile.readerStatus ?? profile.status ?? (profile.confidence === "reader_ready" ? "reader_ready" : ""));
|
|
147
156
|
if (status !== "reader_ready")
|
|
148
157
|
throw new ProfileEngineError("PROFILE_NOT_READY", "profile is not reader_ready", { status });
|
package/dist/frida-worker.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { FridaCommandRuntime } from "./analysis/frida-runtime.js";
|
|
4
|
+
import { RuntimeDumpWriter } from "./analysis/runtime-dump.js";
|
|
5
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
6
|
function record(value) {
|
|
5
7
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
6
8
|
}
|
|
@@ -15,7 +17,7 @@ function positive(value, name) {
|
|
|
15
17
|
* GumJS source; this process only attaches, loads, calls, and cleans it up.
|
|
16
18
|
*/
|
|
17
19
|
async function run(input) {
|
|
18
|
-
if (input.command !== "dynamic-script")
|
|
20
|
+
if (input.command !== "dynamic-script" && input.command !== "runtime-dump")
|
|
19
21
|
throw new Error("unsupported worker command");
|
|
20
22
|
const pid = positive(input.pid, "pid");
|
|
21
23
|
const buildKey = String(input.build ?? input.buildKey ?? "").trim();
|
|
@@ -29,24 +31,74 @@ async function run(input) {
|
|
|
29
31
|
const runtime = new FridaCommandRuntime({ artifactDir: process.env.WOW_ANALYZE_DIR });
|
|
30
32
|
let sessionId;
|
|
31
33
|
let loaded = false;
|
|
34
|
+
const isDump = input.command === "runtime-dump";
|
|
35
|
+
const outputDirectory = isDump ? resolve(String(input.outputDir ?? "")) : undefined;
|
|
36
|
+
const outputDirectoryInput = isDump ? String(input.outputDir ?? "") : "";
|
|
37
|
+
const writer = isDump ? new RuntimeDumpWriter({
|
|
38
|
+
outputDirectory: outputDirectory,
|
|
39
|
+
buildKey,
|
|
40
|
+
pid,
|
|
41
|
+
executableSha256: typeof input.executableSha256 === "string" ? input.executableSha256 : null,
|
|
42
|
+
preferredImageBase: typeof input.preferredImageBase === "string" ? input.preferredImageBase : null,
|
|
43
|
+
moduleSize: Number.isSafeInteger(Number(input.moduleSize)) ? Number(input.moduleSize) : null,
|
|
44
|
+
dumpParameters: input.dumpParameters && typeof input.dumpParameters === "object" ? input.dumpParameters : undefined
|
|
45
|
+
}) : undefined;
|
|
46
|
+
let finalized = false;
|
|
32
47
|
try {
|
|
48
|
+
if (isDump) {
|
|
49
|
+
if (!outputDirectoryInput || !isAbsolute(outputDirectoryInput))
|
|
50
|
+
throw new Error("runtime dump outputDir must be an absolute path");
|
|
51
|
+
await writer.initialize();
|
|
52
|
+
}
|
|
33
53
|
const attached = await runtime.execute({ operation: "attach", pid, buildKey, allowUnmatched: true });
|
|
34
54
|
sessionId = String(attached.sessionId);
|
|
35
55
|
const modules = await runtime.execute({ operation: "modules", sessionId, pid, buildKey });
|
|
36
56
|
const module = Array.isArray(modules.modules)
|
|
37
57
|
? modules.modules.find(item => String(item.name ?? "").toLowerCase() === "wow.exe")
|
|
38
58
|
: undefined;
|
|
59
|
+
const scriptSource = `globalThis.__WOWDUMP_INPUT__ = Object.freeze(${JSON.stringify(input.args ?? {})});\n${source}`;
|
|
60
|
+
let value;
|
|
61
|
+
let messages = [];
|
|
62
|
+
if (isDump) {
|
|
63
|
+
await writeFile(resolve(outputDirectory, "..", "target.json"), `${JSON.stringify({ schema: "wowdump.runtime-target.v1", pid, buildKey, module, executableSha256: input.executableSha256 ?? null, createdAt: new Date().toISOString() }, null, 2)}\n`, "utf8").catch(() => undefined);
|
|
64
|
+
let progressBytes = 0;
|
|
65
|
+
const streamed = await runtime.streamScriptCall({
|
|
66
|
+
operation: "script_load",
|
|
67
|
+
sessionId,
|
|
68
|
+
pid,
|
|
69
|
+
buildKey,
|
|
70
|
+
source: scriptSource,
|
|
71
|
+
exportName: typeof input.exportName === "string" ? input.exportName : "collect"
|
|
72
|
+
}, undefined, async (message, data) => {
|
|
73
|
+
const envelope = message && typeof message === "object" ? message : {};
|
|
74
|
+
if (envelope.type !== "send" || !data)
|
|
75
|
+
return;
|
|
76
|
+
const payload = envelope.payload && typeof envelope.payload === "object" ? envelope.payload : {};
|
|
77
|
+
if (payload.type !== "wowdump.dump.chunk")
|
|
78
|
+
return;
|
|
79
|
+
await writer.writeChunk(payload, data);
|
|
80
|
+
progressBytes += data.byteLength;
|
|
81
|
+
if (input.progress !== false)
|
|
82
|
+
process.stderr.write(`wowdump dump ${progressBytes} bytes\\n`);
|
|
83
|
+
});
|
|
84
|
+
value = streamed.value;
|
|
85
|
+
const result = value && typeof value === "object" ? value : {};
|
|
86
|
+
if (result.ok !== true)
|
|
87
|
+
throw new Error(String(result.error ?? "runtime dump failed"));
|
|
88
|
+
const persisted = await writer.finalize({ ...result, module: module ?? result.module });
|
|
89
|
+
finalized = true;
|
|
90
|
+
await writeFile(resolve(outputDirectory, "..", "dynamic.json"), `${JSON.stringify({ ...result, manifestFile: persisted.manifestFile, generatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8").catch(() => undefined);
|
|
91
|
+
return { ok: true, command: "analyze.runtime.dump", pid, buildKey, manifestFile: persisted.manifestFile, outputDirectory, manifest: persisted.manifest, sections: persisted.sections };
|
|
92
|
+
}
|
|
39
93
|
await runtime.execute({
|
|
40
94
|
operation: "script_load",
|
|
41
95
|
sessionId,
|
|
42
96
|
pid,
|
|
43
97
|
buildKey,
|
|
44
|
-
source:
|
|
98
|
+
source: scriptSource,
|
|
45
99
|
scriptId: "dynamic"
|
|
46
100
|
});
|
|
47
101
|
loaded = true;
|
|
48
|
-
let value;
|
|
49
|
-
let messages = [];
|
|
50
102
|
if (typeof input.exportName === "string" && input.exportName.trim()) {
|
|
51
103
|
const called = await runtime.execute({
|
|
52
104
|
operation: "script_call",
|
|
@@ -79,6 +131,8 @@ async function run(input) {
|
|
|
79
131
|
finally {
|
|
80
132
|
if (sessionId && loaded)
|
|
81
133
|
await runtime.execute({ operation: "script_unload", sessionId, scriptId: "dynamic" }).catch(() => undefined);
|
|
134
|
+
if (isDump && writer && !finalized)
|
|
135
|
+
await writer.abort().catch(() => undefined);
|
|
82
136
|
await runtime.close();
|
|
83
137
|
}
|
|
84
138
|
}
|