From 20e4f83097511090205c9f867c62bca0152e6676 Mon Sep 17 00:00:00 2001 From: Saim Shafique Date: Wed, 14 Oct 2026 04:31:11 +0500 Subject: [PATCH] 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> --- docs/installation.md | 13 +- src/ghidra/GhidraDoctor.test.ts | 7 +- src/ghidra/GhidraInstallation.fixture.ts | 2 + src/ghidra/GhidraInstallation.test.ts | 241 +++++++++++++++++- src/ghidra/GhidraInstallation.ts | 126 +++++++++ src/ghidra/GhidraJavaLaunch.ts | 64 ++++- src/ghidra/GhidraLauncher.ts | 77 +++--- src/ghidra/GhidraProvider.behavior.test.ts | 26 +- src/ghidra/GhidraSnapshotCache.test.ts | 2 + .../filesystem/ghidraPspExtension.test.ts | 2 + tests/boundary/mcp/ghidraMcpHarness.ts | 2 + tests/boundary/mcp/ghidraReopen.test.ts | 2 + .../providers/ghidra/ghidraJavaLaunch.test.ts | 69 ++++- .../providers/ghidra/ghidraLauncher.test.ts | 14 + 14 files changed, 593 insertions(+), 54 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 3ad6b7071..39584b51d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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//` 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, diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index 844438408..47ec9c882 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -15,10 +15,13 @@ import { projectGhidraDoctorInspection } from "./GhidraDoctor.js"; const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; const installationHost = ( overrides: Partial = {}, + 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", diff --git a/src/ghidra/GhidraInstallation.fixture.ts b/src/ghidra/GhidraInstallation.fixture.ts index cb81fa0de..1becec6c8 100644 --- a/src/ghidra/GhidraInstallation.fixture.ts +++ b/src/ghidra/GhidraInstallation.fixture.ts @@ -12,5 +12,7 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = { home: "/jdk-21", bits: 64, runtime: "jdk", + osArch: "amd64", + osName: "Linux", }), }; diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 776c10308..626954c69 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -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(); + 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(); + } + }); +}); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 9675d133f..95c1967b7 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -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( diff --git a/src/ghidra/GhidraJavaLaunch.ts b/src/ghidra/GhidraJavaLaunch.ts index 1e34eeb08..317c0939f 100644 --- a/src/ghidra/GhidraJavaLaunch.ts +++ b/src/ghidra/GhidraJavaLaunch.ts @@ -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, diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index e1527ea81..21990183b 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -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, diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 4a052aaef..c69c83bd9 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -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", diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index 9cf5c24ad..83d293aa2 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -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", diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts index 65a4d1512..7be601d4b 100644 --- a/tests/boundary/filesystem/ghidraPspExtension.test.ts +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -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", diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index 43e49b551..0fce25ebe 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -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", diff --git a/tests/boundary/mcp/ghidraReopen.test.ts b/tests/boundary/mcp/ghidraReopen.test.ts index 6a90f0118..7af55924a 100644 --- a/tests/boundary/mcp/ghidraReopen.test.ts +++ b/tests/boundary/mcp/ghidraReopen.test.ts @@ -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", diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index 56fec2151..9b80722e7 100644 --- a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -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", diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index 98c231240..7e2a89c8f 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -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,