diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index e2150e3fb1..248a5c0d11 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -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. diff --git a/package.json b/package.json index 9ade91edc9..316809a202 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-real-ghidra-mips-relocatable.mjs b/scripts/verify-real-ghidra-mips-relocatable.mjs new file mode 100755 index 0000000000..ab26f0699d --- /dev/null +++ b/scripts/verify-real-ghidra-mips-relocatable.mjs @@ -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 === "::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)); diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index 6de175a1f4..42b9a3e7e0 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -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 [ diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index 3604da691e..accc773fd9 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -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", }; }; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index d5c0b24b62..742ef79f7f 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -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" diff --git a/tests/conformance/c/mips-relocatable.c b/tests/conformance/c/mips-relocatable.c new file mode 100644 index 0000000000..7552a918c6 --- /dev/null +++ b/tests/conformance/c/mips-relocatable.c @@ -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); +} diff --git a/tests/conformance/ghidra/mips-checks.d.mts b/tests/conformance/ghidra/mips-checks.d.mts index e3a8096633..09ba928c6b 100644 --- a/tests/conformance/ghidra/mips-checks.d.mts +++ b/tests/conformance/ghidra/mips-checks.d.mts @@ -9,6 +9,7 @@ export function parseMipsReadelf( text: string, byteOrder: string, floatAbi?: MipsFloatAbi, + objectType?: "exec" | "rel", ): MipsReadelfFacts; export function inspectMipsReadelf( path: string, diff --git a/tests/conformance/ghidra/mips-checks.mjs b/tests/conformance/ghidra/mips-checks.mjs index 20b755788d..3e6af3733a 100644 --- a/tests/conformance/ghidra/mips-checks.mjs +++ b/tests/conformance/ghidra/mips-checks.mjs @@ -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, }; } diff --git a/tests/conformance/ghidra/mips-fixture.mjs b/tests/conformance/ghidra/mips-fixture.mjs index 518d8a70f6..b88669e315 100644 --- a/tests/conformance/ghidra/mips-fixture.mjs +++ b/tests/conformance/ghidra/mips-fixture.mjs @@ -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, }; diff --git a/tests/conformance/ghidra/mips-relocatable-checks.d.mts b/tests/conformance/ghidra/mips-relocatable-checks.d.mts new file mode 100644 index 0000000000..722908b729 --- /dev/null +++ b/tests/conformance/ghidra/mips-relocatable-checks.d.mts @@ -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; + symbols: Map; + relocations: MipsRelocation[]; +} +export interface MipsRelocationCoverage { + types: Record; + 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; + names: readonly { + address: string; + value: string; + symbol?: { external: boolean; type: string }; + }[]; + byteOrder: string; +}): MipsRelocationCoverage; diff --git a/tests/conformance/ghidra/mips-relocatable-checks.mjs b/tests/conformance/ghidra/mips-relocatable-checks.mjs new file mode 100644 index 0000000000..e4c4f0abe2 --- /dev/null +++ b/tests/conformance/ghidra/mips-relocatable-checks.mjs @@ -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(/^::/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; +} diff --git a/tests/conformance/ghidra/mips-relocatable-checks.test.ts b/tests/conformance/ghidra/mips-relocatable-checks.test.ts new file mode 100644 index 0000000000..9ee2edef07 --- /dev/null +++ b/tests/conformance/ghidra/mips-relocatable-checks.test.ts @@ -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[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[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); + }); +});