feat(ghidra): apply validated analyzer overrides before analysis

Apply caller-selected analyzer opt-outs before Ghidra analysis, reject unknown or ineffective names, and commit raw/header imports to the same analyzer profile. Use supported x86 raw compiler specs and cover the real analyzer/readback and raw workflows.

Adopts the original implementation from #1800 with validated corrections. Refs #1783, #1836.

Co-authored-by: akushonkamen <[email protected]>
Co-authored-by: N0zoM1z0 <[email protected]>
Co-authored-by: Kaoru0822 <[email protected]>
This commit is contained in:
Ysopwnable
2026-10-12 03:49:31 +08:00
committed by GitHub
co-authored by akushonkamen Kaoru0822
parent 12ee785e5e
commit 9878bb7296
22 changed files with 677 additions and 38 deletions
@@ -0,0 +1,38 @@
import java.util.Map;
import ghidra.app.plugin.core.analysis.AutoAnalysisManager;
import ghidra.app.util.headless.HeadlessScript;
/** Disable the named auto-analyzers before default auto-analysis. */
public final class ReaGhidraAnalysisOptions extends HeadlessScript {
@Override
public void run() throws Exception {
String[] arguments = getScriptArgs();
if (arguments.length != 1 || arguments[0].isBlank()) {
throw new IllegalArgumentException(
"ReaGhidraAnalysisOptions requires one comma-separated analyzer-name argument");
}
String[] names = arguments[0].split(",");
AutoAnalysisManager analysis = AutoAnalysisManager.getAnalysisManager(currentProgram);
Map<String, String> options = getCurrentAnalysisOptionsAndValues(currentProgram);
for (String requestedName : names) {
monitor.checkCancelled();
String name = requestedName.trim();
if (analysis.getAnalyzer(name) == null || !options.containsKey(name)) {
throw new IllegalArgumentException("Unknown Ghidra analyzer for this program: " + name);
}
}
for (String requestedName : names) {
monitor.checkCancelled();
setAnalysisOption(currentProgram, requestedName.trim(), "false");
}
options = getCurrentAnalysisOptionsAndValues(currentProgram);
for (String requestedName : names) {
monitor.checkCancelled();
String name = requestedName.trim();
if (!"false".equals(options.get(name))) {
throw new IllegalStateException("Ghidra analyzer remained enabled: " + name);
}
}
}
}
+21
View File
@@ -800,6 +800,27 @@ Each line is `address<TAB>kind[<TAB>name]`, where kind is `function`, `code` or
Label seeds can name mapped uninitialized storage such as `.bss`; function and code seeds require initialized bytes.
To skip a named Ghidra auto-analyzer (for example, when default auto-analysis
stalls in one analyzer on a Swift/Objective-C arm64 Mach-O):
```bash
export REA_GHIDRA_DISABLED_ANALYZERS="Objective-C Message Analyzer"
```
The value is a comma-separated list of exact Ghidra analyzer names. A pre-script
disables each name before auto-analysis, the analysis profile records the
override in `analyzer_preset` (for example
`ghidra-default+disabled:Objective-C Message Analyzer`), and a session fails to
open when its committed profile and the setting differ. A name starts with a
letter or digit and contains only letters, digits, spaces, periods, underscores,
and hyphens; at most 32 names are admitted, each at most 120 characters.
Names must identify analyzers registered for the selected program. Unknown names
or options that remain enabled abort the headless import before auto-analysis
and the bridge, so an unsuccessful override cannot produce a session claiming
that the analyzer was disabled. For Windows PE resource analysis, the exact name
is `WindowsResourceReference`. Disabling an analyzer skips the facts it would
have produced.
### Ghidra heap and CPU controls
Set resource controls in the environment that launches the CLI or MCP server,
+11 -7
View File
@@ -35,14 +35,18 @@ Omitted `format` keeps header-based detection; architecture and entry without
`format=raw-binary` are rejected.
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
generic languages:
generic languages and compiler specifications:
| architecture | language_id |
| ------------ | ------------------- |
| x86 | `x86:LE:32:default` |
| x86_64 | `x86:LE:64:default` |
| arm | `ARM:LE:32:v7` |
| arm64 | `AARCH64:LE:64:v8A` |
| architecture | language_id | compiler_spec_id |
| ------------ | ------------------- | ---------------- |
| x86 | `x86:LE:32:default` | `gcc` |
| x86_64 | `x86:LE:64:default` | `gcc` |
| arm | `ARM:LE:32:v7` | `default` |
| arm64 | `AARCH64:LE:64:v8A` | `default` |
Compiler specifications select analysis conventions; they do not identify the
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
provide a compiler specification named `default`.
Those settings, the caller-supplied entry, and `language_selection:
caller-supplied-v1` are committed in the analysis profile. A different entry
+5 -3
View File
@@ -463,7 +463,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
| Ghidra lane | Supported runner/target | Additional local tools |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
| `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback and unknown-name rejection | Ghidra 12.1.4 and full JDK 21 |
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
@@ -803,8 +803,10 @@ through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
prerequisites, it checks preparation and bridge startup, verified load mappings,
instruction and byte readback, canonical-entry session reuse, replacement when
the entry changes without `provider_id`, unchanged source bytes, and owned
cleanup. Other raw CPU families remain covered by deterministic tests rather
than this real-provider lane. See [raw binary analysis](raw-binary.md).
cleanup. Six-byte x86 and x86_64 controls also verify supported compiler
specifications, independent mappings, unchanged bytes, and decompilation of a
known return value. AArch64 remains covered by deterministic tests rather than
this real-provider lane. See [raw binary analysis](raw-binary.md).
## Apple Interface Builder archives
+1
View File
@@ -55,6 +55,7 @@
"bridge/ghidra/ReaGhidraExtensions.java",
"bridge/ghidra/ReaGhidraPrepareCom.java",
"bridge/ghidra/ReaGhidraApplySeeds.java",
"bridge/ghidra/ReaGhidraAnalysisOptions.java",
"bridge/ghidra/ReaGhidraNoReturnFix.java",
"bridge/ghidra/ReaGhidraPrepareRaw.java",
"bridge/android/ReaJadxBridge.java",
+4 -2
View File
@@ -172,11 +172,13 @@ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const runtimeParent = join(workspace, "runtime with spaces");
await mkdir(runtimeParent);
const callerDirectory = join(workspace, "caller cwd with spaces");
// Keep sorted for comparison with the final caller-directory listing.
const scriptCollisions = [
"ReaGhidraBridge.java",
"ReaGhidraPrepareCom.java",
"ReaGhidraAnalysisOptions.java",
"ReaGhidraApplySeeds.java",
"ReaGhidraBridge.java",
"ReaGhidraNoReturnFix.java",
"ReaGhidraPrepareCom.java",
"ReaGhidraPrepareRaw.java",
];
await mkdir(callerDirectory);
+54 -2
View File
@@ -53,6 +53,15 @@ const env = {
...(process.env.JAVA_HOME === undefined
? {}
: { JAVA_HOME: process.env.JAVA_HOME }),
...(process.env.GHIDRA_HEADLESS_MAXMEM === undefined
? {}
: { GHIDRA_HEADLESS_MAXMEM: process.env.GHIDRA_HEADLESS_MAXMEM }),
...(process.env.REA_GHIDRA_DISABLED_ANALYZERS === undefined
? {}
: {
REA_GHIDRA_DISABLED_ANALYZERS:
process.env.REA_GHIDRA_DISABLED_ANALYZERS,
}),
};
const transport = new StdioClientTransport({
command: process.execPath,
@@ -160,6 +169,48 @@ try {
assert.equal(cli.normalized_result.base_address, "0x8000000");
assert.equal(cli.analysis_profile.digest, profiles[0]);
assert.deepEqual(await readFile(path), bytes);
const x86Targets = [];
for (const architecture of ["x86", "x86_64"]) {
const x86Bytes = Buffer.from("b82a000000c3", "hex"); // mov eax, 42; ret.
const x86Sha256 = createHash("sha256").update(x86Bytes).digest("hex");
const x86Path = join(workspace, `${architecture}.bin`);
await writeFile(x86Path, x86Bytes);
await call("open_binary", {
path: x86Path,
format: "raw-binary",
architecture,
entry_point: "0x1000",
});
const image = await call("inspect_native_load_image", {}, x86Sha256);
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image),
);
assert.equal(image.normalized_result.base_address, "0x1000");
assert.equal(image.normalized_result.observations.compiler_spec_id, "gcc");
assert.equal(image.analysis_profile.parameters.compiler_spec_id, "gcc");
const memory = await call(
"read_bytes",
{ address: "0x1000", length: x86Bytes.length },
x86Sha256,
);
assert.equal(memory.normalized_result.bytes_hex, x86Bytes.toString("hex"));
const pseudo = await call(
"procedure_pseudo_code",
{ procedure: "0x1000" },
x86Sha256,
);
assert.match(pseudo.normalized_result, /\b(?:42|0x2a)\b/iu);
await call("close_binary");
assert.deepEqual(await readFile(x86Path), x86Bytes);
x86Targets.push({
architecture,
sha256: x86Sha256,
compiler_spec_id: "gcc",
return_42: true,
});
}
report = {
ok: true,
mocked: false,
@@ -179,6 +230,7 @@ try {
invalid_entry_preserves_active_session: true,
load_images_verified: true,
original_unchanged: true,
x86_targets: x86Targets,
};
} catch (error) {
if (stderr.trim()) process.stderr.write(stderr);
@@ -199,7 +251,7 @@ process.stdout.write(
}) + "\n",
);
async function call(name, args = {}) {
async function call(name, args = {}, targetSha256 = sha256) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
@@ -213,7 +265,7 @@ async function call(name, args = {}) {
)
return projected;
const evidence = parseEvidence(result.structuredContent);
assert.equal(evidence.subject.digest.sha256, sha256);
assert.equal(evidence.subject.digest.sha256, targetSha256);
assert.equal(evidence.subject.format, "raw-binary");
return evidence;
}
+62 -16
View File
@@ -42,11 +42,66 @@ try {
"",
].join("\n"),
);
const output = await runHeadless(target, "seed-regression", [
"-postScript",
"ReaSeedRegressionProbe.java",
seeds,
]);
// Headless can exit zero after a script fails, so require the probe's marker.
assert.ok(output.includes("REA_SEED_REGRESSION_OK "), output);
const analyzer = "Decompiler Switch Analysis";
const disabled = await runHeadless(target, "analyzer-regression-valid", [
"-preScript",
"ReaGhidraAnalysisOptions.java",
analyzer,
"-postScript",
"ReaAnalyzerOptionsRegressionProbe.java",
analyzer,
]);
assert.ok(
disabled.includes(`REA_ANALYZER_OPTIONS_DISABLED_OK ${analyzer}`),
disabled,
);
const unknownAnalyzer = "REA Unknown Analyzer";
const rejected = await runHeadless(target, "analyzer-regression-unknown", [
"-preScript",
"ReaGhidraAnalysisOptions.java",
`${analyzer},${unknownAnalyzer}`,
"-postScript",
"ReaAnalyzerOptionsRegressionProbe.java",
analyzer,
]);
assert.ok(
rejected.includes(
`Unknown Ghidra analyzer for this program: ${unknownAnalyzer}`,
),
rejected,
);
assert.ok(
rejected.includes("Processing aborted as a result of pre-script."),
rejected,
);
assert.ok(!rejected.includes("REA_ANALYZER_OPTIONS_DISABLED_OK"), rejected);
} finally {
if (supervisor !== undefined) {
const stopped = await supervisor.stop();
assert.notEqual(stopped.status, "incomplete", JSON.stringify(stopped));
supervisor.dispose();
}
await rm(workspace, { recursive: true, force: true });
}
console.log(
"Real Ghidra pre-script regression passed: descending function order, out-of-range seed, BSS label, BSS code rejection, analyzer option readback, unknown-analyzer abort before post-scripts, and owned-process cleanup.",
);
async function runHeadless(target, project, scriptArguments) {
const launch = await spawnOwnedProviderProcess({
command: join(installation, "support", "analyzeHeadless"),
arguments: [
workspace,
"seed-regression",
project,
"-import",
target,
"-loader",
@@ -63,9 +118,7 @@ try {
new URL("../../../tests/conformance/ghidra", import.meta.url),
),
].join(";"),
"-postScript",
"ReaSeedRegressionProbe.java",
seeds,
...scriptArguments,
],
runId: run.run_id,
expectedCommand: null,
@@ -82,16 +135,9 @@ try {
const snapshot = supervisor.snapshot();
const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`;
assert.equal(snapshot.exitCode, 0, output);
// Headless can exit zero after a script fails, so require the probe's marker.
assert.ok(output.includes("REA_SEED_REGRESSION_OK "), output);
} finally {
if (supervisor !== undefined) {
const stopped = await supervisor.stop();
assert.notEqual(stopped.status, "incomplete", JSON.stringify(stopped));
supervisor.dispose();
}
await rm(workspace, { recursive: true, force: true });
const stopped = await supervisor.stop();
assert.notEqual(stopped.status, "incomplete", JSON.stringify(stopped));
supervisor.dispose();
supervisor = undefined;
return output;
}
console.log(
"Real Ghidra seed regression passed: descending function order, out-of-range seed, BSS label, BSS code rejection, and owned-process cleanup.",
);
+2
View File
@@ -8,6 +8,7 @@ import {
ghidraCompilerSpecIdSchema,
ghidraLanguageIdSchema,
} from "./ghidraLanguageOverride.js";
import { ghidraDisabledAnalyzersSchema } from "./ghidraDisabledAnalyzers.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
@@ -48,6 +49,7 @@ const environmentSchema = z.object({
),
REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(),
REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(),
REA_GHIDRA_DISABLED_ANALYZERS: ghidraDisabledAnalyzersSchema.optional(),
REA_GHIDRA_SEED_FILE: z
.string()
.min(1)
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod";
export const MAX_GHIDRA_DISABLED_ANALYZER_NAMES = 32;
export const MAX_GHIDRA_DISABLED_ANALYZER_NAME_LENGTH = 120;
// Ghidra's headless parser consumes `-`-prefixed arguments after a script path
// as its own options, and REA joins the names with commas into one pre-script
// argument, so a name can neither start with `-` nor contain a comma. The
// remaining admitted characters keep the argument a single quotable token on
// every supported launcher, including the Windows batch invocation.
const ghidraAnalyzerNamePattern = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/u;
/**
* Parse `REA_GHIDRA_DISABLED_ANALYZERS` into the Ghidra auto-analyzer names to
* disable before default auto-analysis, such as
* `Objective-C Message Analyzer`. Empty and blank entries are dropped; a list
* without names selects no override.
*/
export const ghidraDisabledAnalyzersSchema = z
.string()
.transform((value) =>
value
.split(",")
.map((name) => name.trim())
.filter((name) => name.length > 0),
)
.refine(
(names) => names.length <= MAX_GHIDRA_DISABLED_ANALYZER_NAMES,
`must list at most ${MAX_GHIDRA_DISABLED_ANALYZER_NAMES} analyzer names`,
)
.refine(
(names) =>
names.every(
(name) => name.length <= MAX_GHIDRA_DISABLED_ANALYZER_NAME_LENGTH,
),
`must keep every analyzer name at most ${MAX_GHIDRA_DISABLED_ANALYZER_NAME_LENGTH} characters`,
)
.refine(
(names) => names.every((name) => ghidraAnalyzerNamePattern.test(name)),
"must be Ghidra analyzer names starting with a letter or digit and containing only letters, digits, spaces, periods, underscores, and hyphens",
)
.transform((names) => (names.length === 0 ? undefined : names));
+3
View File
@@ -88,6 +88,9 @@ export const parseConfig = (
...(env.REA_GHIDRA_SEED_FILE === undefined
? {}
: { ghidraSeedFile: env.REA_GHIDRA_SEED_FILE }),
...(env.REA_GHIDRA_DISABLED_ANALYZERS === undefined
? {}
: { ghidraDisabledAnalyzers: env.REA_GHIDRA_DISABLED_ANALYZERS }),
...(env.REA_GHIDRA_NATIVEAOT_JAR === undefined
? {}
: { ghidraNativeAotJar: env.REA_GHIDRA_NATIVEAOT_JAR }),
+1
View File
@@ -14,6 +14,7 @@ export interface AppConfig {
readonly ghidraJavaHome: string | undefined;
readonly ghidraStartupTimeoutMs: number;
readonly ghidraLanguageOverride?: GhidraLanguageOverride;
readonly ghidraDisabledAnalyzers?: readonly string[];
readonly ghidraSeedFile?: string;
readonly ghidraNativeAotJar?: string;
readonly ilspyCmdPath: string | undefined;
+33 -2
View File
@@ -36,6 +36,7 @@ export const resolveGhidraAnalysisProfile = async (
installation: GhidraInstallationInspection,
signal?: AbortSignal,
languageOverride?: GhidraLanguageOverride,
disabledAnalyzers?: readonly string[],
): Promise<Result<AnalysisProfileResolution, AnalysisError>> => {
if (signal?.aborted === true)
return Promise.resolve(err(new AnalysisCancelledError("open_binary")));
@@ -70,6 +71,13 @@ export const resolveGhidraAnalysisProfile = async (
: null;
if (pspExtension !== null && !pspExtension.ok) return pspExtension;
const provider = { ...identity, version: installation.providerVersion };
const analyzerParameters =
disabledAnalyzers === undefined || disabledAnalyzers.length === 0
? { analyzer_preset: "ghidra-default" }
: {
analyzer_preset: `ghidra-default+disabled:${disabledAnalyzers.join(",")}`,
analysis_options: { disabled_analyzers: [...disabledAnalyzers] },
};
if (target.format === "raw-binary") {
if (languageOverride !== undefined)
return Promise.resolve(
@@ -116,7 +124,7 @@ export const resolveGhidraAnalysisProfile = async (
language_selection: "caller-supplied-v1",
entry_point: target.entryPoint,
entry_seed: "external-entry-and-function-before-analysis-v1",
analyzer_preset: "ghidra-default",
...analyzerParameters,
}),
}),
);
@@ -218,7 +226,7 @@ export const resolveGhidraAnalysisProfile = async (
entry_seed: "external-entry-and-function-before-analysis-v1",
}
: {}),
analyzer_preset: "ghidra-default",
...analyzerParameters,
...ghidraMipsProfileParameters(target),
...(pspExtension?.ok === true
? {
@@ -255,3 +263,26 @@ export const sameGhidraLanguageOverride = (
): boolean =>
left?.languageId === right?.languageId &&
left?.compilerSpecId === right?.compilerSpecId;
/** Read the analyzer-option commitment back from a resolved profile. */
export const ghidraProfileDisabledAnalyzers = (
parameters: Readonly<Record<string, unknown>>,
): readonly string[] | undefined => {
const options = parameters.analysis_options;
if (typeof options !== "object" || options === null) return undefined;
const disabled = (options as Record<string, unknown>).disabled_analyzers;
if (!Array.isArray(disabled)) return undefined;
return disabled.every((name) => typeof name === "string")
? (disabled as readonly string[])
: undefined;
};
/** Whether a committed profile and the active configuration disable the same analyzers. */
export const sameGhidraDisabledAnalyzers = (
left: readonly string[] | undefined,
right: readonly string[] | undefined,
): boolean =>
left === undefined || right === undefined
? left === right
: left.length === right.length &&
left.every((name, index) => name === right[index]);
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
ghidraDisabledAnalyzersSchema,
MAX_GHIDRA_DISABLED_ANALYZER_NAMES,
} from "../config/ghidraDisabledAnalyzers.js";
import { parseConfig } from "../config/parseConfig.js";
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const configured = (setting?: string) => {
const config = parseConfig({
GHIDRA_INSTALL_DIR: INSTALL,
...(setting === undefined
? {}
: { REA_GHIDRA_DISABLED_ANALYZERS: setting }),
});
if (!config.ok) throw config.error;
return config.value.ghidraDisabledAnalyzers;
};
describe("REA_GHIDRA_DISABLED_ANALYZERS", () => {
it("stays unset without the setting", () => {
expect(configured(undefined)).toBeUndefined();
});
it("splits a comma-separated list and trims each name", () => {
expect(
configured(" Objective-C Message Analyzer,Decompiler Switch Analysis "),
).toEqual(["Objective-C Message Analyzer", "Decompiler Switch Analysis"]);
});
it("treats an empty or blank list as unset", () => {
expect(configured("")).toBeUndefined();
expect(configured(" , ")).toBeUndefined();
});
it("keeps empty names out of the list instead of failing", () => {
expect(
configured("Objective-C Message Analyzer,,Decompiler Switch Analysis"),
).toEqual(["Objective-C Message Analyzer", "Decompiler Switch Analysis"]);
});
it.each([
["a leading flag name", "-Decompiler Switch Analysis"],
["a name with command metacharacters", "Analyzer(&calc)"],
["a non-ASCII name", "分析器"],
["an over-long name", "A".repeat(121)],
])("rejects %s", (_label, setting) => {
const config = parseConfig({
GHIDRA_INSTALL_DIR: INSTALL,
REA_GHIDRA_DISABLED_ANALYZERS: setting,
});
expect(config.ok).toBe(false);
if (!config.ok)
expect(config.error).toMatchObject({
settings: [
expect.objectContaining({
setting: "REA_GHIDRA_DISABLED_ANALYZERS",
}),
],
});
});
it("rejects more than the admitted analyzer count", () => {
const setting = Array.from(
{ length: MAX_GHIDRA_DISABLED_ANALYZER_NAMES + 1 },
(_, index) => `Analyzer ${index}`,
).join(",");
const config = parseConfig({
GHIDRA_INSTALL_DIR: INSTALL,
REA_GHIDRA_DISABLED_ANALYZERS: setting,
});
expect(config.ok).toBe(false);
if (!config.ok)
expect(config.error).toMatchObject({
settings: [
expect.objectContaining({
setting: "REA_GHIDRA_DISABLED_ANALYZERS",
}),
],
});
});
it("admits the documented Objective-C analyzer name", () => {
expect(
ghidraDisabledAnalyzersSchema.parse("Objective-C Message Analyzer"),
).toEqual(["Objective-C Message Analyzer"]);
});
});
+17
View File
@@ -108,6 +108,8 @@ export interface GhidraHeadlessLauncherOptions {
};
/** Explicit import language committed by the analysis profile. */
readonly languageOverride?: GhidraLanguageOverride;
/** Auto-analyzers disabled before default auto-analysis, as configured. */
readonly disabledAnalyzers?: readonly string[];
/** Caller seeds committed by the analysis profile, applied before analysis. */
readonly analysisSeeds?: GhidraSeedCommitment;
readonly analysisExtensions?: readonly GhidraExtension[];
@@ -213,6 +215,9 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
? {}
: { languageOverride: this.options.languageOverride }),
...(seedPath === undefined ? {} : { seedPath }),
...(this.options.disabledAnalyzers === undefined
? {}
: { disabledAnalyzers: this.options.disabledAnalyzers }),
});
const scriptCommand = ghidraHeadlessCommand({
environment: this.options.environment,
@@ -425,6 +430,8 @@ export interface GhidraHeadlessArgumentOptions {
readonly baseAddr: string;
};
readonly languageOverride?: GhidraLanguageOverride;
/** Auto-analyzers disabled by the pre-analysis options script. */
readonly disabledAnalyzers?: readonly string[];
/** Canonical seed snapshot passed to the pre-analysis seed script. */
readonly seedPath?: string;
}
@@ -555,6 +562,16 @@ export const ghidraHeadlessArguments = (
scriptPath.join(bridgeDirectory, GHIDRA_SEED_SCRIPT),
options.seedPath,
]),
// Analyzer options must apply before default auto-analysis and before the
// post-analysis repair and bridge scripts start serving.
...(options.disabledAnalyzers === undefined ||
options.disabledAnalyzers.length === 0
? []
: [
"-preScript",
scriptPath.join(bridgeDirectory, "ReaGhidraAnalysisOptions.java"),
options.disabledAnalyzers.join(","),
]),
...((options.platform ?? process.platform) === "win32"
? []
: [
+150
View File
@@ -23,6 +23,7 @@ import { parseExecutableHeader } from "../domain/binaryTarget.js";
import { GhidraProvider } from "./GhidraProvider.js";
import type { GhidraTargetSnapshot } from "./GhidraClient.js";
import type { GhidraProviderClientFactory } from "./GhidraProviderClient.js";
import type { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
import { err, ok } from "../domain/result.js";
@@ -1810,3 +1811,152 @@ describe("Ghidra analysis seeds and configured language", () => {
}
});
});
describe("Ghidra disabled analyzer options", () => {
const configuredProvider = (
environment: Record<string, string>,
clientFactory?: GhidraProviderClientFactory,
) => {
const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL, ...environment });
if (!config.ok) throw config.error;
return new GhidraProvider(
config.value,
silentLogger,
{},
installationHost(),
clientFactory,
);
};
it("records the configured analyzer options in the analysis profile", async () => {
const ghidra = configuredProvider({
REA_GHIDRA_DISABLED_ANALYZERS:
"Objective-C Message Analyzer,Decompiler Switch Analysis",
});
const resolved = await ghidra.resolveAnalysisProfile(
executableTarget("elf", "x86_64"),
);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("expected a bound Ghidra profile");
expect(resolved.value.profile.parameters).toMatchObject({
analyzer_preset:
"ghidra-default+disabled:Objective-C Message Analyzer,Decompiler Switch Analysis",
analysis_options: {
disabled_analyzers: [
"Objective-C Message Analyzer",
"Decompiler Switch Analysis",
],
},
});
});
it("hands the configured analyzers to the launcher for a matching profile", async () => {
const launchers: GhidraHeadlessLauncher[] = [];
const ghidra = configuredProvider(
{ REA_GHIDRA_DISABLED_ANALYZERS: "Objective-C Message Analyzer" },
(options) => {
launchers.push(options.launcher as GhidraHeadlessLauncher);
return {
start: () => Promise.resolve(ok(sessionInfo())),
callTool: () => Promise.resolve(ok(null)),
close: () => Promise.resolve(ok(null)),
};
},
);
const target = executableTarget("elf", "x86_64");
const resolved = await ghidra.resolveAnalysisProfile(target);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("expected a bound Ghidra profile");
const client = ghidra.createClient(target, resolved.value.profile);
const health = await client.execute("health", {});
expect(health.ok).toBe(true);
expect(launchers).toHaveLength(1);
expect(launchers[0]?.options.disabledAnalyzers).toEqual([
"Objective-C Message Analyzer",
]);
});
it("binds analyzer overrides and raw import settings in one profile", async () => {
const launchers: GhidraHeadlessLauncher[] = [];
const ghidra = configuredProvider(
{ REA_GHIDRA_DISABLED_ANALYZERS: "Decompiler Switch Analysis" },
(options) => {
launchers.push(options.launcher as GhidraHeadlessLauncher);
return {
start: () => Promise.resolve(ok(sessionInfo())),
callTool: () => Promise.resolve(ok(null)),
close: () => Promise.resolve(ok(null)),
};
},
);
const target: BinaryTarget = {
path: "/tmp/entry.bin",
sha256: "a".repeat(64),
kind: "executable",
format: "raw-binary",
architecture: "x86",
availableArchitectures: ["x86"],
entryPoint: "0x1000",
entryPointSource: "0x1000",
};
const resolved = await ghidra.resolveAnalysisProfile(target);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("expected a bound raw-binary profile");
expect(resolved.value.profile.parameters).toMatchObject({
target_format: "raw-binary",
entry_point: "0x1000",
analyzer_preset: "ghidra-default+disabled:Decompiler Switch Analysis",
analysis_options: { disabled_analyzers: ["Decompiler Switch Analysis"] },
});
const client = ghidra.createClient(target, resolved.value.profile);
expect(launchers).toHaveLength(1);
expect(launchers[0]?.options).toMatchObject({
disabledAnalyzers: ["Decompiler Switch Analysis"],
rawBinary: {
languageId: "x86:LE:32:default",
compilerSpecId: "gcc",
baseAddr: "0x1000",
},
});
await client.close();
});
it("refuses a profile resolved without or with other analyzer options", async () => {
const target = executableTarget("elf", "x86_64");
const plainProvider = configuredProvider({});
const plain = await plainProvider.resolveAnalysisProfile(target);
if (!plain.ok || plain.value.profile === null)
throw new Error("expected profile");
const configuredProvider2 = configuredProvider({
REA_GHIDRA_DISABLED_ANALYZERS: "Objective-C Message Analyzer",
});
const configured = await configuredProvider2.resolveAnalysisProfile(target);
if (!configured.ok || configured.value.profile === null)
throw new Error("expected configured profile");
for (const [profile, environment] of [
[
plain.value.profile,
{ REA_GHIDRA_DISABLED_ANALYZERS: "Objective-C Message Analyzer" },
],
[configured.value.profile, {}],
[
configured.value.profile,
{ REA_GHIDRA_DISABLED_ANALYZERS: "Decompiler Switch Analysis" },
],
] as const) {
let starts = 0;
const client = configuredProvider(environment, () => ({
start: () => {
starts++;
return Promise.resolve(ok(sessionInfo()));
},
callTool: () => Promise.resolve(ok(null)),
close: () => Promise.resolve(ok(null)),
})).createClient(target, profile);
const failed = await client.execute("health", {});
expect(failed.ok).toBe(false);
if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError");
expect(starts).toBe(0);
}
});
});
+1
View File
@@ -200,6 +200,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
installation,
options?.signal,
this.config.ghidraLanguageOverride,
this.config.ghidraDisabledAnalyzers,
);
if (!resolved.ok || resolved.value.profile === null) return resolved;
const override = this.config.ghidraLanguageOverride;
+22
View File
@@ -14,7 +14,9 @@ import {
ghidraSeedLimitations,
} from "./GhidraAnalysisSeeds.js";
import {
ghidraProfileDisabledAnalyzers,
ghidraProfileLanguageOverride,
sameGhidraDisabledAnalyzers,
sameGhidraLanguageOverride,
} from "./GhidraAnalysisProfile.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
@@ -431,6 +433,23 @@ export const createGhidraProviderClient = (input: {
},
}),
);
const disabledAnalyzers = ghidraProfileDisabledAnalyzers(
committedProfile.parameters,
);
if (
!sameGhidraDisabledAnalyzers(
disabledAnalyzers,
config.ghidraDisabledAnalyzers,
)
)
return unavailableClient(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
"The committed analysis profile and REA_GHIDRA_DISABLED_ANALYZERS differ; reopen the target to resolve a new profile.",
},
}),
);
const invalidProfile = validateGhidraExtensionProfile(
extensions,
config,
@@ -501,6 +520,9 @@ export const createGhidraProviderClient = (input: {
},
}),
...(languageOverride === undefined ? {} : { languageOverride }),
...(config.ghidraDisabledAnalyzers === undefined
? {}
: { disabledAnalyzers: config.ghidraDisabledAnalyzers }),
...(seeds === undefined ? {} : { analysisSeeds: seeds }),
platform: installation.platform,
...(extensions.length === 0 ? {} : { analysisExtensions: extensions }),
+6 -4
View File
@@ -3,21 +3,23 @@ import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
/** Bound Ghidra language for a caller-supplied raw-binary architecture. */
export interface GhidraRawBinaryLanguage {
readonly languageId: string;
readonly compilerSpecId: "default";
readonly compilerSpecId: "default" | "gcc";
}
/**
* Map the four caller-visible CPU families to little-endian generic Ghidra
* languages. Big-endian, Thumb-only, and Cortex-M variants are out of scope.
* languages with a supported generic compiler specification. These are
* analysis conventions, not evidence of the target's original compiler.
* Big-endian, Thumb-only, and Cortex-M variants are out of scope.
*/
export const ghidraRawBinaryLanguage = (
architecture: Exclude<BinaryArchitecture, "mips">,
): GhidraRawBinaryLanguage => {
switch (architecture) {
case "x86":
return { languageId: "x86:LE:32:default", compilerSpecId: "default" };
return { languageId: "x86:LE:32:default", compilerSpecId: "gcc" };
case "x86_64":
return { languageId: "x86:LE:64:default", compilerSpecId: "default" };
return { languageId: "x86:LE:64:default", compilerSpecId: "gcc" };
case "arm":
return { languageId: "ARM:LE:32:v7", compilerSpecId: "default" };
case "arm64":
+2 -2
View File
@@ -8,11 +8,11 @@ describe("raw-binary Ghidra language binding", () => {
it("maps the four admitted families to little-endian generic languages", () => {
expect(ghidraRawBinaryLanguage("x86")).toEqual({
languageId: "x86:LE:32:default",
compilerSpecId: "default",
compilerSpecId: "gcc",
});
expect(ghidraRawBinaryLanguage("x86_64")).toEqual({
languageId: "x86:LE:64:default",
compilerSpecId: "default",
compilerSpecId: "gcc",
});
expect(ghidraRawBinaryLanguage("arm")).toEqual({
languageId: "ARM:LE:32:v7",
@@ -289,6 +289,99 @@ describe("Ghidra configured import language and seeds", () => {
});
});
describe("Ghidra disabled analyzers", () => {
const base = {
platform: "darwin" as const,
projectRoot: "/tmp/project",
targetPath: "/tmp/target",
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
descriptorPath: "/tmp/session.json",
ghidraLogPath: "/tmp/ghidra.log",
scriptLogPath: "/tmp/script.log",
};
it("applies the analyzer options after seeds and before analysis scripts", () => {
const arguments_ = ghidraHeadlessArguments({
...base,
dosCom: true,
seedPath: posix.join("/tmp/runtime/seeds", "seeds.tsv"),
disabledAnalyzers: [
"Objective-C Message Analyzer",
"Decompiler Switch Analysis",
],
});
const options = arguments_.indexOf(
posix.join("/package/bridge", "ReaGhidraAnalysisOptions.java"),
);
expect(arguments_[options - 1]).toBe("-preScript");
expect(arguments_[options + 1]).toBe(
"Objective-C Message Analyzer,Decompiler Switch Analysis",
);
const prepare = arguments_.indexOf(
posix.join("/package/bridge", "ReaGhidraPrepareCom.java"),
);
const seeds = arguments_.indexOf(
posix.join("/package/bridge", "ReaGhidraApplySeeds.java"),
);
expect(prepare).toBeLessThan(seeds);
expect(seeds).toBeLessThan(options);
expect(options).toBeLessThan(arguments_.indexOf("-postScript"));
});
it.each(["darwin", "win32"] as const)(
"uses the target platform's script path for the options script on %s",
(platform) => {
const path = platform === "win32" ? win32 : posix;
const arguments_ = ghidraHeadlessArguments({
...base,
platform,
bridgeScriptPath: path.join(
"/package",
"bridge",
"ReaGhidraBridge.java",
),
disabledAnalyzers: ["Objective-C Message Analyzer"],
});
const options = arguments_.indexOf(
path.join("/package", "bridge", "ReaGhidraAnalysisOptions.java"),
);
expect(arguments_[options - 1]).toBe("-preScript");
expect(arguments_[options + 1]).toBe("Objective-C Message Analyzer");
expect(options).toBeLessThan(arguments_.indexOf("-postScript"));
},
);
it("keeps the invocation byte-identical without an override", () => {
expect(ghidraHeadlessArguments(base)).toEqual([
"/tmp/project",
"rea-project",
"-import",
"/tmp/target",
"-readOnly",
"-deleteProject",
"-log",
"/tmp/ghidra.log",
"-scriptlog",
"/tmp/script.log",
"-scriptPath",
"/package/bridge",
"-postScript",
"/package/bridge/ReaGhidraNoReturnFix.java",
"-postScript",
"/package/bridge/ReaGhidraBridge.java",
"/tmp/session.json",
]);
});
it("adds no analyzer script for an empty override", () => {
expect(
ghidraHeadlessArguments({ ...base, disabledAnalyzers: [] }),
).not.toContain(
posix.join("/package/bridge", "ReaGhidraAnalysisOptions.java"),
);
});
});
describe("Ghidra Windows launcher", () => {
it("keeps analysis repair outside Windows P0 authority", () => {
const args = ghidraHeadlessArguments({
@@ -0,0 +1,19 @@
import ghidra.app.util.headless.HeadlessScript;
/** Independent readback oracle for the production analyzer-options pre-script. */
public final class ReaAnalyzerOptionsRegressionProbe extends HeadlessScript {
@Override
public void run() throws Exception {
String[] arguments = getScriptArgs();
if (arguments.length != 1 || arguments[0].isBlank()) {
throw new IllegalArgumentException("Expected one analyzer name");
}
monitor.checkCancelled();
String name = arguments[0];
String enabled = getCurrentAnalysisOptionsAndValues(currentProgram).get(name);
if (!"false".equals(enabled)) {
throw new IllegalStateException("Analyzer option is not disabled: " + name);
}
println("REA_ANALYZER_OPTIONS_DISABLED_OK " + name);
}
}