From 9878bb7296c13c2b4838be76f6128e5fc31eda3c Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:49:31 +0800 Subject: [PATCH] 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 <74654766+akushonkamen@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- bridge/ghidra/ReaGhidraAnalysisOptions.java | 38 +++++ docs/installation.md | 21 +++ docs/raw-binary.md | 18 ++- docs/testing.md | 8 +- package.json | 1 + scripts/verify-packaged-ghidra-windows.mjs | 6 +- scripts/verify-real-ghidra-raw.mjs | 56 ++++++- scripts/verify/ghidra/seeds.mjs | 78 +++++++-- src/config/environment.ts | 2 + src/config/ghidraDisabledAnalyzers.ts | 42 +++++ src/config/parseConfig.ts | 3 + src/config/types.ts | 1 + src/ghidra/GhidraAnalysisProfile.ts | 35 +++- src/ghidra/GhidraDisabledAnalyzers.test.ts | 90 +++++++++++ src/ghidra/GhidraLauncher.ts | 17 ++ src/ghidra/GhidraProvider.behavior.test.ts | 150 ++++++++++++++++++ src/ghidra/GhidraProvider.ts | 1 + src/ghidra/GhidraProviderClient.ts | 22 +++ src/ghidra/GhidraRawBinaryLanguage.ts | 10 +- src/ghidra/GhidraRawBinaryLoadImage.test.ts | 4 +- .../providers/ghidra/ghidraLauncher.test.ts | 93 +++++++++++ .../ReaAnalyzerOptionsRegressionProbe.java | 19 +++ 22 files changed, 677 insertions(+), 38 deletions(-) create mode 100644 bridge/ghidra/ReaGhidraAnalysisOptions.java create mode 100644 src/config/ghidraDisabledAnalyzers.ts create mode 100644 src/ghidra/GhidraDisabledAnalyzers.test.ts create mode 100644 tests/conformance/ghidra/ReaAnalyzerOptionsRegressionProbe.java diff --git a/bridge/ghidra/ReaGhidraAnalysisOptions.java b/bridge/ghidra/ReaGhidraAnalysisOptions.java new file mode 100644 index 000000000..6ecafb19d --- /dev/null +++ b/bridge/ghidra/ReaGhidraAnalysisOptions.java @@ -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 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); + } + } + } +} diff --git a/docs/installation.md b/docs/installation.md index 914dcc4b4..42e179798 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -800,6 +800,27 @@ Each line is `addresskind[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, diff --git a/docs/raw-binary.md b/docs/raw-binary.md index dd86d0107..43fcd54f4 100644 --- a/docs/raw-binary.md +++ b/docs/raw-binary.md @@ -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 diff --git a/docs/testing.md b/docs/testing.md index 1473bbf35..3c0d401b7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/package.json b/package.json index 0cc9979eb..c674258b8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-packaged-ghidra-windows.mjs b/scripts/verify-packaged-ghidra-windows.mjs index 6c43e63e7..b38072c8c 100644 --- a/scripts/verify-packaged-ghidra-windows.mjs +++ b/scripts/verify-packaged-ghidra-windows.mjs @@ -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); diff --git a/scripts/verify-real-ghidra-raw.mjs b/scripts/verify-real-ghidra-raw.mjs index 400c7f378..befba80e0 100644 --- a/scripts/verify-real-ghidra-raw.mjs +++ b/scripts/verify-real-ghidra-raw.mjs @@ -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; } diff --git a/scripts/verify/ghidra/seeds.mjs b/scripts/verify/ghidra/seeds.mjs index b653fc2ec..e9f0435b5 100644 --- a/scripts/verify/ghidra/seeds.mjs +++ b/scripts/verify/ghidra/seeds.mjs @@ -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.", -); diff --git a/src/config/environment.ts b/src/config/environment.ts index babe9ebd1..7dcd955d0 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -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) diff --git a/src/config/ghidraDisabledAnalyzers.ts b/src/config/ghidraDisabledAnalyzers.ts new file mode 100644 index 000000000..9fe3756b7 --- /dev/null +++ b/src/config/ghidraDisabledAnalyzers.ts @@ -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)); diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index fadcb8fb8..757e02af0 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -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 }), diff --git a/src/config/types.ts b/src/config/types.ts index 7f885edce..c398d6f6f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 259b89285..6e1b1e7cc 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -36,6 +36,7 @@ export const resolveGhidraAnalysisProfile = async ( installation: GhidraInstallationInspection, signal?: AbortSignal, languageOverride?: GhidraLanguageOverride, + disabledAnalyzers?: readonly string[], ): Promise> => { 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>, +): readonly string[] | undefined => { + const options = parameters.analysis_options; + if (typeof options !== "object" || options === null) return undefined; + const disabled = (options as Record).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]); diff --git a/src/ghidra/GhidraDisabledAnalyzers.test.ts b/src/ghidra/GhidraDisabledAnalyzers.test.ts new file mode 100644 index 000000000..8acbdf22b --- /dev/null +++ b/src/ghidra/GhidraDisabledAnalyzers.test.ts @@ -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"]); + }); +}); diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index a6b459d83..94c95d6a6 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -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" ? [] : [ diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index b0b8fbd87..57fd8aa8a 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -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, + 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); + } + }); +}); diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 711fd6c65..3936d5c99 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -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; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 2c37cfd24..29a5d28e7 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -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 }), diff --git a/src/ghidra/GhidraRawBinaryLanguage.ts b/src/ghidra/GhidraRawBinaryLanguage.ts index d63592e76..9a4f254b2 100644 --- a/src/ghidra/GhidraRawBinaryLanguage.ts +++ b/src/ghidra/GhidraRawBinaryLanguage.ts @@ -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, ): 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": diff --git a/src/ghidra/GhidraRawBinaryLoadImage.test.ts b/src/ghidra/GhidraRawBinaryLoadImage.test.ts index a20fd9642..2fc11502e 100644 --- a/src/ghidra/GhidraRawBinaryLoadImage.test.ts +++ b/src/ghidra/GhidraRawBinaryLoadImage.test.ts @@ -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", diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index 89d8d5baa..7c2e0efb2 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -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({ diff --git a/tests/conformance/ghidra/ReaAnalyzerOptionsRegressionProbe.java b/tests/conformance/ghidra/ReaAnalyzerOptionsRegressionProbe.java new file mode 100644 index 000000000..704e6339c --- /dev/null +++ b/tests/conformance/ghidra/ReaAnalyzerOptionsRegressionProbe.java @@ -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); + } +}