fix(ghidra): report restricted-host temp write denial on Windows (#8889)

* fix(ghidra): report restricted-host temp write denial on Windows

Add a Windows doctor check that creates and deletes a file under os.tmpdir(), and classify a denied java_home.save in Ghidra startup output with its path, Win32 error 5, and TMP/TEMP remediation. The probe covers REA's own process only; a host that restricts only child processes is reported from the child's output at startup.

Fixes #7075

* fix(ghidra): probe the session root and retain failed probe cleanup

The Windows doctor temp_directory probe now writes under ghidraSessionRoot(), the same root Ghidra session runtimes use, instead of os.tmpdir(), which can differ when the inherited temp path has a dot-prefixed element. A probe file whose removal fails is retained on the doctor resource owner so doctor shutdown retries the removal; the result still reports its path.
This commit is contained in:
Beqa authored and GitHub committed 2026-11-05 17:52:25 +08:00
1 parent c7fce66035
commit 08a923f722
7 files changed
+507 -16

No files matched your search

+27
View File
@@ -224,6 +224,33 @@ launch paths are unsupported. JVM home and temporary properties use quoted
`JDK_JAVA_OPTIONS`, preserving spaces. `APPDATA` and `LOCALAPPDATA` point into
the private runtime, isolating Ghidra preferences, logs, and caches.
### Restricted hosts
Each session's private runtime root is created under the Ghidra session root:
`os.tmpdir()`, which follows `TMP`/`TEMP` on Windows, or, when that path has a
dot-prefixed element that Ghidra rejects, its nearest ancestor without one. Some
sandboxed agent hosts deny writes by child processes such as the JVM outside an
allowed tree even when REA itself can write there. Ghidra's LaunchSupport then
cannot create `java_home.save` and the provider exits at startup with
`java.io.FileNotFoundException: ...\java_home.save (Access is denied)`.
When startup output names `java_home.save` with that denial, REA reports the
path, Win32 error 5, and this remediation without attributing the denial to
antivirus or REA's directory ACL: set `TMP` and `TEMP` to a directory inside the
host's allowed tree, with no dot-prefixed path element, in the environment that
starts REA (your shell or the MCP client's registration), restart REA, and
retry. Creating `java_home.save` in advance does not help because LaunchSupport
rewrites it.
On Windows, `rea doctor` also reports a `temp_directory` check under the Ghidra
provider. It creates and deletes one file under that same session root from the
REA process and fails the provider when either step fails. If the file cannot
be removed, doctor reports its path and retries removal when it finishes. A
passing result does not test writes by Ghidra's child JVM, so a host that
restricts only child processes can still pass doctor and fail at startup with
the message above.
The startup-output match is limited to the English `Access is denied` text.
Process creation assigns a suspended child atomically to an owned Job Object.
Membership and kill-on-owner-close policy are verified before resume.
Breakaway is disabled. Only the child's default file owner is normalized;
+90 -1
View File
@@ -14,7 +14,11 @@ import {
type GhidraInstallationHost,
type GhidraInstallationInspection,
} from "./GhidraInstallation.js";
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
import {
inspectSystemGhidraProvider,
projectGhidraDoctorInspection,
} from "./GhidraDoctor.js";
import { GHIDRA_RESTRICTED_TEMP_REMEDIATION } from "./GhidraTemporaryDirectory.js";
let inspectionResources = createInspectionResources();
afterEach(async () => {
@@ -396,3 +400,88 @@ describe("Windows native doctor failure diagnostics", () => {
);
});
});
describe("Windows Ghidra temporary directory check", () => {
const WINDOWS_INSTALL = "C:\\tools\\ghidra_12.1.4_PUBLIC";
it("fails the provider closed and keeps restricted-host remediation when the probe fails", async () => {
const projected = projectGhidraDoctorInspection(
await inspection({ installDir: WINDOWS_INSTALL, platform: "win32" }),
{
ok: false,
directory: "C:\\sandbox\\tmp",
stage: "write",
code: "EPERM",
message: "EPERM: operation not permitted",
},
);
expect(projected.available).toBe(false);
const check = projected.checks.find(
({ name }) => name === "temp_directory",
);
expect(check).toMatchObject({
ok: false,
code: "temp_directory_unwritable",
classification: "runtime_failure",
remediation: GHIDRA_RESTRICTED_TEMP_REMEDIATION,
details: { directory: "C:\\sandbox\\tmp", system_code: "EPERM" },
});
expect(check?.detail).toContain("C:\\sandbox\\tmp");
expect(check?.detail).not.toMatch(/antivirus|DACL/iu);
});
it("reports a retained probe file whose cleanup doctor retries", async () => {
const projected = projectGhidraDoctorInspection(
await inspection({ installDir: WINDOWS_INSTALL, platform: "win32" }),
{
ok: false,
directory: "C:\\work",
stage: "cleanup",
code: "EPERM",
message: "EPERM: operation not permitted",
retainedPath: "C:\\work\\rea-doctor-probe-1",
},
);
const check = projected.checks.find(
({ name }) => name === "temp_directory",
);
expect(check).toMatchObject({
details: {
directory: "C:\\work",
stage: "cleanup",
retained_path: "C:\\work\\rea-doctor-probe-1",
},
});
expect(check?.detail).toContain("retries removal when doctor finishes");
});
it("reports a passing probe without claiming child-process access", async () => {
const projected = projectGhidraDoctorInspection(
await inspection({ installDir: WINDOWS_INSTALL, platform: "win32" }),
{ ok: true, directory: "C:\\Temp" },
);
expect(projected.checks).toContainEqual(
expect.objectContaining({
name: "temp_directory",
ok: true,
detail: expect.stringContaining(
"does not test writes by Ghidra's child JVM",
),
}),
);
});
it("does not probe when no Windows installation was selected", async () => {
const probe = vi.fn(() => Promise.reject(new Error("unexpected probe")));
const projected = await inspectSystemGhidraProvider(
{},
inspectionResources.owner,
undefined,
probe,
);
expect(probe).not.toHaveBeenCalled();
expect(projected.checks.map(({ name }) => name)).not.toContain(
"temp_directory",
);
});
});
+63 -14
View File
@@ -11,21 +11,30 @@ import {
type GhidraInstallationCheck,
type GhidraInstallationInspection,
} from "./GhidraInstallation.js";
import {
GHIDRA_RESTRICTED_TEMP_REMEDIATION,
probeGhidraTemporaryDirectory,
type GhidraTemporaryDirectoryProbe,
} from "./GhidraTemporaryDirectory.js";
/** Project installation and runtime-authority facts into the doctor contract. */
export const projectGhidraDoctorInspection = (
inspection: GhidraInstallationInspection,
temporaryDirectory?: GhidraTemporaryDirectoryProbe,
): DoctorProviderInspection => {
const readiness =
inspection.status === "available" && inspection.platform === "win32"
? inspectWindowsNativeReadiness(inspection.platform)
: undefined;
const windowsAuthorityUnavailable = readiness?.available === false;
const temporaryDirectoryUnavailable = temporaryDirectory?.ok === false;
return {
id: "ghidra",
configured: inspection.installDir !== null,
available:
inspection.status === "available" && !windowsAuthorityUnavailable,
inspection.status === "available" &&
!windowsAuthorityUnavailable &&
!temporaryDirectoryUnavailable,
providerVersion: inspection.providerVersion,
registrationEnvironment:
inspection.status === "available"
@@ -62,30 +71,70 @@ export const projectGhidraDoctorInspection = (
},
]
: []),
...(temporaryDirectory === undefined
? []
: [temporaryDirectoryCheck(temporaryDirectory)]),
],
};
};
/** Report the REA-process write probe without claiming child-process access. */
const temporaryDirectoryCheck = (
probe: GhidraTemporaryDirectoryProbe,
): DoctorProviderCheck =>
probe.ok
? {
name: "temp_directory",
ok: true,
code: null,
detail: `REA created and deleted a file under the Ghidra session root ${probe.directory}, where session runtimes are created. This does not test writes by Ghidra's child JVM.`,
remediation: null,
classification: "runtime_failure",
}
: {
name: "temp_directory",
ok: false,
code: "temp_directory_unwritable",
detail: `REA could not ${probe.stage === "write" ? "write" : "delete"} a probe file under the Ghidra session root ${probe.directory}, where session runtimes are created${probe.code === undefined ? "" : ` (${probe.code})`}: ${probe.message}${probe.retainedPath === undefined ? "" : `. The probe file ${probe.retainedPath} remains; REA retains its cleanup and retries removal when doctor finishes.`}`,
remediation: GHIDRA_RESTRICTED_TEMP_REMEDIATION,
classification: "runtime_failure",
details: {
directory: probe.directory,
stage: probe.stage,
...(probe.code === undefined ? {} : { system_code: probe.code }),
...(probe.retainedPath === undefined
? {}
: { retained_path: probe.retainedPath }),
},
};
/** Inspect the process-configured BYO Ghidra installation for `rea doctor`. */
export const inspectSystemGhidraProvider = async (
environment: Readonly<NodeJS.ProcessEnv>,
resourceOwner: RetainedResourceScope,
signal?: AbortSignal,
probeTemporaryDirectory: (
resourceOwner: RetainedResourceScope,
) => Promise<GhidraTemporaryDirectoryProbe> = probeGhidraTemporaryDirectory,
): Promise<DoctorProviderInspection> => {
try {
return projectGhidraDoctorInspection(
await inspectGhidraInstallation({
environment,
resourceOwner,
...(signal === undefined ? {} : { signal }),
...(environment.GHIDRA_INSTALL_DIR === undefined
? {}
: { installDir: environment.GHIDRA_INSTALL_DIR }),
...(environment.JAVA_HOME === undefined
? {}
: { javaHome: environment.JAVA_HOME }),
}),
);
const installation = await inspectGhidraInstallation({
environment,
resourceOwner,
...(signal === undefined ? {} : { signal }),
...(environment.GHIDRA_INSTALL_DIR === undefined
? {}
: { installDir: environment.GHIDRA_INSTALL_DIR }),
...(environment.JAVA_HOME === undefined
? {}
: { javaHome: environment.JAVA_HOME }),
});
// The restricted-host denial reported for #7075 is Windows-specific.
const temporaryDirectory =
installation.status === "available" && installation.platform === "win32"
? await probeTemporaryDirectory(resourceOwner)
: undefined;
return projectGhidraDoctorInspection(installation, temporaryDirectory);
} catch (cause: unknown) {
throw analysisClientFailureForOperation(cause, "ghidra", "doctor");
}
+31 -1
View File
@@ -66,6 +66,7 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { parseExecutableHeader } from "../domain/binaryTarget.js";
import { elf, thinMach } from "../domain/binaryTarget.fixture.js";
import { GhidraProvider } from "./GhidraProvider.js";
import { GHIDRA_RESTRICTED_TEMP_REMEDIATION } from "./GhidraTemporaryDirectory.js";
import type { GhidraTargetSnapshot } from "./GhidraClient.js";
import {
GHIDRA_ANNOTATIONS_RETAINED,
@@ -4327,6 +4328,7 @@ describe("Ghidra missing project program", () => {
const startFailure = async (
stdout: string,
environment: Readonly<Record<string, string>> = {},
stderr = "",
) => {
const root = await mkdtemp(join(tmpdir(), "rea-missing-program-"));
onTestFinished(async () => {
@@ -4340,7 +4342,7 @@ describe("Ghidra missing project program", () => {
{
failure_kind: "start",
exit_code: 1,
failure_output: failureOutput(stdout),
failure_output: failureOutput(stdout, stderr),
},
);
const ghidra = provider(
@@ -4416,6 +4418,34 @@ describe("Ghidra missing project program", () => {
const projected = await startFailure("ERROR Unable to lock project");
expect(projected.code).toBe("execution_failure");
});
it("reports a denied java_home.save with its path and restricted-host remediation", async () => {
const path =
"C:\\Users\\a\\AppData\\Local\\Temp\\rea-provider-1\\config\\ghidra\\ghidra_12.1.4_PUBLIC\\java_home.save";
const projected = await startFailure(
"",
{},
`java.io.FileNotFoundException: ${path} (Access is denied)\n`,
);
expect(projected.code).toBe("execution_failure");
expect(projected.message).toContain(path);
expect(projected.message).toContain("Win32 error 5");
expect(projected.message).not.toMatch(/antivirus|DACL/iu);
expect(projected.remediation.action).toBe(
GHIDRA_RESTRICTED_TEMP_REMEDIATION,
);
});
it("keeps the generic start failure for other denied paths", async () => {
const projected = await startFailure(
"",
{},
"java.io.FileNotFoundException: C:\\x\\other.txt (Access is denied)\n",
);
expect(projected.remediation.action).not.toBe(
GHIDRA_RESTRICTED_TEMP_REMEDIATION,
);
});
});
describe("Ghidra changed project", () => {
+14
View File
@@ -10,6 +10,10 @@ import { artifactFilePrimaryFacts } from "../artifacts/ArtifactFileOperation.js"
import { fileURLToPath } from "node:url";
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import {
GHIDRA_RESTRICTED_TEMP_REMEDIATION,
ghidraJavaHomeSaveDenial,
} from "./GhidraTemporaryDirectory.js";
import {
createAnalysisExecution,
@@ -1488,6 +1492,16 @@ const projectPrimarySessionError = (
limits.ghidraExistingProject?.programName !== undefined,
context,
);
const javaHomeSave =
output === undefined ? undefined : ghidraJavaHomeSaveDenial(output.text);
if (javaHomeSave !== undefined)
return new ProviderAdapterError("ghidra", operation, {
...context,
userMessage: `Ghidra's launcher could not open ${javaHomeSave.path} ("Access is denied", Win32 error 5), so Ghidra exited before its bridge started. The output does not establish why the host denied the write.${
uncertainty === undefined ? "" : ` ${uncertainty}`
}`,
remediationAction: GHIDRA_RESTRICTED_TEMP_REMEDIATION,
});
const killed = projectSigkill(operation, failure, context, {
restart: exitedProviderMessage(operation, failure, health),
uncertainty,
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { tmpdir } from "node:os";
import { dirname, resolve } from "node:path";
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
import {
ghidraJavaHomeSaveDenial,
probeGhidraTemporaryDirectory,
type GhidraTemporaryDirectoryHost,
} from "./GhidraTemporaryDirectory.js";
import { ghidraSessionRoot } from "./GhidraSessionRoot.js";
const denied = (code: string): NodeJS.ErrnoException =>
Object.assign(new Error(`${code}: operation not permitted`), { code });
let resources = createInspectionResources();
afterEach(async () => {
await resources.close().catch(() => undefined);
resources = createInspectionResources();
});
const host = (
overrides: Partial<GhidraTemporaryDirectoryHost> = {},
): GhidraTemporaryDirectoryHost => ({
sessionRoot: () => "C:\\sandbox\\tmp",
writeProbe: () => Promise.resolve(),
removeProbe: () => Promise.resolve(),
...overrides,
});
describe("Ghidra temporary directory probe", () => {
it("reports success only after the probe file is written and removed", async () => {
const removeProbe = vi.fn(() => Promise.resolve());
await expect(
probeGhidraTemporaryDirectory(resources.owner, host({ removeProbe })),
).resolves.toEqual({ ok: true, directory: "C:\\sandbox\\tmp" });
expect(removeProbe).toHaveBeenCalledTimes(1);
});
it("fails closed with the system code when the write is denied", async () => {
const removeProbe = vi.fn(() => Promise.reject(denied("ENOENT")));
const result = await probeGhidraTemporaryDirectory(
resources.owner,
host({ writeProbe: () => Promise.reject(denied("EPERM")), removeProbe }),
);
expect(result).toMatchObject({
ok: false,
directory: "C:\\sandbox\\tmp",
stage: "write",
code: "EPERM",
});
expect(result).not.toHaveProperty("retainedPath");
});
it("probes the Ghidra session root rather than an unusable inherited temp", async () => {
const writeProbe = vi.fn((_path: string) => Promise.resolve());
const names = ["TMPDIR", "TMP", "TEMP"] as const;
const previous = names.map((name) => process.env[name]);
const dotted = resolve("work", ".cache", "tmp");
for (const name of names) process.env[name] = dotted;
let expected: string;
try {
expected = ghidraSessionRoot();
expect(resolve(tmpdir())).toBe(dotted);
expect(expected).not.toBe(dotted);
await expect(
probeGhidraTemporaryDirectory(resources.owner, {
writeProbe,
removeProbe: () => Promise.resolve(),
}),
).resolves.toEqual({ ok: true, directory: expected });
} finally {
names.forEach((name, index) => {
const value = previous[index];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
});
}
expect(dirname(writeProbe.mock.calls[0]?.[0] ?? "")).toBe(expected);
});
it.each([
["a failed write", "write", () => Promise.reject(denied("EIO"))],
["a written probe", "cleanup", () => Promise.resolve()],
] as const)(
"retains cleanup ownership of %s whose removal fails and retries it on close",
async (_label, stage, writeProbe) => {
const removeProbe = vi
.fn<(path: string) => Promise<void>>()
.mockRejectedValueOnce(denied("EPERM"))
.mockRejectedValueOnce(denied("EPERM"))
.mockResolvedValue(undefined);
const result = await probeGhidraTemporaryDirectory(
resources.owner,
host({ writeProbe, removeProbe }),
);
expect(result).toMatchObject({
ok: false,
stage,
retainedPath: expect.stringContaining("rea-doctor-probe-"),
});
const retainedPath = result.ok ? undefined : result.retainedPath;
const callsBeforeClose = removeProbe.mock.calls.length;
await expect(resources.close()).rejects.toThrow();
await expect(resources.close()).resolves.toBeUndefined();
expect(removeProbe.mock.calls.slice(callsBeforeClose)).toEqual([
[retainedPath],
[retainedPath],
]);
},
);
it("does not retain cleanup when the probe was removed", async () => {
const removeProbe = vi.fn(() => Promise.resolve());
await probeGhidraTemporaryDirectory(resources.owner, host({ removeProbe }));
await resources.close();
expect(removeProbe).toHaveBeenCalledTimes(1);
});
});
describe("Ghidra java_home.save denial", () => {
it("extracts the denied path from JVM output", () => {
expect(
ghidraJavaHomeSaveDenial([
"INFO starting\r\njava.io.FileNotFoundException: C:\\Users\\a b\\Temp\\rea-provider-1\\config\\ghidra\\ghidra_12.1.4_PUBLIC\\java_home.save (Access is denied)\r\n\tat java.base/java.io.FileOutputStream.open0(Native Method)",
]),
).toEqual({
path: "C:\\Users\\a b\\Temp\\rea-provider-1\\config\\ghidra\\ghidra_12.1.4_PUBLIC\\java_home.save",
});
});
it.each([
[
"another denied path",
"java.io.FileNotFoundException: C:\\x\\other.txt (Access is denied)",
],
[
"a different failure for the file",
"java.io.FileNotFoundException: C:\\x\\java_home.save (The system cannot find the path specified)",
],
["only a mention", "Reading java_home.save failed"],
["no output", ""],
])("does not classify %s", (_label, text) => {
expect(ghidraJavaHomeSaveDenial([text])).toBeUndefined();
});
});
+135
View File
@@ -0,0 +1,135 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { open, unlink } from "node:fs/promises";
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
import { ghidraSessionRoot } from "./GhidraSessionRoot.js";
/**
* Remediation for a host that denies writes under the session temporary root.
* It states only what REA observed; it does not name an antivirus product or
* REA's own private-directory ACL as the cause.
*/
export const GHIDRA_RESTRICTED_TEMP_REMEDIATION =
"REA places each Ghidra session's private runtime under the Ghidra session root: os.tmpdir(), which follows TMP/TEMP on Windows, or, when that path has a dot-prefixed element that Ghidra rejects, its nearest ancestor without one. On a restricted or sandboxed host that denies child-process writes outside an allowed tree, set TMP and TEMP to a directory inside that allowed tree whose path has no dot-prefixed element, in the environment that starts REA (your shell or the MCP client's registration), restart REA, and retry. Ghidra's LaunchSupport rewrites java_home.save, so creating that file in advance does not help.";
/** Outcome of one write-and-delete probe of the session temporary root. */
export type GhidraTemporaryDirectoryProbe =
| { readonly ok: true; readonly directory: string }
| {
readonly ok: false;
readonly directory: string;
/** Whether creating/writing the file or removing it failed. */
readonly stage: "write" | "cleanup";
/** Node system code such as EACCES or EPERM, when reported. */
readonly code: string | undefined;
readonly message: string;
/** Probe file whose removal failed; its cleanup owner is retained. */
readonly retainedPath?: string;
};
/** Filesystem seam so classification is testable without a restricted host. */
export interface GhidraTemporaryDirectoryHost {
readonly sessionRoot: () => string;
readonly writeProbe: (path: string) => Promise<void>;
readonly removeProbe: (path: string) => Promise<void>;
}
const systemHost: GhidraTemporaryDirectoryHost = {
sessionRoot: () => ghidraSessionRoot(),
writeProbe: async (path) => {
const handle = await open(path, "wx", 0o600);
try {
await handle.writeFile("rea doctor probe\n");
} finally {
await handle.close();
}
},
removeProbe: unlink,
};
const errorCode = (cause: unknown): string | undefined =>
cause instanceof Error && "code" in cause && typeof cause.code === "string"
? cause.code
: undefined;
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
/**
* Create and delete one file under the Ghidra session root from the REA
* process. This proves only that REA itself can use the root; a host that
* restricts child processes separately is not detected here and is classified
* from the child's own failure output instead. A probe file that cannot be
* removed stays owned by `resourceOwner`, whose close retries the removal.
*/
export const probeGhidraTemporaryDirectory = async (
resourceOwner: RetainedResourceScope,
overrides: Partial<GhidraTemporaryDirectoryHost> = {},
): Promise<GhidraTemporaryDirectoryProbe> => {
const host = { ...systemHost, ...overrides };
const directory = host.sessionRoot();
const path = join(directory, `rea-doctor-probe-${randomUUID()}`);
const remove = () =>
host.removeProbe(path).catch((cause: unknown) => {
if (errorCode(cause) !== "ENOENT") throw cause;
});
const retain = () =>
resourceOwner.retain({ close: remove, cleanupResources: [path] }, path);
try {
await host.writeProbe(path);
} catch (cause: unknown) {
// A failed write may still have created the entry; try to remove it.
const removed = await remove().then(
() => true,
() => false,
);
if (!removed) retain();
return {
ok: false,
directory,
stage: "write",
code: errorCode(cause),
message: errorMessage(cause),
...(removed ? {} : { retainedPath: path }),
};
}
try {
await host.removeProbe(path);
} catch (cause: unknown) {
retain();
return {
ok: false,
directory,
stage: "cleanup",
code: errorCode(cause),
message: errorMessage(cause),
retainedPath: path,
};
}
return { ok: true, directory };
};
/** A denied `java_home.save` write reported in Ghidra launcher output. */
export interface GhidraJavaHomeSaveDenial {
readonly path: string;
}
const JAVA_HOME_SAVE_DENIED =
/FileNotFoundException:\s*(.*?java_home\.save)\s*\(Access is denied\)/iu;
/**
* Recognize the JVM's access-denied refusal to open `java_home.save` (Win32
* error 5). Only a line naming that file and the denial counts; other output,
* including other denied paths, is not classified.
*/
export const ghidraJavaHomeSaveDenial = (
text: readonly string[],
): GhidraJavaHomeSaveDenial | undefined => {
for (const block of text)
for (const line of block.split(/\r?\n/u)) {
const path = JAVA_HOME_SAVE_DENIED.exec(line)?.[1]?.trim();
if (path !== undefined && path !== "") return { path };
}
return undefined;
};