From e9fe6437a7da381f0aa5aa77571dcf03317122e7 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:50:31 +0800 Subject: [PATCH] feat(ghidra): admit verified MIPS16 shared ELF images Admit the verified ELF32 o32 MIPS32r2 MIPS16 shared-image profile after complete source load and relocation inspection. Preserve caller-selected bytes, pin the endian-matched language, and report per-address decoder context. Refuse unverified relocation shapes with explicit reasons. Verified on Linux x64 with Ghidra 12.1.4: eight source-owned public CLI/MCP controls, original-artifact instruction/pseudocode/export replay, snapshot restore and owned cleanup. 152 integrated focused tests, static checks and documentation checks passed. Fixes #1851 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- bridge/ghidra/ReaGhidraBridge.java | 13 + docs/ghidra-mips.md | 64 +++- docs/native-investigation.md | 10 +- package.json | 1 + scripts/verify-real-ghidra-mips-shared.mjs | 338 ++++++++++++++++++ src/application/BinaryTargetResolver.ts | 19 +- ...{MipsElfAbiFlags.ts => MipsElfMetadata.ts} | 64 +++- src/application/MipsElfSharedLoad.ts | 220 ++++++++++++ src/domain/binaryTargetTypes.ts | 9 + src/domain/native/nativeInstruction.ts | 18 +- src/ghidra/GhidraAnalysisProfile.ts | 37 +- src/ghidra/GhidraMipsProfile.test.ts | 125 +++++++ src/ghidra/GhidraMipsProfile.ts | 88 ++++- src/ghidra/GhidraProviderClient.ts | 8 +- .../boundary/filesystem/mipsAbiFlags.test.ts | 4 +- .../filesystem/mipsSharedLoad.test.ts | 260 ++++++++++++++ tests/conformance/ghidra/mips16-recordless.ld | 22 ++ .../ghidra/mips16-shared-fixture.mjs | 156 ++++++++ tests/conformance/ghidra/mips16-shared.S | 41 +++ 19 files changed, 1460 insertions(+), 37 deletions(-) create mode 100644 scripts/verify-real-ghidra-mips-shared.mjs rename src/application/{MipsElfAbiFlags.ts => MipsElfMetadata.ts} (73%) create mode 100644 src/application/MipsElfSharedLoad.ts create mode 100644 tests/boundary/filesystem/mipsSharedLoad.test.ts create mode 100644 tests/conformance/ghidra/mips16-recordless.ld create mode 100644 tests/conformance/ghidra/mips16-shared-fixture.mjs create mode 100644 tests/conformance/ghidra/mips16-shared.S diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 350b6011f..e45f97172 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -908,6 +908,19 @@ public final class ReaGhidraBridge extends HeadlessScript { result.add("procedure", function == null ? JsonNull.INSTANCE : GSON.toJsonTree(canonicalAddress(function.getEntryPoint()))); result.addProperty("architecture", currentProgram.getLanguageID().getIdAsString()); result.addProperty("mode", currentProgram.getLanguage().getLanguageDescription().getVariant()); + if (currentProgram.getLanguageID().getIdAsString().startsWith("MIPS:")) { + JsonArray decoderContext = new JsonArray(); + var modeRegister = currentProgram.getRegister("ISA_MODE"); + if (modeRegister != null) { + var modeValue = currentProgram.getProgramContext().getValue(modeRegister, address, false); + JsonObject context = new JsonObject(); + context.addProperty("register", modeRegister.getName()); + context.add("value", modeValue == null ? JsonNull.INSTANCE : GSON.toJsonTree(modeValue.toString())); + context.addProperty("source", "ghidra-program-context"); + decoderContext.add(context); + } + result.add("decoder_context", decoderContext); + } JsonArray operands = new JsonArray(); JsonArray references = new JsonArray(); JsonArray destinations = new JsonArray(); diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index 248a5c0d1..a3e77f7a0 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -16,9 +16,10 @@ PE, managed and Mach-O architecture sets are unchanged. 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 +conforms to that ISA. ELF64, n32/n64, unspecified ABI, microMIPS, +machine-specific variants and PSP PRX are not covered by this standard-instruction +lane. MIPS16 shared images have the separate bounded profile below; other shared +or compressed images remain unsupported. Their family may be identified, but provider selection returns the specific unsupported constraint rather than guessing a compatible profile. Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their @@ -49,6 +50,63 @@ values are retained for diagnosis but do not gain provider support. These checks bind the declared interpretation; the fixtures do not establish full floating-point behavioral conformance. +### MIPS16 shared images + +A separate profile admits ELF32 `ET_DYN`, either byte order, declaring explicit +o32, MIPS32r2 and exactly the MIPS16 ASE. Machine variants, n32, FP64 and NAN2008 +remain refused. Source inspection must examine the complete dynamic relocation +declarations through uniquely mapped file-backed `PT_LOAD` ranges. This profile +verifies `R_MIPS_NONE` and symbol-index-zero `R_MIPS_REL32`; section declarations +must describe that same dynamic table. Unavailable section headers and additional +section relocation tables (including `--emit-relocs` output) remain unsupported. +Symbol-bearing REL32, +other relocation types, RELA and PLT declarations remain unsupported. A real +negative control shows that Ghidra can normalize a symbol-bearing MIPS16 function +pointer to an even address, losing the source mode tag. REA refuses that shape +rather than rewriting the source or silently repairing the analysis result. + +The profile pins the endian-matched `MIPS::32:default` processor and default +compiler specification, and checks the bridge-reported language after import. +The generic processor preserves mixed MIPS32/MIPS16 code through per-address +`ISA_MODE` context; a global `16e` processor would impose one mode across the +image. A caller-selected incompatible processor or compiler specification is +explicitly refused without replacing the requested choice. + +An observed ABI flags record must agree with the header and MIPS16 ASE, and +must declare the same verified floating-point combinations as the standard +lane. With no observed record, every ABI declaration table must have been +completely examined. The header still declares o32, but floating-point ABI +semantics remain unknown and are stated in Evidence; selecting the default +compiler specification does not establish floating-point storage. Partial +section tables do not prove record absence. The existing soft-float prototype +policy applies when the source explicitly declares soft-float. + +Returned addresses are analysis coordinates, not runtime load addresses. +`inspect_native_load_image` preserves original relocation bytes, current loaded +memory bytes and provider status. External GOT bindings remain unverified +without their defining images. The provider's `modified_sha256` describes its +ephemeral relocated image and can differ from `original_sha256`; it does not +mean that the caller's artifact was written. `inspect_native_instruction` retains the program +language variant in `mode` and reports actual per-address `ISA_MODE` separately +in `decoder_context`, including null when unavailable. Artifact bytes and digests +remain unchanged; this workflow does not execute the shared library. + +The optional real-provider check builds eight source-owned GNU controls, covering +both byte orders, mixed MIPS32/MIPS16 code, declared and absent ABI records, and +the rejected symbol-bearing and additional section relocations. It compares source instruction bytes, +function symbols and the local function-pointer tag with actual CLI/MCP results, +checks context through a snapshot round trip, and verifies process cleanup. +It requires caller-supplied GNU MIPS cross-binutils and Ghidra; no fixture target +is executed or patched after linking. Floating-point behavior of compressed +functions is not established by these integer controls. + +```bash +REA_MIPS_AS=/absolute/path/to/mips-linux-gnu-as \ +REA_MIPS_LD=/absolute/path/to/mips-linux-gnu-ld \ +GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra \ +npm run verify:ghidra:mips-shared +``` + MIPS header and ABI record facts are committed in the analysis profile so interpretation changes invalidate profile-bound snapshots. The support-lane identity is versioned independently of file identity. Evidence, lifecycle diff --git a/docs/native-investigation.md b/docs/native-investigation.md index dc7d6987f..7b163ec24 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -202,8 +202,14 @@ rea inspect-native-data-type --address --provider Instruction facts include bytes, length, decoder text, ordered register/scalar/ address tokens, typed references and flow destinations. Mid-instruction, data, outside-memory and undecodable addresses have separate outcomes. Effective -memory base/index/displacement roles and per-instruction context mode remain -unavailable; `mode` is the program language variant. +memory base/index/displacement roles remain unavailable. Ghidra reports its program +language variant in `mode`; other providers retain their own mode label. Ghidra +MIPS results additionally expose `decoder_context` +with the observed `ISA_MODE` register at the queried address and its provider +source. A null value is unavailable, not mode zero; an empty array reports no +available context register, and omission preserves older captures or providers +that do not expose this facet. The context is an analysis observation, not proof +of a runtime processor state. `inspect_native_instruction` also returns `incoming_references` to the exact queried address, including data, interior-instruction and undecodable addresses. diff --git a/package.json b/package.json index 316809a20..0108d6d05 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-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.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", diff --git a/scripts/verify-real-ghidra-mips-shared.mjs b/scripts/verify-real-ghidra-mips-shared.mjs new file mode 100644 index 000000000..da52495ce --- /dev/null +++ b/scripts/verify-real-ghidra-mips-shared.mjs @@ -0,0 +1,338 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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 { buildMips16SharedFixture } from "../tests/conformance/ghidra/mips16-shared-fixture.mjs"; +import { + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +if ( + process.env.GHIDRA_INSTALL_DIR === undefined || + !["linux", "darwin"].includes(process.platform) +) + throw new Error( + "This optional lane requires BYO Ghidra on Linux/macOS and GNU MIPS cross-binutils.", + ); +const execute = promisify(execFile); +const run = createVerifierRun(); +const workspace = await mkdtemp(join(tmpdir(), "rea-mips-shared-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: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.JAVA_HOME === undefined + ? {} + : { JAVA_HOME: process.env.JAVA_HOME }), + ...(process.env.GHIDRA_HEADLESS_MAXMEM === undefined + ? {} + : { GHIDRA_HEADLESS_MAXMEM: process.env.GHIDRA_HEADLESS_MAXMEM }), +}; +const reports = []; +let failure; +try { + for (const byteOrder of ["big", "little"]) { + for (const variant of [ + "declared", + "recordless", + "symbol-relative", + "section-relocations", + ]) { + const fixture = await buildMips16SharedFixture( + workspace, + byteOrder, + variant, + ); + const resolved = await parseBinaryTarget(fixture.path); + if (!resolved.ok) throw resolved.error; + assert.equal(resolved.value.sha256, fixture.sha256); + assert.equal(resolved.value.architecture, "mips"); + if (variant === "section-relocations") { + assert.equal(resolved.value.mips.sharedLoad.status, "unavailable"); + assert.match( + resolved.value.mips.sharedLoad.reason, + /section relocation declarations/u, + ); + } else { + assert.equal(resolved.value.mips.sharedLoad.status, "complete"); + assert.deepEqual( + resolved.value.mips.sharedLoad.relocationTypes, + [0, 3], + ); + assert.equal( + resolved.value.mips.sharedLoad.symbolRelativeCount, + variant === "symbol-relative" ? 1 : 0, + ); + } + if (variant === "recordless") + assert.equal(resolved.value.mips.abiFlags, null); + else { + assert.equal(resolved.value.mips.abiFlags.fpAbi, 5); + assert.equal(resolved.value.mips.abiFlags.ases, 0x400); + } + const transport = new StdioClientTransport({ + command: process.execPath, + args: ["scripts/rea.mjs", "mcp"], + cwd: process.cwd(), + env, + stderr: "pipe", + }); + const client = new Client({ + name: "rea-mips16-shared-conformance", + version: "1", + }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-262144); + }); + let calls = 0; + const reply = async (name, args = {}) => { + const response = await client.callTool( + { name, arguments: args }, + { timeout: 180000 }, + ); + await writeFile( + join(workspace, `${byteOrder}-${variant}-${++calls}-${name}.json`), + JSON.stringify(response, null, 2), + ); + return response; + }; + const call = async (name, args) => + requireMcpOperationResult(await reply(name, args), name); + const original = await readFile(fixture.path); + try { + await client.connect(transport); + await client.listTools(); + if ( + variant === "symbol-relative" || + variant === "section-relocations" + ) { + const refusal = requireMcpToolError( + await reply("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }), + ); + assert.equal(refusal.category, "unsupported_provider"); + assert.match( + JSON.stringify(refusal), + variant === "symbol-relative" + ? /Symbol-bearing R_MIPS_REL32/u + : /section relocation declarations/u, + ); + assert.deepEqual(await readFile(fixture.path), original); + await client.ping(); + reports.push({ ...fixture, status: "refused", ping: true }); + continue; + } + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + const loadedReply = await reply("inspect_native_load_image"); + const loaded = requireMcpOperationResult( + loadedReply, + "inspect_native_load_image", + ); + const evidence = parseEvidence( + JSON.parse( + loadedReply.content.find((item) => item.type === "text").text, + ), + ); + const language = `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`; + assert.equal(loaded.observations.language_id, language); + assert.equal(loaded.observations.compiler_spec_id, "default"); + assert( + loaded.observations.source_files.some( + (file) => file.original_sha256 === fixture.sha256, + ), + ); + assert( + evidence.limitations.some((item) => + item.includes("External GOT bindings"), + ), + ); + if (variant === "recordless") + assert( + evidence.limitations.some((item) => + item.includes("does not establish the floating-point ABI"), + ), + ); + const base = BigInt(loaded.observations.image_base); + const address = (source, clearTag = false) => + `0x${(base + (clearTag ? BigInt(source) & ~1n : BigInt(source))).toString(16)}`; + const compressed = address(fixture.symbols.rea_mips16_probe, true); + const standard = address(fixture.symbols.rea_mips32_probe); + const procedures = await call("list_procedures"); + assert( + procedures.some( + (item) => + item.address === compressed && item.value === "rea_mips16_probe", + ), + ); + assert( + procedures.some( + (item) => + item.address === standard && item.value === "rea_mips32_probe", + ), + ); + let firstInstruction; + const expected = + byteOrder === "little" + ? ["5a6a", "8eea", "074a", "a0e8"] + : ["6a5a", "ea8e", "4a07", "e8a0"]; + for (let i = 0; i < 4; i += 1) { + const instruction = await call("inspect_native_instruction", { + address: `0x${(BigInt(compressed) + BigInt(2 * i)).toString(16)}`, + }); + assert.equal(instruction.status, "decoded"); + assert.equal(instruction.mode, "default"); + assert.equal(instruction.length, 2); + assert.equal(instruction.bytes, expected[i]); + assert.deepEqual(instruction.decoder_context, [ + { + register: "ISA_MODE", + value: "1", + source: "ghidra-program-context", + }, + ]); + if (i === 0) firstInstruction = instruction; + } + const standardInstruction = await call("inspect_native_instruction", { + address: standard, + }); + assert.equal(standardInstruction.length, 4); + assert.equal( + standardInstruction.bytes, + byteOrder === "little" ? "03008224" : "24820003", + ); + assert.equal( + standardInstruction.decoder_context[0].register, + "ISA_MODE", + ); + assert( + [null, "0"].includes(standardInstruction.decoder_context[0].value), + ); + const pointer = await call("read_bytes", { + address: address(fixture.symbols.rea_mips16_pointer), + length: 4, + }); + const pointerBytes = Buffer.from(pointer.bytes_hex, "hex"); + const pointerValue = + byteOrder === "little" + ? pointerBytes.readUInt32LE(0) + : pointerBytes.readUInt32BE(0); + assert.equal( + BigInt(pointerValue), + base + BigInt(fixture.symbols.rea_mips16_probe), + ); + const dossier = await call("analyze_function", { + procedure: compressed, + }); + assert.match(dossier.pseudocode, /\^\s*0x5a/u); + assert.match(dossier.pseudocode, /\+\s*(?:7|0x7)\b/u); + const standardDossier = await call("analyze_function", { + procedure: standard, + }); + assert.match(standardDossier.pseudocode, /\+\s*(?:3|0x3)\b/u); + const snapshotPath = join( + workspace, + `${byteOrder}-${variant}.snapshot.json`, + ); + await call("close_binary", { snapshot_path: snapshotPath }); + const snapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(snapshotPath, "utf8")), + ); + assert.equal( + snapshot.binding.analysis_profile.parameters.language_selection, + "provider-pinned-v1", + ); + assert.equal( + snapshot.binding.analysis_profile.parameters.language_id, + language, + ); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + assert.deepEqual( + await call("inspect_native_instruction", { address: compressed }), + firstInstruction, + ); + await call("close_binary"); + await client.ping(); + const cli = await execute( + process.execPath, + [ + "scripts/rea.mjs", + "inspect-native-instruction", + fixture.path, + compressed, + "--provider", + "ghidra", + "--format", + "json", + ], + { env, timeout: 180000, maxBuffer: 8 * 1024 * 1024 }, + ); + assert.deepEqual( + parseEvidence(JSON.parse(cli.stdout)).normalized_result, + firstInstruction, + ); + assert.deepEqual(await readFile(fixture.path), original); + reports.push({ + ...fixture, + status: "passed", + language_id: language, + mixed_mode: true, + standard_decoder_context: standardInstruction.decoder_context, + pointer_tag_preserved: true, + snapshot_context_preserved: true, + cli_mcp_parity: true, + source_unchanged: true, + ping: true, + }); + } finally { + await client.close(); + await transport.close(); + await writeFile( + join(workspace, `${byteOrder}-${variant}.stderr`), + stderr, + ); + } + } + } +} catch (cause) { + failure = cause; +} +const completed = await completeVerifierRun(run); +if ( + completed.process_lineage.status !== "verified" || + completed.process_lineage.descendants.length !== 0 +) + throw new Error(`Cleanup unverified; retained workspace: ${workspace}`, { + cause: failure, + }); +if (failure !== undefined) + throw new Error( + `MIPS16 shared verification failed; retained workspace: ${workspace}`, + { cause: failure }, + ); +await rm(workspace, { recursive: true, force: true }); +console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2)); diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 9e0f5274c..0cffae284 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -17,7 +17,7 @@ import { import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; -import { readMipsElfAbiFlags } from "./MipsElfAbiFlags.js"; +import { readMipsElfMetadata } from "./MipsElfMetadata.js"; import { resolveAppBundleExecutable, type ResolvedAppBundle, @@ -430,18 +430,23 @@ const readExecutableMetadata = async ( parsed.value.mips.elfClass !== 32 ) return parsed; - const abiFlags = await readMipsElfAbiFlags(handle, parsed.value.mips, () => - throwIfTargetResolutionCancelled(signal), + const declarations = await readMipsElfMetadata( + handle, + parsed.value.mips, + () => throwIfTargetResolutionCancelled(signal), ); - if (!abiFlags.ok) return abiFlags; + if (!declarations.ok) return declarations; return ok({ ...parsed.value, mips: { ...parsed.value.mips, - abiFlags: abiFlags.value.abiFlags, - ...(abiFlags.value.limitations.length === 0 + abiFlags: declarations.value.abiFlags, + ...(declarations.value.sharedLoad === undefined ? {} - : { limitations: abiFlags.value.limitations }), + : { sharedLoad: declarations.value.sharedLoad }), + ...(declarations.value.limitations.length === 0 + ? {} + : { limitations: declarations.value.limitations }), }, }); }; diff --git a/src/application/MipsElfAbiFlags.ts b/src/application/MipsElfMetadata.ts similarity index 73% rename from src/application/MipsElfAbiFlags.ts rename to src/application/MipsElfMetadata.ts index 6558b7747..8f2d97115 100644 --- a/src/application/MipsElfAbiFlags.ts +++ b/src/application/MipsElfMetadata.ts @@ -4,24 +4,29 @@ import type { MipsElfMetadata, } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; +import { + readMipsElfSharedLoad, + type MipsElfRelocationSection, +} from "./MipsElfSharedLoad.js"; -/** Observed ABI declarations and coverage of their source tables. */ -export interface MipsAbiFlagsInspection { +/** Observed ABI and load declarations, retaining unavailable source coverage. */ +export interface MipsElfInspection { readonly abiFlags: MipsAbiFlags | null; readonly limitations: readonly string[]; + readonly sharedLoad?: MipsElfMetadata["sharedLoad"]; } /** - * Read ELF32 MIPS ABI declarations from the caller's already-open file. + * Read ELF32 MIPS ABI and shared-load declarations from the caller's already-open file. * Inspect both typed tables (not section names), including records beyond the * initial header probe. A missing record is unknown, not a default FP ABI. * The caller supplies its cancellation check so its tagged error is preserved. */ -export const readMipsElfAbiFlags = async ( +export const readMipsElfMetadata = async ( handle: FileHandle, metadata: MipsElfMetadata, checkCancelled: () => void, -): Promise> => { +): Promise> => { checkCancelled(); if (metadata.elfClass !== 32) return err("MIPS ABI record inspection currently supports ELF32 only"); @@ -107,6 +112,9 @@ export const readMipsElfAbiFlags = async ( if (u16(bytes, 44) === 0xffff || u16(bytes, 48) >= 0xff00) return err("extended or reserved MIPS ELF table numbering is unsupported"); let record: Buffer | undefined; + let sharedLoad: MipsElfMetadata["sharedLoad"]; + let programHeaders: Buffer | undefined; + const relocationSections: MipsElfRelocationSection[] = []; const limitations: string[] = []; for (const table of tables) { if (table.count === 0) { @@ -135,12 +143,20 @@ export const readMipsElfAbiFlags = async ( table.label, ); if (!contents.ok) return contents; + if (!table.optional && metadata.type === 3) programHeaders = contents.value; let found = false; for (let i = 0; i < count; i += 1) { checkCancelled(); const position = i * table.entrySize; - if (u32(contents.value, position + table.typeOffset) !== table.recordType) - continue; + const type = u32(contents.value, position + table.typeOffset); + if (metadata.type === 3 && table.optional && (type === 4 || type === 9)) + relocationSections.push({ + type, + offset: u32(contents.value, position + 16), + size: u32(contents.value, position + 20), + entrySize: u32(contents.value, position + 36), + }); + if (type !== table.recordType) continue; if (found) return err(`duplicate MIPS ABI records in ${table.label}`); found = true; const offset = u32(contents.value, position + table.dataOffset); @@ -154,7 +170,37 @@ export const readMipsElfAbiFlags = async ( record = candidate.value; } } - if (record === undefined) return ok({ abiFlags: null, limitations }); + if (metadata.type === 3 && programHeaders !== undefined) { + if (limitations.length > 0) + sharedLoad = { + status: "unavailable", + reason: `MIPS shared-image relocation declaration sources are incomplete. ${limitations.join(" ")}`, + }; + else { + const shared = await readMipsElfSharedLoad( + programHeaders, + little, + fileSize, + read, + checkCancelled, + relocationSections, + ); + sharedLoad = shared.ok + ? shared.value + : { status: "unavailable", reason: shared.error }; + } + } + const shared = + metadata.type === 3 + ? { + sharedLoad: sharedLoad ?? { + status: "unavailable" as const, + reason: "MIPS shared image has no inspected program header table", + }, + } + : {}; + if (record === undefined) + return ok({ abiFlags: null, limitations, ...shared }); // Preserve declarations verbatim, including unknown versions/values. Provider // admission, not parsing, decides which interpretations have been verified. const abiFlags: MipsAbiFlags = { @@ -170,5 +216,5 @@ export const readMipsElfAbiFlags = async ( flags1: u32(record, 16), flags2: u32(record, 20), }; - return ok({ abiFlags, limitations }); + return ok({ abiFlags, limitations, ...shared }); }; diff --git a/src/application/MipsElfSharedLoad.ts b/src/application/MipsElfSharedLoad.ts new file mode 100644 index 000000000..7c4dcf755 --- /dev/null +++ b/src/application/MipsElfSharedLoad.ts @@ -0,0 +1,220 @@ +import type { MipsElfMetadata } from "../domain/binaryTargetTypes.js"; +import { err, ok, type Result } from "../domain/result.js"; + +type ReadRange = ( + offset: number, + length: number, + label: string, +) => Promise>; + +export interface MipsElfRelocationSection { + readonly type: number; + readonly offset: number; + readonly size: number; + readonly entrySize: number; +} +type SharedLoadFacts = Extract< + NonNullable, + { status: "complete" } +>; + +/** Source ELF facts only; the provider owns interpretation and admission. */ +export const readMipsElfSharedLoad = async ( + programHeaders: Buffer, + little: boolean, + fileSize: number, + read: ReadRange, + checkCancelled: () => void, + sections: readonly MipsElfRelocationSection[], +): Promise> => { + const u32 = (bytes: Buffer, offset: number): number => + little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); + const loads: { + offset: number; + address: number; + size: number; + memorySize: number; + }[] = []; + let dynamic: { offset: number; address: number; size: number } | undefined; + for (let position = 0; position < programHeaders.length; position += 32) { + checkCancelled(); + const type = u32(programHeaders, position); + const offset = u32(programHeaders, position + 4); + const size = u32(programHeaders, position + 16); + if (type === 1) { + const address = u32(programHeaders, position + 8); + const memorySize = u32(programHeaders, position + 20); + if ( + offset > fileSize - size || + size > memorySize || + address + memorySize > 0x100000000 + ) + return err("invalid or incomplete MIPS PT_LOAD range"); + loads.push({ offset, address, size, memorySize }); + } + if (type === 2) { + if (dynamic !== undefined) return err("duplicate MIPS PT_DYNAMIC tables"); + dynamic = { offset, address: u32(programHeaders, position + 8), size }; + } + } + if (dynamic === undefined) + return err("MIPS shared image has no PT_DYNAMIC table"); + if (dynamic.size === 0 || dynamic.size % 8 !== 0) + return err("invalid MIPS ELF32 dynamic table size"); + if (dynamic.offset > fileSize - dynamic.size) + return err("MIPS PT_DYNAMIC range extends beyond the file"); + const ranges = loads + .filter((load) => load.memorySize > 0) + .sort((left, right) => left.address - right.address); + for (let i = 1; i < ranges.length; i += 1) { + checkCancelled(); + const previous = ranges[i - 1]!; + if (ranges[i]!.address < previous.address + previous.memorySize) + return err( + "overlapping MIPS PT_LOAD memory ranges require separate mapping verification", + ); + } + // One normalized index prevents relocation count * program-header count work. + const mapped = (address: number, length: number, fileBacked: boolean) => { + let low = 0; + let high = ranges.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (ranges[middle]!.address <= address) low = middle + 1; + else high = middle; + } + const range = ranges[low - 1]; + return range !== undefined && + address - range.address <= + (fileBacked ? range.size : range.memorySize) - length + ? range + : undefined; + }; + const dynamicMapping = mapped(dynamic.address, dynamic.size, true); + if ( + dynamicMapping === undefined || + dynamicMapping.offset + dynamic.address - dynamicMapping.address !== + dynamic.offset + ) + return err( + "MIPS PT_DYNAMIC has no unique matching file-backed PT_LOAD range", + ); + + // Bound temporary storage, not admitted artifact size. ELF32 records are + // exact eight-byte structures; every declared record is examined or refused. + const chunkBytes = 64 * 1024; + const relevant = new Set([2, 7, 8, 9, 17, 18, 19, 20, 23]); + const tags = new Map(); + let terminated = false; + for ( + let offset = 0; + offset < dynamic.size && !terminated; + offset += chunkBytes + ) { + const chunk = await read( + dynamic.offset + offset, + Math.min(chunkBytes, dynamic.size - offset), + "dynamic table", + ); + if (!chunk.ok) return chunk; + for (let position = 0; position < chunk.value.length; position += 8) { + checkCancelled(); + const tag = u32(chunk.value, position); + if (tag === 0) { + terminated = true; + break; + } + if (!relevant.has(tag)) continue; + if (tags.has(tag)) return err(`duplicate MIPS dynamic tag ${tag}`); + tags.set(tag, u32(chunk.value, position + 4)); + } + } + if (!terminated) return err("MIPS dynamic table has no DT_NULL terminator"); + if ([2, 7, 8, 9, 23].some((tag) => (tags.get(tag) ?? 0) !== 0)) + return err( + "MIPS shared RELA or PLT relocation declarations require separate verification", + ); + const address = tags.get(17); + const size = tags.get(18); + const stride = tags.get(19); + const checkSections = ( + offset: number, + length: number, + ): string | undefined => { + let observed = 0; + for (const section of sections) { + checkCancelled(); + if (section.size === 0) continue; + if ( + section.type !== 9 || + section.entrySize !== 8 || + section.offset !== offset || + section.size !== length + ) + return "MIPS section relocation declarations outside the dynamic REL table require separate verification"; + observed += 1; + } + return observed > 1 + ? "duplicate MIPS section relocation declarations require separate verification" + : undefined; + }; + const emptyRelocations = (): Result => { + const reason = checkSections(0, 0); + return reason === undefined + ? ok({ + status: "complete", + relocationCount: 0, + relocationTypes: [], + symbolRelativeCount: 0, + }) + : err(reason); + }; + if (address === undefined && size === undefined && stride === undefined) + return emptyRelocations(); + if ( + address === undefined || + size === undefined || + stride !== 8 || + size % 8 !== 0 + ) + return err( + "incomplete or invalid MIPS DT_REL/DT_RELSZ/DT_RELENT declaration", + ); + if (size === 0) return emptyRelocations(); + const mapping = mapped(address, size, true); + if (mapping === undefined) + return err( + "MIPS DT_REL range has no unique complete file-backed PT_LOAD mapping", + ); + const fileOffset = mapping.offset + address - mapping.address; + const sectionReason = checkSections(fileOffset, size); + if (sectionReason !== undefined) return err(sectionReason); + let symbolRelativeCount = 0; + const types = new Set(); + for (let offset = 0; offset < size; offset += chunkBytes) { + const chunk = await read( + fileOffset + offset, + Math.min(chunkBytes, size - offset), + "dynamic REL records", + ); + if (!chunk.ok) return chunk; + for (let position = 0; position < chunk.value.length; position += 8) { + checkCancelled(); + const info = u32(chunk.value, position + 4); + const type = info & 0xff; + const relocationAddress = u32(chunk.value, position); + if (type === 3 && mapped(relocationAddress, 4, false) === undefined) + return err( + "MIPS REL32 destination has no unique complete PT_LOAD memory range", + ); + types.add(type); + if (type === 3 && info >>> 8 !== 0) symbolRelativeCount += 1; + } + } + return ok({ + status: "complete", + relocationCount: size / 8, + relocationTypes: [...types].sort((left, right) => left - right), + symbolRelativeCount, + }); +}; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 5d7deba68..55914377c 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -18,6 +18,15 @@ export interface MipsElfMetadata { readonly abiFlags?: MipsAbiFlags | null; /** Unavailable declaration sources; missing records there remain unknown. */ readonly limitations?: readonly string[]; + /** Complete dynamic REL declarations, or the reason they could not be inspected. */ + readonly sharedLoad?: + | { + readonly status: "complete"; + readonly relocationCount: number; + readonly relocationTypes: readonly number[]; + readonly symbolRelativeCount: number; + } + | { readonly status: "unavailable"; readonly reason: string }; } /** Declared fields of the 24-byte MIPS ABI flags record, not inferred semantics. */ diff --git a/src/domain/native/nativeInstruction.ts b/src/domain/native/nativeInstruction.ts index e4c112cd1..652aa014b 100644 --- a/src/domain/native/nativeInstruction.ts +++ b/src/domain/native/nativeInstruction.ts @@ -53,7 +53,23 @@ export const nativeInstructionSchema = z.strictObject({ ]), procedure: z.string().nullable(), architecture: z.string(), - mode: z.string(), + mode: z + .string() + .describe( + "Provider-reported mode label. Per-address decoder context, when available, is reported separately; a program-wide label does not establish runtime encoding mode.", + ), + decoder_context: z + .array( + z.strictObject({ + register: z.string().min(1), + value: z.string().min(1).nullable(), + source: z.string().min(1), + }), + ) + .optional() + .describe( + "Observed per-address decoder context. Null means no value was available; omission means the provider or older capture does not expose this facet. The separate mode field retains the program language variant.", + ), bytes: z.string().nullable(), length: z.number().int().positive().nullable(), mnemonic: z.string().nullable(), diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 6e1b1e7cc..b8bec6c61 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -13,7 +13,10 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; import type { GhidraInstallationInspection } from "./GhidraInstallation.js"; -import { ghidraMipsProfileParameters } from "./GhidraMipsProfile.js"; +import { + ghidraMipsProfileParameters, + ghidraMipsSharedLanguageOverride, +} from "./GhidraMipsProfile.js"; import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; import { @@ -132,6 +135,22 @@ export const resolveGhidraAnalysisProfile = async ( const dosMz = target.format === "dos-mz"; const dosCom = target.format === "dos-com"; const dos = dosMz || dosCom; + const mipsLanguage = ghidraMipsSharedLanguageOverride(target); + if ( + mipsLanguage !== undefined && + languageOverride !== undefined && + (languageOverride.languageId !== mipsLanguage.languageId || + (languageOverride.compilerSpecId !== undefined && + languageOverride.compilerSpecId !== mipsLanguage.compilerSpecId)) + ) + return err( + new AnalysisUnsupportedTargetError( + "resolve_analysis_profile", + target.path, + "The caller-selected processor or compiler specification is outside the verified endian-matched MIPS32 shared-image profile. The requested override has not been changed.", + ), + ); + const selectedLanguage = languageOverride ?? mipsLanguage; // DOS imports already force an admitted loader, language, and context. if (dos && languageOverride !== undefined) return Promise.resolve( @@ -199,14 +218,16 @@ export const resolveGhidraAnalysisProfile = async ( : "auto-from-header", language_id: dos ? "x86:LE:16:Real Mode" - : (languageOverride?.languageId ?? "auto-from-header"), + : (selectedLanguage?.languageId ?? "auto-from-header"), compiler_spec_id: dos ? "default" - : languageOverride === undefined + : selectedLanguage === undefined ? "auto-default" - : (languageOverride.compilerSpecId ?? "language-default"), + : (selectedLanguage.compilerSpecId ?? "language-default"), ...(languageOverride === undefined - ? {} + ? mipsLanguage === undefined + ? {} + : { language_selection: "provider-pinned-v1" } : { language_selection: "configured-v1" }), ...(dos ? { @@ -246,7 +267,11 @@ export const resolveGhidraAnalysisProfile = async ( export const ghidraProfileLanguageOverride = ( parameters: Readonly>, ): GhidraLanguageOverride | undefined => { - if (parameters.language_selection !== "configured-v1") return undefined; + if ( + parameters.language_selection !== "configured-v1" && + parameters.language_selection !== "provider-pinned-v1" + ) + return undefined; const languageId = parameters.language_id; const compilerSpecId = parameters.compiler_spec_id; if (typeof languageId !== "string" || typeof compilerSpecId !== "string") diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index 42b9a3e7e..31a121cd1 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -13,6 +13,7 @@ import { GhidraProvider } from "./GhidraProvider.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js"; import type { GhidraMipsSoftFloatReport } from "./GhidraMipsProfile.js"; +import { ghidraProfileLanguageOverride } from "./GhidraAnalysisProfile.js"; // Fake installation inspection tests admission/profile contracts, not Ghidra. const host: GhidraInstallationHost = { @@ -217,6 +218,130 @@ const session = async ( }; }; +describe("MIPS16 shared-image profile", () => { + const shared = (change: Partial = {}) => + target({ + type: 3, + flags: 0x74001005, + abiFlags: null, + sharedLoad: { + status: "complete", + relocationCount: 14, + relocationTypes: [0, 3], + symbolRelativeCount: 0, + }, + ...change, + }); + it.each(["little", "big"] as const)( + "pins a generic 32-bit %s language and retains the source ABI uncertainty", + async (byteOrder) => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const value = shared({ byteOrder }); + expect(ghidra.inspectTargetSupport(value).status).toBe("supported"); + const resolved = await ghidra.resolveAnalysisProfile(value); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected shared profile"); + const parameters = resolved.value.profile.parameters; + expect(parameters).toMatchObject({ + language_id: `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`, + compiler_spec_id: "default", + language_selection: "provider-pinned-v1", + mips_support_lane: "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1", + mips_elf: { abi_flags: null }, + }); + expect(ghidraProfileLanguageOverride(parameters)).toEqual({ + languageId: parameters.language_id, + compilerSpecId: "default", + }); + }, + ); + it("retains shared-image and unknown floating-point limitations on the session", async () => { + const { client } = await session(shared(), undefined); + const health = await client.execute("health", {}); + if (!health.ok) throw health.error; + expect(health.value.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("External GOT bindings"), + expect.stringContaining("does not establish the floating-point ABI"), + ]), + ); + }); + it("continues to admit a declared FPXX record only when its MIPS16 ASE agrees", () => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + expect( + ghidra.inspectTargetSupport(shared(abi({ ases: 0x400 }))).status, + ).toBe("supported"); + expect(ghidra.inspectTargetSupport(shared(abi({ ases: 0 }))).status).toBe( + "unsupported", + ); + }); + it.each([ + { flags: 0x74001045 }, + { limitations: ["Unexamined section declarations"] }, + { + sharedLoad: { + status: "complete", + relocationCount: 1, + relocationTypes: [3], + symbolRelativeCount: 1, + }, + }, + ] satisfies Partial[])( + "refuses incomplete or unverified source declarations %j", + (change) => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + expect(ghidra.inspectTargetSupport(shared(change)).status).toBe( + "unsupported", + ); + }, + ); + it.each(["sharedLoad", "abiFlags"] as const)( + "refuses an uninspected %s declaration", + (declaration) => { + const value = shared(); + if (value.kind !== "executable" || value.architecture !== "mips") + throw new Error("expected MIPS target"); + const facts = { ...value.mips }; + delete facts[declaration]; + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + expect( + ghidra.inspectTargetSupport({ ...value, mips: facts }), + ).toMatchObject({ + status: "unsupported", + reason: expect.stringContaining( + declaration === "abiFlags" + ? "inspected ABI flags" + : "have not been inspected", + ), + }); + }, + ); + it("refuses an endian pin committed for a different target", async () => { + const { counts, client } = await session( + shared({ byteOrder: "big" }), + undefined, + shared(), + ); + const health = await client.execute("health", {}); + expect(health.ok).toBe(false); + expect(counts.starts).toBe(0); + }); + it("rejects a caller-selected global MIPS16 processor without replacing the request", async () => { + const parsed = parseConfig({ + GHIDRA_INSTALL_DIR: "/ghidra", + REA_GHIDRA_LANGUAGE_ID: "MIPS:LE:32:16e", + }); + if (!parsed.ok) throw parsed.error; + const ghidra = new GhidraProvider(parsed.value, silentLogger, {}, host); + const resolved = await ghidra.resolveAnalysisProfile(shared()); + expect(resolved.ok).toBe(false); + if (!resolved.ok) + expect(resolved.error.message).toContain( + "requested override has not been changed", + ); + }); +}); + describe("soft-float MIPS storage", () => { it("admits soft-float o32 and commits its storage policy only for it", async () => { const soft = target(softFloat); diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index accc773fd..031e273f1 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -1,9 +1,30 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; const MIPS_FP_ABI_SOFT = 3; const MIPS_ET_REL = 1; const MIPS_ET_EXEC = 2; +const MIPS_ET_DYN = 3; +const MIPS16_HEADER_ASE = 0x04000000; + +/** A distinct compressed shared-image shape; admission still checks every declaration. */ +const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean => + target.kind === "executable" && + target.architecture === "mips" && + target.mips.type === MIPS_ET_DYN && + (target.mips.flags & 0x0f000000) === MIPS16_HEADER_ASE; + +/** Generic MIPS32 permits per-function ISA_MODE; a global 16e processor does not. */ +export const ghidraMipsSharedLanguageOverride = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + isGhidraMips16SharedTarget(target) && target.architecture === "mips" + ? { + languageId: `MIPS:${target.mips.byteOrder === "little" ? "LE" : "BE"}:32:default`, + compilerSpecId: "default", + } + : undefined; /** Keep family recognition separate from the deliberately bounded Ghidra lane. */ export const ghidraMipsUnsupportedReason = ( @@ -14,11 +35,16 @@ export const ghidraMipsUnsupportedReason = ( const metadata = target.mips; if (target.format !== "elf" || metadata === undefined) return "MIPS analysis requires ELF header metadata; a family name alone does not select an ISA or ABI."; + const shared = isGhidraMips16SharedTarget(target); if (metadata.elfClass !== 32) return "This Ghidra MIPS lane supports ELF32 only; MIPS64 and n64 remain unsupported."; - if (metadata.type !== MIPS_ET_EXEC && metadata.type !== MIPS_ET_REL) + if ( + metadata.type !== MIPS_ET_EXEC && + metadata.type !== MIPS_ET_REL && + !shared + ) 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) + if ((metadata.flags & 0x0f000000) !== 0 && !shared) return "MIPS16, microMIPS and other ASE encodings are outside this standard-instruction MIPS lane."; if ((metadata.flags & 0x00ff0000) !== 0) return "Machine-specific MIPS variants, including PSP Allegrex, need a separately supported processor profile."; @@ -33,7 +59,20 @@ export const ghidraMipsUnsupportedReason = ( return "EF_MIPS_FP64 requires separate verification of 64-bit floating-point register semantics in this Ghidra lane."; if ((metadata.flags & 0x00000400) !== 0) return "EF_MIPS_NAN2008 requires separate verification of NaN encoding semantics in this Ghidra lane."; + if (shared) { + if ((metadata.flags & 0x40) !== 0) + return "EF_MIPS_ABI_ON32 declares an unverified ABI for the MIPS16 shared-image lane."; + const load = metadata.sharedLoad; + if (load === undefined || load.status !== "complete") + return `MIPS16 shared images require complete dynamic relocation inspection. ${load?.reason ?? "The source declarations have not been inspected."}`; + if (load.relocationTypes.some((type) => type !== 0 && type !== 3)) + return "This MIPS16 shared-image lane verifies R_MIPS_NONE and R_MIPS_REL32 only; other dynamic relocation types require separate verification."; + if (load.symbolRelativeCount !== 0) + return "Symbol-bearing R_MIPS_REL32 is unsupported: Ghidra normalizes MIPS16 function addresses and can lose the source function-pointer mode tag. Symbol-index-zero REL32 is verified separately."; + } const abi = metadata.abiFlags; + if (shared && abi === null && (metadata.limitations?.length ?? 0) === 0) + return null; // Inspected absence leaves FP semantics unknown, never inferred. if (abi === undefined || abi === null) return [ "This Ghidra MIPS lane requires an inspected ABI flags record; header flags alone do not establish the floating-point ABI.", @@ -49,8 +88,8 @@ export const ghidraMipsUnsupportedReason = ( const softFloat = abi.cpr1Size === 0 && abi.fpAbi === MIPS_FP_ABI_SOFT; if (!hardFloat && !softFloat) return "This Ghidra MIPS lane admits double-precision or FPXX ABI declarations with 32-bit CPR1, or soft-float without CPR1; single-float, FP64 and unspecified modes need separate verification."; - if (abi.isaExtension !== 0 || abi.ases !== 0) - return "MIPS ABI flags declare an ISA extension or ASE outside this standard-instruction lane."; + if (abi.isaExtension !== 0 || abi.ases !== (shared ? 0x400 : 0)) + return "MIPS ABI flags declare an ISA extension or ASE outside this profile's verified instruction declarations."; // MIPS_AFL_FLAGS1_ODDSPREG (bit 0) is the GCC and LLVM default for MIPS32 o32 // whatever the FP ABI; soft-float objects from both carry it. With no CPR1 it // selects nothing, so it is accepted for soft-float as well as hard-float. @@ -116,6 +155,22 @@ export const ghidraMipsRelocatableLimitations = ( ] : []; +/** Shared-image results are analysis observations, not dynamic-loader execution. */ +export const ghidraMipsSharedLimitations = ( + target: BinaryTarget, +): readonly string[] => + isGhidraMips16SharedTarget(target) && target.architecture === "mips" + ? [ + "MIPS16 shared ELF: the endian-matched generic MIPS32 language uses the provider's per-address ISA_MODE context. The program language variant is not the instruction's encoding mode.", + "Shared-image addresses are Ghidra analysis coordinates, not runtime addresses. The source bytes remain unchanged; inspect_native_load_image retains original relocation bytes, loaded memory bytes and provider relocation status. External GOT bindings remain unverified without their defining images.", + ...(target.mips.abiFlags === null + ? [ + "No MIPS ABI flags record was found in the completely examined declaration tables. The header declares o32 but does not establish the floating-point ABI; the default compiler specification does not verify floating-point argument or return storage.", + ] + : []), + ] + : []; + /** Commit source ELF interpretation without confusing target ISA with host CPU. */ export const ghidraMipsProfileParameters = ( target: BinaryTarget, @@ -152,12 +207,33 @@ export const ghidraMipsProfileParameters = ( flags1: abi.flags1, flags2: abi.flags2, }, + ...(target.mips.sharedLoad === undefined + ? {} + : { + shared_load: + target.mips.sharedLoad.status === "complete" + ? { + status: "complete", + relocation_count: target.mips.sharedLoad.relocationCount, + relocation_types: [ + ...target.mips.sharedLoad.relocationTypes, + ], + symbol_relative_count: + target.mips.sharedLoad.symbolRelativeCount, + } + : { + status: "unavailable", + reason: target.mips.sharedLoad.reason, + }, + }), }, ...(isGhidraMipsSoftFloatTarget(target) ? { mips_soft_float_storage: GHIDRA_MIPS_SOFT_FLOAT_STORAGE } : {}), - mips_support_lane: - target.mips.type === MIPS_ET_REL + instruction_context_evidence: "per-address-isa-mode-v1", + mips_support_lane: isGhidraMips16SharedTarget(target) + ? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1" + : target.mips.type === MIPS_ET_REL ? "elf32-rel-o32-arch32r2-standard-v1" : "elf32-exec-o32-arch32r2-standard-v3", }; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 5503c62cc..d44998c6f 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -60,6 +60,8 @@ import { GHIDRA_MIPS_SOFT_FLOAT_STORAGE, ghidraMipsSoftFloatFailure, ghidraMipsRelocatableLimitations, + ghidraMipsSharedLimitations, + ghidraMipsSharedLanguageOverride, ghidraMipsSoftFloatLimitations, isGhidraMipsSoftFloatTarget, } from "./GhidraMipsProfile.js"; @@ -444,7 +446,10 @@ export const createGhidraProviderClient = (input: { committedProfile.parameters, ); if ( - !sameGhidraLanguageOverride(languageOverride, config.ghidraLanguageOverride) + !sameGhidraLanguageOverride( + languageOverride, + config.ghidraLanguageOverride ?? ghidraMipsSharedLanguageOverride(target), + ) ) return unavailableClient( new ProviderAdapterError("ghidra", "open_binary", { @@ -512,6 +517,7 @@ export const createGhidraProviderClient = (input: { ? (target.mips.limitations ?? []) : []), ...ghidraMipsRelocatableLimitations(target), + ...ghidraMipsSharedLimitations(target), ]; const providerLimitations = installation.platform === "win32" diff --git a/tests/boundary/filesystem/mipsAbiFlags.test.ts b/tests/boundary/filesystem/mipsAbiFlags.test.ts index 01a219657..1b724d31b 100644 --- a/tests/boundary/filesystem/mipsAbiFlags.test.ts +++ b/tests/boundary/filesystem/mipsAbiFlags.test.ts @@ -7,7 +7,7 @@ import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import type { MipsElfMetadata } from "../../../src/domain/binaryTargetTypes.js"; -import { readMipsElfAbiFlags } from "../../../src/application/MipsElfAbiFlags.js"; +import { readMipsElfMetadata } from "../../../src/application/MipsElfMetadata.js"; import { ok } from "../../../src/domain/result.js"; import { ghidraMipsProfileParameters, @@ -85,7 +85,7 @@ const inspect = async ( const path = await writeFixture(bytes); const handle = await open(path, "r"); try { - const result = await readMipsElfAbiFlags( + const result = await readMipsElfMetadata( handle, metadata(little), checkCancelled, diff --git a/tests/boundary/filesystem/mipsSharedLoad.test.ts b/tests/boundary/filesystem/mipsSharedLoad.test.ts new file mode 100644 index 000000000..6ad6a38c2 --- /dev/null +++ b/tests/boundary/filesystem/mipsSharedLoad.test.ts @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { open, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { it } from "vitest"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { readMipsElfMetadata } from "../../../src/application/MipsElfMetadata.js"; +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { + ghidraMipsProfileParameters, + ghidraMipsUnsupportedReason, +} from "../../../src/ghidra/GhidraMipsProfile.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const relOffset = 4096; +const fixture = (little: boolean, count = 2): Buffer => { + const bytes = Buffer.alloc(relOffset + count * 8 + 16); + bytes.set([0x7f, 0x45, 0x4c, 0x46, 1, little ? 1 : 2, 1]); + const u16 = (value: number, offset: number) => + little + ? bytes.writeUInt16LE(value, offset) + : bytes.writeUInt16BE(value, offset); + const u32 = (value: number, offset: number) => + little + ? bytes.writeUInt32LE(value, offset) + : bytes.writeUInt32BE(value, offset); + u16(3, 16); + u16(8, 18); + u32(1, 20); + u32(52, 28); + u32(0x74001005, 36); + u16(52, 40); + u16(32, 42); + u16(2, 44); + u32(1, 52); + u32(bytes.length, 68); + u32(bytes.length, 72); + u32(2, 84); + u32(256, 88); + u32(256, 92); + u32(32, 100); + u32(32, 104); + u32(17, 256); + u32(relOffset, 260); + u32(18, 264); + u32(count * 8, 268); + u32(19, 272); + u32(8, 276); + for (let i = 1; i < count; i += 1) { + u32(512, relOffset + 8 * i); + u32(3, relOffset + 8 * i + 4); + } + return bytes; +}; +const resolve = async (bytes: Buffer) => { + const directory = await createTestTempDirectory("rea-mips-shared-test-"); + const path = join(directory, "shared.elf"); + await writeFile(path, bytes); + const result = await parseBinaryTarget(path); + assert.ok(result.ok); + assert.equal(result.value.kind, "executable"); + assert.equal(result.value.architecture, "mips"); + if ( + result.value.kind !== "executable" || + result.value.architecture !== "mips" + ) + throw new Error("expected MIPS ELF"); + assert.equal( + result.value.sha256, + createHash("sha256").update(bytes).digest("hex"), + ); + assert.deepEqual(await readFile(path), bytes); + return result.value; +}; + +for (const little of [true, false]) { + it(`retains complete dynamic declarations beyond the initial probe (${little ? "LE" : "BE"})`, async () => { + const target = await resolve(fixture(little)); + assert.equal(target.mips.abiFlags, null); + assert.deepEqual(target.mips.sharedLoad, { + status: "complete", + relocationCount: 2, + relocationTypes: [0, 3], + symbolRelativeCount: 0, + }); + assert.equal(ghidraMipsUnsupportedReason(target), null); + assert.deepEqual(ghidraMipsProfileParameters(target).mips_elf, { + elf_class: 32, + byte_order: little ? "little" : "big", + type: 3, + flags: 0x74001005, + abi_flags: null, + shared_load: { + status: "complete", + relocation_count: 2, + relocation_types: [0, 3], + symbol_relative_count: 0, + }, + }); + }); + it(`retains and refuses symbol-bearing REL32 without repairing the mode tag (${little ? "LE" : "BE"})`, async () => { + const bytes = fixture(little); + if (little) bytes.writeUInt32LE(0x103, relOffset + 12); + else bytes.writeUInt32BE(0x103, relOffset + 12); + const target = await resolve(bytes); + assert.equal(target.mips.sharedLoad?.status, "complete"); + assert.match( + ghidraMipsUnsupportedReason(target) ?? "", + /Symbol-bearing R_MIPS_REL32/u, + ); + }); +} + +it("examines relocation records beyond a read chunk before deciding completeness", async () => { + const count = 8194; + const bytes = fixture(true, count); + bytes.writeUInt32LE(0x103, relOffset + (count - 1) * 8 + 4); + const target = await resolve(bytes); + assert.deepEqual(target.mips.sharedLoad, { + status: "complete", + relocationCount: count, + relocationTypes: [0, 3], + symbolRelativeCount: 1, + }); + assert.match(ghidraMipsUnsupportedReason(target) ?? "", /mode tag/u); +}); + +it("does not treat uncaptured ABI declaration headers as an inspected absence", async () => { + const bytes = fixture(true); + bytes.writeUInt32LE(bytes.length + 1, 32); + bytes.writeUInt16LE(40, 46); + bytes.writeUInt16LE(1, 48); + const target = await resolve(bytes); + assert.equal(target.mips.abiFlags, null); + assert.equal(target.mips.sharedLoad?.status, "unavailable"); + assert.match( + ghidraMipsUnsupportedReason(target) ?? "", + /unavailable headers remain unknown/u, + ); +}); + +const malformed: [string, (bytes: Buffer) => void, RegExp][] = [ + [ + "overlapping load ranges", + (b) => { + b.writeUInt16LE(3, 44); + b.writeUInt32LE(1, 116); + b.writeUInt32LE(256, 124); + b.writeUInt32LE(16, 132); + b.writeUInt32LE(16, 136); + }, + /overlapping MIPS PT_LOAD/u, + ], + [ + "inconsistent dynamic address", + (b) => b.writeUInt32LE(0x10000, 92), + /matching file-backed PT_LOAD/u, + ], + ["missing dynamic table", (b) => b.writeUInt32LE(0, 84), /no PT_DYNAMIC/u], + ["repeated dynamic tables", (b) => b.writeUInt32LE(2, 52), /duplicate/u], + [ + "declared dynamic tail outside file", + (b) => b.writeUInt32LE(b.length, 100), + /beyond the file/u, + ], + [ + "unavailable load bytes", + (b) => b.writeUInt32LE(b.length + 1, 68), + /PT_LOAD range/u, + ], + ["missing terminator", (b) => b.writeUInt32LE(1, 280), /DT_NULL/u], + [ + "repeated scalar tags", + (b) => b.writeUInt32LE(17, 272), + /duplicate MIPS dynamic tag/u, + ], + ["invalid REL entry size", (b) => b.writeUInt32LE(16, 276), /DT_RELENT/u], + ["truncated REL record", (b) => b.writeUInt32LE(17, 268), /DT_RELENT/u], + [ + "unmapped REL table", + (b) => b.writeUInt32LE(0x10000, 260), + /PT_LOAD mapping/u, + ], + [ + "unmapped REL destination", + (b) => b.writeUInt32LE(0x10000, relOffset + 8), + /PT_LOAD memory range/u, + ], + [ + "RELA declaration", + (b) => { + b.writeUInt32LE(7, 272); + b.writeUInt32LE(4096, 276); + }, + /RELA or PLT/u, + ], +]; +it.each(malformed)( + "preserves useful partial header facts for %s", + async (_name, mutate, reason) => { + const bytes = fixture(true); + mutate(bytes); + const target = await resolve(bytes); + assert.equal(target.mips.flags, 0x74001005); + assert.equal(target.mips.abiFlags, null); + assert.equal(target.mips.sharedLoad?.status, "unavailable"); + assert.match(ghidraMipsUnsupportedReason(target) ?? "", reason); + }, +); + +it("retains unverified relocation type instead of claiming every examined record is supported", async () => { + const bytes = fixture(true); + bytes.writeUInt32LE(5, relOffset + 12); + const target = await resolve(bytes); + assert.deepEqual(target.mips.sharedLoad, { + status: "complete", + relocationCount: 2, + relocationTypes: [0, 5], + symbolRelativeCount: 0, + }); + assert.match( + ghidraMipsUnsupportedReason(target) ?? "", + /other dynamic relocation types/u, + ); +}); + +it("does not equate complete dynamic REL coverage with coverage of another section relocation table", async () => { + const bytes = fixture(true); + bytes.writeUInt32LE(1024, 32); + bytes.writeUInt16LE(40, 46); + bytes.writeUInt16LE(1, 48); + bytes.writeUInt32LE(9, 1028); + bytes.writeUInt32LE(2048, 1040); + bytes.writeUInt32LE(8, 1044); + bytes.writeUInt32LE(8, 1060); + const target = await resolve(bytes); + assert.equal(target.mips.sharedLoad?.status, "unavailable"); + assert.match( + ghidraMipsUnsupportedReason(target) ?? "", + /section relocation declarations/u, + ); +}); + +it("preserves the caller cancellation error during a bounded relocation scan", async () => { + const target = await resolve(fixture(true, 8194)); + const handle = await open(target.path, "r"); + const cancellation = new AnalysisCancelledError("open_binary"); + let checks = 0; + try { + await assert.rejects( + readMipsElfMetadata(handle, target.mips, () => { + checks += 1; + if (checks === 100) throw cancellation; + }), + (error) => error === cancellation, + ); + } finally { + await handle.close(); + } +}); diff --git a/tests/conformance/ghidra/mips16-recordless.ld b/tests/conformance/ghidra/mips16-recordless.ld new file mode 100644 index 000000000..8d90dc432 --- /dev/null +++ b/tests/conformance/ghidra/mips16-recordless.ld @@ -0,0 +1,22 @@ +/* Build an older-style shared image from source without ABI declarations. */ +PHDRS { + headers PT_PHDR PHDRS; + text PT_LOAD FILEHDR PHDRS FLAGS(5); + data PT_LOAD FLAGS(6); + dynamic PT_DYNAMIC FLAGS(6); +} +SECTIONS { + . = SIZEOF_HEADERS; + .hash : { *(.hash) } :text + .gnu.hash : { *(.gnu.hash) } :text + .dynsym : { *(.dynsym) } :text + .dynstr : { *(.dynstr) } :text + .rel.dyn : { *(.rel.*) } :text + .text : { *(.text*) } :text + . = ALIGN(0x10000); + .dynamic : { *(.dynamic) } :data :dynamic + .got : { *(.got*) } :data + .data : { *(.data*) } :data + .bss : { *(.bss*) *(COMMON) } :data + /DISCARD/ : { *(.MIPS.abiflags) *(.reginfo) *(.pdr) *(.mdebug.*) *(.comment) *(.note.*) } +} diff --git a/tests/conformance/ghidra/mips16-shared-fixture.mjs b/tests/conformance/ghidra/mips16-shared-fixture.mjs new file mode 100644 index 000000000..37ebddeab --- /dev/null +++ b/tests/conformance/ghidra/mips16-shared-fixture.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); +const source = fileURLToPath(new URL("./mips16-shared.S", import.meta.url)); +const linkerScript = fileURLToPath( + new URL("./mips16-recordless.ld", import.meta.url), +); +const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +/** BYO GNU cross-binutils; generated controls are never executed or byte-patched. */ +export async function buildMips16SharedFixture(directory, byteOrder, variant) { + assert(["little", "big"].includes(byteOrder)); + assert( + [ + "declared", + "recordless", + "symbol-relative", + "section-relocations", + ].includes(variant), + ); + const assembler = process.env.REA_MIPS_AS ?? "mips-linux-gnu-as"; + const linker = process.env.REA_MIPS_LD ?? "mips-linux-gnu-ld"; + const endian = byteOrder === "little" ? "-EL" : "-EB"; + const prefix = join(directory, `${byteOrder}-${variant}`); + const object = `${prefix}.o`; + const path = `${prefix}.so`; + const assemblerVersion = ( + await execute(assembler, ["--version"], { timeout: 10000 }) + ).stdout; + const linkerVersion = ( + await execute(linker, ["--version"], { timeout: 10000 }) + ).stdout; + assert( + assemblerVersion.includes("GNU assembler"), + "Use GNU's MIPS assembler: other assemblers have different compressed-instruction behavior.", + ); + assert(linkerVersion.includes("GNU ld")); + await execute( + assembler, + [ + "-32", + "-mips32r2", + endian, + ...(variant === "symbol-relative" + ? ["--defsym", "REA_MIPS_SYMBOL_RELATIVE=1"] + : []), + source, + "-o", + object, + ], + { timeout: 60000 }, + ); + await execute( + linker, + [ + endian, + "-shared", + ...(variant === "section-relocations" ? ["--emit-relocs"] : []), + ...(variant === "recordless" ? ["-T", linkerScript] : []), + object, + "-o", + path, + ], + { timeout: 60000 }, + ); + const bytes = await readFile(path); + const little = byteOrder === "little"; + const u16 = (offset) => + little ? bytes.readUInt16LE(offset) : bytes.readUInt16BE(offset); + const u32 = (offset) => + little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); + assert.equal( + bytes.subarray(0, 6).toString("hex"), + little ? "7f454c460101" : "7f454c460102", + ); + assert.equal(u16(16), 3); + assert.equal(u16(18), 8); + assert.equal(u32(36), 0x74001007); // noreorder/PIC/CPIC plus o32/MIPS16/MIPS32r2. + const headers = Array.from( + { length: u16(44) }, + (_, i) => u32(28) + i * u16(42), + ); + const fileOffset = (address, size) => { + const mappings = headers.filter( + (offset) => + u32(offset) === 1 && + address >= u32(offset + 8) && + address - u32(offset + 8) <= u32(offset + 16) - size, + ); + assert.equal(mappings.length, 1); + return u32(mappings[0] + 4) + address - u32(mappings[0] + 8); + }; + const readelf = await execute( + process.env.REA_MIPS_READELF ?? "readelf", + ["-W", "-h", "-l", "-s", "-r", path], + { timeout: 10000 }, + ); + const symbols = {}; + for (const name of [ + "rea_mips16_probe", + "rea_mips32_probe", + "rea_mips16_pointer", + ]) { + const addresses = new Set( + readelf.stdout + .split("\n") + .map((line) => line.trim().split(/\s+/u)) + .filter( + (fields) => + fields[0]?.endsWith(":") === true && + ["FUNC", "OBJECT"].includes(fields[3]) && + fields.at(-1) === name, + ) + .map((fields) => Number.parseInt(fields[1], 16)), + ); + assert.equal(addresses.size, 1, `Missing or conflicting ${name}`); + symbols[name] = [...addresses][0]; + } + assert.equal(symbols.rea_mips16_probe & 1, 1); + assert.equal(symbols.rea_mips32_probe & 1, 0); + const compressedOffset = fileOffset(symbols.rea_mips16_probe - 1, 8); + assert.equal( + bytes.subarray(compressedOffset, compressedOffset + 8).toString("hex"), + little ? "5a6a8eea074aa0e8" : "6a5aea8e4a07e8a0", + ); + const standardOffset = fileOffset(symbols.rea_mips32_probe, 12); + assert.equal( + bytes.subarray(standardOffset, standardOffset + 12).toString("hex"), + little ? "030082240800e00300000000" : "2482000303e0000800000000", + ); + const pointerOffset = fileOffset(symbols.rea_mips16_pointer, 4); + const rawPointer = u32(pointerOffset); + if (variant !== "symbol-relative") + assert.equal(rawPointer, symbols.rea_mips16_probe); + return { + path, + sha256: hash(bytes), + byte_order: byteOrder, + variant, + elf_flags: u32(36), + symbols, + raw_pointer: rawPointer, + source_sha256: hash(await readFile(source)), + linker_script_sha256: + variant === "recordless" ? hash(await readFile(linkerScript)) : null, + assembler_version: assemblerVersion.split("\n")[0], + linker_version: linkerVersion.split("\n")[0], + readelf_output: readelf.stdout, + }; +} diff --git a/tests/conformance/ghidra/mips16-shared.S b/tests/conformance/ghidra/mips16-shared.S new file mode 100644 index 000000000..d3d41802e --- /dev/null +++ b/tests/conformance/ghidra/mips16-shared.S @@ -0,0 +1,41 @@ +/* Source-owned mixed-instruction shared-image control. */ +.abicalls +.module fp=xx +.module nooddspreg +.text +.set noreorder +.set nomicromips +.set mips16 +.globl rea_mips16_probe +.type rea_mips16_probe, @function +.ent rea_mips16_probe +rea_mips16_probe: + li $2, 90 + xor $2, $4 + addiu $2, 7 + jrc $31 +.end rea_mips16_probe +.size rea_mips16_probe, . - rea_mips16_probe +.ifndef REA_MIPS_SYMBOL_RELATIVE +.hidden rea_mips16_probe +.endif + +.set nomips16 +.globl rea_mips32_probe +.type rea_mips32_probe, @function +.ent rea_mips32_probe +rea_mips32_probe: + addiu $2, $4, 3 + jr $31 + nop +.end rea_mips32_probe +.size rea_mips32_probe, . - rea_mips32_probe + +.data +.balign 4 +.globl rea_mips16_pointer +.type rea_mips16_pointer, @object +.size rea_mips16_pointer, 4 +rea_mips16_pointer: + .word rea_mips16_probe +.section .note.GNU-stack, "", @progbits