feat(ghidra): admit recordless legacy MIPS32 ELFs with a pinned 32-bit language (#1898)

* feat(ghidra): admit recordless legacy MIPS32 ELFs with a pinned 32-bit language

Firmware built with pre-ABI-flags GNU toolchains declares EF_MIPS_ARCH_32
with an unspecified ABI field and no PT_MIPS_ABIFLAGS record. The lane
refused them before provider selection, and Ghidra's own header
auto-selection picks a 64-bit R6 language for exactly these files.

Admit ELF32 ET_EXEC headers with EF_MIPS_ARCH_32 or EF_MIPS_ARCH_32R2,
an unspecified ABI field, EF_MIPS_ABI2 clear and no observed ABI flags
record. o32 is recorded as an inferred analysis assumption
(mips_abi_basis), floating-point ABI semantics stay unverified, and the
existing refusals (FP64, NAN2008, ASEs, machine variants, ELF64) are
unchanged. The profile pins -processor MIPS:<LE|BE>:32:default through a
provider-pinned-v1 selection the bridge-reported language_id is checked
against after import; a caller-selected REA_GHIDRA_LANGUAGE_ID still
wins. The lane identity is versioned
elf32-exec-arch32-legacy-pinned-v1.

verify:ghidra:mips gains a recordless legacy round for both byte orders
that strips the ABI record declarations from a real mips32 fixture,
checks REA's interpretation against GNU readelf, asserts the pinned
language after import and the retained assumption limitations through a
snapshot round-trip.

Fixes #1620

* fix(ghidra): require an inspected absence for the recordless MIPS lane

Address review: undefined ABI flags means the record was not inspected,
not that none exists. Only an inspected absence (null) establishes the
recordless legacy admission; a merely unobserved record keeps its
specific refusal. Also document the legacy admission and its pinned
language in the MIPS provider guide.

* fix(ghidra): tighten the recordless MIPS admission boundaries

Address review: a recordless-looking header whose ABI-record inspection
left declaration sources unexamined (carved ELF with a partial section
table and no program-header record) no longer establishes the recordless
admission; it is refused with the inspection limitation retained. An
EF_MIPS_ARCH_32 header that declares an ABI is refused rather than
relabeled through the 32R2 declared-ABI lane.

* fix(ghidra): reject EF_MIPS_ABI_ON32 from the recordless MIPS lane

Address review: bit 0x40 declares the old n32 ABI. A recordless header
carrying it was admitted with the inferred-o32 assumption even though
the header itself declares n32; the recordless shape now requires the
bit clear and refuses it with a specific constraint.

* fix(ghidra): keep recordless relocatable MIPS objects off the legacy lane

The recordless admission now requires ET_EXEC: the lane and its
verification cover executable firmware images, and a recordless ET_REL
object must stay on the declared-ABI path instead of inheriting the
inferred-o32 assumption under an executable lane id. The ABI-record
requirement paragraph is scoped to the declared-ABI lane so it no longer
contradicts the recordless admission documented above it.

* fix(ghidra): name the ET_EXEC scope for recordless objects and probe the legacy lane

A recordless relocatable object is not an incomplete inspection, so its
refusal now names the ET_EXEC scope of the legacy admission instead of
claiming unavailable ABI headers remain unknown. The recordless legacy
verifier round reads the probe pair and applies the full assertMipsProbe
oracle like the standard round, and its report keeps the independent
GNU readelf receipt beside the fixture facts.

* fix(ghidra): retain the concrete ELF32 legacy compiler pin

* fix(ghidra): bind legacy profiles to fully admitted targets

---------

Co-authored-by: BaaRai <baarai@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
BaaRai 2026-10-13 15:42:38 +02:00 committed by GitHub
parent c6503edfc4
commit a4b8e9ccee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 707 additions and 39 deletions

View File

@ -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:<LE|BE>: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.

View File

@ -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"]]
: [

View File

@ -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
? {

View File

@ -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<MipsElfMetadata>, 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<MipsElfMetadata>, 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<MipsElfMetadata> = {}) =>
target({

View File

@ -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<Record<string, JsonValue>> => {
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<Record<string, unknown>>,
): 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 };
};

View File

@ -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,

View File

@ -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");

View File

@ -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",