fix(ghidra): fail doctor when the JVM os.arch selects an unvalidated decompiler directory (#2792)

* fix(ghidra): fail doctor when the JVM os.arch selects an unvalidated decompiler directory

probeJava now also reads the JVM os.arch property. When it maps to a
different native decompiler directory than the one validated from the
host architecture (for example an x86_64 JDK under Rosetta on Apple
silicon selecting os/mac_x86_64 while only mac_arm_64 was built), the
native_decompiler check fails with a remediation pointing at a
matching-arch JDK instead of reporting a healthy installation that
fails every analysis. Unknown os.arch values keep the previous
behavior.

* fix(ghidra): tighten JVM arch remediation and cover os.arch extraction

Address review findings on the JVM os.arch decompiler-directory check:
- The remediation no longer suggests building the decompiler for the
  JVM-selected directory, which could never clear the check; it only
  advises pointing JAVA_HOME at a matching-architecture JDK.
- Add a test running the real Java probe against captured
  -XshowSettings:properties output, asserting os.arch reaches the
  installation diagnostics.
- Document the JDK-architecture prerequisite in the Ghidra setup guide.

* fix(ghidra): refuse unrecognized JVM native architectures

* docs(ghidra): align doctor prerequisites with JVM architecture admission

* fix(ghidra): reject unreported JVM architecture

* test(ghidra): separate JVM and executable admission cases

* fix(ghidra): defer decompiler arch validation when Java is absent

* fix(ghidra): reject JVM native architecture override

* fix(ghidra): validate JVM platform overrides

* fix(ghidra): validate JVM os.name and helper-environment platform overrides

* test(ci): isolate report fixtures and declare PowerPC Java bounds

* test(ghidra): provide observed JVM platform in PowerPC fixture

* fix(ghidra): parse quoted JVM environment options before admission

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Saim Shafique 2026-10-14 04:31:11 +05:00 committed by GitHub
parent 97aff28201
commit 20e4f83097
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 593 additions and 54 deletions

View File

@ -493,7 +493,13 @@ copying license secrets into logs, or killing unrelated Hopper processes.
REA connects to an existing Ghidra installation on Linux x64/arm64, macOS x64/arm64,
or experimental Windows x64 P0.
It accepts Ghidra 12.1.x and the 64-bit full JDK declared by that installation's
`application.java.min` and `application.java.max`. Current 12.1 releases require
`application.java.min` and `application.java.max`. The JDK's reported platform must also
map to the same native decompiler directory as the host: a JDK running under
emulation (for example x86_64 under Rosetta on Apple silicon) or reporting a
non-host `os.name` fails the doctor
even when it is an otherwise accepted 64-bit full JDK. Select a matching JDK
through `JAVA_HOME`; adding another decompiler in the JVM-selected directory
does not satisfy this host-validated admission rule. Current 12.1 releases require
JDK 21 or newer and set no maximum. The bridge is verified with Ghidra 12.1.4
and JDK 21. Each installation must include the native decompiler for the host
architecture in `Ghidra/Features/Decompiler/os/<platform>/` or the corresponding
@ -556,6 +562,11 @@ registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagno
Doctor validates the platform, architecture, Ghidra 12.1.x application version,
`support/analyzeHeadless` or `support/analyzeHeadless.bat`, the installation's
Java major range, 64-bit JDK bitness, and the presence of `javac`/`javac.exe`.
It also checks that the reported JVM `os.arch` and `os.name` map to the same native decompiler
directory validated for the host; unrecognized reported architectures or a
non-host operating system are refused. LaunchSupport overrides of either
property, including through helper-environment JVM option variables, are
rejected at launch.
When Java is found through `PATH`, setup records its observed JDK home so GUI
MCP clients do not depend on an incidental shell path. Setup shows every exact
environment entry in its plan, writes only after approval, and never downloads,

View File

@ -15,10 +15,13 @@ import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const installationHost = (
overrides: Partial<GhidraInstallationHost> = {},
platform: NodeJS.Platform = "linux",
): GhidraInstallationHost => ({
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: platform === "win32" ? "Windows 11" : "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",
@ -45,7 +48,7 @@ const inspection = (
? {}
: { installDir: options.installDir }),
},
installationHost(overrides),
installationHost(overrides, options.platform ?? "linux"),
);
const doctorHost = (
@ -129,6 +132,8 @@ describe("Ghidra doctor integration", () => {
{ installDir: INSTALL },
{
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "17.0.12",
major: 17,
home: "/usr/lib/jvm/jdk-17",

View File

@ -12,5 +12,7 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = {
home: "/jdk-21",
bits: 64,
runtime: "jdk",
osArch: "amd64",
osName: "Linux",
}),
};

View File

@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { spawnSync } from "node:child_process";
import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js";
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
import {
ghidraInstallationDiagnostics,
ghidraJavaEnvironment,
inspectGhidraInstallation,
type GhidraExecutableHeaderProbe,
@ -16,6 +18,14 @@ import {
ghidraJavaRequirement,
} from "./GhidraInstallationPolicy.js";
// The real Java probe shells out via spawnSync; stub it so one test can run
// the real probeJava against captured `-XshowSettings:properties` output.
// Every other test overrides probeJava, so the stub never fires for them.
vi.mock("node:child_process", async (importOriginal) => {
const original = await importOriginal<typeof import("node:child_process")>();
return { ...original, spawnSync: vi.fn() };
});
const INSTALL = "/opt/ghidra";
const PROPERTIES = `${INSTALL}/Ghidra/application.properties`;
const HEADLESS = `${INSTALL}/support/analyzeHeadless`;
@ -32,6 +42,8 @@ const headerProbe = (bytes: Buffer): GhidraExecutableHeaderProbe => ({
});
const JAVA: GhidraJavaObservation = {
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: SUPPORTED_GHIDRA_JAVA_MAJOR,
home: "/usr/lib/jvm/jdk-21",
@ -174,6 +186,11 @@ describe("Ghidra installation inspection", () => {
{ environment: {}, installDir: INSTALL, platform, architecture },
host({
executable: (path) => path === HEADLESS || path === decompiler,
probeJava: () => ({
...JAVA,
osArch: architecture === "arm64" ? "aarch64" : "amd64",
osName: platform === "darwin" ? "Mac OS X" : "Linux",
}),
}),
),
).toMatchObject({
@ -202,6 +219,7 @@ describe("Ghidra installation inspection", () => {
return {
...JAVA,
home: "C:\\Java\\jdk-21",
osName: "Windows 11",
};
},
};
@ -721,7 +739,10 @@ describe("Ghidra FAT native decompiler admission", () => {
platform: "darwin",
architecture: "arm64",
},
host({ executableHeader: () => headerProbe(bytes) }),
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: () => headerProbe(bytes),
}),
);
expect(result.status).toBe(status);
expect(
@ -739,7 +760,10 @@ describe("Ghidra FAT native decompiler admission", () => {
platform: "darwin",
architecture: "arm64",
},
host({ executableHeader: () => headerProbe(bytes) }),
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: () => headerProbe(bytes),
}),
);
expect(result.status).toBe("available");
expect(
@ -788,7 +812,14 @@ describe("Ghidra native decompiler admission", () => {
])("distinguishes $name", ({ options, executableHeader }) => {
const result = inspectGhidraInstallation(
options,
host({ executableHeader }),
host({
probeJava: () => ({
...JAVA,
osArch: options.architecture === "arm64" ? "aarch64" : "amd64",
osName: options.platform === "darwin" ? "Mac OS X" : "Linux",
}),
executableHeader,
}),
);
expect(result.status).toBe("unavailable");
expect(
@ -849,6 +880,7 @@ describe("Ghidra native decompiler admission", () => {
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: (path: string) =>
path === MAC_DECOMPILER ? headerProbe(bytes) : undefined,
}),
@ -872,6 +904,7 @@ describe("Ghidra native decompiler admission", () => {
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: (path: string) =>
path === MAC_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined,
}),
@ -889,3 +922,205 @@ describe("Ghidra native decompiler admission", () => {
});
});
});
describe("Ghidra JVM native directory admission", () => {
it("rejects a JDK whose os.arch selects a different native decompiler directory", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch: "x86_64", osName: "Mac OS X" }),
}),
);
expect(result.status).toBe("unavailable");
if (result.status !== "unavailable") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
code: "executable_missing",
detail: expect.stringContaining(
"JVM reports os.arch=x86_64, so Ghidra selects its native decompiler from os/mac_x86_64",
),
remediation: expect.stringContaining("JAVA_HOME"),
});
});
it("accepts a JDK whose os.arch selects the validated native decompiler directory", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
}),
);
expect(result.status).toBe("available");
});
it("rejects a successful Java probe that omits os.arch", () => {
const { osArch: _observedArchitecture, ...withoutArchitecture } = JAVA;
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({ probeJava: () => withoutArchitecture }),
);
expect(result.status).toBe("unavailable");
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("did not report os.arch"),
});
expect(projectGhidraDoctorInspection(result).available).toBe(false);
});
it.each(["riscv64", "sparcv9", ""])(
"refuses an unrecognized JVM os.arch (%s) instead of assuming alignment",
(osArch) => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch }),
}),
);
expect(result.status).toBe("unavailable");
if (result.status !== "unavailable") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("unrecognized os.arch="),
});
},
);
it("rejects a JDK whose os.name selects a different host OS", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
probeJava: () => ({
...JAVA,
osArch: "aarch64",
osName: "Windows 11",
}),
}),
);
expect(result.status).toBe("unavailable");
if (result.status !== "unavailable") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
code: "executable_missing",
detail: expect.stringContaining("JVM reports os.name=Windows 11"),
remediation: expect.stringContaining("JAVA_HOME"),
});
});
});
describe("Ghidra JVM OS admission", () => {
it("rejects a successful Java probe that omits os.name", () => {
const { osName: _observedOsName, ...withoutOsName } = JAVA;
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "linux",
architecture: "x64",
},
host({ probeJava: () => withoutOsName }),
);
expect(result.status).toBe("unavailable");
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("did not report os.name"),
});
expect(projectGhidraDoctorInspection(result).available).toBe(false);
});
it.each(["SunOS", "FreeBSD", ""])(
"refuses an unrecognized JVM os.name (%s) instead of assuming alignment",
(osName) => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "linux",
architecture: "x64",
},
host({
probeJava: () => ({ ...JAVA, osName }),
}),
);
expect(result.status).toBe("unavailable");
if (result.status !== "unavailable") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("os.name="),
});
},
);
});
describe("Ghidra Java probe os.arch extraction", () => {
it("extracts os.arch through the real Java probe into the installation diagnostics", () => {
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: "",
stderr: [
"Property settings:",
" file.encoding = UTF-8",
" java.home = /usr/lib/jvm/jdk-21",
" java.version = 21.0.11",
" java.vm.name = OpenJDK 64-Bit Server VM",
" os.arch = x86_64",
" os.name = Linux",
" sun.arch.data.model = 64",
"",
].join("\n"),
} as never);
try {
// No host override: the real system host runs the real probeJava
// against the stubbed spawnSync.
const inspection = inspectGhidraInstallation({
environment: {},
installDir: INSTALL,
javaHome: "/usr/lib/jvm/jdk-21",
platform: "linux",
architecture: "x64",
});
expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({
java_os_arch: "x86_64",
java_os_name: "Linux",
});
} finally {
vi.mocked(spawnSync).mockReset();
}
});
});

View File

@ -203,6 +203,17 @@ interface GhidraInstallationObservation {
readonly javaCommand: string;
/** Observed java.vm.name; omitted when the probe cannot establish VM identity. */
readonly javaVmName?: string;
/**
* Observed JVM os.arch; omitted when the probe cannot establish it. Ghidra
* selects its native decompiler directory from this value.
*/
readonly javaOsArch?: string;
/**
* Observed JVM os.name; omitted when the probe cannot establish it. Ghidra
* selects its native decompiler OS directory from the JVM OS/architecture
* pair, so a non-host value selects an unvalidated directory.
*/
readonly javaOsName?: string;
readonly checks: readonly GhidraInstallationCheck[];
}
@ -244,6 +255,17 @@ export interface GhidraJavaObservation {
readonly bits: number;
readonly runtime: "jdk" | "jre";
readonly vmName?: string;
/**
* Observed JVM os.arch property, which Ghidra uses to select its native
* decompiler directory. Omitted when the probe cannot establish it.
*/
readonly osArch?: string;
/**
* Observed JVM os.name property, which Ghidra uses with os.arch to select
* its native decompiler OS directory. Omitted when the probe cannot
* establish it.
*/
readonly osName?: string;
}
/** A bounded executable-header probe: a small prefix plus the whole file size. */
@ -324,6 +346,12 @@ export const inspectGhidraInstallation = (
...(coordinates.java?.vmName === undefined
? {}
: { javaVmName: coordinates.java.vmName }),
...(coordinates.java?.osArch === undefined
? {}
: { javaOsArch: coordinates.java.osArch }),
...(coordinates.java?.osName === undefined
? {}
: { javaOsName: coordinates.java.osName }),
checks,
};
if (failed !== undefined)
@ -555,6 +583,8 @@ export const ghidraInstallationDiagnostics = (
java_home: inspection.javaHome,
java_version: inspection.javaVersion,
java_vm_name: inspection.javaVmName ?? null,
java_os_arch: inspection.javaOsArch ?? null,
java_os_name: inspection.javaOsName ?? null,
platform: inspection.platform,
architecture: inspection.architecture,
checks: inspection.checks.map((check) => ({
@ -632,6 +662,61 @@ const nativeDecompilerCheck = ({
architecture,
host.executableHeader?.(path),
);
// Ghidra resolves its native decompiler directory from the JVM's os.arch,
// not the host process architecture. A JDK running under emulation (for
// example x86_64 under Rosetta on Apple silicon) selects a directory that
// was never validated, so every analysis fails after a passing doctor.
// Ghidra resolves the OS directory from the JVM os.name/os.arch pair, so a
// JVM reporting a non-host os.name (for example through -Dos.name=Windows)
// selects a different OS directory even when os.arch matches.
const osArch = coordinates.java?.osArch;
const osName = coordinates.java?.osName;
const selectedDirectory = jvmOsArchNativeDirectory(platform, osArch);
const validatedDirectory = NATIVE_PLATFORMS[platform]?.[architecture];
// Without a JVM observation the java check reports the missing runtime;
// the decompiler directory cannot be selected, so this check defers to it.
if (coordinates.java !== undefined) {
if (selectedDirectory === undefined)
return installationCheck({
name: "native_decompiler",
passed: false,
code: "executable_missing",
detail:
osArch === undefined
? "The Java probe did not report os.arch; the JVM-selected native decompiler directory is unknown and cannot be matched to the validated host directory"
: `JVM reports unrecognized os.arch=${osArch}; its selected native decompiler directory is unknown and cannot be matched to the validated host directory`,
remediation: `Point JAVA_HOME at a JDK whose reported architecture maps to the validated Ghidra decompiler (${validatedDirectory ?? "unsupported host"}). REA does not build or install native tools.`,
});
if (
validatedDirectory !== undefined &&
selectedDirectory !== validatedDirectory &&
path !== null
)
return installationCheck({
name: "native_decompiler",
passed: false,
code: "executable_missing",
detail: `JVM reports os.arch=${osArch}, so Ghidra selects its native decompiler from os/${selectedDirectory}, but the validated decompiler targets ${validatedDirectory} (host ${platform}/${architecture})`,
remediation: `Point JAVA_HOME at a JDK whose architecture matches the built decompiler (${validatedDirectory}). REA does not build or install native tools.`,
});
if (osName === undefined)
return installationCheck({
name: "native_decompiler",
passed: false,
code: "executable_missing",
detail:
"The Java probe did not report os.name; the JVM-selected native decompiler OS directory is unknown and cannot be matched to the validated host platform",
remediation: `Point JAVA_HOME at a JDK whose reported operating system matches the host platform (${platform}). REA does not build or install native tools.`,
});
if (!jvmOsNameMatchesPlatform(platform, osName) && path !== null)
return installationCheck({
name: "native_decompiler",
passed: false,
code: "executable_missing",
detail: `JVM reports os.name=${osName}, which does not match the validated host platform ${platform} (host ${platform}/${architecture})`,
remediation: `Point JAVA_HOME at a JDK whose reported operating system matches the host platform (${platform}). REA does not build or install native tools.`,
});
}
return installationCheck({
name: "native_decompiler",
passed: path !== null && compatibility?.status !== "incompatible",
@ -646,6 +731,43 @@ const nativeDecompilerCheck = ({
});
};
/**
* Whether a JVM os.name value selects the host platform's native OS directory.
* Unknown values never align with the validated host directory; admission
* must not treat them as matching.
*/
const jvmOsNameMatchesPlatform = (
platform: NodeJS.Platform,
osName: string,
): boolean => {
const normalized = osName.toLowerCase();
if (platform === "win32") return normalized.startsWith("windows");
if (platform === "darwin")
return normalized.startsWith("mac") || normalized.startsWith("darwin");
if (platform === "linux") return normalized.startsWith("linux");
return false;
};
/**
* Map a JVM os.arch value to the Ghidra native directory that JVM selects.
* Unknown values stay unknown; admission must not treat them as aligned with
* the validated host directory.
*/
const jvmOsArchNativeDirectory = (
platform: NodeJS.Platform,
osArch: string | undefined,
): string | undefined => {
if (osArch === undefined) return undefined;
const normalized = osArch.toLowerCase();
const node: NodeJS.Architecture | undefined =
normalized === "x86_64" || normalized === "amd64" || normalized === "x64"
? "x64"
: normalized === "aarch64" || normalized === "arm64"
? "arm64"
: undefined;
return node === undefined ? undefined : NATIVE_PLATFORMS[platform]?.[node];
};
const versionCheck = (
properties: GhidraApplicationProperties | undefined,
): GhidraInstallationCheck => {
@ -781,6 +903,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
const version = javaProperty(output, "java.version");
const home = javaProperty(output, "java.home");
const vmName = javaProperty(output, "java.vm.name");
const osArch = javaProperty(output, "os.arch");
const osName = javaProperty(output, "os.name");
const bits = Number.parseInt(
javaProperty(output, "sun.arch.data.model") ?? "0",
10,
@ -791,6 +915,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
major: javaMajor(version),
home,
...(vmName === null ? {} : { vmName }),
...(osArch === null ? {} : { osArch }),
...(osName === null ? {} : { osName }),
bits,
runtime: executablePath(
(process.platform === "win32" ? win32 : posix).join(

View File

@ -2,11 +2,43 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { dirname, join } from "node:path";
import { execFileOutput } from "../process/ExecFileOutput.js";
/** JVM option environments remove quotes, including quotes inside an option. */
const jvmEnvironmentArguments = (value: string): readonly string[] => {
const arguments_: string[] = [];
let argument = "";
let quote: "'" | '"' | undefined;
for (const character of value) {
if (quote !== undefined) {
if (character === quote) quote = undefined;
else argument += character;
} else if (character === "'" || character === '"') quote = character;
else if (/\s/u.test(character)) {
if (argument !== "") arguments_.push(argument);
argument = "";
} else argument += character;
}
if (quote !== undefined)
throw new Error(
"Invalid quoted Ghidra LaunchSupport JVM environment option",
);
if (argument !== "") arguments_.push(argument);
return arguments_;
};
const rejectJvmPlatformOverrides = (arguments_: readonly string[]): void => {
if (
arguments_.some((argument) => /^-Dos\.(?:arch|name)(?:=|$)/u.test(argument))
)
throw new Error(
"Ghidra LaunchSupport may not override os.arch or os.name; the native decompiler was validated for the inspected JVM platform.",
);
};
/** Launch coordinates obtained from Ghidra's own release configuration helper. */
export const ghidraJavaLaunch = async (options: {
readonly platform: NodeJS.Platform;
readonly analyzeHeadlessPath: string;
readonly javaHome: string;
readonly javaHome?: string;
readonly javaVmName?: string;
readonly homeRoot: string;
readonly tempRoot: string;
@ -21,7 +53,17 @@ export const ghidraJavaLaunch = async (options: {
}> => {
const supportRoot = dirname(options.analyzeHeadlessPath);
const installRoot = dirname(supportRoot);
const command = join(options.javaHome, "bin", "java");
const selectedJavaHome = options.javaHome ?? options.environment.JAVA_HOME;
const command =
selectedJavaHome === undefined
? options.platform === "win32"
? "java.exe"
: "java"
: join(
selectedJavaHome,
"bin",
options.platform === "win32" ? "java.exe" : "java",
);
const isolatedProperties = [
`-Duser.home=${options.homeRoot}`,
`-Djava.io.tmpdir=${options.tempRoot}`,
@ -48,6 +90,9 @@ export const ghidraJavaLaunch = async (options: {
[...supportArguments, "-envvars"],
{
env: environment,
// Wait for the helper's exit on abort so a cancelled or timed-out
// startup never leaves the probe running without an owning process.
stopSignal: "SIGTERM",
...(options.signal === undefined ? {} : { signal: options.signal }),
},
);
@ -65,11 +110,25 @@ export const ghidraJavaLaunch = async (options: {
if ((environment[key] ?? "") === "")
environment[key] = line.slice(separator + 1);
}
// The merged environment becomes the analysis JVM's environment, and the
// JVM applies these options variables automatically. An injected
// -Dos.arch/-Dos.name there selects an unvalidated native directory without
// appearing in vmArguments, so reject the effective values as well.
for (const key of [
"JAVA_TOOL_OPTIONS",
"_JAVA_OPTIONS",
"JDK_JAVA_OPTIONS",
] as const) {
rejectJvmPlatformOverrides(jvmEnvironmentArguments(environment[key] ?? ""));
}
const configuredArguments = await execFileOutput(
command,
[...supportArguments, "-vmargs"],
{
env: environment,
// Wait for the helper's exit on abort so a cancelled or timed-out
// startup never leaves the probe running without an owning process.
stopSignal: "SIGTERM",
...(options.signal === undefined ? {} : { signal: options.signal }),
},
);
@ -83,6 +142,7 @@ export const ghidraJavaLaunch = async (options: {
)
)
throw new Error("Invalid Ghidra LaunchSupport JVM argument");
rejectJvmPlatformOverrides(vmArguments);
return {
command,
environment,

View File

@ -172,6 +172,8 @@ export interface GhidraHeadlessLauncherOptions {
readonly mipsSoftFloat?: true;
/** Spawn seam for provider-boundary lifecycle tests. */
readonly spawnProcess?: typeof spawnOwnedProviderProcess;
/** LaunchSupport probe seam for Java-free launcher fixtures. */
readonly javaLaunch?: typeof ghidraJavaLaunch;
}
/** Launch Ghidra without copying scripts into or modifying its installation. */
@ -303,43 +305,46 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
throw new GhidraLaunchError(
"POSIX Ghidra requires its inspected JDK home",
);
const inspectedJavaLaunch = await (
this.options.javaLaunch ?? ghidraJavaLaunch
)({
platform,
analyzeHeadlessPath: this.options.analyzeHeadlessPath,
...(this.options.javaHome === undefined
? {}
: { javaHome: this.options.javaHome }),
...(this.options.javaVmName === undefined
? {}
: { javaVmName: this.options.javaVmName }),
homeRoot: paths.homeRoot,
tempRoot: paths.tempRoot,
arguments: headlessArguments,
environment,
onStderr: (mode, stderr) => {
if (stderr === "") return;
const bytes = Buffer.byteLength(stderr);
const redacted = redactCapturedTransportCredential(
{ text: stderr, bytes, observedBytes: bytes },
session.token,
"[REDACTED]",
);
const bounded = boundedUtf8Text(
redacted,
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
);
launchSupport.push({
mode,
stderr: bounded.text,
stderr_bytes: bytes,
stderr_truncated: bounded.truncated,
});
},
...(options.signal === undefined ? {} : { signal: options.signal }),
});
const command =
platform !== "win32" && this.options.javaHome !== undefined
? await ghidraJavaLaunch({
platform,
analyzeHeadlessPath: this.options.analyzeHeadlessPath,
javaHome: this.options.javaHome,
...(this.options.javaVmName === undefined
? {}
: { javaVmName: this.options.javaVmName }),
homeRoot: paths.homeRoot,
tempRoot: paths.tempRoot,
arguments: headlessArguments,
environment,
onStderr: (mode, stderr) => {
if (stderr === "") return;
const bytes = Buffer.byteLength(stderr);
const redacted = redactCapturedTransportCredential(
{ text: stderr, bytes, observedBytes: bytes },
session.token,
"[REDACTED]",
);
const bounded = boundedUtf8Text(
redacted,
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
);
launchSupport.push({
mode,
stderr: bounded.text,
stderr_bytes: bytes,
stderr_truncated: bounded.truncated,
});
},
...(options.signal === undefined
? {}
: { signal: options.signal }),
})
: { ...scriptCommand, environment };
platform === "win32"
? { ...scriptCommand, environment }
: inspectedJavaLaunch;
started = await (this.options.spawnProcess ?? spawnOwnedProviderProcess)({
command: command.command,
arguments: command.arguments,

View File

@ -37,12 +37,16 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const installationHost = (): GhidraInstallationHost => ({
platform: "linux",
const installationHost = (
platform: NodeJS.Platform = "linux",
): GhidraInstallationHost => ({
platform,
architecture: "x64",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: platform === "win32" ? "Windows 11" : "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",
@ -102,7 +106,7 @@ describe("Ghidra provider", () => {
},
},
});
const windows = provider({ ...installationHost(), platform: "win32" });
const windows = provider(installationHost("win32"));
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
});
it("discovers the exact installation once without launching Ghidra", () => {
@ -112,6 +116,8 @@ describe("Ghidra provider", () => {
probeJava: () => {
probeCount += 1;
return {
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",
@ -193,7 +199,7 @@ describe("Ghidra provider", () => {
describe("Ghidra raw-binary profile", () => {
it("admits raw 6502 on Windows and binds its 16-bit language without mutation authority", async () => {
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
const target: BinaryTarget = {
path: "/tmp/6502.bin",
sha256: "a".repeat(64),
@ -260,7 +266,7 @@ describe("Ghidra raw-binary profile", () => {
expect(shifted.value.profile?.digest).not.toBe(
resolved.value.profile.digest,
);
const windows = provider({ ...installationHost(), platform: "win32" });
const windows = provider(installationHost("win32"));
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
});
});
@ -302,7 +308,7 @@ describe("Ghidra Mach-O slice support", () => {
describe("Ghidra platform support", () => {
it("commits the PE role and managed classification to profile identity", async () => {
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
const application = peTarget("x86_64");
const library = {
...application,
@ -343,7 +349,7 @@ describe("Ghidra platform support", () => {
});
it("keeps Windows annotation mutation unavailable independently of native controls", () => {
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
expect(
ghidra
.capabilities()
@ -363,7 +369,7 @@ describe("Ghidra platform support", () => {
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
});
onTestFinished(() => native.mockRestore());
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
const nativeApplication = peTarget("x86_64");
for (const architecture of ["x86", "x86_64"] as const)
@ -454,7 +460,7 @@ describe("Ghidra Windows PE fixture admission", () => {
await promisify(execFile)(process.execPath, [
join(root, "scripts/create-ghidra-windows-fixture.mjs"),
]);
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
for (const architecture of ["x86", "x86_64"] as const) {
for (const dll of [false, true]) {
const path = join(
@ -1818,6 +1824,8 @@ describe("compatible Ghidra builds", () => {
readText: () =>
"application.version=12.1.2\napplication.java.min=21\napplication.java.max=\n",
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "27",
major: 27,
home: "/usr/lib/jvm/java-27-openjdk",

View File

@ -43,6 +43,8 @@ const fixture = (version = "12.1.4") => {
`application.version=${version}\napplication.java.min=21\n`,
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",

View File

@ -55,6 +55,8 @@ const host: GhidraInstallationHost = {
readText: () => "application.version=12.1.3\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.12",
major: 21,
home: "/jdk-21",

View File

@ -106,6 +106,8 @@ export const ghidraInstallationHost = (): GhidraInstallationHost => ({
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",

View File

@ -102,6 +102,8 @@ const installationHost: GhidraInstallationHost = {
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: 21,
home: "/fixture/jdk-21",

View File

@ -50,9 +50,16 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
process.stderr.write("failed " + probe + "\\n");
process.exitCode = 3;
} else if (probe === "-envvars") {
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : "GHIDRA_MAXMEM=1G\\n");
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : mode.startsWith("env-injection:") ? mode.slice("env-injection:".length) + "\\n" : "GHIDRA_MAXMEM=1G\\n");
} else {
process.stdout.write(mode === "vm-malformed" ? "invalid JVM argument\\n" : "-Dfile.encoding=UTF8\\n");
const vmArguments = mode === "vm-malformed"
? "invalid JVM argument\\n"
: mode === "os-arch-override"
? "-Dos.arch=x86_64\\n"
: mode === "os-name-override"
? "-Dos.name=Windows\\n"
: "-Dfile.encoding=UTF8\\n";
process.stdout.write(vmArguments);
}
`,
{ mode: 0o755 },
@ -215,6 +222,57 @@ describe.skipIf(process.platform === "win32")(
},
);
it.each(["os-arch-override", "os-name-override"])(
"rejects LaunchSupport %s before starting analysis",
async (mode) => {
const { session, launcher, analysisArguments } = await fixture(mode);
const launched = await launcher.launch(session);
expect(launched.ok).toBe(false);
expect(analysisArguments).toEqual([]);
if (launched.ok)
throw new Error("Expected a JVM platform override failure");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining(
"may not override os.arch or os.name",
),
});
},
);
it.each([
["JAVA_TOOL_OPTIONS", "-Dos.arch=x86_64"],
["JAVA_TOOL_OPTIONS", '"-Dos.arch=x86_64"'],
["_JAVA_OPTIONS", "'-Dos.name=Windows'"],
["JDK_JAVA_OPTIONS", '-D"os.name"=Windows'],
["JDK_JAVA_OPTIONS", "-D'os.arch'=x86_64"],
])(
"rejects helper-environment platform injection from %s=%s before analysis",
async (key, value) => {
const { session, launcher, analysisArguments } = await fixture(
`env-injection:${key}=${value}`,
);
const launched = await launcher.launch(session);
expect(launched.ok).toBe(false);
expect(analysisArguments).toEqual([]);
if (launched.ok)
throw new Error("Expected a JVM platform override failure");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining(
"may not override os.arch or os.name",
),
});
},
);
it("admits a quoted non-platform option without mistaking its value for a platform argument", async () => {
const { session, launcher, analysisArguments } = await fixture(
'env-injection:JAVA_TOOL_OPTIONS=-Ddescription="text -Dos.arch=x86_64"',
);
const launched = await launcher.launch(session);
if (!launched.ok) throw launched.error;
expect(analysisArguments).toHaveLength(1);
});
it("bounds retained UTF-8 warnings and reports original byte counts", async () => {
const { session, launcher } = await fixture("large");
const launched = await launcher.launch(session);
@ -290,6 +348,13 @@ describe.skipIf(process.platform === "win32")(
"application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: process.arch === "arm64" ? "aarch64" : "amd64",
osName:
process.platform === "darwin"
? "Mac OS X"
: process.platform === "win32"
? "Windows 11"
: "Linux",
version: "21.0.12",
major: 21,
home: "/fixture/jdk",

View File

@ -524,6 +524,13 @@ describe("Ghidra Windows launcher runtime", () => {
),
javaHome: "C:\\Java\\jdk-21",
bridgeScriptPath: join(parent, "ReaGhidraBridge.java"),
// The preflight validates the inspected JVM's LaunchSupport output;
// these fixtures assert batch-spawn behavior without a working JDK.
javaLaunch: async () => ({
command: "C:\\Java\\jdk-21\\bin\\java.exe",
arguments: [],
environment: {},
}),
spawnProcess,
});
const launched = await launcher.launch({
@ -569,6 +576,13 @@ describe("Ghidra Windows launcher runtime", () => {
platform: "win32",
javaHome,
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
// The preflight validates the inspected JVM's LaunchSupport output;
// this fixture captures the batch launcher without a working JDK.
javaLaunch: async () => ({
command: `${javaHome}\\bin\\java.exe`,
arguments: [],
environment: {},
}),
});
const launched = await launcher.launch({
runtimeRoot,