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:
parent
f39e71ce18
commit
3728c8267b
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue