fix(ghidra): reject a native decompiler built for another host (#1280)

Reject positively identified foreign-only Ghidra decompiler binaries and non-regular native prerequisites before provider startup, while retaining unknown classifications for incomplete or unrecognized headers.

Verified with 54 focused tests and real Linux Ghidra CLI/MCP admission, decompilation, cleanup, wrong-host PE rejection, and non-blocking FIFO rejection. All 19 final-head checks passed.

Fixes #1192

Co-authored-by: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com>
This commit is contained in:
Dery Ferdika 2026-10-10 03:52:25 +07:00 committed by GitHub
parent f39e71ce18
commit 3728c8267b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 356 additions and 13 deletions

View File

@ -472,8 +472,11 @@ and JDK 21. Each installation must include the native decompiler for the host
architecture in `Ghidra/Features/Decompiler/os/<platform>/` or the corresponding
`build/os/<platform>/` directory. Linux ARM64 uses `linux_arm_64`; official
release archives may require you to build that native component separately.
REA checks the executable prerequisite and does not build or install native
tools, or change Gatekeeper quarantine settings.
REA checks the executable prerequisite by inspecting the file's own executable
header, so a native component built for another platform or architecture is
reported as incompatible instead of being admitted from its directory name, and
a header that cannot be read or recognized is reported as unknown. REA does not
build or install native tools, or change Gatekeeper quarantine settings.
The adapter exposes 25 read-only operations: thirteen inventory/name/search
operations and twelve function-analysis operations. These cover metadata,

View File

@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js";
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
import {
ghidraJavaEnvironment,
@ -510,3 +511,171 @@ describe("Ghidra configuration paths", () => {
);
});
});
describe("Ghidra FAT native decompiler admission", () => {
it.each([
{
name: "foreign-only FAT",
bytes: fat([0x01000007]),
status: "unavailable",
detail: "requires mach-o arm64",
},
{
name: "matching universal FAT",
bytes: fat([0x01000007, 0x0100000c]),
status: "available",
detail: "matches darwin/arm64",
},
{
name: "truncated FAT table",
bytes: fat([0x01000007], 2),
status: "available",
detail: "could not be established",
},
])("distinguishes $name", ({ bytes, status, detail }) => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({ executableHeader: () => ({ bytes, size: bytes.length }) }),
);
expect(result.status).toBe(status);
expect(
result.checks.find(({ name }) => name === "native_decompiler")?.detail,
).toContain(detail);
});
it("keeps a truncated host slice unknown despite a readable foreign slice", () => {
const bytes = fat([0x01000007, 0x0100000c]);
bytes.writeUInt32BE(bytes.length + 1, 8 + 20 + 12);
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({ executableHeader: () => ({ bytes, size: bytes.length }) }),
);
expect(result.status).toBe("available");
expect(
result.checks.find(({ name }) => name === "native_decompiler")?.detail,
).toContain("could not be established");
});
});
describe("Ghidra native decompiler admission", () => {
it.each([
{
name: "a Linux x86-64 decompiler at the macOS arm64 path",
options: {
environment: {},
installDir: INSTALL,
platform: "darwin" as const,
architecture: "arm64" as const,
},
executableHeader: (path: string) =>
path === MAC_DECOMPILER
? { bytes: elf(2, 1, 62), size: 52 }
: undefined,
},
{
name: "a macOS arm64 decompiler at the Linux x64 path",
options: {
environment: {},
installDir: INSTALL,
platform: "linux" as const,
architecture: "x64" as const,
},
executableHeader: (path: string) =>
path === LINUX_DECOMPILER
? { bytes: thinMach(0xcffaedfe, 0x0100000c), size: 32 }
: undefined,
},
])("distinguishes $name", ({ options, executableHeader }) => {
const result = inspectGhidraInstallation(
options,
host({ executableHeader }),
);
expect(result.status).toBe("unavailable");
expect(
result.checks.find(({ status }) => status === "failed"),
).toMatchObject({ name: "native_decompiler", code: "executable_missing" });
});
it("explains an accepted decompiler by its header, not only its path", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "linux",
architecture: "x64",
},
host({
executableHeader: (path: string) =>
path === LINUX_DECOMPILER
? { bytes: elf(2, 1, 62), size: 52 }
: undefined,
}),
);
expect(result.status).toBe("available");
if (result.status !== "available") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "passed",
detail: `${LINUX_DECOMPILER} reports elf x86_64, which matches linux/x64`,
});
});
it("keeps an unreadable header unknown instead of failing the install", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "linux",
architecture: "x64",
},
host({ executableHeader: () => undefined }),
);
expect(result.status).toBe("available");
if (result.status !== "available") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "passed",
detail: expect.stringContaining("could not be established"),
});
});
it("names the observed and the required executable in the failure", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
executableHeader: (path: string) =>
path === MAC_DECOMPILER
? { bytes: elf(2, 1, 62), size: 52 }
: undefined,
}),
);
expect(result.status).toBe("unavailable");
if (result.status !== "unavailable") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
detail: expect.stringMatching(
/reports elf x86_64, but darwin\/arm64 requires mach-o arm64/u,
),
remediation: expect.stringContaining("os/mac_arm_64/decompile"),
});
});
});

View File

@ -1,10 +1,23 @@
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { spawnSync } from "node:child_process";
import { constants } from "node:fs";
import { accessSync, readFileSync } from "node:fs";
import {
accessSync,
closeSync,
fstatSync,
openSync,
readFileSync,
readSync,
statSync,
} from "node:fs";
import { posix, win32 } from "node:path";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import {
parseExecutableHeader,
type ExecutableMetadata,
} from "../domain/binaryTarget.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
ghidraApplicationProperties,
@ -34,6 +47,103 @@ const NATIVE_PLATFORMS: Readonly<
win32: { x64: "win_x86_64" },
};
/** Executable format each supported host runs its own Ghidra native tools as. */
const NATIVE_TOOL_FORMATS: Readonly<
Partial<Record<NodeJS.Platform, ExecutableMetadata["format"]>>
> = {
linux: "elf",
darwin: "mach-o",
win32: "pe",
};
/** Node architecture identifiers as the executable-header parser reports them. */
const NATIVE_ARCHITECTURES: readonly {
readonly node: NodeJS.Architecture;
readonly binary: BinaryArchitecture;
}[] = [
{ node: "x64", binary: "x86_64" },
{ node: "ia32", binary: "x86" },
{ node: "arm", binary: "arm" },
{ node: "arm64", binary: "arm64" },
];
/** Bound installation discovery reads; larger headers remain unknown. */
const EXECUTABLE_HEADER_PROBE_BYTES = 4096;
/** Compatibility of one present native decompiler with the inspected host. */
type NativeDecompilerCompatibility =
| {
readonly status: "incompatible";
readonly detail: string;
readonly remediation: string;
}
| { readonly status: "compatible"; readonly detail: string }
| { readonly status: "unknown"; readonly detail: string };
/**
* Judge a native decompiler by its own executable header instead of the
* directory name that admits it.
*
* Only a header that positively contradicts the host is `incompatible`. An
* absent, unreadable, truncated, or unrecognized header stays `unknown`, so a
* file the other checks already accept is never refused on missing evidence.
*/
const assessNativeDecompiler = (
path: string,
platform: NodeJS.Platform,
architecture: NodeJS.Architecture,
probe: GhidraExecutableHeaderProbe | undefined,
): NativeDecompilerCompatibility => {
const expectedFormat = NATIVE_TOOL_FORMATS[platform];
const expectedArchitecture = NATIVE_ARCHITECTURES.find(
({ node }) => node === architecture,
)?.binary;
if (expectedFormat === undefined || expectedArchitecture === undefined)
return {
status: "unknown",
detail: `${path} is executable, but no native tool format is defined for ${platform}/${architecture}`,
};
let parsed =
probe === undefined
? undefined
: parseExecutableHeader(probe.bytes, architecture, probe.size);
if (probe !== undefined && parsed !== undefined && !parsed.ok) {
// FAT parsing selects a host slice. A validated foreign slice can still
// prove that its complete architecture table excludes this host. If the
// host is listed but its slice is malformed, preserve the unknown outcome.
for (const { node } of NATIVE_ARCHITECTURES) {
if (node === architecture) continue;
const alternative = parseExecutableHeader(probe.bytes, node, probe.size);
if (
alternative.ok &&
!alternative.value.availableArchitectures.includes(expectedArchitecture)
) {
parsed = alternative;
break;
}
}
}
if (parsed === undefined || !parsed.ok)
return {
status: "unknown",
detail: `${path} is executable, but its executable format for ${platform}/${architecture} could not be established`,
};
const observed = `${parsed.value.format} ${parsed.value.architecture}`;
const expected = `${expectedFormat} ${expectedArchitecture}`;
if (observed === expected)
return {
status: "compatible",
detail: `${path} reports ${observed}, which matches ${platform}/${architecture}`,
};
const nativeDirectory = NATIVE_PLATFORMS[platform]?.[architecture];
const executable = platform === "win32" ? "decompile.exe" : "decompile";
return {
status: "incompatible",
detail: `${path} reports ${observed}, but ${platform}/${architecture} requires ${expected}`,
remediation: `Provide an executable Ghidra/Features/Decompiler/os/${nativeDirectory ?? "<platform>"}/${executable} built for ${expected}, or its build/os equivalent. REA does not build or install native tools.`,
};
};
/** Caller-owned paths and host coordinates used for one installation probe. */
export interface GhidraInstallationOptions {
readonly environment: Readonly<NodeJS.ProcessEnv>;
@ -117,12 +227,27 @@ export interface GhidraJavaObservation {
readonly runtime: "jdk" | "jre";
}
/** A bounded executable-header probe: a small prefix plus the whole file size. */
export interface GhidraExecutableHeaderProbe {
/** A bounded prefix; a larger or unreadable header remains unknown. */
readonly bytes: Buffer;
/** Whole-file size, since header commitments are checked against it, not the prefix. */
readonly size: number;
}
/** Narrow synchronous seam used by provider discovery without launching Ghidra. */
export interface GhidraInstallationHost {
readonly platform?: NodeJS.Platform;
readonly architecture?: NodeJS.Architecture;
readText(path: string): string | undefined;
executable(path: string): boolean;
/**
* Read a bounded header prefix of one executable so admission can reject a
* tool built for another platform or architecture. An omitted seam, or an
* unreadable file, reports the header as unknown and never rejects an
* installation that the other checks already accept.
*/
executableHeader?(path: string): GhidraExecutableHeaderProbe | undefined;
probeJava(
command: string,
environment: NodeJS.ProcessEnv,
@ -352,15 +477,7 @@ const installationChecks = ({
remediation:
"Restore support/analyzeHeadless or support/analyzeHeadless.bat from the official Ghidra release.",
}),
installationCheck({
name: "native_decompiler",
passed: coordinates.nativeDecompilerPath !== null,
code: "executable_missing",
detail:
coordinates.nativeDecompilerPath ??
`Matching native decompiler for ${platform}/${architecture} was not found`,
remediation: `Provide an executable Ghidra/Features/Decompiler/os/${NATIVE_PLATFORMS[platform]?.[architecture] ?? "<platform>"}/${platform === "win32" ? "decompile.exe" : "decompile"}, or its build/os equivalent. REA does not build or install native tools.`,
}),
nativeDecompilerCheck({ coordinates, platform, architecture, host }),
javaCheck(
coordinates.java,
coordinates.javaCommand,
@ -472,6 +589,40 @@ const installationCheck = (options: {
remediation: options.remediation,
};
/** Require a present native decompiler without positively incompatible bytes. */
const nativeDecompilerCheck = ({
coordinates,
platform,
architecture,
host,
}: Pick<
GhidraInstallationCheckContext,
"coordinates" | "platform" | "architecture" | "host"
>): GhidraInstallationCheck => {
const path = coordinates.nativeDecompilerPath;
const compatibility =
path === null
? undefined
: assessNativeDecompiler(
path,
platform,
architecture,
host.executableHeader?.(path),
);
return installationCheck({
name: "native_decompiler",
passed: path !== null && compatibility?.status !== "incompatible",
code: "executable_missing",
detail:
compatibility?.detail ??
`Matching native decompiler for ${platform}/${architecture} was not found`,
remediation:
compatibility?.status === "incompatible"
? compatibility.remediation
: `Provide an executable Ghidra/Features/Decompiler/os/${NATIVE_PLATFORMS[platform]?.[architecture] ?? "<platform>"}/${platform === "win32" ? "decompile.exe" : "decompile"}, or its build/os equivalent. REA does not build or install native tools.`,
});
};
const versionCheck = (
properties: GhidraApplicationProperties | undefined,
): GhidraInstallationCheck => {
@ -543,10 +694,30 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
return undefined;
}
},
executableHeader(path) {
let handle: number | undefined;
try {
handle = openSync(
path,
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOCTTY,
);
const metadata = fstatSync(handle);
if (!metadata.isFile()) return undefined;
const bytes = Buffer.alloc(EXECUTABLE_HEADER_PROBE_BYTES);
const read = readSync(handle, bytes, 0, bytes.length, 0);
return { bytes: bytes.subarray(0, read), size: metadata.size };
} catch (cause: unknown) {
// best-effort cleanup: a header that cannot be read stays unknown.
void cause;
return undefined;
} finally {
if (handle !== undefined) closeSync(handle);
}
},
executable(path) {
try {
accessSync(path, constants.X_OK);
return true;
return statSync(path).isFile();
} catch (cause: unknown) {
// best-effort cleanup: optional executable probing; failure means missing.
void cause;