Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/ghidra-mips.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ 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.

A third admission covers recordless MIPS I ELF32 `ET_EXEC` images whose header
explicitly declares o32, with complete ABI-record inspection and no ASE,
machine-specific, n32, 32-bit-on-64-bit, FP64 or NaN2008 declarations. Both byte
orders use the pinned `MIPS:<LE|BE>:32:default` language, checked after import.
The header's o32 declaration is retained as a fact; floating-point storage remains
unverified and is reported in Evidence limitations. ABI-record-bearing MIPS I,
MIPS II/III, relocatable/shared MIPS I and unspecified-ABI MIPS I remain outside
this increment. This does not widen the existing Windows P0 boundary.

Run `node scripts/verify-real-ghidra-mips1.mjs` after building, with the existing
Ghidra/Java installation and `REA_READELF` when GNU readelf is not on PATH.
The source-owned integer fixture is encoded directly from its declared MIPS I
instructions; no cross compiler is required. GNU readelf independently checks
its header and symbol. The verifier checks both byte orders through production
MCP, exact loaded words, instruction/delay-slot decoding, dossier output, retained
FP uncertainty, source immutability and snapshot replay; it also checks the LE
fixture through the production CLI. Real macOS arm64/Ghidra 12.1.4 was exercised;
Linux, Windows and a real firmware corpus were not.

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
Expand Down
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"scripts/verify/fridaBindingsCompatibility.ts",
"scripts/verify/termux/browser.mjs",
"scripts/verify-real-ghidra-noreturn.mjs",
"scripts/verify-real-ghidra-mips1.mjs",
"tests/processOwnershipRunner.ts"
],
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
Expand Down
181 changes: 181 additions & 0 deletions scripts/verify-real-ghidra-mips1.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { buildMips1Fixture } from "../tests/conformance/ghidra/mips1-fixture.mjs";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { functionDossierSchema } from "../dist/domain/hopperValues.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";

// Static inspection only. No MIPS compiler, acquisition or target execution.
if (!process.env.GHIDRA_INSTALL_DIR || !process.env.JAVA_HOME)
throw new Error("Supply GHIDRA_INSTALL_DIR and JAVA_HOME.");
const execute = promisify(execFile);
const root = await mkdtemp(join(tmpdir(), "rea-ghidra-mips1-"));
const reports = [];
try {
for (const byteOrder of ["little", "big"]) {
const fixture = await buildMips1Fixture(root, byteOrder);
const { stdout: headers } = await execute(
process.env.REA_READELF ?? "readelf",
["-h", "-s", fixture.path],
);
assert.match(headers, /ELF32/u);
assert.match(headers, /0x1001.*o32.*mips1/u);
assert.match(headers, /00401000.*rea_mips1_entry/u);
const transport = new StdioClientTransport({
command: process.execPath,
args: [fileURLToPath(new URL("./rea.mjs", import.meta.url)), "mcp"],
env: {
...process.env,
Comment thread
Bobtoshi marked this conversation as resolved.
Resolved
REA_ANALYSIS_PROVIDER: "ghidra",
REA_LOG_LEVEL: "silent",
TMPDIR: root,
},
stderr: "inherit",
});
const client = new Client({ name: "rea-mips1-verifier", version: "1" });
const call = async (name, args = {}) =>
requireMcpOperationResult(
await client.callTool({ name, arguments: args }, { timeout: 240_000 }),
name,
);
try {
await client.connect(transport);
const selected = await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
});
assert.equal(selected.sha256, fixture.sha256);
const image = await call("inspect_native_load_image");
assert.equal(
image.observations.language_id,
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
);
const first = await call("inspect_native_instruction", {
address: fixture.entry,
});
const delay = await call("inspect_native_instruction", {
address: "0x401008",
});
const branch = await call("inspect_native_instruction", {
address: "0x401004",
});
assert.equal(first.status, "decoded");
assert.match(first.mnemonic, /^addiu$/iu);
assert.match(delay.mnemonic, /^_?sll$/iu);
assert.equal(branch.status, "decoded");
assert.match(branch.mnemonic, /^beq$/iu);
assert.equal(branch.flow.kind, "jump");
assert.equal(branch.flow.conditional, true);
assert.deepEqual(branch.flow.direct_destinations, ["0x401010"]);
const loaded = await call("read_bytes", {
address: fixture.entry,
length: 24,
});
assert.equal(loaded.returned_bytes, 24);
const bytes = Buffer.from(loaded.bytes_hex, "hex");
const words = Array.from({ length: 6 }, (_, index) =>
byteOrder === "little"
? bytes.readUInt32LE(index * 4)
: bytes.readUInt32BE(index * 4),
);
assert.deepEqual(words, fixture.words);
const dossier = functionDossierSchema.parse(
await call("analyze_function", { procedure: fixture.entry }),
);
assert.ok(dossier.pseudocode.trim().length > 0);
assert.ok(dossier.assembly.some((line) => /sll/iu.test(line)));

const snapshotPath = join(root, `snapshot-${byteOrder}.json`);
await call("close_binary", { snapshot_path: snapshotPath });
const snapshot = parseAnalysisSnapshot(
JSON.parse(await readFile(snapshotPath, "utf8")),
);
assert.ok(
snapshot.evidence_bundle.records.some((record) =>
parseEvidence(record).limitations.some((text) =>
/Floating-point ABI behavior remains unverified/u.test(text),
),
),
);
assert.equal(
snapshot.binding.analysis_profile.parameters.mips_support_lane,
"elf32-exec-mips1-recordless-pinned-v1",
);
await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
const replay = await call("analyze_function", {
procedure: fixture.entry,
});
assert.deepEqual(replay, dossier);
await call("close_binary");
if (byteOrder === "little") {
const { stdout } = await execute(
process.execPath,
[
fileURLToPath(new URL("./rea.mjs", import.meta.url)),
"function",
fixture.path,
fixture.entry,
"--provider",
"ghidra",
"--full-output",
"--json",
],
{ env: process.env, timeout: 240_000, maxBuffer: 32 * 1024 ** 2 },
);
const envelope = JSON.parse(stdout);
assert.equal(envelope.ok, true);
const cliEvidence = parseEvidence(envelope.data);
assert.equal(cliEvidence.subject?.digest.sha256, fixture.sha256);
const cliDossier = functionDossierSchema.parse(
cliEvidence.normalized_result,
);
assert.equal(cliDossier.pseudocode, dossier.pseudocode);
}
assert.equal(
createHash("sha256")
.update(await readFile(fixture.path))
.digest("hex"),
fixture.sha256,
);
reports.push({
byte_order: byteOrder,
sha256: fixture.sha256,
language: image.observations.language_id,
source_unchanged: true,
loaded_bytes: true,
cli: byteOrder === "little" ? "verified" : "not-run",
instruction_decode: true,
conditional_branch_destination: true,
expected_delay_slot_instruction_decoded: true,
delay_slot_relationship: "not-exposed-by-instruction-contract",
dossier: true,
snapshot_replay: true,
fp_semantics: "unverified",
target_executed: false,
});
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
}
console.log(JSON.stringify({ status: "passed", reports }, null, 2));
} finally {
await rm(root, { recursive: true, force: true });
}
44 changes: 44 additions & 0 deletions src/ghidra/GhidraMipsProfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,50 @@ const abi = (
};
const softFloat = abi({ cpr1Size: 0, fpAbi: 3 });

it("pins inspected recordless MIPS I/o32 executables without inventing a floating-point ABI", async () => {
const provider = new GhidraProvider(config, silentLogger, {}, host);
for (const byteOrder of ["little", "big"] as const) {
const value = target({ flags: 0x1001, byteOrder, abiFlags: null });
expect(provider.inspectTargetSupport(value).status).toBe("supported");
const resolved = await provider.resolveAnalysisProfile(value);
if (!resolved.ok) throw resolved.error;
expect(resolved.value.profile?.parameters).toMatchObject({
mips_support_lane: "elf32-exec-mips1-recordless-pinned-v1",
mips_abi_basis: "declared-o32-header-fp-unverified-v1",
language_id: `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
mips_elf: { abi_flags: null },
});
}
const unexamined = target({ flags: 0x1001, abiFlags: null });
if (unexamined.kind !== "executable" || unexamined.architecture !== "mips")
throw new Error("Expected MIPS fixture");
const { abiFlags, ...unexaminedHeader } = unexamined.mips;
expect(abiFlags).toBeNull();
expect(
provider.inspectTargetSupport({ ...unexamined, mips: unexaminedHeader })
.status,
).toBe("unsupported");
for (const change of [
{ flags: 1 },
{ flags: 0x1021 },
{ flags: 0x1041 },
{ flags: 0x1101 },
{ flags: 0x1201 },
{ flags: 0x1401 },
{ flags: 0x04001001 },
{ elfClass: 64 as const },
{ type: 1 },
{ type: 3 },
{ limitations: ["ABI declarations were not fully inspected"] },
]) {
expect(
provider.inspectTargetSupport(
target({ flags: 0x1001, abiFlags: null, ...change }),
).status,
).toBe("unsupported");
}
});

describe("bounded Ghidra MIPS admission", () => {
it("binds endian and flags without widening Windows P0", async () => {
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
Expand Down
51 changes: 42 additions & 9 deletions src/ghidra/GhidraMipsProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ const MIPS_ET_EXEC = 2;
const MIPS_ET_DYN = 3;
const MIPS16_HEADER_ASE = 0x04000000;

/** Inspected recordless MIPS I executables with an explicitly declared o32 ABI. */
const hasMips1HeaderShape = (target: BinaryTarget): boolean => {
if (
target.kind !== "executable" ||
target.format !== "elf" ||
target.architecture !== "mips"
)
return false;
const { flags, abiFlags, limitations } = target.mips;
return (
target.mips.elfClass === 32 &&
target.mips.type === MIPS_ET_EXEC &&
(flags & 0xf0000000) === 0 &&
(flags & 0x0000f000) === 0x00001000 &&
(flags & 0x0fff0000) === 0 &&
// ABI2, ABI_ON32, 32BITMODE, FP64 and NAN2008 require separate semantics.
(flags & 0x760) === 0 &&
Comment thread
Bobtoshi marked this conversation as resolved.
Resolved
abiFlags === null &&
Comment thread
Bobtoshi marked this conversation as resolved.
Resolved
(limitations?.length ?? 0) === 0
);
};

/** A distinct compressed shared-image shape; admission still checks every declaration. */
const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean =>
target.kind === "executable" &&
Expand All @@ -19,7 +41,9 @@ const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean =>
export const ghidraMipsLanguageOverride = (
target: BinaryTarget,
): GhidraLanguageOverride | undefined =>
(isGhidraMips16SharedTarget(target) || hasLegacyMipsHeaderShape(target)) &&
(isGhidraMips16SharedTarget(target) ||
hasLegacyMipsHeaderShape(target) ||
hasMips1HeaderShape(target)) &&
Comment thread
Bobtoshi marked this conversation as resolved.
Resolved
ghidraMipsUnsupportedReason(target) === null &&
target.architecture === "mips"
? {
Expand Down Expand Up @@ -88,6 +112,7 @@ export const ghidraMipsUnsupportedReason = (
if ((metadata.flags & 0x00ff0000) !== 0)
return "Machine-specific MIPS variants, including PSP Allegrex, need a separately supported processor profile.";
const arch = metadata.flags & 0xf0000000;
if (hasMips1HeaderShape(target)) return null;
Comment thread
Bobtoshi marked this conversation as resolved.
Resolved
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 =
Expand Down Expand Up @@ -258,9 +283,12 @@ export const ghidraMipsProfileParameters = (
// 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 mips1 = hasMips1HeaderShape(target);
const language = ghidraMipsLanguageOverride(target);
const pinnedLanguage =
legacy && languageOverride === undefined && language !== undefined
(legacy || mips1) &&
languageOverride === undefined &&
language !== undefined
? {
language_id: language.languageId,
compiler_spec_id: language.compilerSpecId ?? "default",
Expand Down Expand Up @@ -316,14 +344,19 @@ export const ghidraMipsProfileParameters = (
? { mips_soft_float_storage: GHIDRA_MIPS_SOFT_FLOAT_STORAGE }
: {}),
instruction_context_evidence: "per-address-isa-mode-v1",
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",
mips_support_lane: mips1
? "elf32-exec-mips1-recordless-pinned-v1"
: 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" } : {}),
...(mips1
? { mips_abi_basis: "declared-o32-header-fp-unverified-v1" }
: {}),
...pinnedLanguage,
};
};
Expand Down
6 changes: 6 additions & 0 deletions src/ghidra/GhidraProviderClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,12 @@ export const createGhidraProviderClient = (input: {
]
: []),
...ghidraMipsRelocatableLimitations(target),
...(committedProfile.parameters.mips_abi_basis ===
"declared-o32-header-fp-unverified-v1"
? [
"The recordless MIPS I ELF header explicitly declares o32; no ABI flags record establishes floating-point storage semantics. Floating-point ABI behavior remains unverified.",
]
: []),
...ghidraMipsSharedLimitations(target),
...ghidraRiscvLimitations(target),
];
Expand Down
Loading