diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index d5b58f00b..f010b4f4c 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -22,6 +22,28 @@ lane. MIPS16 shared images have the separate bounded profile below; other shared or compressed images remain unsupported. Their family may be identified, but provider selection returns the specific unsupported constraint rather than guessing a compatible profile. +A second admission covers recordless legacy firmware images +([#1620](https://github.com/morluto/rea/issues/1620)): ELF32 `ET_EXEC` headers +declaring `EF_MIPS_ARCH_32` or `EF_MIPS_ARCH_32R2` with an unspecified ABI +field, `EF_MIPS_ABI2` clear, and an _inspected_ absence of any ABI flags +record (a target whose record was merely not observed stays refused). For +these images o32 is an analysis assumption derived from the ELF32/ET_EXEC +structure, recorded as `mips_abi_basis` in the profile and stated as a +limitation in Evidence, never a declared fact; floating-point ABI semantics +stay unverified. Ghidra's own header auto-selection picks a 64-bit language +for exactly these files, so the lane pins `-processor MIPS::32:default` +through a `provider-pinned-v1` profile selection and checks the +bridge-reported `language_id` after import. A caller-selected +`REA_GHIDRA_LANGUAGE_ID`/`REA_GHIDRA_COMPILER_SPEC_ID` still wins over the +pin; combining the pinned profile with an environment override is a typed +refusal. A header that declares an ABI field but carries no record is still +refused. Two further boundaries apply: an inspection that could not examine +every ABI declaration source (for example a carved ELF whose section table +extends past the captured bytes without a program-header record) does not +establish the recordless admission, and an `EF_MIPS_ARCH_32` header that +_declares_ an ABI is refused rather than relabeled, because the declared-ABI +lane is verified for `EF_MIPS_ARCH_32R2` headers only. + Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and soft-float targets. Target resolution also reads the 24-byte ABI flags record @@ -40,12 +62,14 @@ absent or supply a default ABI. The field layout follows [`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h). -Provider admission requires record version 0, MIPS32r2, 32-bit GPRs, no CPR2, -no ISA extension, ASE or unknown general flags, and one of two floating-point -declarations: 32-bit CPR1 with a double-precision or FPXX ABI, or no CPR1 with -the soft-float ABI. Other combinations, including single-float, FP64 and -unspecified ABIs, are refused. Missing records remain unknown rather than -defaulting to a compatible ABI, and this lane refuses them. Unknown record +Provider admission on the declared-ABI lane above requires record version 0, +MIPS32r2, 32-bit GPRs, no CPR2, no ISA extension, ASE or unknown general +flags, and one of two floating-point declarations: 32-bit CPR1 with a +double-precision or FPXX ABI, or no CPR1 with the soft-float ABI. Other +combinations, including single-float, FP64 and unspecified ABIs, are refused. +Missing records remain unknown rather than defaulting to a compatible ABI, and +the declared-ABI lane refuses them; the recordless legacy admission above is +the only path that admits a recordless image. Unknown record values are retained for diagnosis but do not gain provider support. These checks bind the declared interpretation; the fixtures do not establish full floating-point behavioral conformance. diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index 1c04ea978..12c00366d 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; import { inspectMipsReadelf, + inspectLegacyMipsReadelf, assertMipsLoadedImage, assertMipsProbe, assertMipsGlobal, @@ -122,6 +123,157 @@ async function verifySoftFloat(call, symbols, byteOrder) { const reports = []; let primaryFailure; try { + if (!psp) { + // Recordless legacy admission: ABI field 0, no ABI flags record, pinned + // 32-bit language. Independent GNU readelf checks the header interpretation. + for (const byteOrder of ["little", "big"]) { + const fixture = await buildMipsFixture(workspace, byteOrder, "hard", { + legacy: true, + }); + const legacy = await inspectLegacyMipsReadelf(fixture.path, byteOrder); + const resolved = await parseBinaryTarget(fixture.path); + if (!resolved.ok) throw resolved.error; + assert.equal(resolved.value.mips.flags, 0x50000001); + assert.equal(resolved.value.mips.abiFlags, null); + assert.deepEqual( + resolved.value.mips.limitations ?? [], + legacy.limitations, + "REA legacy ABI-record absence interpretation disagrees with GNU readelf", + ); + assert.equal(resolved.value.sha256, fixture.sha256); + const snapshotPath = join(workspace, `${byteOrder}-legacy.snapshot.json`); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + const client = new Client({ + name: "rea-real-mips-legacy-proof", + version: "1", + }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); + }); + const call = async (name, args = {}) => { + const reply = await client.callTool( + { name, arguments: args }, + undefined, + { timeout: 180000 }, + ); + return requireMcpOperationResult(reply, name); + }; + try { + await client.connect(transport); + const selected = await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + assert.equal(selected.architecture, "mips"); + assert.equal(selected.sha256, fixture.sha256); + const image = await call("inspect_native_load_image"); + assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256); + // The pinned import language is the checked one, not an auto-selection. + assert.equal( + image.observations.language_id, + `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`, + ); + const procedures = await call("list_procedures"); + const entry = procedures.find((p) => p.value === "rea_mips_entry"); + assert.ok(entry, "Entry function missing from legacy inventory"); + assert.equal( + BigInt(entry.address), + BigInt(legacy.symbols.rea_mips_entry), + ); + const probe = await call("inspect_native_instruction", { + address: legacy.symbols.rea_mips_probe, + }); + const probeBranch = await call("inspect_native_instruction", { + address: `0x${(BigInt(legacy.symbols.rea_mips_probe) + 4n).toString(16)}`, + }); + assertMipsProbe( + probe, + probeBranch, + byteOrder, + legacy.symbols.rea_mips_probe, + ); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { procedure: entry.address }), + ); + assert.ok(dossier.pseudocode.trim().length > 0); + const saved = await call("close_binary", { + snapshot_path: snapshotPath, + }); + assert.ok(saved.evidence_records > 0); + const snapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(snapshotPath, "utf8")), + ); + assert.equal(snapshot.target.architecture, "mips"); + const parameters = snapshot.binding.analysis_profile.parameters; + assert.equal( + parameters.language_id, + `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`, + ); + assert.equal(parameters.language_selection, "provider-pinned-v1"); + assert.equal( + parameters.mips_support_lane, + "elf32-exec-arch32-legacy-pinned-v1", + ); + assert.equal(parameters.mips_abi_basis, "inferred-o32-assumption-v1"); + assert.ok( + snapshot.evidence_bundle.records.some((record) => + parseEvidence(record).limitations.some((item) => + item.includes("analysis assumption"), + ), + ), + "Legacy o32 assumption limitation missing from retained Evidence", + ); + assert.ok( + snapshot.evidence_bundle.records.some((record) => + parseEvidence(record).limitations.some((item) => + item.includes("Floating-point ABI semantics are unverified"), + ), + ), + "Legacy floating-point uncertainty missing from retained Evidence", + ); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + assert.deepEqual(await call("list_procedures"), procedures); + await call("close_binary"); + } catch (cause) { + throw new Error( + `MIPS legacy ${byteOrder} verification failed; MCP stderr: ${stderr}`, + { cause }, + ); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + reports.push({ + ...fixture, + independent_reader: legacy, + variant: "recordless-legacy-pinned", + effective_language: `MIPS:${ + byteOrder === "little" ? "LE" : "BE" + }:32:default`, + procedures: "verified-through-snapshot", + checks: [ + "recordless header admitted with inferred o32 assumption", + "pinned 32-bit language checked after import", + "legacy limitations retained in Evidence", + "snapshot round-trip and profile parameters", + ], + status: "passed", + }); + } + } for (const [byteOrder, floatAbi] of psp ? [["little", "hard"]] : [ diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 81defabd3..66be760bf 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -15,7 +15,7 @@ import { err, ok, type Result } from "../domain/result.js"; import type { GhidraInstallationInspection } from "./GhidraInstallation.js"; import { ghidraMipsProfileParameters, - ghidraMipsSharedLanguageOverride, + ghidraMipsLanguageOverride, } from "./GhidraMipsProfile.js"; import { ghidraRiscvLanguageOverride, @@ -44,8 +44,7 @@ import { export const ghidraPinnedLanguage = ( target: BinaryTarget, ): GhidraLanguageOverride | undefined => - ghidraMipsSharedLanguageOverride(target) ?? - ghidraRiscvLanguageOverride(target); + ghidraMipsLanguageOverride(target) ?? ghidraRiscvLanguageOverride(target); /** Resolve version-bound, deterministic semantics before Ghidra imports a target. */ export const resolveGhidraAnalysisProfile = async ( @@ -168,7 +167,7 @@ export const resolveGhidraAnalysisProfile = async ( `The caller-selected processor or compiler specification is outside the verified ${ target.architecture === "riscv" ? "generic little-endian RISC-V" - : "endian-matched MIPS32 shared-image" + : "endian-matched MIPS32" } profile. The requested override has not been changed.`, ), ); @@ -273,7 +272,7 @@ export const resolveGhidraAnalysisProfile = async ( } : {}), ...analyzerParameters, - ...ghidraMipsProfileParameters(target), + ...ghidraMipsProfileParameters(target, languageOverride), ...ghidraRiscvProfileParameters(target), ...(pspExtension?.ok === true ? { diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index ae72a3069..800de9490 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -10,10 +10,17 @@ import { HopperProvider } from "../hopper/HopperProvider.js"; import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js"; import { IdaProvider } from "../ida/IdaProvider.js"; import { GhidraProvider } from "./GhidraProvider.js"; +import { + ghidraMipsProfileParameters, + ghidraPinnedMipsLanguage, +} from "./GhidraMipsProfile.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js"; import type { GhidraMipsSoftFloatReport } from "./GhidraMipsProfile.js"; -import { ghidraProfileLanguageOverride } from "./GhidraAnalysisProfile.js"; +import { + ghidraPinnedLanguage, + ghidraProfileLanguageOverride, +} from "./GhidraAnalysisProfile.js"; // Fake installation inspection tests admission/profile contracts, not Ghidra. const host: GhidraInstallationHost = { @@ -161,6 +168,126 @@ describe("bounded Ghidra MIPS admission", () => { }); }); +describe("recordless legacy MIPS32 admission", () => { + it("keeps recordless relocatable objects off the inferred-o32 lane", async () => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const object = target({ + flags: 0x50000001, + abiFlags: null, + type: 1, + }); + expect(ghidra.inspectTargetSupport(object)).toMatchObject({ + status: "unsupported", + }); + const resolved = await ghidra.resolveAnalysisProfile(object); + expect(resolved.ok).toBe(false); + }); + + it.each([ + [{ flags: 0x50000001 }, "EF_MIPS_ARCH_32"], + [{ flags: 0x70000001 }, "EF_MIPS_ARCH_32R2"], + ] satisfies [Partial, string][])( + "admits a recordless legacy header (%s) with a pinned 32-bit language", + async (change) => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const little = target({ ...change, abiFlags: null }); + const big = target({ ...change, abiFlags: null, byteOrder: "big" }); + expect(ghidra.inspectTargetSupport(little).status).toBe("supported"); + expect(ghidra.inspectTargetSupport(big).status).toBe("supported"); + const resolved = await ghidra.resolveAnalysisProfile(little); + if (!resolved.ok) throw resolved.error; + expect(resolved.value.profile?.parameters).toMatchObject({ + mips_support_lane: "elf32-exec-arch32-legacy-pinned-v1", + mips_abi_basis: "inferred-o32-assumption-v1", + language_id: "MIPS:LE:32:default", + compiler_spec_id: "default", + language_selection: "provider-pinned-v1", + }); + const resolvedBig = await ghidra.resolveAnalysisProfile(big); + if (!resolvedBig.ok) throw resolvedBig.error; + expect(resolvedBig.value.profile?.parameters).toMatchObject({ + language_id: "MIPS:BE:32:default", + }); + }, + ); + + it("requires an inspected absence; a merely unobserved record stays refused", () => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const withRecord = target({ flags: 0x50000001 }) as BinaryTarget & { + mips: MipsElfMetadata; + }; + const { abiFlags: _record, ...unobservedMips } = withRecord.mips; + void _record; + expect( + ghidra.inspectTargetSupport({ + ...withRecord, + mips: unobservedMips, + } as BinaryTarget), + ).toMatchObject({ + status: "unsupported", + reason: expect.stringContaining("recordless legacy header"), + }); + }); + + it.each([ + [{ flags: 0x50000021 }, "n32"], + [{ flags: 0x50000041 }, "EF_MIPS_ABI_ON32"], + [{ flags: 0x50000101 }, "EF_MIPS_32BITMODE"], + [{ flags: 0x70000101 }, "EF_MIPS_32BITMODE"], + [ + { flags: 0x50001001 }, + "EF_MIPS_ARCH_32R2 for headers that declare an ABI", + ], + [{ flags: 0x50000201 }, "EF_MIPS_FP64"], + [{ flags: 0x50000401 }, "EF_MIPS_NAN2008"], + [{ flags: 0x60000001 }, "32R2"], + [ + { + flags: 0x50000001, + limitations: [ + "MIPS section header table at file offset 96 extends beyond the captured file: examined 2 of 5 complete headers. ABI declarations in unavailable headers remain unknown.", + ], + }, + "complete ABI-record inspection", + ], + ] satisfies [Partial, string][])( + "keeps refusing unverified recordless variants %j", + (change, reason) => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + expect( + ghidra.inspectTargetSupport(target({ ...change, abiFlags: null })), + ).toMatchObject({ + status: "unsupported", + reason: expect.stringContaining(reason), + }); + }, + ); + + it("a caller-selected language wins over the provider pin", () => { + const parameters = ghidraMipsProfileParameters( + target({ flags: 0x50000001, abiFlags: null }), + { languageId: "MIPS:LE:32:default", compilerSpecId: "default" }, + ); + expect(parameters.language_selection).toBeUndefined(); + expect(parameters.language_id).toBeUndefined(); + expect(parameters).toMatchObject({ + mips_support_lane: "elf32-exec-arch32-legacy-pinned-v1", + }); + }); + + it("reads the pinned import language back only from pinned profiles", () => { + const pinned = ghidraMipsProfileParameters( + target({ flags: 0x50000001, abiFlags: null }), + ); + expect(ghidraPinnedMipsLanguage(pinned)).toEqual({ + languageId: "MIPS:LE:32:default", + compilerSpecId: "default", + }); + const standard = ghidraMipsProfileParameters(target()); + expect(ghidraPinnedMipsLanguage(standard)).toBeUndefined(); + }); +}); + const report: GhidraMipsSoftFloatReport = { policy: "o32-soft-float-prototype-v1", functions: 5, @@ -218,6 +345,56 @@ const session = async ( }; }; +describe("legacy profile bindings", () => { + it("refuses a legacy endian pin committed for a different target", async () => { + const { counts, client } = await session( + target({ flags: 0x50000001, abiFlags: null, byteOrder: "big" }), + undefined, + target({ flags: 0x50000001, abiFlags: null, byteOrder: "little" }), + ); + expect((await client.execute("health", {})).ok).toBe(false); + expect(counts.starts).toBe(0); + }); + it.each([0x50000201, 0x50000401, 0x54000001, 0x50910001])( + "refuses unsupported same-endian legacy flags %i before startup", + async (flags) => { + const value = target({ flags, abiFlags: null }); + expect(ghidraPinnedLanguage(value)).toBeUndefined(); + const { counts, client } = await session( + value, + undefined, + target({ flags: 0x50000001, abiFlags: null }), + ); + expect((await client.execute("health", {})).ok).toBe(false); + expect(counts.starts).toBe(0); + }, + ); + const unpinnedTargets: BinaryTarget[] = [ + target(), + { + path: "/fixture.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }, + ]; + it.each(unpinnedTargets)( + "refuses a pinned profile for an unpinned target $architecture", + async (value) => { + expect(ghidraPinnedLanguage(value)).toBeUndefined(); + const { counts, client } = await session( + value, + undefined, + target({ flags: 0x50000001, abiFlags: null }), + ); + expect((await client.execute("health", {})).ok).toBe(false); + expect(counts.starts).toBe(0); + }, + ); +}); + describe("MIPS16 shared-image profile", () => { const shared = (change: Partial = {}) => target({ diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index 031e273f1..76a753ccb 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -1,6 +1,6 @@ +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; const MIPS_FP_ABI_SOFT = 3; const MIPS_ET_REL = 1; @@ -15,17 +15,56 @@ const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean => target.mips.type === MIPS_ET_DYN && (target.mips.flags & 0x0f000000) === MIPS16_HEADER_ASE; -/** Generic MIPS32 permits per-function ISA_MODE; a global 16e processor does not. */ -export const ghidraMipsSharedLanguageOverride = ( +/** Derive the verified MIPS32 import pin from this target's observed byte order. */ +export const ghidraMipsLanguageOverride = ( target: BinaryTarget, ): GhidraLanguageOverride | undefined => - isGhidraMips16SharedTarget(target) && target.architecture === "mips" + (isGhidraMips16SharedTarget(target) || hasLegacyMipsHeaderShape(target)) && + ghidraMipsUnsupportedReason(target) === null && + target.architecture === "mips" ? { languageId: `MIPS:${target.mips.byteOrder === "little" ? "LE" : "BE"}:32:default`, compilerSpecId: "default", } : undefined; +/** + * Recordless legacy ELF32 MIPS admission: the header declares no ABI field and + * carries no ABI flags record, so o32 is an analysis assumption derived from + * ELF32/ET_EXEC structure, never a declared fact. Ghidra's header + * auto-selection picks a 64-bit language for exactly these files, so the lane + * pins the 32-bit language instead. + */ +const hasLegacyMipsHeaderShape = (target: BinaryTarget): boolean => { + if ( + target.kind !== "executable" || + target.architecture !== "mips" || + target.format !== "elf" || + target.mips === undefined + ) + return false; + const { flags, abiFlags, limitations } = target.mips; + const arch = flags & 0xf0000000; + return ( + // The lane and its verification cover executable firmware images only; + // recordless relocatable objects stay outside the inferred-o32 assumption. + target.mips.elfClass === 32 && + target.mips.type === MIPS_ET_EXEC && + (arch === 0x50000000 || arch === 0x70000000) && + (flags & 0x0000f000) === 0 && + (flags & 0x20) === 0 && + // EF_MIPS_ABI_ON32 (bit 0x40) declares the old n32 ABI; it is not an + // unspecified-ABI header and must not inherit the o32 assumption. + (flags & 0x40) === 0 && + (flags & 0x100) === 0 && + // Undefined means the record was not inspected; only an inspected + // absence establishes the recordless legacy admission. An inspection + // that could not examine every declaration source stays unknown. + abiFlags === null && + (limitations === undefined || limitations.length === 0) + ); +}; + /** Keep family recognition separate from the deliberately bounded Ghidra lane. */ export const ghidraMipsUnsupportedReason = ( target: BinaryTarget, @@ -48,13 +87,41 @@ export const ghidraMipsUnsupportedReason = ( return "MIPS16, microMIPS and other ASE encodings are outside this standard-instruction MIPS lane."; if ((metadata.flags & 0x00ff0000) !== 0) return "Machine-specific MIPS variants, including PSP Allegrex, need a separately supported processor profile."; - if ((metadata.flags & 0xf0000000) !== 0x70000000) - return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2; other ISA revisions need separate verification."; + const arch = metadata.flags & 0xf0000000; + if (arch !== 0x70000000 && arch !== 0x50000000) + return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2 or a recordless legacy EF_MIPS_ARCH_32 header; other ISA revisions need separate verification."; + const recordlessShape = + (metadata.flags & 0x0000f000) === 0 && + (metadata.flags & 0x20) === 0 && + metadata.abiFlags === null; + if (recordlessShape) { + if ((metadata.flags & 0x100) !== 0) + return "EF_MIPS_32BITMODE declares code for a 64-bit processor in 32-bit mode; it is outside the recordless MIPS32 admission."; + if ((metadata.flags & 0x40) !== 0) + return "EF_MIPS_ABI_ON32 declares the n32 ABI; recordless headers carrying it are outside the recordless legacy admission."; + if ((metadata.flags & 0x00000200) !== 0) + return "EF_MIPS_FP64 requires separate verification of 64-bit floating-point register semantics in this Ghidra lane."; + if ((metadata.flags & 0x00000400) !== 0) + return "EF_MIPS_NAN2008 requires separate verification of NaN encoding semantics in this Ghidra lane."; + if (hasLegacyMipsHeaderShape(target)) return null; + // A recordless relocatable object is not an incomplete inspection; the + // lane is scoped to executable firmware images by design. + if (metadata.type !== MIPS_ET_EXEC) + return "The recordless legacy admission covers ET_EXEC firmware images only; recordless relocatable objects need their own verified build, and the declared-ABI lane requires an ABI flags record."; + // The inspection could not examine every ABI declaration source, so the + // recordless admission is not established for this header. + return [ + "This recordless legacy admission requires a complete ABI-record inspection; ABI declarations in unavailable headers remain unknown.", + ...(metadata.limitations ?? []), + ].join(" "); + } if ( (metadata.flags & 0x0000f000) !== 0x00001000 || (metadata.flags & 0x20) !== 0 ) - return "This Ghidra MIPS lane requires an explicit o32 ABI; unspecified, n32 and EABI targets remain unsupported."; + return "This Ghidra MIPS lane requires an explicit o32 ABI or a recordless legacy header with an unspecified ABI; n32, EABI and mixed declarations remain unsupported."; + if (arch !== 0x70000000) + return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2 for headers that declare an ABI; EF_MIPS_ARCH_32 headers are admitted only through the recordless legacy admission."; if ((metadata.flags & 0x00000200) !== 0) return "EF_MIPS_FP64 requires separate verification of 64-bit floating-point register semantics in this Ghidra lane."; if ((metadata.flags & 0x00000400) !== 0) @@ -98,6 +165,11 @@ export const ghidraMipsUnsupportedReason = ( return null; }; +/** A legacy lane is established only after every MIPS declaration is admitted. */ +export const isLegacyMipsAdmission = (target: BinaryTarget): boolean => + hasLegacyMipsHeaderShape(target) && + ghidraMipsUnsupportedReason(target) === null; + /** Soft-float prototype policy applied by ReaGhidraMipsSoftFloat.java after analysis. */ export const GHIDRA_MIPS_SOFT_FLOAT_STORAGE = "o32-soft-float-prototype-v1"; @@ -174,6 +246,7 @@ export const ghidraMipsSharedLimitations = ( /** Commit source ELF interpretation without confusing target ISA with host CPU. */ export const ghidraMipsProfileParameters = ( target: BinaryTarget, + languageOverride?: GhidraLanguageOverride, ): Readonly> => { if ( target.kind !== "executable" || @@ -182,6 +255,18 @@ export const ghidraMipsProfileParameters = ( ) return {}; const abi = target.mips.abiFlags; + // A deliberate caller-selected language wins over the legacy pin; the pin + // exists because header auto-selection misselects a 64-bit language. + const legacy = isLegacyMipsAdmission(target); + const language = ghidraMipsLanguageOverride(target); + const pinnedLanguage = + legacy && languageOverride === undefined && language !== undefined + ? { + language_id: language.languageId, + compiler_spec_id: language.compilerSpecId ?? "default", + language_selection: "provider-pinned-v1", + } + : {}; return { mips_elf: { elf_class: target.mips.elfClass, @@ -231,10 +316,28 @@ export const ghidraMipsProfileParameters = ( ? { mips_soft_float_storage: GHIDRA_MIPS_SOFT_FLOAT_STORAGE } : {}), instruction_context_evidence: "per-address-isa-mode-v1", - mips_support_lane: isGhidraMips16SharedTarget(target) - ? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1" - : target.mips.type === MIPS_ET_REL - ? "elf32-rel-o32-arch32r2-standard-v1" - : "elf32-exec-o32-arch32r2-standard-v3", + mips_support_lane: legacy + ? "elf32-exec-arch32-legacy-pinned-v1" + : isGhidraMips16SharedTarget(target) + ? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1" + : target.mips.type === MIPS_ET_REL + ? "elf32-rel-o32-arch32r2-standard-v1" + : "elf32-exec-o32-arch32r2-standard-v3", + ...(legacy ? { mips_abi_basis: "inferred-o32-assumption-v1" } : {}), + ...pinnedLanguage, }; }; + +/** Read the provider-pinned import language back from a resolved legacy profile. */ +export const ghidraPinnedMipsLanguage = ( + parameters: Readonly>, +): GhidraLanguageOverride | undefined => { + if (parameters.language_selection !== "provider-pinned-v1") return undefined; + const languageId = parameters.language_id; + const compilerSpecId = parameters.compiler_spec_id; + if (typeof languageId !== "string" || typeof compilerSpecId !== "string") + return undefined; + return compilerSpecId === "language-default" + ? { languageId } + : { languageId, compilerSpecId }; +}; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 3a15dbc4e..45a20ba6f 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -20,6 +20,7 @@ import { sameGhidraDisabledAnalyzers, sameGhidraLanguageOverride, } from "./GhidraAnalysisProfile.js"; +import { ghidraPinnedMipsLanguage } from "./GhidraMipsProfile.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { @@ -449,13 +450,39 @@ export const createGhidraProviderClient = (input: { }), ); const seeds = seedProfile.data; - const languageOverride = ghidraProfileLanguageOverride( + const parameterLanguage = ghidraProfileLanguageOverride( committedProfile.parameters, ); + const pinnedLanguage = ghidraPinnedMipsLanguage(committedProfile.parameters); + const targetLanguage = ghidraPinnedLanguage(target); + if (pinnedLanguage !== undefined && targetLanguage === undefined) + return unavailableClient( + new ProviderAdapterError("ghidra", "open_binary", { + diagnostics: { + reason: + "The committed profile pins a MIPS language that the current target does not establish; reopen an admitted target to resolve a new profile.", + }, + }), + ); + // Provider-pinned profiles declare their language through the same + // parameter slot caller-configured profiles use, so only an environment + // override conflicts with the legacy pin; the slot itself carries it. + if ( + pinnedLanguage !== undefined && + config.ghidraLanguageOverride !== undefined + ) + return unavailableClient( + new ProviderAdapterError("ghidra", "open_binary", { + diagnostics: { + reason: + "The committed legacy MIPS profile pins its import language; REA_GHIDRA_LANGUAGE_ID/REA_GHIDRA_COMPILER_SPEC_ID cannot be combined with it. Reopen the target without the override.", + }, + }), + ); if ( !sameGhidraLanguageOverride( - languageOverride, - config.ghidraLanguageOverride ?? ghidraPinnedLanguage(target), + parameterLanguage, + config.ghidraLanguageOverride ?? targetLanguage, ) ) return unavailableClient( @@ -466,6 +493,9 @@ export const createGhidraProviderClient = (input: { }, }), ); + // Both caller-selected and provider-pinned languages use the validated + // committed slot; the profile cannot supply its own applicability proof. + const importLanguage = parameterLanguage; const disabledAnalyzers = ghidraProfileDisabledAnalyzers( committedProfile.parameters, ); @@ -483,6 +513,7 @@ export const createGhidraProviderClient = (input: { }, }), ); + const invalidProfile = validateGhidraExtensionProfile( extensions, config, @@ -523,6 +554,13 @@ export const createGhidraProviderClient = (input: { ...(target.kind === "executable" && target.architecture === "mips" ? (target.mips.limitations ?? []) : []), + ...(committedProfile.parameters.mips_abi_basis === + "inferred-o32-assumption-v1" + ? [ + "The ELF header declares no ABI field and no ABI flags record was observed; o32 calling conventions are an analysis assumption of this lane, not a declared fact.", + "Floating-point ABI semantics are unverified for this recordless legacy admission.", + ] + : []), ...ghidraMipsRelocatableLimitations(target), ...ghidraMipsSharedLimitations(target), ...ghidraRiscvLimitations(target), @@ -563,7 +601,9 @@ export const createGhidraProviderClient = (input: { baseAddr: target.entryPoint, }, }), - ...(languageOverride === undefined ? {} : { languageOverride }), + ...(importLanguage === undefined + ? {} + : { languageOverride: importLanguage }), ...(config.ghidraDisabledAnalyzers === undefined ? {} : { disabledAnalyzers: config.ghidraDisabledAnalyzers }), @@ -588,13 +628,13 @@ export const createGhidraProviderClient = (input: { expectedCompilerSpecId: "default", } : rawLanguage === undefined - ? languageOverride === undefined + ? importLanguage === undefined ? {} : { - expectedLanguageId: languageOverride.languageId, - ...(languageOverride.compilerSpecId === undefined + expectedLanguageId: importLanguage.languageId, + ...(importLanguage.compilerSpecId === undefined ? {} - : { expectedCompilerSpecId: languageOverride.compilerSpecId }), + : { expectedCompilerSpecId: importLanguage.compilerSpecId }), } : { expectedLanguageId: rawLanguage.languageId, diff --git a/tests/conformance/ghidra/mips-checks.mjs b/tests/conformance/ghidra/mips-checks.mjs index 3e6af3733..e5c6d335c 100644 --- a/tests/conformance/ghidra/mips-checks.mjs +++ b/tests/conformance/ghidra/mips-checks.mjs @@ -160,7 +160,89 @@ export async function inspectMipsReadelf(path, byteOrder, floatAbi = "hard") { }; } -/** Check effective Ghidra observations, not the requested analysis profile. */ +/** Independent GNU readelf interpretation for the recordless legacy fixture. */ +export async function inspectLegacyMipsReadelf(path, byteOrder) { + const command = process.env.REA_MIPS_READELF ?? "readelf"; + const options = { + env: { ...process.env, LC_ALL: "C" }, + timeout: 10000, + maxBuffer: 1024 * 1024, + }; + const version = await execute(command, ["--version"], options); + assert.match( + version.stdout, + /^GNU readelf\b/u, + "This lane requires GNU readelf", + ); + const header = await execute(command, ["-h", "-W", path], options); + assert.equal( + header.stderr.trim(), + "", + `readelf diagnostics: ${header.stderr}`, + ); + const field = (expression, label) => { + const matches = [...header.stdout.matchAll(expression)]; + assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`); + return matches[0][1].trim(); + }; + assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), "ELF32"); + assert.equal( + field(/^\s*Data:.*?,\s*(little|big) endian\s*$/gmu, "byte order"), + byteOrder, + ); + assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u); + assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^MIPS\b/u); + const flags = field(/^\s*Flags:\s*(0x[\da-f]+)\b.*$/gmu, "flags"); + assert.equal( + Number(flags), + 0x50000001, + "Legacy fixture must carry EF_MIPS_ARCH_32 with an unspecified ABI field", + ); + const abi = await execute(command, ["-A", path], options); + assert.equal(abi.stderr.trim(), "", `readelf diagnostics: ${abi.stderr}`); + // -A also prints unrelated MIPS facts such as the static GOT; only the ABI + // flags declaration itself must be gone from the recordless fixture. + assert.doesNotMatch( + abi.stdout, + /MIPS ABI Flags Version/u, + "GNU readelf still observes an ABI flags declaration in the legacy fixture", + ); + const symbols = await execute(command, ["-s", "-W", path], options); + assert.equal( + symbols.stderr.trim(), + "", + `readelf diagnostics: ${symbols.stderr}`, + ); + const parsed = {}; + for (const name of ["rea_mips_entry", "rea_mips_probe"]) { + const matches = [ + ...symbols.stdout.matchAll( + new RegExp( + `^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+FUNC\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`, + "gmu", + ), + ), + ]; + assert.equal( + matches.length, + 1, + `Missing or ambiguous readelf symbol ${name}`, + ); + parsed[name] = `0x${BigInt(`0x${matches[0][1]}`).toString(16)}`; + } + return { + flags: Number(flags), + limitations: [], + symbols: parsed, + command, + version: version.stdout.split("\n")[0], + raw: { + header: header.stdout, + abi: abi.stdout, + symbols: symbols.stdout, + }, + }; +} export function assertMipsLoadedImage(observations, byteOrder, sha256) { assert.equal(observations.language_id, language(byteOrder)); assert.equal(observations.compiler_spec_id, "default"); diff --git a/tests/conformance/ghidra/mips-fixture.mjs b/tests/conformance/ghidra/mips-fixture.mjs index b88669e31..1d19aaa57 100644 --- a/tests/conformance/ghidra/mips-fixture.mjs +++ b/tests/conformance/ghidra/mips-fixture.mjs @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -12,11 +12,13 @@ export async function buildMipsFixture( directory, byteOrder, floatAbi = "hard", + options = {}, ) { if (byteOrder !== "little" && byteOrder !== "big") throw new Error("MIPS fixture byte order must be little or big"); if (floatAbi !== "hard" && floatAbi !== "soft") throw new Error("MIPS fixture float ABI must be hard or soft"); + const legacy = options.legacy === true; const soft = floatAbi === "soft"; const target = byteOrder === "little" ? "mipsel-linux-gnu" : "mips-linux-gnu"; const source = fileURLToPath(new URL("../c/mips.c", import.meta.url)); @@ -24,10 +26,13 @@ export async function buildMipsFixture( new URL("../c/mips-soft-float.c", import.meta.url), ); const probe = fileURLToPath(new URL("./mips-probe.S", import.meta.url)); - const path = join(directory, `${target}${soft ? "-soft-float" : ""}.elf`); + const path = join( + directory, + `${target}${soft ? "-soft-float" : ""}${legacy ? "-legacy" : ""}.elf`, + ); const args = [ `--target=${target}`, - "-march=mips32r2", + `-march=${legacy ? "mips32" : "mips32r2"}`, "-mabi=32", ...(soft ? ["-msoft-float"] : []), "-mno-abicalls", @@ -45,8 +50,88 @@ export async function buildMipsFixture( "-o", path, ]; + const compiled = await compileMipsFixture(path, byteOrder, 2, args, { + expectedFlags: legacy ? 0x50001001 : 0x70001001, + }); + // Legacy variant: retype the ABI record declarations out of the image and + // zero the header ABI field, matching pre-ABI-flags 2000s firmware toolchains + // (their compilers predate the record; the header leaves the ABI unspecified). + let attested = compiled; + if (legacy) { + const raw = await readFile(path); + const write16 = (offset, value) => + byteOrder === "little" + ? raw.writeUInt16LE(value, offset) + : raw.writeUInt16BE(value, offset); + const write32 = (offset, value) => + byteOrder === "little" + ? raw.writeUInt32LE(value, offset) + : raw.writeUInt32BE(value, offset); + const read16 = (offset) => + byteOrder === "little" + ? raw.readUInt16LE(offset) + : raw.readUInt16BE(offset); + const read32 = (offset) => + byteOrder === "little" + ? raw.readUInt32LE(offset) + : raw.readUInt32BE(offset); + const flags = read32(36); + if ((flags & 0xf0000000) !== 0x50000000) + throw new Error("Legacy fixture did not build as EF_MIPS_ARCH_32"); + write32(36, flags & ~0x0000f000); + const programOffset = read32(28); + const programCount = read16(44); + if (read16(42) !== 32) + throw new Error("Legacy fixture has non-standard program headers"); + for (let index = 0; index < programCount; index += 1) { + const offset = programOffset + index * 32; + if (read32(offset) === 0x70000003) write32(offset, 0); + } + const sectionOffset = read32(32); + const sectionCount = read16(48); + if (read16(46) !== 40) + throw new Error("Legacy fixture has non-standard section headers"); + for (let index = 0; index < sectionCount; index += 1) { + const offset = sectionOffset + index * 40; + if (read32(offset + 4) === 0x7000002a) write32(offset + 4, 1); + } + // GNU readelf resolves the record by section name, not type, so a retyped + // header alone keeps a named declaration. Replace the name in the string + // table in place, preserving every remaining name and offset. + const stringTableIndex = read16(50); + const stringTableOffset = + stringTableIndex < sectionCount + ? read32(sectionOffset + stringTableIndex * 40 + 16) + : -1; + const named = + stringTableOffset === -1 + ? -1 + : raw.indexOf( + Buffer.from(".MIPS.abiflags", "ascii"), + stringTableOffset, + ); + if (named === -1) + throw new Error("Legacy fixture lacks the expected ABI section name"); + raw.write("x.rea.no.flags", named, "ascii"); + await writeFile(path, raw); + const patched = await readFile(path); + const patchedFlags = + byteOrder === "little" + ? patched.readUInt32LE(36) + : patched.readUInt32BE(36); + if (patchedFlags !== 0x50000001) + throw new Error( + "Legacy fixture did not end with the recordless o32 declaration", + ); + attested = { + ...compiled, + flags: patchedFlags, + sha256: createHash("sha256").update(patched).digest("hex"), + }; + } return { - ...(await compileMipsFixture(path, byteOrder, 2, args)), + ...attested, + legacy, float_abi: floatAbi, source_sha256: createHash("sha256") .update(await readFile(source)) @@ -108,7 +193,13 @@ export async function buildMipsRelocatableFixture( } /** Compile and attest the shared ELF32/o32/MIPS32r2 fixture boundary. */ -async function compileMipsFixture(path, byteOrder, objectType, args) { +async function compileMipsFixture( + path, + byteOrder, + objectType, + args, + options = {}, +) { const compiler = process.env.REA_MIPS_CLANG ?? "clang"; const version = await execute(compiler, ["--version"], { timeout: 10000 }); await execute(compiler, args, { timeout: 60000 }); @@ -128,7 +219,7 @@ async function compileMipsFixture(path, byteOrder, objectType, args) { bytes[5] !== (byteOrder === "little" ? 1 : 2) || read16(16) !== objectType || read16(18) !== 8 || - read32(36) !== 0x70001001 + read32(36) !== (options.expectedFlags ?? 0x70001001) ) throw new Error( "Compiler did not produce the declared ELF32/o32/MIPS32r2 fixture",