[GH-ISSUE #6212] [Bug] Ghidra accepts platform overrides through JDK_JAVA_OPTIONS argument files #3783

Closed
opened 2026-10-31 00:44:00 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 22, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6212

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

At the launcher boundary, a helper-produced JDK_JAVA_OPTIONS=@platform.options whose file contains -Dos.arch=rea_argfile_probe reached the process-creation callback. A real JDK probe using the callback's environment exited 0 and reported os.arch = rea_argfile_probe. The equivalent literal JDK_JAVA_OPTIONS=-Dos.arch=rea_argfile_probe was rejected before that callback. With no helper override, the JDK exited 0 and reported os.arch = aarch64.

This was reproduced through the production headless-launcher boundary using controlled LaunchSupport output and a real JDK. No Ghidra analysis, bridge, target import, or native-decompiler execution was performed.

Reproduction

This production-boundary reproduction uses the real headless launcher, a controlled executable that supplies LaunchSupport-shaped output, and a real JDK for property evaluation. It intentionally stops at process creation after the property probe; it does not start Ghidra or import a target. The final fixture reproduced the admission difference once, with three arms: indirect override accepted, literal override rejected, and empty-options control retaining the host architecture. Two earlier attempts failed due to an escaping error in the test's generated helper, which was corrected before the successful run.

Save the following complete fixture as tests/boundary/providers/ghidra/jvmArgfile.test.ts. The temporary filename used during verification has been normalized here; the fixture content is unchanged. Run from the source checkout with existing dependencies. The command below preserves the invocation used, with local scratch and JDK paths replaced by variables.

import { expect, it } from "vitest";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { GhidraHeadlessLauncher, type GhidraLaunchSession } from "../../../../src/ghidra/GhidraLauncher.js";

it("compares indirect and literal helper platform overrides at the launcher boundary", async () => {
  const root = process.env.REA_VERIFY_SCRATCH!;
  const java = process.env.REA_VERIFY_JAVA!;
  const optionsFile = join(root, "platform.options");
  await writeFile(optionsFile, "-Dos.arch=rea_argfile_probe\n");
  const results: unknown[] = [];
  for (const value of ["@" + optionsFile, "-Dos.arch=rea_argfile_probe", ""]) {
    const caseRoot = join(root, "case-" + results.length);
    const javaHome = join(caseRoot, "jdk");
    await mkdir(join(javaHome, "bin"), { recursive: true });
    await writeFile(join(javaHome, "bin", "java"), `#!/usr/bin/env node
const phase = process.argv.at(-1);
if (phase === "-envvars") process.stdout.write(${JSON.stringify(value === "" ? "" : "JDK_JAVA_OPTIONS=" + value + "\n")});
else if (phase === "-vmargs") process.stdout.write(${JSON.stringify("-Dfile.encoding=UTF8\n")});
else process.exit(64);
`, { mode: 0o755 });
    let observed: { status: number | null; architecture: string | undefined } | undefined;
    const launcher = new GhidraHeadlessLauncher({
      platform: process.platform,
      environment: { PATH: process.env.PATH ?? "" },
      javaHome,
      javaVmName: "OpenJDK 64-Bit Server VM",
      analyzeHeadlessPath: join(caseRoot, "ghidra", "support", "analyzeHeadless"),
      configurationDirectoryName: "ghidra_12.1.4_PUBLIC",
      bridgeScriptPath: join(caseRoot, "ReaGhidraBridge.java"),
      spawnProcess: async (options) => {
        const probe = spawnSync(java, ["-XshowSettings:properties", "-version"], { env: options.env, encoding: "utf8" });
        observed = { status: probe.status, architecture: /os\.arch = ([^\r\n]+)/u.exec(probe.stderr)?.[1] };
        throw new Error("Probe completed; no analysis JVM started");
      },
    });
    const session: GhidraLaunchSession = {
      runtimeRoot: join(caseRoot, "runtime"), transport: "unix-socket",
      endpointPath: join(caseRoot, "endpoint.sock"), token: "fixture-token",
      runId: "fixture-run", targetPath: join(caseRoot, "target.bin"),
      targetSha256: "0".repeat(64), providerVersion: "test", profileDigest: "test",
    };
    const launched = await launcher.launch(session);
    if (value.startsWith("@")) expect(observed).toEqual({ status: 0, architecture: "rea_argfile_probe" });
    else if (value.startsWith("-D")) {
      expect(observed).toBeUndefined();
      expect(launched.ok).toBe(false);
      if (!launched.ok) expect(launched.error.cause).toMatchObject({ message: expect.stringContaining("may not override os.arch or os.name") });
    } else {
      expect(observed?.status).toBe(0);
      expect(observed?.architecture).toBe("aarch64");
    }
    results.push({ input: value.startsWith("@") ? "@platform.options" : value || "control", observed: observed ?? "rejected before spawn" });
    await rm(caseRoot, { recursive: true, force: true });
  }
  console.log(JSON.stringify(results));
});
proof_root=$(mktemp -d)
mkdir -p "$proof_root/home" "$proof_root/dsh" "$proof_root/config" "$proof_root/cache"
# Set JAVA_HOME to the installed full JDK 21 home before running this command.
HOME="$proof_root/home" DSH_HOME="$proof_root/dsh" \
XDG_CONFIG_HOME="$proof_root/config" XDG_CACHE_HOME="$proof_root/cache" \
REA_VERIFY_SCRATCH="$proof_root" REA_VERIFY_JAVA="$JAVA_HOME/bin/java" \
node node_modules/vitest/vitest.mjs run tests/boundary/providers/ghidra/jvmArgfile.test.ts --maxWorkers=1

Expected behavior

The installation guide promises that LaunchSupport overrides of os.arch or os.name, including helper-environment JVM option variables, are rejected at launch because native-decompiler admission is tied to the inspected host/JVM platform. An indirect property override should not evade that same check. Ordinary launches should continue to work.

Impact and scope

The demonstrated defect is a POSIX platform-admission bypass in user-managed Ghidra configuration. It can invalidate the platform facts REA uses to admit native analysis; downstream native-decompiler selection and a full analysis failure were not exercised. Remove the platform override from the helper option file to preserve the host platform. This is not a demonstrated security boundary crossing: the operator controls the local configuration, and REA's security policy does not sandbox user-managed Java/Ghidra or protect against malicious processes running as the same user.

REA version

Source commit c465ff5a81 (main)

Runtime and platform

Node v26.12.0; macOS 27.0.1 arm64; Homebrew OpenJDK 21.0.12.1 (build 21.0.12.1). The repository pins Node 24.18; the test ran on the stated installed Node. Ghidra 12.1.4 helper configuration was source-inspected, not executed.

Target and artifact context

Not applicable to the demonstrated launch-preparation failure: the fixture uses a synthetic session and performs no target import.

Interface

Other

Earliest failure stage

Setup or provider admission

Relevant output or evidence

The successful boundary-test run produced:

[
  {"input":"@platform.options","observed":{"status":0,"architecture":"rea_argfile_probe"}},
  {"input":"-Dos.arch=rea_argfile_probe","observed":"rejected before spawn"},
  {"input":"control","observed":{"status":0,"architecture":"aarch64"}}
]

Contract: installation guide. Source-derived cause: the literal-property guard and helper environment merge/validation do not expand the @file token. POSIX launch selection and process creation forward this environment.

Oracle's JDK 21 java command documentation supports argument files in JDK_JAVA_OPTIONS. A separate real-JDK matrix confirmed this expansion; JAVA_TOOL_OPTIONS and _JAVA_OPTIONS each exited 1 with an unrecognized-option error for the same @file token. This report therefore applies to JDK_JAVA_OPTIONS only.

Typed outcome or recovery expected

Reject the effective platform override before analysis process creation, preserving an actionable launch failure. The boundary control already returns a failure whose cause says LaunchSupport may not override os.arch or os.name for a literal override. Ordinary launch admission should remain available.

Analysis or suspected cause

Source-derived reachability: caller-inherited option variables are cleared; the POSIX helper can populate the empty JDK_JAVA_OPTIONS slot afterward. In a Ghidra installation, the corresponding input is ENVVARS=JDK_JAVA_OPTIONS=@<absolute-options-file> in support/launch.properties. Ghidra 12.1.4 LaunchProperties.getEnvVarList accepts this value and LaunchSupport.handleEnvVars prints it. REA checks the token rather than the effective properties Java reads from the file. This real-provider configuration is source-derived, not an executed reproduction. The report leaves the validation strategy open.

Additional context

Related but different: #2785 and #2792 concern a mismatch in the inspected JDK architecture, #1698 concerns helper warning output, and #1255 concerns resource-control documentation. Seven searches across open and closed issues and PRs found no matching report. The fetched upstream main at ce5bc8cb01 had no changes to the cited launch implementation. Windows uses a different final launch environment and is outside the demonstrated scope. NOT_ENOUGH_INFO: no full real-Ghidra analysis was run, so this report does not establish a particular downstream decompiler error or frequency in installed-provider workflows.

Originally created by @coygeek on GitHub (Oct 22, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6212 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior At the launcher boundary, a helper-produced `JDK_JAVA_OPTIONS=@platform.options` whose file contains `-Dos.arch=rea_argfile_probe` reached the process-creation callback. A real JDK probe using the callback's environment exited 0 and reported `os.arch = rea_argfile_probe`. The equivalent literal `JDK_JAVA_OPTIONS=-Dos.arch=rea_argfile_probe` was rejected before that callback. With no helper override, the JDK exited 0 and reported `os.arch = aarch64`. This was reproduced through the production headless-launcher boundary using controlled LaunchSupport output and a real JDK. No Ghidra analysis, bridge, target import, or native-decompiler execution was performed. ### Reproduction This production-boundary reproduction uses the real headless launcher, a controlled executable that supplies LaunchSupport-shaped output, and a real JDK for property evaluation. It intentionally stops at process creation after the property probe; it does not start Ghidra or import a target. The final fixture reproduced the admission difference once, with three arms: indirect override accepted, literal override rejected, and empty-options control retaining the host architecture. Two earlier attempts failed due to an escaping error in the test's generated helper, which was corrected before the successful run. Save the following complete fixture as `tests/boundary/providers/ghidra/jvmArgfile.test.ts`. The temporary filename used during verification has been normalized here; the fixture content is unchanged. Run from the source checkout with existing dependencies. The command below preserves the invocation used, with local scratch and JDK paths replaced by variables. ```ts import { expect, it } from "vitest"; import { mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { GhidraHeadlessLauncher, type GhidraLaunchSession } from "../../../../src/ghidra/GhidraLauncher.js"; it("compares indirect and literal helper platform overrides at the launcher boundary", async () => { const root = process.env.REA_VERIFY_SCRATCH!; const java = process.env.REA_VERIFY_JAVA!; const optionsFile = join(root, "platform.options"); await writeFile(optionsFile, "-Dos.arch=rea_argfile_probe\n"); const results: unknown[] = []; for (const value of ["@" + optionsFile, "-Dos.arch=rea_argfile_probe", ""]) { const caseRoot = join(root, "case-" + results.length); const javaHome = join(caseRoot, "jdk"); await mkdir(join(javaHome, "bin"), { recursive: true }); await writeFile(join(javaHome, "bin", "java"), `#!/usr/bin/env node const phase = process.argv.at(-1); if (phase === "-envvars") process.stdout.write(${JSON.stringify(value === "" ? "" : "JDK_JAVA_OPTIONS=" + value + "\n")}); else if (phase === "-vmargs") process.stdout.write(${JSON.stringify("-Dfile.encoding=UTF8\n")}); else process.exit(64); `, { mode: 0o755 }); let observed: { status: number | null; architecture: string | undefined } | undefined; const launcher = new GhidraHeadlessLauncher({ platform: process.platform, environment: { PATH: process.env.PATH ?? "" }, javaHome, javaVmName: "OpenJDK 64-Bit Server VM", analyzeHeadlessPath: join(caseRoot, "ghidra", "support", "analyzeHeadless"), configurationDirectoryName: "ghidra_12.1.4_PUBLIC", bridgeScriptPath: join(caseRoot, "ReaGhidraBridge.java"), spawnProcess: async (options) => { const probe = spawnSync(java, ["-XshowSettings:properties", "-version"], { env: options.env, encoding: "utf8" }); observed = { status: probe.status, architecture: /os\.arch = ([^\r\n]+)/u.exec(probe.stderr)?.[1] }; throw new Error("Probe completed; no analysis JVM started"); }, }); const session: GhidraLaunchSession = { runtimeRoot: join(caseRoot, "runtime"), transport: "unix-socket", endpointPath: join(caseRoot, "endpoint.sock"), token: "fixture-token", runId: "fixture-run", targetPath: join(caseRoot, "target.bin"), targetSha256: "0".repeat(64), providerVersion: "test", profileDigest: "test", }; const launched = await launcher.launch(session); if (value.startsWith("@")) expect(observed).toEqual({ status: 0, architecture: "rea_argfile_probe" }); else if (value.startsWith("-D")) { expect(observed).toBeUndefined(); expect(launched.ok).toBe(false); if (!launched.ok) expect(launched.error.cause).toMatchObject({ message: expect.stringContaining("may not override os.arch or os.name") }); } else { expect(observed?.status).toBe(0); expect(observed?.architecture).toBe("aarch64"); } results.push({ input: value.startsWith("@") ? "@platform.options" : value || "control", observed: observed ?? "rejected before spawn" }); await rm(caseRoot, { recursive: true, force: true }); } console.log(JSON.stringify(results)); }); ``` ```sh proof_root=$(mktemp -d) mkdir -p "$proof_root/home" "$proof_root/dsh" "$proof_root/config" "$proof_root/cache" # Set JAVA_HOME to the installed full JDK 21 home before running this command. HOME="$proof_root/home" DSH_HOME="$proof_root/dsh" \ XDG_CONFIG_HOME="$proof_root/config" XDG_CACHE_HOME="$proof_root/cache" \ REA_VERIFY_SCRATCH="$proof_root" REA_VERIFY_JAVA="$JAVA_HOME/bin/java" \ node node_modules/vitest/vitest.mjs run tests/boundary/providers/ghidra/jvmArgfile.test.ts --maxWorkers=1 ``` ### Expected behavior The installation guide promises that LaunchSupport overrides of `os.arch` or `os.name`, including helper-environment JVM option variables, are rejected at launch because native-decompiler admission is tied to the inspected host/JVM platform. An indirect property override should not evade that same check. Ordinary launches should continue to work. ### Impact and scope The demonstrated defect is a POSIX platform-admission bypass in user-managed Ghidra configuration. It can invalidate the platform facts REA uses to admit native analysis; downstream native-decompiler selection and a full analysis failure were not exercised. Remove the platform override from the helper option file to preserve the host platform. This is not a demonstrated security boundary crossing: the operator controls the local configuration, and REA's security policy does not sandbox user-managed Java/Ghidra or protect against malicious processes running as the same user. ### REA version Source commit c465ff5a814b8e1a386fc547cbcd94df5c35f086 (main) ### Runtime and platform Node v26.12.0; macOS 27.0.1 arm64; Homebrew OpenJDK 21.0.12.1 (build 21.0.12.1). The repository pins Node 24.18; the test ran on the stated installed Node. Ghidra 12.1.4 helper configuration was source-inspected, not executed. ### Target and artifact context Not applicable to the demonstrated launch-preparation failure: the fixture uses a synthetic session and performs no target import. ### Interface Other ### Earliest failure stage Setup or provider admission ### Relevant output or evidence The successful boundary-test run produced: ```json [ {"input":"@platform.options","observed":{"status":0,"architecture":"rea_argfile_probe"}}, {"input":"-Dos.arch=rea_argfile_probe","observed":"rejected before spawn"}, {"input":"control","observed":{"status":0,"architecture":"aarch64"}} ] ``` Contract: [installation guide](https://github.com/morluto/rea/blob/c465ff5a814b8e1a386fc547cbcd94df5c35f086/docs/installation.md#L921). Source-derived cause: [the literal-property guard](https://github.com/morluto/rea/blob/c465ff5a814b8e1a386fc547cbcd94df5c35f086/src/ghidra/GhidraJavaLaunch.ts#L67) and [helper environment merge/validation](https://github.com/morluto/rea/blob/c465ff5a814b8e1a386fc547cbcd94df5c35f086/src/ghidra/GhidraJavaLaunch.ts#L142) do not expand the `@file` token. [POSIX launch selection and process creation](https://github.com/morluto/rea/blob/c465ff5a814b8e1a386fc547cbcd94df5c35f086/src/ghidra/GhidraLauncher.ts#L454) forward this environment. Oracle's [JDK 21 java command documentation](https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html#using-the-jdk_java_options-launcher-environment-variable) supports argument files in `JDK_JAVA_OPTIONS`. A separate real-JDK matrix confirmed this expansion; `JAVA_TOOL_OPTIONS` and `_JAVA_OPTIONS` each exited 1 with an unrecognized-option error for the same `@file` token. This report therefore applies to `JDK_JAVA_OPTIONS` only. ### Typed outcome or recovery expected Reject the effective platform override before analysis process creation, preserving an actionable launch failure. The boundary control already returns a failure whose cause says LaunchSupport may not override `os.arch` or `os.name` for a literal override. Ordinary launch admission should remain available. ### Analysis or suspected cause Source-derived reachability: caller-inherited option variables are cleared; the POSIX helper can populate the empty `JDK_JAVA_OPTIONS` slot afterward. In a Ghidra installation, the corresponding input is `ENVVARS=JDK_JAVA_OPTIONS=@<absolute-options-file>` in `support/launch.properties`. Ghidra 12.1.4 [LaunchProperties.getEnvVarList](https://github.com/NationalSecurityAgency/ghidra/blob/Ghidra_12.1.4_build/GhidraBuild/LaunchSupport/src/main/java/ghidra/launch/LaunchProperties.java) accepts this value and [LaunchSupport.handleEnvVars](https://github.com/NationalSecurityAgency/ghidra/blob/Ghidra_12.1.4_build/GhidraBuild/LaunchSupport/src/main/java/LaunchSupport.java) prints it. REA checks the token rather than the effective properties Java reads from the file. This real-provider configuration is source-derived, not an executed reproduction. The report leaves the validation strategy open. ### Additional context Related but different: #2785 and #2792 concern a mismatch in the inspected JDK architecture, #1698 concerns helper warning output, and #1255 concerns resource-control documentation. Seven searches across open and closed issues and PRs found no matching report. The fetched upstream main at ce5bc8cb0149c230e571126e2e7d9f43c86a823d had no changes to the cited launch implementation. Windows uses a different final launch environment and is outside the demonstrated scope. NOT_ENOUGH_INFO: no full real-Ghidra analysis was run, so this report does not establish a particular downstream decompiler error or frequency in installed-provider workflows.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3783
No description provided.