feat(ghidra): admit relocatable MIPS32r2 ELF objects (#1950)

* 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 <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Aurelio Bernalandmorluto authored and GitHub committed 2026-10-12 05:02:55 +08:00
1 parent 5e219de441
commit 290d301ec7
13 files changed
+1343 -110

No files matched your search

+50 -7
View File
@@ -13,13 +13,13 @@ retains class, byte order, `e_type`, raw `e_flags`, and inspected ABI declaratio
Recognition does not mean every provider or MIPS variant is supported. Existing
PE, managed and Mach-O architecture sets are unchanged.
The first Ghidra lane admits ELF32 `ET_EXEC`, little or big endian, with explicit
o32 and `EF_MIPS_ARCH_32R2` flags and no machine-specific or ASE encoding flags.
These are header declarations, not proof that every instruction conforms to
that ISA. ELF64, n32/n64, unspecified ABI, MIPS16/microMIPS, machine-specific
variants, shared/relocatable objects and PSP PRX are not covered by this lane.
Their family may be identified, but provider selection returns the specific
unsupported constraint rather than guessing a compatible profile.
The Ghidra lane admits ELF32 `ET_EXEC` and `ET_REL`, little or big endian, with
explicit o32 and `EF_MIPS_ARCH_32R2` flags and no machine-specific or ASE
encoding flags. These are header declarations, not proof that every instruction
conforms to that ISA. ELF64, n32/n64, unspecified ABI, MIPS16/microMIPS,
machine-specific variants, shared objects and PSP PRX are not covered by this
lane. Their family may be identified, but provider selection returns the
specific unsupported constraint rather than guessing a compatible profile.
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
@@ -86,6 +86,20 @@ Callables without typed float signatures keep Ghidra's default prototype, under
which Ghidra already infers integer-register parameters. Hard-float profiles do
not gain the policy, so their digests are unchanged.
### Relocatable objects
`ET_REL` objects, such as Linux kernel modules (`.ko`), are imported by Ghidra's
ELF loader, which assigns each allocated section an analysis address and applies
the object's REL relocations there. Undefined symbols are placed in Ghidra's
`EXTERNAL` block: a thunk for code, a label for data. These addresses are
analysis coordinates, not the addresses a kernel or linker would choose, and
session Evidence says so. `inspect_native_load_image` reports each relocation
with Ghidra's status; HI16 entries report `PARTIAL` because Ghidra applies them
together with their paired LO16. Relocatable targets commit
`mips_support_lane: elf32-rel-o32-arch32r2-standard-v1`, separate from the
executable lane, whose identity executable targets keep. Kernel modules are
usually soft-float and therefore also use the soft-float storage policy.
## Use
With a caller-installed Ghidra and compatible JDK on a supported Linux/macOS host:
@@ -103,6 +117,8 @@ REA's ELF parser.
## Verification
### Executables
The source-owned freestanding fixture is `tests/conformance/c/mips.c`; the
soft-float variant also links `tests/conformance/c/mips-soft-float.c`. The
optional cross-target lane requires a caller-supplied Clang with MIPS targets,
@@ -151,3 +167,30 @@ delay-slot execution or complete floating-point semantics. Assertion regressions
reject incorrect byte order, constants, destinations and loaded identities.
Parser/profile tests are narrower checks, not substitutes for this real-provider
run. Report the actual lanes run and leave unexecuted verification pending.
### Relocatable objects
This lane has its own command and fixture:
```bash
GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra npm run verify:ghidra:mips-relocatable
```
It compiles `tests/conformance/c/mips-relocatable.c` with `-c -G0` in both byte
orders, plus a little-endian soft-float `-mlong-calls` variant matching Linux
module options. GNU readelf independently supplies sections, symbols and REL
entries. For every relocation in an allocated section, the lane reads the
loaded word through `read_bytes` and requires it to encode `S + A`: `S` from
Ghidra's own section placement (`list_segments`) or `EXTERNAL` placement, `A`
from the original file bytes. HI16/LO16 are checked as the effective address
each LO16 forms with each HI16 it resolves, including several HI16s before one
LO16, so a missing signed-low carry fails and a HI16 without a following LO16
fails. The lane also requires matching Ghidra relocation entries and statuses,
section file offsets and sizes, coverage of a shared HI16, a LO16 carry,
external code and data and `R_MIPS_32` words, direct calls from the entry
function, the relocatable support lane in the snapshot profile, the
relocatable limitation in Evidence and an unchanged source object. The fixture
also calls `powf`, an external whose float prototype comes from Ghidra's C
library archive; every variant must decompile `powf(1.5,2.5)` without FPU
inputs, and the soft-float variant must report that the soft-float prototype
covered it.
+1
View File
@@ -165,6 +165,7 @@
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
+333
View File
@@ -0,0 +1,333 @@
#!/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));
+105 -72
View File
@@ -117,9 +117,8 @@ describe("bounded Ghidra MIPS admission", () => {
it.each([
[{ elfClass: 64 }, "ELF32"],
[{ type: 1 }, "ET_EXEC"],
[{ type: 3 }, "ET_EXEC"],
[{ type: 0xffa0 }, "ET_EXEC"],
[{ type: 3 }, "ET_EXEC and ET_REL"],
[{ type: 0xffa0 }, "ET_EXEC and ET_REL"],
[{ flags: 0x10a23001 }, "separate MIPS ABI flags"],
[{ flags: 0x70001021 }, "o32"],
[{ flags: 0x70000001 }, "o32"],
@@ -161,64 +160,64 @@ describe("bounded Ghidra MIPS admission", () => {
});
});
describe("soft-float MIPS storage", () => {
const report: GhidraMipsSoftFloatReport = {
policy: "o32-soft-float-prototype-v1",
functions: 5,
definitions: 1,
};
const session = async (
value: BinaryTarget,
reported: GhidraMipsSoftFloatReport | undefined,
profileFor: BinaryTarget = value,
) => {
const counts = { starts: 0, calls: 0, closes: 0 };
const ghidra = new GhidraProvider(config, silentLogger, {}, host, () => ({
start: () => {
counts.starts++;
return Promise.resolve(
ok({
name: "REA Ghidra bridge" as const,
run_id: "11111111-1111-4111-8111-111111111111",
profile_digest: "a".repeat(64),
provider: { id: "ghidra" as const, version: "12.1.4" },
read_only: false,
analysis_complete: true,
analysis_timed_out: false,
capabilities: [...GHIDRA_SESSION_CAPABILITIES],
target: {
name: "fixture.elf",
language_id: "MIPS:LE:32:default",
compiler_spec_id: "default",
image_base: "0x0",
default_address_space: "ram",
sha256: "a".repeat(64),
},
...(reported === undefined
? {}
: { mips_soft_float_storage: reported }),
}),
);
},
callTool: () => {
counts.calls++;
return Promise.resolve(ok(null));
},
close: () => {
counts.closes++;
return Promise.resolve(ok(null));
},
}));
const resolved = await ghidra.resolveAnalysisProfile(profileFor);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("expected a MIPS profile");
return {
counts,
profile: resolved.value.profile,
client: ghidra.createClient(value, resolved.value.profile),
};
const report: GhidraMipsSoftFloatReport = {
policy: "o32-soft-float-prototype-v1",
functions: 5,
definitions: 1,
};
const session = async (
value: BinaryTarget,
reported: GhidraMipsSoftFloatReport | undefined,
profileFor: BinaryTarget = value,
) => {
const counts = { starts: 0, calls: 0, closes: 0 };
const ghidra = new GhidraProvider(config, silentLogger, {}, host, () => ({
start: () => {
counts.starts++;
return Promise.resolve(
ok({
name: "REA Ghidra bridge" as const,
run_id: "11111111-1111-4111-8111-111111111111",
profile_digest: "a".repeat(64),
provider: { id: "ghidra" as const, version: "12.1.4" },
read_only: false,
analysis_complete: true,
analysis_timed_out: false,
capabilities: [...GHIDRA_SESSION_CAPABILITIES],
target: {
name: "fixture.elf",
language_id: "MIPS:LE:32:default",
compiler_spec_id: "default",
image_base: "0x0",
default_address_space: "ram",
sha256: "a".repeat(64),
},
...(reported === undefined
? {}
: { mips_soft_float_storage: reported }),
}),
);
},
callTool: () => {
counts.calls++;
return Promise.resolve(ok(null));
},
close: () => {
counts.closes++;
return Promise.resolve(ok(null));
},
}));
const resolved = await ghidra.resolveAnalysisProfile(profileFor);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("expected a MIPS profile");
return {
counts,
profile: resolved.value.profile,
client: ghidra.createClient(value, resolved.value.profile),
};
};
describe("soft-float MIPS storage", () => {
it("admits soft-float o32 and commits its storage policy only for it", async () => {
const soft = target(softFloat);
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
@@ -265,21 +264,55 @@ describe("soft-float MIPS storage", () => {
);
});
it("fails closed on a soft-float session without a storage report before any analysis call", async () => {
const { counts, client } = await session(target(softFloat), undefined);
const failed = await client.execute("list_procedures", {});
expect(failed.ok).toBe(false);
if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError");
expect(counts).toEqual({ starts: 1, calls: 0, closes: 1 });
it("admits relocatable objects under their own lane with coordinate limitations", async () => {
const relocatable = target({ type: 1 });
const { profile, client } = await session(relocatable, undefined);
expect(profile.parameters.mips_support_lane).toBe(
"elf32-rel-o32-arch32r2-standard-v1",
);
const executable = await session(target(), undefined);
expect(executable.profile.parameters.mips_support_lane).toBe(
"elf32-exec-o32-arch32r2-standard-v3",
);
const health = await client.execute("health", {});
if (!health.ok) throw health.error;
expect(health.value.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("analysis coordinates"),
expect.stringContaining("EXTERNAL block"),
]),
);
const executableHealth = await executable.client.execute("health", {});
if (!executableHealth.ok) throw executableHealth.error;
expect(executableHealth.value.limitations.join(" ")).not.toContain(
"Relocatable MIPS ELF",
);
});
it("rejects soft-float storage reported by a hard-float session", async () => {
const { counts, client } = await session(target(), report);
const failed = await client.execute("health", {});
expect(failed.ok).toBe(false);
if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError");
expect(counts).toEqual({ starts: 1, calls: 0, closes: 1 });
});
it.each([
{
reason:
"a soft-float session without a storage report before any analysis call",
value: target(softFloat),
storageReport: undefined,
operation: "list_procedures",
},
{
reason: "soft-float storage reported by a hard-float session",
value: target(),
storageReport: report,
operation: "health",
},
] as const)(
"rejects $reason",
async ({ value, storageReport, operation }) => {
const { counts, client } = await session(value, storageReport);
const failed = await client.execute(operation, {});
expect(failed.ok).toBe(false);
if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError");
expect(counts).toEqual({ starts: 1, calls: 0, closes: 1 });
},
);
it("refuses a profile whose storage policy disagrees with the target", async () => {
for (const [value, profileFor] of [
+21 -3
View File
@@ -2,6 +2,8 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
const MIPS_FP_ABI_SOFT = 3;
const MIPS_ET_REL = 1;
const MIPS_ET_EXEC = 2;
/** Keep family recognition separate from the deliberately bounded Ghidra lane. */
export const ghidraMipsUnsupportedReason = (
@@ -14,8 +16,8 @@ export const ghidraMipsUnsupportedReason = (
return "MIPS analysis requires ELF header metadata; a family name alone does not select an ISA or ABI.";
if (metadata.elfClass !== 32)
return "This Ghidra MIPS lane supports ELF32 only; MIPS64 and n64 remain unsupported.";
if (metadata.type !== 2)
return "This Ghidra MIPS lane supports ET_EXEC only; relocatable objects, shared images and PSP PRX need separate load verification.";
if (metadata.type !== MIPS_ET_EXEC && metadata.type !== MIPS_ET_REL)
return "This Ghidra MIPS lane supports ET_EXEC and ET_REL only; shared images and PSP PRX need separate load verification.";
if ((metadata.flags & 0x0f000000) !== 0)
return "MIPS16, microMIPS and other ASE encodings are outside this standard-instruction MIPS lane.";
if ((metadata.flags & 0x00ff0000) !== 0)
@@ -101,6 +103,19 @@ export const ghidraMipsSoftFloatLimitations = (
]
: [];
/** Ghidra-placed relocatable objects need coordinates and relocation caveats. */
export const ghidraMipsRelocatableLimitations = (
target: BinaryTarget,
): readonly string[] =>
target.kind === "executable" &&
target.architecture === "mips" &&
target.mips?.type === MIPS_ET_REL
? [
"Relocatable MIPS ELF: Ghidra assigns each allocated section an analysis address and applies the object's relocations there. Returned addresses are analysis coordinates, not the addresses a kernel or linker would choose at load time.",
"Undefined symbols (for example kernel exports used by a module) are placed in Ghidra's EXTERNAL block; their definitions are outside this artifact. inspect_native_load_image reports each relocation with Ghidra's status; HI16 entries report PARTIAL because Ghidra applies them together with their paired LO16.",
]
: [];
/** Commit source ELF interpretation without confusing target ISA with host CPU. */
export const ghidraMipsProfileParameters = (
target: BinaryTarget,
@@ -141,6 +156,9 @@ export const ghidraMipsProfileParameters = (
...(isGhidraMipsSoftFloatTarget(target)
? { mips_soft_float_storage: GHIDRA_MIPS_SOFT_FLOAT_STORAGE }
: {}),
mips_support_lane: "elf32-exec-o32-arch32r2-standard-v3",
mips_support_lane:
target.mips.type === MIPS_ET_REL
? "elf32-rel-o32-arch32r2-standard-v1"
: "elf32-exec-o32-arch32r2-standard-v3",
};
};
+2
View File
@@ -59,6 +59,7 @@ import { ghidraPspExtensionSchema } from "./GhidraPspExtension.js";
import {
GHIDRA_MIPS_SOFT_FLOAT_STORAGE,
ghidraMipsSoftFloatFailure,
ghidraMipsRelocatableLimitations,
ghidraMipsSoftFloatLimitations,
isGhidraMipsSoftFloatTarget,
} from "./GhidraMipsProfile.js";
@@ -510,6 +511,7 @@ export const createGhidraProviderClient = (input: {
...(target.kind === "executable" && target.architecture === "mips"
? (target.mips.limitations ?? [])
: []),
...ghidraMipsRelocatableLimitations(target),
];
const providerLimitations =
installation.platform === "win32"
+53
View File
@@ -0,0 +1,53 @@
/* Relocatable (ET_REL) static-analysis fixture, built with -c -G0 and never
* executed or linked by REA. It exercises the relocations Linux MIPS kernel
* modules use: R_MIPS_26 calls, HI16/LO16 address pairs (one HI16 shared by
* several LO16s, and a LO16 >= 0x8000 that forces a HI16 carry), R_MIPS_32
* data words, and undefined external code and data symbols. powf is an
* external whose float prototype Ghidra's C library archive supplies by name,
* so soft-float builds must give it the soft-float prototype too.
*/
extern int rea_mips_rel_external(int value);
extern int rea_mips_rel_external_data;
extern float powf(float base, float exponent);
struct rea_mips_rel_pair {
int first;
int second;
};
struct rea_mips_rel_pair rea_mips_rel_pair = { 0x1111, 0x2222 };
/* Places rea_mips_rel_carry past a signed 16-bit low-half boundary. */
char rea_mips_rel_padding[0x8000] = { 1 };
int rea_mips_rel_carry = 0x3333;
static const char rea_mips_rel_marker[] = "rea-mips-relocatable-fixture";
__attribute__((noinline)) int rea_mips_rel_leaf(int value)
{
return value * 3 + rea_mips_rel_pair.first + rea_mips_rel_pair.second;
}
__attribute__((noinline, long_call)) int rea_mips_rel_far(int value);
int rea_mips_rel_far(int value)
{
return value + rea_mips_rel_marker[value & 7] + rea_mips_rel_carry;
}
int (*const rea_mips_rel_table[])(int) = {
rea_mips_rel_leaf,
rea_mips_rel_far,
rea_mips_rel_external,
};
__attribute__((noinline)) int rea_mips_rel_scaled(int value)
{
float scaled = powf(1.5f, 2.5f);
return value ^ *(int *)&scaled;
}
int rea_mips_rel_entry(int value)
{
return rea_mips_rel_leaf(value) + rea_mips_rel_far(value) +
rea_mips_rel_external(value) + rea_mips_rel_external_data +
rea_mips_rel_table[value & 1](value) + rea_mips_rel_scaled(value);
}
@@ -9,6 +9,7 @@ export function parseMipsReadelf(
text: string,
byteOrder: string,
floatAbi?: MipsFloatAbi,
objectType?: "exec" | "rel",
): MipsReadelfFacts;
export function inspectMipsReadelf(
path: string,
+32 -12
View File
@@ -23,9 +23,18 @@ const SOFT_FLOAT_SYMBOLS = [
"rea_mips_soft_caller",
];
/** Parse only the documented GNU readelf fields needed by our tiny fixture. */
export function parseMipsReadelf(text, byteOrder, floatAbi = "hard") {
/**
* Parse only the documented GNU readelf fields needed by our tiny fixtures.
* `rel` objects carry their own symbol checks in mips-relocatable-checks.mjs.
*/
export function parseMipsReadelf(
text,
byteOrder,
floatAbi = "hard",
objectType = "exec",
) {
assert.ok(floatAbi === "hard" || floatAbi === "soft");
assert.ok(objectType === "exec" || objectType === "rel");
language(byteOrder);
const field = (expression, label) => {
const matches = [...text.matchAll(expression)];
@@ -37,7 +46,10 @@ export function parseMipsReadelf(text, byteOrder, floatAbi = "hard") {
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*Type:\s*(.+)$/gmu, "type"),
objectType === "rel" ? /^REL\b/u : /^EXEC\b/u,
);
assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^MIPS\b/u);
const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+),.*$/gmu, "flags"));
assert.equal(flags, 0x70001001, "Fixture ELF flags differ from build intent");
@@ -85,14 +97,16 @@ export function parseMipsReadelf(text, byteOrder, floatAbi = "hard") {
),
};
const symbols = {};
for (const name of [
"rea_mips_entry",
"rea_mips_leaf",
"rea_mips_probe",
"rea_mips_global",
"rea_mips_marker",
...(floatAbi === "soft" ? SOFT_FLOAT_SYMBOLS : []),
]) {
for (const name of objectType === "rel"
? []
: [
"rea_mips_entry",
"rea_mips_leaf",
"rea_mips_probe",
"rea_mips_global",
"rea_mips_marker",
...(floatAbi === "soft" ? SOFT_FLOAT_SYMBOLS : []),
]) {
const value = field(
new RegExp(
`^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+(?:FUNC|OBJECT)\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`,
@@ -103,7 +117,13 @@ export function parseMipsReadelf(text, byteOrder, floatAbi = "hard") {
symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`;
}
return {
mips: { elfClass: 32, byteOrder, type: 2, flags, abiFlags },
mips: {
elfClass: 32,
byteOrder,
type: objectType === "rel" ? 1 : 2,
flags,
abiFlags,
},
symbols,
};
}
+66 -16
View File
@@ -18,7 +18,6 @@ export async function buildMipsFixture(
if (floatAbi !== "hard" && floatAbi !== "soft")
throw new Error("MIPS fixture float ABI must be hard or soft");
const soft = floatAbi === "soft";
const compiler = process.env.REA_MIPS_CLANG ?? "clang";
const target = byteOrder === "little" ? "mipsel-linux-gnu" : "mips-linux-gnu";
const source = fileURLToPath(new URL("../c/mips.c", import.meta.url));
const softSource = fileURLToPath(
@@ -46,6 +45,71 @@ export async function buildMipsFixture(
"-o",
path,
];
return {
...(await compileMipsFixture(path, byteOrder, 2, args)),
float_abi: floatAbi,
source_sha256: createHash("sha256")
.update(await readFile(source))
.digest("hex"),
...(soft
? {
soft_source_sha256: createHash("sha256")
.update(await readFile(softSource))
.digest("hex"),
}
: {}),
probe_sha256: createHash("sha256")
.update(await readFile(probe))
.digest("hex"),
};
}
/**
* Build the source-owned ET_REL object. `-G0` keeps gp-relative relocations
* out, and `kernelModule` adds the soft-float long-call options Linux MIPS
* modules use, so every call becomes a HI16/LO16 pair.
*/
export async function buildMipsRelocatableFixture(
directory,
byteOrder,
kernelModule = false,
) {
if (byteOrder !== "little" && byteOrder !== "big")
throw new Error("MIPS fixture byte order must be little or big");
const target = byteOrder === "little" ? "mipsel-linux-gnu" : "mips-linux-gnu";
const source = fileURLToPath(
new URL("../c/mips-relocatable.c", import.meta.url),
);
const path = join(directory, `${target}${kernelModule ? "-module" : ""}.o`);
const args = [
`--target=${target}`,
"-march=mips32r2",
"-mabi=32",
...(kernelModule ? ["-msoft-float", "-mlong-calls"] : []),
"-mno-abicalls",
"-fno-pic",
"-G0",
// Keeps powf(1.5f, 2.5f) a real external call instead of a folded constant.
"-fno-builtin",
"-O1",
"-c",
source,
"-o",
path,
];
return {
...(await compileMipsFixture(path, byteOrder, 1, args)),
float_abi: kernelModule ? "soft" : "hard",
kernel_module_options: kernelModule,
source_sha256: createHash("sha256")
.update(await readFile(source))
.digest("hex"),
};
}
/** Compile and attest the shared ELF32/o32/MIPS32r2 fixture boundary. */
async function compileMipsFixture(path, byteOrder, objectType, args) {
const compiler = process.env.REA_MIPS_CLANG ?? "clang";
const version = await execute(compiler, ["--version"], { timeout: 10000 });
await execute(compiler, args, { timeout: 60000 });
const bytes = await readFile(path);
@@ -62,7 +126,7 @@ export async function buildMipsFixture(
bytes.toString("hex", 0, 4) !== "7f454c46" ||
bytes[4] !== 1 ||
bytes[5] !== (byteOrder === "little" ? 1 : 2) ||
read16(16) !== 2 ||
read16(16) !== objectType ||
read16(18) !== 8 ||
read32(36) !== 0x70001001
)
@@ -72,22 +136,8 @@ export async function buildMipsFixture(
return {
path,
byte_order: byteOrder,
float_abi: floatAbi,
flags: read32(36),
sha256: createHash("sha256").update(bytes).digest("hex"),
source_sha256: createHash("sha256")
.update(await readFile(source))
.digest("hex"),
...(soft
? {
soft_source_sha256: createHash("sha256")
.update(await readFile(softSource))
.digest("hex"),
}
: {}),
probe_sha256: createHash("sha256")
.update(await readFile(probe))
.digest("hex"),
compiler: version.stdout.trim(),
arguments: args,
};
@@ -0,0 +1,69 @@
import type { MipsReadelfFacts, MipsFloatAbi } from "./mips-checks.mjs";
export interface MipsRelocatableSection {
index: number;
name: string;
type: string;
offset: number;
size: number;
allocated: boolean;
}
export interface MipsRelocatableSymbol {
index: number;
value: number;
type: string;
ndx: string;
name: string;
}
export interface MipsRelocation {
section: string;
offset: number;
type: number;
typeName: string;
symbolIndex: number;
}
export interface MipsRelocatableFacts {
sections: Map<number, MipsRelocatableSection>;
symbols: Map<number, MipsRelocatableSymbol>;
relocations: MipsRelocation[];
}
export interface MipsRelocationCoverage {
types: Record<string, number>;
shared_hi16: boolean;
multiple_hi16: boolean;
lo16_carry: boolean;
external_code: boolean;
external_data: boolean;
unplaced: number;
}
export function parseMipsRelocatableReadelf(text: string): MipsRelocatableFacts;
export function inspectMipsRelocatableReadelf(
path: string,
byteOrder: string,
floatAbi: MipsFloatAbi,
): Promise<
MipsReadelfFacts & MipsRelocatableFacts & { command: string; version: string }
>;
export function assertMipsRelocations(input: {
facts: MipsRelocatableFacts;
fileBytes: Buffer;
segments: readonly { name: string; start: string; end: string }[];
mappings: readonly {
block: string;
start: string;
length: number;
file_offset: number | null;
}[];
ghidraRelocations: readonly {
address: string;
type: number;
status: string;
}[];
words: Map<bigint, number>;
names: readonly {
address: string;
value: string;
symbol?: { external: boolean; type: string };
}[];
byteOrder: string;
}): MipsRelocationCoverage;
@@ -0,0 +1,346 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { parseMipsReadelf } from "./mips-checks.mjs";
const execute = promisify(execFile);
const RELOCATION_TYPES = new Map([
[2, "R_MIPS_32"],
[4, "R_MIPS_26"],
[5, "R_MIPS_HI16"],
[6, "R_MIPS_LO16"],
]);
const hex = (value) => `0x${value.toString(16)}`;
const signed16 = (value) => ((value & 0xffff) ^ 0x8000) - 0x8000;
/** Parse GNU `readelf -S -s -r -W` sections, symbols and REL entries. */
export function parseMipsRelocatableReadelf(text) {
const sections = new Map();
for (const match of text.matchAll(
/^\s*\[\s*(\d+)\]\s+(\S+)\s+(\S+)\s+([\da-f]{8})\s+([\da-f]{6,})\s+([\da-f]{6,})\s+[\da-f]{2}\s+([A-Za-z]*)\s+\d+\s+\d+\s+\d+\s*$/gmu,
)) {
const index = Number(match[1]);
assert.ok(!sections.has(index), `Duplicate readelf section ${index}`);
sections.set(index, {
index,
name: match[2],
type: match[3],
offset: Number.parseInt(match[5], 16),
size: Number.parseInt(match[6], 16),
allocated: match[7].includes("A"),
});
}
assert.ok(sections.size > 0, "Missing readelf section headers");
const symbols = new Map();
for (const match of text.matchAll(
/^\s*(\d+):\s+([\da-f]{8})\s+\d+\s+(NOTYPE|OBJECT|FUNC|SECTION|FILE)\s+(LOCAL|GLOBAL|WEAK)\s+\S+\s+(UND|ABS|\d+)(?:[ \t]+(\S+))?\s*$/gmu,
)) {
const index = Number(match[1]);
assert.ok(!symbols.has(index), `Duplicate readelf symbol ${index}`);
symbols.set(index, {
index,
value: Number.parseInt(match[2], 16),
type: match[3],
ndx: match[5],
name: match[6] ?? "",
});
}
assert.ok(symbols.size > 0, "Missing readelf symbol table");
const relocations = [];
const headers = [
...text.matchAll(
/^Relocation section '\.rel(\S+)' at offset 0x[\da-f]+ contains (\d+) entr(?:y|ies):$/gmu,
),
];
for (const [position, header] of headers.entries()) {
const body = text.slice(
(header.index ?? 0) + header[0].length,
headers[position + 1]?.index ?? text.length,
);
const entries = [
...body.matchAll(
/^([\da-f]{8})\s+([\da-f]{8})\s+(R_MIPS_\S+)\s+[\da-f]{8}\s+\S.*$/gmu,
),
].map((match) => {
const info = Number.parseInt(match[2], 16);
const type = info & 0xff;
assert.equal(
match[3],
RELOCATION_TYPES.get(type) ?? match[3],
"readelf relocation name disagrees with r_info",
);
return {
section: header[1],
offset: Number.parseInt(match[1], 16),
type,
typeName: match[3],
symbolIndex: info >>> 8,
};
});
assert.equal(
entries.length,
Number(header[2]),
`readelf .rel${header[1]} entry count`,
);
relocations.push(...entries);
}
assert.ok(relocations.length > 0, "Missing readelf relocations");
return { sections, symbols, relocations };
}
/** Independent reader is a bounded test prerequisite, never an REA provider. */
export async function inspectMipsRelocatableReadelf(path, byteOrder, floatAbi) {
const command = process.env.REA_MIPS_READELF ?? "readelf";
const options = {
env: { ...process.env, LC_ALL: "C" },
timeout: 10000,
maxBuffer: 4 * 1024 * 1024,
};
const version = await execute(command, ["--version"], options);
assert.match(
version.stdout,
/^GNU readelf\b/u,
"This lane requires GNU readelf",
);
const result = await execute(
command,
["-h", "-A", "-S", "-s", "-r", "-W", path],
options,
);
assert.equal(
result.stderr.trim(),
"",
`readelf diagnostics: ${result.stderr}`,
);
return {
...parseMipsReadelf(result.stdout, byteOrder, floatAbi, "rel"),
...parseMipsRelocatableReadelf(result.stdout),
command,
version: version.stdout.split("\n")[0],
};
}
/**
* Recompute every allocated-section relocation from the original file bytes
* (REL addends are in place) and Ghidra's own section placement, then require
* Ghidra's loaded words to encode exactly S + A. HI16/LO16 are checked as the
* effective address each LO16 forms with its HI16, so a missing carry fails.
* Placement comes from list_segments (uninitialized blocks included); file
* mappings must agree with each file-backed section's offset and size. A
* symbol in an empty section has no placement and is counted as unplaced.
*/
export function assertMipsRelocations({
facts,
fileBytes,
segments,
mappings,
ghidraRelocations,
words,
names,
byteOrder,
}) {
const read32 = (buffer, offset) =>
byteOrder === "little"
? buffer.readUInt32LE(offset)
: buffer.readUInt32BE(offset);
const segment = (name) => {
const found = segments.filter((item) => item.name === name);
assert.equal(found.length, 1, `Ghidra segment ${name}`);
return {
start: BigInt(found[0].start),
size: BigInt(found[0].end) - BigInt(found[0].start),
};
};
const sectionNamed = new Map(
[...facts.sections.values()].map((section) => [section.name, section]),
);
for (const section of facts.sections.values()) {
if (!section.allocated || section.size === 0) continue;
assert.equal(
segment(section.name).size,
BigInt(section.size),
`${section.name} size`,
);
if (section.type === "NOBITS") continue;
const mapped = mappings.filter((item) => item.block === section.name);
assert.equal(mapped.length, 1, `File mapping ${section.name}`);
assert.equal(mapped[0].length, section.size, `${section.name} mapped size`);
assert.equal(
mapped[0].file_offset,
section.offset,
`${section.name} offset`,
);
}
const external = segment("EXTERNAL");
const symbolAddress = (index) => {
const symbol = facts.symbols.get(index);
assert.ok(symbol, `Relocation symbol ${index} missing from readelf`);
if (symbol.ndx === "UND") {
const found = names.filter(
(item) =>
item.symbol?.external === false &&
item.value.replace(/^<EXTERNAL>::/u, "") === symbol.name &&
BigInt(item.address) >= external.start &&
BigInt(item.address) < external.start + external.size,
);
assert.equal(found.length, 1, `External placement of ${symbol.name}`);
return {
address: BigInt(found[0].address),
external: found[0].symbol.type === "function" ? "code" : "data",
};
}
const section = facts.sections.get(Number(symbol.ndx));
assert.ok(section, `Section for symbol ${symbol.name}`);
if (section.size === 0) return null;
return {
address: segment(section.name).start + BigInt(symbol.value),
external: null,
};
};
const site = (relocation) => {
const section = sectionNamed.get(relocation.section);
assert.ok(section, `Relocated section ${relocation.section}`);
return section;
};
const checked = facts.relocations.filter((item) => site(item).allocated);
// Non-allocated sections (.pdr, debug) live in Ghidra overlay spaces.
const loaded = ghidraRelocations.filter((item) =>
/^0x[\da-f]+$/u.test(item.address),
);
const ghidraByAddress = new Map(
loaded.map((item) => [BigInt(item.address), item]),
);
const inside = loaded.filter((item) =>
[...facts.sections.values()].some((section) => {
if (!section.allocated || section.size === 0) return false;
const { start } = segment(section.name);
const address = BigInt(item.address);
return address >= start && address < start + BigInt(section.size);
}),
);
assert.equal(inside.length, checked.length, "Ghidra relocation count");
const coverage = {
types: {},
shared_hi16: false,
multiple_hi16: false,
lo16_carry: false,
external_code: false,
external_data: false,
unplaced: 0,
};
// HI16 entries wait for the next LO16 of their symbol, which resolves all of
// them; later LO16s of that symbol reuse the HI16s it resolved.
let outstanding = [];
let resolved = new Map();
let currentSection;
const requireResolved = () =>
assert.deepEqual(
outstanding.map((high) => hex(high.address)),
[],
"HI16 relocations without a following LO16",
);
for (const relocation of checked) {
const section = site(relocation);
if (relocation.section !== currentSection) {
requireResolved();
resolved = new Map();
currentSection = relocation.section;
}
const address = segment(section.name).start + BigInt(relocation.offset);
const original = read32(fileBytes, section.offset + relocation.offset);
const memory = words.get(address);
assert.ok(memory !== undefined, `Loaded word at ${hex(address)}`);
const reported = ghidraByAddress.get(address);
assert.ok(reported, `Ghidra relocation at ${hex(address)}`);
assert.equal(reported.type, relocation.type, `Type at ${hex(address)}`);
assert.ok(
reported.status === "APPLIED" ||
(relocation.type === 5 && reported.status === "PARTIAL"),
`Ghidra ${reported.status} at ${hex(address)}`,
);
const target = symbolAddress(relocation.symbolIndex);
const name = RELOCATION_TYPES.get(relocation.type);
assert.ok(name, `Unexpected relocation type ${relocation.type}`);
coverage.types[name] = (coverage.types[name] ?? 0) + 1;
if (target === null) {
// Empty-section symbols have no address to recompute against.
coverage.unplaced += 1;
continue;
}
if (target.external === "code") coverage.external_code = true;
if (target.external === "data") coverage.external_data = true;
if (relocation.type === 2) {
assert.equal(
BigInt(memory),
(target.address + BigInt(original)) & 0xffffffffn,
`R_MIPS_32 at ${hex(address)}`,
);
} else if (relocation.type === 4) {
const addend = BigInt((original & 0x3ffffff) << 2);
assert.equal(
memory >>> 26,
original >>> 26,
`jump opcode ${hex(address)}`,
);
assert.equal(
BigInt(memory & 0x3ffffff),
((target.address + addend) >> 2n) & 0x3ffffffn,
`R_MIPS_26 at ${hex(address)}`,
);
} else if (relocation.type === 5) {
assert.equal(
memory >>> 16,
original >>> 16,
`HI16 opcode ${hex(address)}`,
);
outstanding.push({
address,
symbolIndex: relocation.symbolIndex,
originalHigh: original & 0xffff,
memoryHigh: memory & 0xffff,
lows: 0,
});
} else {
assert.equal(
memory >>> 16,
original >>> 16,
`LO16 opcode ${hex(address)}`,
);
const low = signed16(original);
const loaded = signed16(memory);
const matched = outstanding.filter(
(high) => high.symbolIndex === relocation.symbolIndex,
);
if (matched.length > 0) {
outstanding = outstanding.filter((high) => !matched.includes(high));
resolved.set(relocation.symbolIndex, matched);
if (matched.length > 1) coverage.multiple_hi16 = true;
}
const highs = resolved.get(relocation.symbolIndex) ?? [];
// The value each o32 lui/addiu (or load/store) pair actually forms.
for (const high of highs) {
const effective =
(BigInt(high.memoryHigh << 16) + BigInt(loaded)) & 0xffffffffn;
const expected =
(target.address + BigInt(high.originalHigh << 16) + BigInt(low)) &
0xffffffffn;
assert.equal(
effective,
expected,
`HI16 at ${hex(high.address)} with LO16 at ${hex(address)}`,
);
high.lows += 1;
if (high.lows > 1) coverage.shared_hi16 = true;
if ((memory & 0x8000) !== 0) coverage.lo16_carry = true;
}
if (highs.length === 0)
assert.equal(
BigInt(memory & 0xffff),
(target.address + BigInt(low)) & 0xffffn,
`Unpaired LO16 at ${hex(address)}`,
);
}
}
requireResolved();
return coverage;
}
@@ -0,0 +1,264 @@
import { describe, expect, it } from "vitest";
import {
assertMipsRelocations,
parseMipsRelocatableReadelf,
} from "./mips-relocatable-checks.mjs";
// GNU readelf excerpt for a tiny ET_REL object; tests the reader boundary.
const readelf = `Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000040 000010 00 AX 0 0 16
[ 2] .rel.text REL 00000000 000100 000018 08 I 5 1 4
[ 3] .data PROGBITS 00000000 000050 008010 00 WA 0 0 16
[ 4] .rel.data REL 00000000 000118 000010 08 I 5 3 4
[ 5] .symtab SYMTAB 00000000 000128 000040 10 1 1 4
Relocation section '.rel.text' at offset 0x100 contains 3 entries:
Offset Info Type Sym. Value Symbol's Name
00000000 00000205 R_MIPS_HI16 00008000 far_data
00000004 00000206 R_MIPS_LO16 00008000 far_data
00000008 00000104 R_MIPS_26 00000000 func
Relocation section '.rel.data' at offset 0x118 contains 2 entries:
Offset Info Type Sym. Value Symbol's Name
00000000 00000102 R_MIPS_32 00000000 func
00000004 00000302 R_MIPS_32 00000000 ext_data
Symbol table '.symtab' contains 4 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 16 FUNC GLOBAL DEFAULT 1 func
2: 00008000 4 OBJECT GLOBAL DEFAULT 3 far_data
3: 00000000 0 NOTYPE GLOBAL DEFAULT UND ext_data
`;
// Ghidra placement: .text 0x10000, .data 0x10010, EXTERNAL 0x19000. far_data
// is at 0x18010, so its LO16 is 0x8010 and the HI16 must carry to 2.
const fileBytes = Buffer.alloc(0x50 + 0x8010);
fileBytes.writeUInt32LE(0x3c010000, 0x40); // lui $1, 0
fileBytes.writeUInt32LE(0x8c220000, 0x44); // lw $2, 0($1)
fileBytes.writeUInt32LE(0x0c000000, 0x48); // jal 0
const segments = [
{ name: ".text", start: "0x10000", end: "0x10010" },
{ name: ".data", start: "0x10010", end: "0x18020" },
{ name: "EXTERNAL", start: "0x19000", end: "0x19004" },
];
const mappings = [
{ block: ".text", start: "0x10000", length: 0x10, file_offset: 0x40 },
{ block: ".data", start: "0x10010", length: 0x8010, file_offset: 0x50 },
];
const names = [
{
address: "0x19000",
value: "ext_data",
symbol: { external: false, type: "label" },
},
];
const relocations = () => [
{ address: "0x10000", type: 5, status: "PARTIAL" },
{ address: "0x10004", type: 6, status: "APPLIED" },
{ address: "0x10008", type: 4, status: "APPLIED" },
{ address: "0x10010", type: 2, status: "APPLIED" },
{ address: "0x10014", type: 2, status: "APPLIED" },
{ address: ".pdr:0x0", type: 2, status: "APPLIED" },
];
const words = () =>
new Map([
[0x10000n, 0x3c010002],
[0x10004n, 0x8c228010],
[0x10008n, 0x0c004000],
[0x10010n, 0x10000],
[0x10014n, 0x19000],
]);
const check = (
overrides: Partial<Parameters<typeof assertMipsRelocations>[0]> = {},
) =>
assertMipsRelocations({
facts: parseMipsRelocatableReadelf(readelf),
fileBytes,
segments,
mappings,
ghidraRelocations: relocations(),
words: words(),
names,
byteOrder: "little",
...overrides,
});
describe("MIPS relocatable real-verifier expectations", () => {
it("reads sections, symbols and REL entries from r_info", () => {
const facts = parseMipsRelocatableReadelf(readelf);
expect(facts.sections.get(3)).toMatchObject({
name: ".data",
offset: 0x50,
allocated: true,
});
expect(facts.symbols.get(3)).toMatchObject({
ndx: "UND",
name: "ext_data",
});
expect(facts.relocations).toEqual([
expect.objectContaining({ section: ".text", type: 5, symbolIndex: 2 }),
expect.objectContaining({ section: ".text", type: 6, symbolIndex: 2 }),
expect.objectContaining({ section: ".text", type: 4, symbolIndex: 1 }),
expect.objectContaining({ section: ".data", type: 2, symbolIndex: 1 }),
expect.objectContaining({ section: ".data", type: 2, symbolIndex: 3 }),
]);
});
it("rejects a REL table whose entry count disagrees with its header", () => {
expect(() =>
parseMipsRelocatableReadelf(
readelf.replace("contains 2 entries", "contains 3 entries"),
),
).toThrow(/entry count/u);
});
it("accepts relocations that encode S + A, including a HI16 carry", () => {
expect(check()).toMatchObject({
lo16_carry: true,
external_data: true,
types: { R_MIPS_HI16: 1, R_MIPS_LO16: 1, R_MIPS_26: 1, R_MIPS_32: 2 },
});
});
it.each([
["a HI16 without the signed-low carry", 0x10000n, 0x3c010001],
["a jump to the wrong target", 0x10008n, 0x0c004001],
["a rewritten opcode", 0x10004n, 0x8c238010],
["an unrelocated external word", 0x10014n, 0],
])("rejects %s", (_label, address, value) => {
const changed = words();
changed.set(address, value);
expect(() => check({ words: changed })).toThrow();
});
it("rejects a failed or missing Ghidra relocation", () => {
const failed = relocations().map((item, index) =>
index === 2 ? { ...item, status: "FAILURE" } : item,
);
expect(() => check({ ghidraRelocations: failed })).toThrow(/FAILURE/u);
expect(() => check({ ghidraRelocations: relocations().slice(1) })).toThrow(
/relocation count/u,
);
});
it("rejects a section Ghidra placed from different file bytes", () => {
expect(() =>
check({
mappings: mappings.map((item) =>
item.block === ".data" ? { ...item, file_offset: 0x60 } : item,
),
}),
).toThrow(/\.data offset/u);
});
it("counts a relocation against an empty section as unplaced, not verified", () => {
const withEmpty = readelf
.replace(
" [ 5] .symtab",
" [ 6] .sbss NOBITS 00000000 008060 000000 00 WAp 0 0 4\n [ 5] .symtab",
)
.replace("contains 2 entries", "contains 3 entries")
.replace(
"00000004 00000302 R_MIPS_32 00000000 ext_data\n",
"00000004 00000302 R_MIPS_32 00000000 ext_data\n00000008 00000402 R_MIPS_32 00000000 .sbss\n",
)
.replace(
" 3: 00000000 0 NOTYPE GLOBAL DEFAULT UND ext_data\n",
" 3: 00000000 0 NOTYPE GLOBAL DEFAULT UND ext_data\n 4: 00000000 0 SECTION LOCAL DEFAULT 6 .sbss\n",
);
const loaded = words();
loaded.set(0x10018n, 0x12345678);
expect(
check({
facts: parseMipsRelocatableReadelf(withEmpty),
ghidraRelocations: [
...relocations(),
{ address: "0x10018", type: 2, status: "APPLIED" },
],
words: loaded,
}).unplaced,
).toBe(1);
});
});
describe("MIPS relocatable HI16 entries resolved by one LO16", () => {
// lui $1; lui $3; jal (unrelated R_MIPS_26); lw: both HI16s pair with the LO16.
const multi = readelf.replace(
/Relocation section '\.rel\.text'[^]*?\n\n/u,
[
"Relocation section '.rel.text' at offset 0x100 contains 4 entries:",
" Offset Info Type Sym. Value Symbol's Name",
"00000000 00000205 R_MIPS_HI16 00008000 far_data",
"00000004 00000205 R_MIPS_HI16 00008000 far_data",
"00000008 00000104 R_MIPS_26 00000000 func",
"0000000c 00000206 R_MIPS_LO16 00008000 far_data",
"",
"",
].join("\n"),
);
const bytes = Buffer.from(fileBytes);
bytes.writeUInt32LE(0x3c010000, 0x40); // lui $1, 0
bytes.writeUInt32LE(0x3c030000, 0x44); // lui $3, 0
bytes.writeUInt32LE(0x0c000000, 0x48); // jal 0
bytes.writeUInt32LE(0x8c220000, 0x4c); // lw $2, 0($1)
const ghidra = () => [
{ address: "0x10000", type: 5, status: "PARTIAL" },
{ address: "0x10004", type: 5, status: "PARTIAL" },
{ address: "0x10008", type: 4, status: "APPLIED" },
{ address: "0x1000c", type: 6, status: "APPLIED" },
{ address: "0x10010", type: 2, status: "APPLIED" },
{ address: "0x10014", type: 2, status: "APPLIED" },
];
const loaded = () =>
new Map([
[0x10000n, 0x3c010002],
[0x10004n, 0x3c030002],
[0x10008n, 0x0c004000],
[0x1000cn, 0x8c228010],
[0x10010n, 0x10000],
[0x10014n, 0x19000],
]);
const checkMulti = (
overrides: Partial<Parameters<typeof assertMipsRelocations>[0]> = {},
) =>
check({
facts: parseMipsRelocatableReadelf(multi),
fileBytes: bytes,
ghidraRelocations: ghidra(),
words: loaded(),
...overrides,
});
it("checks every HI16 against the LO16, across unrelated relocations", () => {
expect(checkMulti()).toMatchObject({
multiple_hi16: true,
lo16_carry: true,
});
});
it("rejects a bad earlier HI16 that a later HI16 would have hidden", () => {
const words = loaded();
words.set(0x10000n, 0x3c010001);
expect(() => checkMulti({ words })).toThrow(/HI16 at 0x10000 /u);
});
it("rejects a HI16 that no LO16 resolves", () => {
const unmatched = multi
.replace("contains 4 entries", "contains 3 entries")
.replace(
"0000000c 00000206 R_MIPS_LO16 00008000 far_data\n",
"",
);
expect(() =>
checkMulti({
facts: parseMipsRelocatableReadelf(unmatched),
ghidraRelocations: ghidra().filter(
(item) => item.address !== "0x1000c",
),
}),
).toThrow(/without a following LO16/u);
});
});