feat(ghidra): launch bounded Windows headless sessions

Invoke analyzeHeadless.bat through an absolute cmd.exe with fixed interpreter switches, quoted arguments, delayed expansion disabled, and fail-closed metacharacter and length checks. Isolate Windows home and temporary coordinates, hide provider windows, and terminate the P0 launcher tree through bounded taskkill cleanup. Advertise explicit limitations that this preview does not provide Job Object ownership, DACL enforcement, reparse-point authority, or hostile-target safety.
This commit is contained in:
N0zoM1z0 2026-07-17 00:54:54 +08:00 committed by N0zoM1z0
parent 629ffc3872
commit bc0fb45f16
10 changed files with 325 additions and 41 deletions

View File

@ -187,6 +187,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
bridgeScriptPath: fileURLToPath(
new URL("../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
),
platform: installation.platform,
}),
targetPath: parsedTarget.value.path,
targetSha256: parsedTarget.value.sha256,

View File

@ -1,11 +1,15 @@
import { mkdir } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { basename, dirname, join, win32 } from "node:path";
import writeFileAtomic from "write-file-atomic";
import { AnalysisCancelledError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
import {
cleanupOwnedProcessGroup,
cleanupWindowsProcessTree,
type ProcessCleanupResult,
} from "../process/ProcessOwnership.js";
import {
type ProviderProcessLaunch,
type SpawnedOwnedProviderProcess,
@ -60,6 +64,8 @@ export interface GhidraHeadlessLauncherOptions {
readonly analyzeHeadlessPath: string;
readonly javaHome?: string;
readonly bridgeScriptPath: string;
readonly platform?: NodeJS.Platform;
readonly comSpec?: string;
}
/** Launch Ghidra without copying scripts into or modifying its installation. */
@ -73,6 +79,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
const paths = ghidraRuntimePaths(session.runtimeRoot);
const platform = this.options.platform ?? process.platform;
let started: SpawnedOwnedProviderProcess | undefined;
try {
await Promise.all(
@ -101,21 +108,30 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
const headlessArguments = ghidraHeadlessArguments({
projectRoot: paths.projectRoot,
targetPath: session.targetPath,
bridgeScriptPath: this.options.bridgeScriptPath,
descriptorPath: paths.descriptorPath,
ghidraLogPath: paths.ghidraLogPath,
scriptLogPath: paths.scriptLogPath,
});
const command = ghidraHeadlessCommand({
platform,
analyzeHeadlessPath: this.options.analyzeHeadlessPath,
arguments: headlessArguments,
...(this.options.comSpec === undefined
? {}
: { comSpec: this.options.comSpec }),
});
started = await spawnOwnedProviderProcess({
command: this.options.analyzeHeadlessPath,
arguments: ghidraHeadlessArguments({
projectRoot: paths.projectRoot,
targetPath: session.targetPath,
bridgeScriptPath: this.options.bridgeScriptPath,
descriptorPath: paths.descriptorPath,
ghidraLogPath: paths.ghidraLogPath,
scriptLogPath: paths.scriptLogPath,
}),
command: command.command,
arguments: command.arguments,
runId: session.runId,
// analyzeHeadless is an interpreter-driven script. Parent identity and
// the per-process run token remain the cleanup authority.
expectedCommand: null,
env: ghidraLaunchEnvironment(paths, this.options.javaHome),
env: ghidraLaunchEnvironment(paths, this.options.javaHome, platform),
});
await writeFileAtomic(
paths.ownershipPath,
@ -125,29 +141,31 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
pid: started.ownership.leaderPid,
process_group_id: started.ownership.processGroupId,
parent_pid: process.pid,
ownership_kind:
platform === "win32"
? "windows-process-tree-p0"
: "posix-process-group",
launcher: this.options.analyzeHeadlessPath,
created_at: new Date().toISOString(),
})}\n`,
{ encoding: "utf8", mode: 0o600 },
);
if (isAborted(options.signal)) {
await cleanupOwnedProcessGroup(started.ownership);
await cleanupStartedProcess(started, platform);
return err(new AnalysisCancelledError("open_binary"));
}
const ownership = started.ownership;
const spawned = started;
return ok({
process: started.process,
process: spawned.process,
ownsProcessLifetime: true,
cleanup: () => cleanupOwnedProcessGroup(ownership),
cleanup: () => cleanupStartedProcess(spawned, platform),
projectRoot: paths.projectRoot,
ghidraLogPath: paths.ghidraLogPath,
scriptLogPath: paths.scriptLogPath,
});
} catch (cause: unknown) {
if (started !== undefined)
await cleanupOwnedProcessGroup(started.ownership).catch(
() => undefined,
);
await cleanupStartedProcess(started, platform).catch(() => undefined);
return isAborted(options.signal)
? err(new AnalysisCancelledError("open_binary"))
: err(
@ -157,6 +175,67 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
}
}
/** Exact executable and argv passed to shell-free process creation. */
export interface GhidraHeadlessCommand {
readonly command: string;
readonly arguments: readonly string[];
}
/** Build a direct POSIX launch or a conservatively quoted Windows batch call. */
export const ghidraHeadlessCommand = (options: {
readonly platform: NodeJS.Platform;
readonly analyzeHeadlessPath: string;
readonly arguments: readonly string[];
readonly comSpec?: string;
}): GhidraHeadlessCommand => {
if (options.platform !== "win32")
return {
command: options.analyzeHeadlessPath,
arguments: [...options.arguments],
};
const comSpec =
options.comSpec ??
process.env.ComSpec ??
(process.env.SystemRoot === undefined
? "C:\\Windows\\System32\\cmd.exe"
: win32.join(process.env.SystemRoot, "System32", "cmd.exe"));
if (
!win32.isAbsolute(comSpec) ||
win32.basename(comSpec).toLowerCase() !== "cmd.exe"
)
throw new GhidraLaunchError(
"Windows ComSpec must be an absolute cmd.exe path",
);
const tokens = [options.analyzeHeadlessPath, ...options.arguments].map(
quoteWindowsBatchToken,
);
const invocation = `"${tokens.join(" ")}"`;
if (invocation.length > 30_000)
throw new GhidraLaunchError(
"Windows Ghidra command exceeds the P0 length limit",
);
return {
command: comSpec,
arguments: ["/d", "/e:on", "/v:off", "/s", "/c", invocation],
};
};
const quoteWindowsBatchToken = (value: string): string => {
if (value.length === 0 || /[\u0000\r\n"%^!&|<>]/u.test(value))
throw new GhidraLaunchError(
"Windows Ghidra P0 paths cannot contain command-interpreter metacharacters",
);
return `"${value}"`;
};
const cleanupStartedProcess = (
started: SpawnedOwnedProviderProcess,
platform: NodeJS.Platform,
): Promise<ProcessCleanupResult> =>
platform === "win32"
? cleanupWindowsProcessTree(started.ownership.leaderPid)
: cleanupOwnedProcessGroup(started.ownership);
/** Paths encoded into one bounded analyzeHeadless invocation. */
export interface GhidraHeadlessArgumentOptions {
readonly projectRoot: string;
@ -208,15 +287,29 @@ const ghidraRuntimePaths = (runtimeRoot: string) => ({
const ghidraLaunchEnvironment = (
paths: ReturnType<typeof ghidraRuntimePaths>,
javaHome: string | undefined,
): NodeJS.ProcessEnv => ({
...ghidraJavaEnvironment(javaHome),
HOME: paths.homeRoot,
TMPDIR: paths.tempRoot,
XDG_CACHE_HOME: paths.cacheRoot,
XDG_CONFIG_HOME: paths.configRoot,
XDG_DATA_HOME: paths.dataRoot,
GHIDRA_HEADLESS_MAXMEM: GHIDRA_MAX_HEAP,
GHIDRA_HEADLESS_JAVA_OPTIONS: `-Duser.home=${paths.homeRoot} -Djava.io.tmpdir=${paths.tempRoot}`,
});
platform: NodeJS.Platform,
): NodeJS.ProcessEnv => {
const javaOptions =
platform === "win32"
? `"-Duser.home=${paths.homeRoot}" "-Djava.io.tmpdir=${paths.tempRoot}"`
: `-Duser.home=${paths.homeRoot} -Djava.io.tmpdir=${paths.tempRoot}`;
return {
...ghidraJavaEnvironment(javaHome, process.env, platform),
HOME: paths.homeRoot,
TMPDIR: paths.tempRoot,
...(platform === "win32"
? {
USERPROFILE: paths.homeRoot,
TEMP: paths.tempRoot,
TMP: paths.tempRoot,
}
: {}),
XDG_CACHE_HOME: paths.cacheRoot,
XDG_CONFIG_HOME: paths.configRoot,
XDG_DATA_HOME: paths.dataRoot,
GHIDRA_HEADLESS_MAXMEM: GHIDRA_MAX_HEAP,
GHIDRA_HEADLESS_JAVA_OPTIONS: javaOptions,
};
};
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;

View File

@ -109,6 +109,11 @@ const healthLimitations = Object.freeze([
"The session serves operations only after default Ghidra auto-analysis completes; an analysis timeout fails the open instead of exposing partial results.",
"The imported Program and temporary project are ephemeral, read-only to REA, and deleted on close.",
]);
const windowsP0Limitations = Object.freeze([
"Windows Ghidra P0 accepts approved native x86-64 PE applications only; DLL, managed, hostile, sensitive, and mutable-path targets are unsupported.",
"The Windows bridge uses authenticated IPv4 loopback because Node path-based IPC does not expose Java AF_UNIX sockets; the endpoint file contains no bearer token.",
"Windows P0 process-tree cleanup uses bounded taskkill termination and does not claim Job Object ownership, private DACL enforcement, or reparse-point-safe path authority.",
]);
const limitationsFor = (operation: string): readonly string[] => {
const common = [
@ -228,6 +233,17 @@ const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
}),
),
);
const WINDOWS_P0_CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
CAPABILITIES.map((capability) =>
Object.freeze({
...capability,
limitations: Object.freeze([
...capability.limitations,
...windowsP0Limitations,
]),
}),
),
);
const SUPPORTED_ARCHITECTURES = new Set(["x86", "x86_64", "arm", "arm64"]);
const SUPPORTED_FORMATS = new Set(["elf", "pe", "mach-o"]);
@ -254,7 +270,9 @@ export class GhidraProvider implements AnalysisProviderCandidate {
}
capabilities(): readonly CapabilityDescriptor[] {
return CAPABILITIES;
return (this.installationHost?.platform ?? process.platform) === "win32"
? WINDOWS_P0_CAPABILITIES
: CAPABILITIES;
}
inspectAvailability(): ProviderAvailability {
@ -345,6 +363,8 @@ export class GhidraProvider implements AnalysisProviderCandidate {
);
if (!prerequisites.ok) return unavailableClient(prerequisites.error);
const committedProfile = prerequisites.value.profile;
const providerLimitations =
installation.platform === "win32" ? windowsP0Limitations : [];
const client = this.clientFactory({
launcher: new GhidraHeadlessLauncher({
analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath,
@ -354,6 +374,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
bridgeScriptPath: fileURLToPath(
new URL("../../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
),
platform: installation.platform,
}),
targetPath: target.path,
targetSha256: target.sha256,
@ -386,7 +407,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
return ok(
createAnalysisExecution(started.value, committedProfile.provider, {
analysisProfile: committedProfile,
limitations: healthLimitations,
limitations: [...healthLimitations, ...providerLimitations],
}),
);
}
@ -410,7 +431,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
createAnalysisExecution(result.value, committedProfile.provider, {
rawResult: called.value,
analysisProfile: committedProfile,
limitations: limitationsFor(operation),
limitations: [...limitationsFor(operation), ...providerLimitations],
}),
);
},

View File

@ -39,6 +39,11 @@ export interface ProcessOwnershipHost {
signalGroup(processGroupId: number, signal: NodeJS.Signals): void;
}
/** Narrow Windows P0 seam for bounded process-tree termination. */
export interface WindowsProcessTreeHost {
terminateTree(rootPid: number): Promise<"terminated" | "missing">;
}
/** Per-member reason that token-verified cleanup failed closed. */
interface ProcessOwnershipValidationFailure {
readonly pid: number;
@ -63,6 +68,30 @@ export type ProcessGroupObservation =
| { readonly state: "alive" }
| { readonly state: "unverifiable"; readonly reason: string };
/**
* Terminate one Windows process tree through the platform utility.
*
* This is a bounded P0 cleanup mechanism, not Job Object ownership proof. The
* caller-visible Windows limitations must preserve that distinction.
*/
export const cleanupWindowsProcessTree = async (
rootPid: number,
host: WindowsProcessTreeHost = systemWindowsProcessTreeHost,
): Promise<ProcessCleanupResult> => {
if (!Number.isSafeInteger(rootPid) || rootPid <= 0)
return { cleaned: false, reason: "Windows process-tree PID is invalid" };
try {
const result = await host.terminateTree(rootPid);
return { cleaned: true, signaled: result === "terminated" };
} catch {
return {
cleaned: false,
reason:
"Windows P0 process-tree termination failed; Job Object ownership is unavailable",
};
}
};
/** Select the PTY root group and groups led by an observed captured process. */
export const selectCapturedProcessGroupIds = (
rootPid: number,
@ -135,6 +164,27 @@ const systemHost: ProcessOwnershipHost = {
},
};
const systemWindowsProcessTreeHost: WindowsProcessTreeHost = {
async terminateTree(rootPid) {
try {
await execFileAsync(
"taskkill.exe",
["/pid", String(rootPid), "/t", "/f"],
{ windowsHide: true, timeout: 5_000 },
);
return "terminated";
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === 128 || cause.code === "ESRCH")
)
return "missing";
throw cause;
}
},
};
/** Read the capture run token currently exposed by one live process. */
export const readProcessRunId = async (
pid: number,

View File

@ -105,6 +105,7 @@ export const spawnOwnedProviderProcess = async (
): Promise<SpawnedOwnedProviderProcess> => {
const child = spawn(options.command, [...options.arguments], {
shell: false,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
env: {

View File

@ -0,0 +1,2 @@
@echo off
node "%~dp0captureGhidraLaunch.mjs" %*

View File

@ -14,7 +14,10 @@ await writeFile(
arguments: arguments_,
environment: {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
TMPDIR: process.env.TMPDIR,
TEMP: process.env.TEMP,
TMP: process.env.TMP,
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
XDG_DATA_HOME: process.env.XDG_DATA_HOME,

View File

@ -7,15 +7,23 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
ghidraHeadlessArguments,
ghidraHeadlessCommand,
GhidraHeadlessLauncher,
} from "../src/ghidra/GhidraLauncher.js";
const fixturePath = fileURLToPath(
new URL("./fixtures/captureGhidraLaunch.mjs", import.meta.url),
new URL(
process.platform === "win32"
? "./fixtures/captureGhidraLaunch.cmd"
: "./fixtures/captureGhidraLaunch.mjs",
import.meta.url,
),
);
const roots: string[] = [];
beforeAll(() => chmod(fixturePath, 0o755));
beforeAll(async () => {
if (process.platform !== "win32") await chmod(fixturePath, 0o755);
});
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(
@ -57,6 +65,48 @@ describe("Ghidra headless launcher", () => {
]);
});
it("wraps the Windows batch launcher without enabling a Node shell", () => {
const command = ghidraHeadlessCommand({
platform: "win32",
analyzeHeadlessPath:
"C:\\Program Files\\Ghidra 12.1.2\\support\\analyzeHeadless.bat",
arguments: [
"C:\\REA Runtime\\project",
"rea-project",
"-import",
"C:\\REA Runtime\\target.exe",
],
comSpec: "C:\\Windows\\System32\\cmd.exe",
});
expect(command).toEqual({
command: "C:\\Windows\\System32\\cmd.exe",
arguments: [
"/d",
"/e:on",
"/v:off",
"/s",
"/c",
'""C:\\Program Files\\Ghidra 12.1.2\\support\\analyzeHeadless.bat" "C:\\REA Runtime\\project" "rea-project" "-import" "C:\\REA Runtime\\target.exe""',
],
});
});
it.each([
"C:\\targets\\%TEMP%\\fixture.exe",
"C:\\targets\\fixture & calc.exe",
'C:\\targets\\fixture".exe',
])("rejects a Windows command-interpreter path: %s", (targetPath) => {
expect(() =>
ghidraHeadlessCommand({
platform: "win32",
analyzeHeadlessPath: "C:\\Ghidra\\support\\analyzeHeadless.bat",
arguments: ["-import", targetPath],
comSpec: "C:\\Windows\\System32\\cmd.exe",
}),
).toThrow(/metacharacters/u);
});
it("keeps authority in a private descriptor and isolates Ghidra state", async () => {
vi.stubEnv("GHIDRA_JAVA_OPTIONS", "-javaagent:/unapproved/agent.jar");
vi.stubEnv("JAVA_TOOL_OPTIONS", "-Duser.home=/unapproved/home");
@ -65,9 +115,11 @@ describe("Ghidra headless launcher", () => {
const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-launcher-test-"));
roots.push(runtimeRoot);
const token = "secret-token-that-must-not-leak";
const javaHome =
process.platform === "win32" ? "C:\\Java\\jdk-21" : "/opt/jdk-21";
const launcher = new GhidraHeadlessLauncher({
analyzeHeadlessPath: fixturePath,
javaHome: "/opt/jdk-21",
javaHome,
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
});
const launched = await launcher.launch({
@ -91,10 +143,17 @@ describe("Ghidra headless launcher", () => {
expect(encodedArguments).not.toContain(token);
expect(encodedEnvironment).not.toContain(token);
expect(capture).toMatchObject({
descriptor_mode: 0o600,
...(process.platform === "win32" ? {} : { descriptor_mode: 0o600 }),
descriptor_has_token: true,
environment: {
HOME: join(runtimeRoot, "home"),
...(process.platform === "win32"
? {
USERPROFILE: join(runtimeRoot, "home"),
TEMP: join(runtimeRoot, "tmp"),
TMP: join(runtimeRoot, "tmp"),
}
: {}),
TMPDIR: join(runtimeRoot, "tmp"),
XDG_CACHE_HOME: join(runtimeRoot, "cache"),
XDG_CONFIG_HOME: join(runtimeRoot, "config"),
@ -104,17 +163,22 @@ describe("Ghidra headless launcher", () => {
JAVA_TOOL_OPTIONS: "",
JDK_JAVA_OPTIONS: "",
_JAVA_OPTIONS: "",
JAVA_HOME: "/opt/jdk-21",
JAVA_HOME: javaHome,
REA_PROCESS_RUN_ID: "d6fcbb66-e829-4ff6-a535-0035aec63139",
},
});
expect(capture.environment.PATH).toMatch(/^\/opt\/jdk-21\/bin:/u);
expect(capture.environment.PATH).toMatch(
process.platform === "win32"
? /^C:\\Java\\jdk-21\\bin;/u
: /^\/opt\/jdk-21\/bin:/u,
);
expect(capture.environment.GHIDRA_HEADLESS_JAVA_OPTIONS).toContain(
`-Duser.home=${join(runtimeRoot, "home")}`,
);
expect((await stat(join(runtimeRoot, "ownership.json"))).mode & 0o777).toBe(
0o600,
);
if (process.platform !== "win32")
expect(
(await stat(join(runtimeRoot, "ownership.json"))).mode & 0o777,
).toBe(0o600);
const cleaned = await launched.value.cleanup?.();
expect(cleaned).toMatchObject({ cleaned: true });

View File

@ -144,6 +144,17 @@ describe("Ghidra provider", () => {
managed: false,
},
});
expect(ghidra.capabilities()).toEqual(
expect.arrayContaining([
expect.objectContaining({
operation: "list_procedures",
limitations: expect.arrayContaining([
expect.stringContaining("bounded taskkill termination"),
expect.stringContaining("private DACL enforcement"),
]),
}),
]),
);
expect(
ghidra.inspectTargetSupport({
...nativeApplication,

View File

@ -1,10 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
cleanupWindowsProcessTree,
observeOwnedProcessGroup,
parseProcessEnvironment,
selectCapturedProcessGroupIds,
type ProcessOwnershipHost,
type WindowsProcessTreeHost,
} from "../src/process/ProcessOwnership.js";
const ownership = {
@ -224,3 +226,39 @@ describe("owned process-group cleanup", () => {
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("Windows P0 process-tree cleanup", () => {
it("reports whether taskkill signaled or found an exited tree", async () => {
const terminated: WindowsProcessTreeHost = {
terminateTree: () => Promise.resolve("terminated"),
};
const missing: WindowsProcessTreeHost = {
terminateTree: () => Promise.resolve("missing"),
};
await expect(cleanupWindowsProcessTree(42, terminated)).resolves.toEqual({
cleaned: true,
signaled: true,
});
await expect(cleanupWindowsProcessTree(42, missing)).resolves.toEqual({
cleaned: true,
signaled: false,
});
});
it("keeps invalid identity and termination failures explicit", async () => {
const failing: WindowsProcessTreeHost = {
terminateTree: () => Promise.reject(new Error("taskkill failed")),
};
await expect(cleanupWindowsProcessTree(0, failing)).resolves.toEqual({
cleaned: false,
reason: "Windows process-tree PID is invalid",
});
await expect(cleanupWindowsProcessTree(42, failing)).resolves.toEqual({
cleaned: false,
reason:
"Windows P0 process-tree termination failed; Job Object ownership is unavailable",
});
});
});