mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
* feat(ghidra): admit soft-float MIPS32r2 ELF with integer-register float storage Soft-float o32 executables (Linux kernel builds, bare-metal firmware) were refused because their ABI flags record declares no CPR1 and FP ABI 3. Ghidra's MIPS32 compiler specifications model only hard-float o32, so typed float/double signatures (DWARF) would be bound to $f12/$f14/$f0 and the decompiler loses the real dataflow: `return sel ? a : b` decompiled to `return in_f0;`, and callers passed phantom FPU inputs. Admit ELF32 ET_EXEC o32 MIPS32r2 targets whose ABI record declares soft-float with no CPR1, and commit `mips_soft_float_storage: o32-integer-registers-v1` in their analysis profile only (hard-float profile digests are unchanged). ReaGhidraMipsSoftFloat.java runs after auto-analysis and, for each function with a float/double formal type, either keeps the types with the storage Ghidra's o32 model assigns to a same-size integer (pair alignment, stack spill), or, for auto-parameters such as hidden struct-return pointers, swaps float types for same-size integers under dynamic storage. No function is left on hard-float storage. The report is written last; the provider client requires it exactly when the profile commits the policy and refuses the session before any operation otherwise. Verification: real Ghidra 12.1.4 lane (verify:ghidra:mips) now builds hard- and soft-float fixtures in both byte orders and checks exact parameter/return storage through inspect_native_api, caller constants, snapshot profile and Evidence outcome; test:fast, verify:package and an installed-tarball soft-float run pass. * fix(ghidra): model soft-float MIPS as a prototype for every typed callable Review feedback on #1949: rewriting storage per Function left external functions and indirect calls through typed function pointers on Ghidra's hard-float o32 prototype, which still read $f12/$f14/$f0 while the session reported success. A negative control confirms it: without the change, `(*rea_mips_soft_callback)(8.5f, 9.5f, sel)` decompiles to `(*rea_mips_soft_callback)(in_f12, in_f14, sel)`. ReaGhidraMipsSoftFloat.java now derives a `__rea_soft_float` prototype from the program's own default o32 model (encoded, float pentries and float-consuming rules removed, so byte-order-specific aggregate rules and the hidden struct-return pointer are kept), installs it as a specification extension, and assigns it with dynamic storage to every non-thunk function, external function and function-pointer definition whose formal types use float or double. Thunks follow their targets. Float types are kept everywhere, so the integer-retype fallback is removed. The script fails without writing its report if the derived prototype or any reassigned function still uses an FPU register. The policy becomes `o32-soft-float-prototype-v1` with a {functions, definitions} report. The soft-float fixture adds an indirect call through a typed pointer; the real-Ghidra lane requires its constants with no FPU inputs and the struct return's `float scale` in $a1. * test(ghidra): pin ODDSPREG acceptance for soft-float MIPS ABI records Review on #1949 suggested requiring flags1 == 0 for soft-float, since MIPS_AFL_FLAGS1_ODDSPREG names CPR1 registers. GCC 5.2 kernel modules, LLVM/Rust firmware and Clang -msoft-float objects all record flags1 = 1 with FP ABI soft-float: it is the MIPS32 o32 toolchain default and selects nothing without CPR1. Keep accepting it, say why at the check, and test that soft-float with ODDSPREG is admitted while any other general flag is refused. * feat(ghidra): admit relocatable MIPS32r2 ELF objects Linux MIPS kernel modules (.ko) and other ELF32 o32 MIPS32r2 ET_REL objects were refused by the Ghidra MIPS lane. Ghidra's ELF loader already places each allocated section and applies REL relocations; this admits ET_REL alongside ET_EXEC (ET_DYN and PRX stay refused) under its own support lane, `elf32-rel-o32-arch32r2-standard-v1`, so executable targets keep theirs. Relocatable sessions carry limitations: addresses are analysis coordinates, undefined symbols live in Ghidra's EXTERNAL block, and HI16 relocations report PARTIAL because Ghidra applies them with their paired LO16. Verification: new `verify:ghidra:mips-relocatable` real-Ghidra lane builds -c -G0 objects in both byte orders plus a soft-float -mlong-calls module variant. GNU readelf supplies sections, symbols and REL entries; every relocation in an allocated section must encode S + A in the loaded word (S from Ghidra placement, A from original bytes), with HI16/LO16 checked as the effective address each LO16 forms, and coverage of a shared HI16, a LO16 carry, external code/data and R_MIPS_32 is asserted. The executable lane, unit suites pass, and on 30 real .ko files 54,972 relocations matched (93 reference empty sections and are not checkable). * fix(ghidra): verify every MIPS HI16 and archive-typed soft-float externals Review feedback on #1950: - The relocation oracle kept one pending HI16, so a second HI16 before the LO16 that resolves both overwrote the first, and unrelated relocations cleared it; the earlier site's high half and carry were never compared with S + A. Every HI16 now waits for the next LO16 of its symbol, which checks all of them; later LO16s reuse those HI16s, and a HI16 left unresolved at a section end fails. On 30 real soft-float kernel modules, 24 contain several HI16s per LO16; all 54,972 placed relocations match. - External callees with float prototypes: the relocatable fixture now calls powf, whose prototype Ghidra's C library archive applies by name. Every variant must decompile `powf(1.5,2.5)` without FPU inputs, and the soft-float module variant must report that __rea_soft_float covered it (with the external step removed it decompiles as `powf(param_1,param_2)`). `-fno-builtin` keeps the call from being folded. Also updates the module variant's expected soft-float policy to `o32-soft-float-prototype-v1`. --------- Co-authored-by: morluto <[email protected]>
334 lines
12 KiB
JavaScript
Executable File
334 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtemp, mkdir, 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 { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
|
import { parseEvidence } from "../dist/domain/evidence.js";
|
|
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
|
|
import { functionDossierSchema } from "../dist/domain/hopperValues.js";
|
|
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
|
|
import { buildMipsRelocatableFixture } from "../tests/conformance/ghidra/mips-fixture.mjs";
|
|
import {
|
|
assertMipsRelocations,
|
|
inspectMipsRelocatableReadelf,
|
|
} from "../tests/conformance/ghidra/mips-relocatable-checks.mjs";
|
|
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
|
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
|
|
|
// Optional cross-target lane for ET_REL objects. No tool acquisition, no
|
|
// linking and no target execution.
|
|
if (process.argv.length > 2)
|
|
throw new Error(
|
|
"Usage: node scripts/verify-real-ghidra-mips-relocatable.mjs",
|
|
);
|
|
if (!["linux", "darwin"].includes(process.platform))
|
|
throw new Error(
|
|
"MIPS verification requires a supported Linux/macOS Ghidra host",
|
|
);
|
|
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
|
throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR");
|
|
const installation = inspectGhidraInstallation({
|
|
environment: process.env,
|
|
installDir: process.env.GHIDRA_INSTALL_DIR,
|
|
...(process.env.JAVA_HOME === undefined
|
|
? {}
|
|
: { javaHome: process.env.JAVA_HOME }),
|
|
});
|
|
if (installation.status !== "available")
|
|
throw new Error(
|
|
`Ghidra prerequisite unavailable: ${JSON.stringify(installation)}`,
|
|
);
|
|
const execute = promisify(execFile);
|
|
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
|
const run = createVerifierRun();
|
|
const workspace = await mkdtemp(join(tmpdir(), "rea-mips-rel-proof-"));
|
|
const runtime = join(workspace, "runtime");
|
|
await mkdir(runtime);
|
|
const env = {
|
|
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
TMPDIR: runtime,
|
|
REA_LOG_LEVEL: "silent",
|
|
REA_ANALYSIS_PROVIDER: "ghidra",
|
|
REA_PROCESS_RUN_ID: run.run_id,
|
|
GHIDRA_INSTALL_DIR: installation.installDir,
|
|
JAVA_HOME: installation.javaHome,
|
|
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
|
};
|
|
for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
|
|
if (process.env[setting] !== undefined) env[setting] = process.env[setting];
|
|
}
|
|
|
|
/** Load every relocated word through the public read_bytes operation. */
|
|
async function loadedWords(call, independent, segments, byteOrder) {
|
|
const words = new Map();
|
|
for (const relocation of independent.relocations) {
|
|
const section = [...independent.sections.values()].find(
|
|
(item) => item.name === relocation.section,
|
|
);
|
|
if (!section?.allocated) continue;
|
|
const segment = segments.find((item) => item.name === section.name);
|
|
assert.ok(segment, `Ghidra segment ${section.name}`);
|
|
const address = BigInt(segment.start) + BigInt(relocation.offset);
|
|
const read = await call("read_bytes", {
|
|
address: `0x${address.toString(16)}`,
|
|
length: 4,
|
|
});
|
|
assert.equal(read.returned_bytes, 4);
|
|
const bytes = Buffer.from(read.bytes_hex, "hex");
|
|
words.set(
|
|
address,
|
|
byteOrder === "little" ? bytes.readUInt32LE(0) : bytes.readUInt32BE(0),
|
|
);
|
|
}
|
|
return words;
|
|
}
|
|
|
|
async function verifyFixture(byteOrder, kernelModule) {
|
|
const fixture = await buildMipsRelocatableFixture(
|
|
workspace,
|
|
byteOrder,
|
|
kernelModule,
|
|
);
|
|
const independent = await inspectMipsRelocatableReadelf(
|
|
fixture.path,
|
|
byteOrder,
|
|
fixture.float_abi,
|
|
);
|
|
const resolved = await parseBinaryTarget(fixture.path);
|
|
if (!resolved.ok) throw resolved.error;
|
|
assert.deepEqual(
|
|
resolved.value.mips,
|
|
independent.mips,
|
|
"REA ELF/ABI interpretation disagrees with GNU readelf",
|
|
);
|
|
assert.equal(resolved.value.sha256, fixture.sha256);
|
|
const label = `${byteOrder}${kernelModule ? "-module" : ""}`;
|
|
const snapshotPath = join(workspace, `${label}.snapshot.json`);
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [entrypoint, "mcp"],
|
|
env,
|
|
stderr: "pipe",
|
|
});
|
|
const client = new Client({ name: "rea-real-mips-rel-proof", version: "1" });
|
|
let stderr = "";
|
|
transport.stderr?.on("data", (chunk) => {
|
|
stderr = (stderr + chunk.toString()).slice(-65536);
|
|
});
|
|
const call = async (name, args = {}) =>
|
|
requireMcpOperationResult(
|
|
await client.callTool({ name, arguments: args }, undefined, {
|
|
timeout: 180000,
|
|
}),
|
|
name,
|
|
);
|
|
try {
|
|
await client.connect(transport);
|
|
const target = await call("open_binary", {
|
|
path: fixture.path,
|
|
provider_id: "ghidra",
|
|
});
|
|
assert.equal(target.architecture, "mips");
|
|
assert.equal(target.sha256, fixture.sha256);
|
|
const image = (await call("inspect_native_load_image")).observations;
|
|
assert.equal(
|
|
image.language_id,
|
|
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
|
|
);
|
|
assert.equal(image.source_files.length, 1);
|
|
assert.equal(image.source_files[0].original_sha256, fixture.sha256);
|
|
assert.notEqual(
|
|
image.source_files[0].modified_sha256,
|
|
fixture.sha256,
|
|
"Relocations must change the loaded bytes",
|
|
);
|
|
const names = await call("list_names");
|
|
const segments = await call("list_segments");
|
|
const coverage = assertMipsRelocations({
|
|
facts: independent,
|
|
fileBytes: await readFile(fixture.path),
|
|
segments,
|
|
mappings: image.mappings,
|
|
ghidraRelocations: image.relocations,
|
|
words: await loadedWords(call, independent, segments, byteOrder),
|
|
names,
|
|
byteOrder,
|
|
});
|
|
assert.equal(coverage.unplaced, 0, "Fixture references an empty section");
|
|
assert.ok(coverage.shared_hi16, "No HI16 shared by several LO16s");
|
|
assert.ok(coverage.lo16_carry, "No LO16 >= 0x8000 forced a HI16 carry");
|
|
assert.ok(coverage.external_code, "No external code relocation");
|
|
assert.ok(coverage.external_data, "No external data relocation");
|
|
assert.ok(coverage.types.R_MIPS_32 > 0, "No R_MIPS_32 relocation");
|
|
assert.equal(
|
|
coverage.types.R_MIPS_26 === undefined,
|
|
kernelModule,
|
|
"Long calls must replace every R_MIPS_26 with HI16/LO16",
|
|
);
|
|
const procedures = await call("list_procedures");
|
|
const named = (name) => {
|
|
const found = procedures.filter((item) => item.value === name);
|
|
assert.equal(found.length, 1, `Procedure ${name}`);
|
|
return found[0].address;
|
|
};
|
|
const entry = named("rea_mips_rel_entry");
|
|
const callees = await call("procedure_callees", { procedure: entry });
|
|
for (const callee of [
|
|
named("rea_mips_rel_leaf"),
|
|
named("rea_mips_rel_far"),
|
|
])
|
|
assert.ok(callees.includes(callee), `Missing direct call to ${callee}`);
|
|
// Ghidra lists the external function and its EXTERNAL-block thunk.
|
|
assert.ok(
|
|
procedures.some(
|
|
(item) =>
|
|
item.value === "<EXTERNAL>::rea_mips_rel_external" &&
|
|
callees.includes(item.address),
|
|
),
|
|
"Missing call to the external function",
|
|
);
|
|
const dossier = functionDossierSchema.parse(
|
|
await call("analyze_function", { procedure: entry }),
|
|
);
|
|
for (const name of [
|
|
"rea_mips_rel_leaf",
|
|
"rea_mips_rel_far",
|
|
"rea_mips_rel_external",
|
|
])
|
|
assert.ok(dossier.pseudocode.includes(name), `Pseudocode lost ${name}`);
|
|
// powf's float prototype comes from Ghidra's C library archive; soft-float
|
|
// builds pass 1.5f/2.5f in $a0/$a1, hard-float builds in $f12/$f14.
|
|
const scaled = functionDossierSchema.parse(
|
|
await call("analyze_function", {
|
|
procedure: named("rea_mips_rel_scaled"),
|
|
}),
|
|
);
|
|
assert.match(scaled.pseudocode, /\bpowf\(1\.5,2\.5\)/u);
|
|
assert.doesNotMatch(scaled.pseudocode, /\bin_f\d+\b/u);
|
|
await call("close_binary", { snapshot_path: snapshotPath });
|
|
const snapshot = parseAnalysisSnapshot(
|
|
JSON.parse(await readFile(snapshotPath, "utf8")),
|
|
);
|
|
const parameters = snapshot.binding.analysis_profile.parameters;
|
|
assert.equal(parameters.mips_elf.type, 1);
|
|
assert.equal(
|
|
parameters.mips_support_lane,
|
|
"elf32-rel-o32-arch32r2-standard-v1",
|
|
);
|
|
assert.equal(
|
|
parameters.mips_soft_float_storage,
|
|
kernelModule ? "o32-soft-float-prototype-v1" : undefined,
|
|
);
|
|
assert.ok(
|
|
snapshot.evidence_bundle.records.some((record) =>
|
|
parseEvidence(record).limitations.some((item) =>
|
|
item.includes("Relocatable MIPS ELF"),
|
|
),
|
|
),
|
|
"Relocatable limitation missing from retained Evidence",
|
|
);
|
|
assert.equal(
|
|
snapshot.evidence_bundle.records.some((record) =>
|
|
parseEvidence(record).limitations.some((item) =>
|
|
/Soft-float prototype outcome: [1-9]\d* functions/u.test(item),
|
|
),
|
|
),
|
|
kernelModule,
|
|
"Soft-float prototype must cover the archive-typed external in module builds",
|
|
);
|
|
await call("open_binary", {
|
|
path: fixture.path,
|
|
provider_id: "ghidra",
|
|
snapshot_path: snapshotPath,
|
|
});
|
|
assert.deepEqual(await call("list_procedures"), procedures);
|
|
await call("close_binary");
|
|
// Same public operation through CLI, not a private injected provider.
|
|
const cli = await execute(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"function",
|
|
fixture.path,
|
|
"rea_mips_rel_entry",
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const evidence = parseEvidence(JSON.parse(cli.stdout));
|
|
assert.equal(evidence.subject?.architecture, "mips");
|
|
assert.equal(evidence.subject?.digest.sha256, fixture.sha256);
|
|
assert.equal(
|
|
functionDossierSchema.parse(evidence.normalized_result).procedure.address,
|
|
dossier.procedure.address,
|
|
);
|
|
assert.equal(
|
|
createHash("sha256")
|
|
.update(await readFile(fixture.path))
|
|
.digest("hex"),
|
|
fixture.sha256,
|
|
"Analysis modified the source object",
|
|
);
|
|
return {
|
|
...fixture,
|
|
independent_reader: {
|
|
command: independent.command,
|
|
version: independent.version,
|
|
},
|
|
effective_language: image.language_id,
|
|
relocation_coverage: coverage,
|
|
procedures: procedures.length,
|
|
status: "passed",
|
|
};
|
|
} catch (cause) {
|
|
throw new Error(
|
|
`MIPS ${label} relocatable verification failed; MCP stderr: ${stderr}`,
|
|
{ cause },
|
|
);
|
|
} finally {
|
|
try {
|
|
await client.close();
|
|
} finally {
|
|
await transport.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
const reports = [];
|
|
let primaryFailure;
|
|
try {
|
|
for (const [byteOrder, kernelModule] of [
|
|
["little", false],
|
|
["big", false],
|
|
["little", true],
|
|
])
|
|
reports.push(await verifyFixture(byteOrder, kernelModule));
|
|
} catch (cause) {
|
|
primaryFailure = cause;
|
|
}
|
|
const completed = await completeVerifierRun(run);
|
|
if (
|
|
completed.process_lineage.status !== "verified" ||
|
|
completed.process_lineage.descendants.length !== 0
|
|
)
|
|
throw new Error(
|
|
`Cleanup unverified; retained diagnostic workspace: ${workspace}`,
|
|
{ cause: primaryFailure },
|
|
);
|
|
if (primaryFailure !== undefined)
|
|
throw new Error(
|
|
`MIPS relocatable verification failed; retained workspace: ${workspace}`,
|
|
{ cause: primaryFailure },
|
|
);
|
|
await rm(workspace, { recursive: true, force: true });
|
|
console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2));
|