diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 1674d8fd11..1f4ff14d60 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -123,8 +123,10 @@ Select a verified native child for the existing tools: ``` Then use `analyze_function`, `inspect_native_api` or the other native primitives. -Native architecture and executable validation still apply. This milestone does -not add MIPS/PPC/RISC-V decompilation, raw flash load profiles, bootloader/device +Native architecture and executable validation still apply. Generic +[MIPS ELF](ghidra-mips.md) and [RISC-V ELF](ghidra-riscv.md) lanes are separate +documents with their own admission boundaries. This milestone does +not add PPC decompilation, raw flash load profiles, bootloader/device tree semantics, vulnerability scanning, emulation or hardware runtime capture. ## Resource and lifecycle policy diff --git a/docs/ghidra-riscv.md b/docs/ghidra-riscv.md new file mode 100644 index 0000000000..e180122d71 --- /dev/null +++ b/docs/ghidra-riscv.md @@ -0,0 +1,105 @@ +# RISC-V ELF analysis through Ghidra + +This lane extends native ELF analysis for `EM_RISCV` targets. It does not +execute RISC-V code, emulate a boot sequence, or rehost firmware. It addresses +[#2063](https://github.com/morluto/rea/issues/2063) and follows the same +lane shape as [generic MIPS support](ghidra-mips.md). + +## Admission boundary + +ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The +target also retains the raw ELF declarations that decide a lane: class, byte +order, `e_type`, and `e_flags`. Recognition does not mean every provider or +RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets +are unchanged, and caller-supplied headerless raw-binary entry still accepts only +its existing four families. + +The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that +declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`, +`EF_RISCV_TSO`, or any bit outside `EF_RISCV_RVC | EF_RISCV_FLOAT_ABI`. These are +header declarations, not proof that every instruction conforms to that ISA. + +Provider selection returns the specific unsupported constraint rather than +guessing a compatible profile: + +| Declaration | Outcome | +| ----------------------------------------------- | --------------------------------------------------------------------------- | +| Little-endian ELF64 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:64:default` + `gcc` | +| Little-endian ELF32 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:32:default` + `gcc` | +| Big-endian | refused; Ghidra 12.1.4 ships no big-endian RISC-V language | +| `ET_REL`, `ET_DYN`, other `e_type` | refused with the declared value | +| `EF_RISCV_RVE` | refused; the embedded 15-register ABI needs its own processor specification | +| `EF_RISCV_TSO` | refused; the total-store-order memory model is unverified here | +| Soft, single, or quad float ABI | refused; the pinned generic compiler specification models double-float | +| Reserved or newer `e_flags` bits | refused with the offending bits named | + +`e_type` is the only load-shape declaration this lane reads. A fixed-address +`ET_EXEC` declaration does **not** establish static linkage: `PT_INTERP` and +dynamic dependencies are not inspected, and the session records that boundary as +a limitation rather than inferring a load model. + +## Why the language is pinned + +Ghidra's header-based selection is not reliable for RV32. The 12.1.4 +`riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves the +32-bit constraint unpinned with the comment that the loader "must choose between +'default' and 'AndeStar' variants". Measured against the installed release tree: + +| Fixture | Ghidra auto-selection | +| ----------------------------------------------- | ----------------------------- | +| RV64 ELF, `e_flags 0x5` (RVC, double-float ABI) | `RISCV:LE:64:default:gcc` | +| RV32 ELF, `e_flags 0x5`, no Andes declaration | `RISCV:LE:32:AndeStar_v5:gcc` | + +So an ordinary RV32 image lands on an Andes processor model unless the language +is stated. REA therefore pins `RISCV:LE:32:default` or `RISCV:LE:64:default` +with the `gcc` compiler specification per class, records +`language_selection: provider-pinned-v1`, and refuses a caller-selected +`REA_GHIDRA_LANGUAGE_ID` or `REA_GHIDRA_COMPILER_SPEC_ID` that disagrees with the +pin without changing the requested override. An override that matches the pin is +accepted and recorded as `configured-v1`. + +## Evidence + +The lane identity is `elf32-exec-le-double-float-generic-v1` or +`elf64-exec-le-double-float-generic-v1`, and `riscv_elf` retains `elf_class`, +`byte_order`, `type`, and the raw `flags` alongside the read-only booleans +`float_abi_double`, `rvc_declared`, `rve_declared`, and `tso_declared`. Those +fields report declarations the header makes; none of them is evidence of the +original compiler, of instruction conformance, or of a floating-point calling +convention beyond the ABI the pinned language models. + +## Verification and limits + +Ghidra 12.1.4 PUBLIC with a 64-bit JDK 21+ is required, and on macOS the release +tree ships no host decompiler binary, so `Decompiler/os//decompile` and +`sleigh` must come from your own build; see +[installation](installation.md#ghidra). + +Run `npm run verify:ghidra:riscv` with an existing Ghidra 12.1.4 installation, +JDK 21+, and a RISC-V-capable Clang, LLD and LLVM readelf. The verifier acquires +no tools and never executes target code. Select installed tools explicitly when +they are not on `PATH`: + +```sh +GHIDRA_INSTALL_DIR=/path/to/ghidra_12.1.4_PUBLIC \ +JAVA_HOME=/path/to/jdk-21 \ +REA_RISCV_CLANG=/path/to/llvm/bin/clang \ +REA_RISCV_LLD=/path/to/lld/bin/ld.lld \ +REA_RISCV_READELF=/path/to/llvm/bin/llvm-readelf \ +npm run verify:ghidra:riscv +``` + +The lane builds the committed freestanding C and assembly sources into RV32 and +RV64 `ET_EXEC` fixtures. Independent LLVM readelf observations establish ELF +declarations and symbol addresses. Public CLI and stdio MCP workflows verify +function enumeration, decompilation, pinned language/compiler identity, decoded +probe bytes, global data, and direct calls. It validates advertised MCP output +schemas and both JSON representations, saves and reopens snapshots, rejects +header-mutated soft-float and unsupported high-bit declarations, and checks +unchanged source bytes and owned cleanup. Failures retain the diagnostic +workspace; successful runs remove it and report the observed process lineage. + +Module tests cover admission and malformed headers. Call-graph completeness, +string inventories, dynamic linkage, Andes or RVE/TSO images, and big-endian +targets are **not** established by this lane. `.riscv.attributes` ISA extensions +are not inspected; the generic language pin remains an analysis convention. diff --git a/docs/testing.md b/docs/testing.md index b80ff8f787..7bb14effeb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -308,6 +308,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build. | `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback and unknown-name rejection | Ghidra 12.1.4 and full JDK 21 | | `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites | diff --git a/package.json b/package.json index e5de6a3dbe..dd0ef57d80 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,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:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.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", diff --git a/scripts/verify-real-ghidra-riscv.mjs b/scripts/verify-real-ghidra-riscv.mjs new file mode 100644 index 0000000000..a87adeeb5b --- /dev/null +++ b/scripts/verify-real-ghidra-riscv.mjs @@ -0,0 +1,391 @@ +#!/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, + readdir, + rm, + writeFile, +} 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 Ajv from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; +import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js"; +import { buildRiscvFixture } from "../tests/conformance/ghidra/riscv-fixture.mjs"; +import { + mcpTextValue, + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +if (process.argv.length !== 2) + throw new Error("Usage: node scripts/verify-real-ghidra-riscv.mjs"); +if (!["linux", "darwin"].includes(process.platform)) + throw new Error("This optional lane requires a Linux/macOS Ghidra host."); +if (process.env.GHIDRA_INSTALL_DIR === undefined) + throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR"); +for (const key of ["REA_GHIDRA_LANGUAGE_ID", "REA_GHIDRA_COMPILER_SPEC_ID"]) + if (process.env[key] !== undefined) + throw new Error( + `Unset ${key}: this lane verifies provider-pinned selection.`, + ); +const installation = inspectGhidraInstallation({ + environment: process.env, + installDir: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.JAVA_HOME === undefined + ? {} + : { javaHome: process.env.JAVA_HOME }), +}); +assert.equal(installation.status, "available", JSON.stringify(installation)); +assert.equal(installation.providerVersion, "12.1.4"); + +const execute = promisify(execFile); +const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const run = createVerifierRun(); +const workspace = await mkdtemp(join(tmpdir(), "rea-riscv-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, +}; +for (const key of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) + if (process.env[key] !== undefined) env[key] = process.env[key]; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", +}); +const client = new Client({ name: "rea-real-riscv-proof", version: "1" }); +let stderr = ""; +transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); +}); + +const reports = []; +let failure; +try { + await client.connect(transport); + const catalog = await client.listTools(); + const ajv = new Ajv({ strict: false }); + addFormats(ajv); + const validators = new Map(); + for (const tool of catalog.tools) { + assert.ok( + ajv.validateSchema(tool.outputSchema), + `${tool.name}: invalid output schema`, + ); + const existing = + typeof tool.outputSchema.$id === "string" + ? ajv.getSchema(tool.outputSchema.$id) + : undefined; + validators.set(tool.name, existing ?? ajv.compile(tool.outputSchema)); + } + for (const bits of [32, 64]) { + const fixture = await buildRiscvFixture(workspace, bits); + const resolved = await parseBinaryTarget(fixture.path); + if (!resolved.ok) throw resolved.error; + assert.equal(resolved.value.architecture, "riscv"); + assert.equal(resolved.value.sha256, fixture.sha256); + assert.deepEqual(resolved.value.riscv, fixture.riscv); + const expectedLanguage = `RISCV:LE:${bits}:default`; + const assertProfile = (profile) => { + assert.equal(profile.provider.id, "ghidra"); + assert.equal(profile.provider.version, installation.providerVersion); + const parameters = profile.parameters; + assert.equal(parameters.language_id, expectedLanguage); + assert.equal(parameters.compiler_spec_id, "gcc"); + assert.equal(parameters.language_selection, "provider-pinned-v1"); + assert.equal( + parameters.riscv_support_lane, + `elf${bits}-exec-le-double-float-generic-v1`, + ); + assert.deepEqual(parameters.riscv_elf, { + elf_class: bits, + byte_order: "little", + type: 2, + flags: 5, + float_abi_double: true, + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }); + }; + const assertEvidence = (evidence) => { + assert.equal(evidence.provider.id, "ghidra"); + assert.equal(evidence.subject.architecture, "riscv"); + assert.equal(evidence.subject.digest.sha256, fixture.sha256); + assertProfile(evidence.analysis_profile); + }; + const call = async (name, args = {}) => { + const reply = await client.callTool( + { name, arguments: args }, + { timeout: 240000 }, + ); + const projected = requireMcpOperationResult(reply, name); + const textual = JSON.parse(mcpTextValue(reply)); + assert.deepEqual(reply.structuredContent, textual); + const validate = validators.get(name); + assert.ok(validate, `${name} missing from advertised catalog`); + assert.ok( + validate(textual), + `${name}: ${JSON.stringify(validate.errors)}`, + ); + if (!["open_binary", "close_binary", "binary_session"].includes(name)) + assertEvidence(parseEvidence(textual)); + return projected; + }; + const opened = await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + assert.equal(opened.architecture, "riscv"); + assert.equal(opened.sha256, fixture.sha256); + const image = await call("inspect_native_load_image"); + assert.equal(image.observations.language_id, expectedLanguage); + assert.equal(image.observations.compiler_spec_id, "gcc"); + assert.ok(image.observations.source_files.length > 0); + for (const source of image.observations.source_files) { + assert.equal(source.original_sha256, fixture.sha256); + assert.equal(source.modified_sha256, fixture.sha256); + } + const procedures = await call("list_procedures"); + for (const name of [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + ]) { + const procedure = procedures.find((value) => value.value === name); + assert.ok(procedure, `${name} missing from Ghidra inventory`); + assert.equal(BigInt(procedure.address), BigInt(fixture.symbols[name])); + } + const probe = await call("inspect_native_instruction", { + address: fixture.symbols.rea_riscv_probe, + }); + assert.equal(probe.status, "decoded"); + assert.equal(probe.architecture, expectedLanguage); + assert.equal(probe.length, 4); + assert.equal(probe.bytes, "13051501"); // addi a0, a0, 17 + const mapping = await call("address_to_file_offset", { + address: fixture.symbols.rea_riscv_probe, + }); + const sourceBytes = await readFile(fixture.path); + assert.equal( + sourceBytes + .subarray(mapping.file_offset, mapping.file_offset + 4) + .toString("hex"), + probe.bytes, + ); + const global = await call("read_bytes", { + address: fixture.symbols.rea_riscv_global, + length: 4, + }); + assert.equal(global.bytes_hex, "07000000"); + const callees = await call("procedure_callees", { + procedure: fixture.symbols.rea_riscv_entry, + }); + for (const name of ["rea_riscv_leaf", "rea_riscv_probe"]) + assert.ok( + callees.some( + (address) => BigInt(address) === BigInt(fixture.symbols[name]), + ), + ); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { + procedure: fixture.symbols.rea_riscv_entry, + }), + ); + assert.ok(dossier.pseudocode.trim().length > 0); + const floatCode = await call("procedure_pseudo_code", { + procedure: fixture.symbols.rea_riscv_float, + }); + assert.ok(typeof floatCode === "string" && floatCode.trim().length > 0); + assert.match(floatCode, /\bdouble\b/u); + + const refusals = []; + for (const [flags, reason] of [ + [1, "EF_RISCV_FLOAT_ABI_DOUBLE"], + [0x80000005, "(0x80000000)"], + ]) { + // Header-mutated copies test admission; they are not new compiler claims. + const rejectedPath = join( + workspace, + `rv${bits}-flags-${flags.toString(16)}.elf`, + ); + const rejectedBytes = Buffer.from(sourceBytes); + rejectedBytes.writeUInt32LE(flags, bits === 32 ? 36 : 48); + await writeFile(rejectedPath, rejectedBytes); + const reply = await client.callTool( + { + name: "open_binary", + arguments: { path: rejectedPath, provider_id: "ghidra" }, + }, + { timeout: 60000 }, + ); + const error = requireMcpToolError(reply); + assert.ok(JSON.stringify(error).includes(reason), JSON.stringify(error)); + await assert.rejects( + execute( + process.execPath, + [ + entrypoint, + "function", + rejectedPath, + "rea_riscv_entry", + "--provider", + "ghidra", + "--json", + ], + { + env, + timeout: 60000, + maxBuffer: 1024 * 1024, + }, + ), + (cause) => + Number.isInteger(cause.code) && + cause.code !== 0 && + `${cause.stdout}\n${cause.stderr}`.includes(reason), + ); + refusals.push({ flags, reason }); + } + const snapshotPath = join(workspace, `rv${bits}.snapshot.json`); + const saved = await call("close_binary", { snapshot_path: snapshotPath }); + assert.ok(saved.evidence_records > 0); + const snapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(snapshotPath, "utf8")), + ); + assert.equal(snapshot.target.architecture, "riscv"); + assert.equal(snapshot.target.sha256, fixture.sha256); + assertProfile(snapshot.binding.analysis_profile); + for (const record of snapshot.evidence_bundle.records) + assertEvidence(parseEvidence(record)); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + assert.deepEqual(await call("list_procedures"), procedures); + await call("close_binary"); + + for (const [command, arguments_] of [ + ["search", ["rea_riscv_", "--kind", "procedures"]], + ["function", ["rea_riscv_entry"]], + ]) { + const cli = await execute( + process.execPath, + [ + entrypoint, + command, + fixture.path, + ...arguments_, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = parseEvidence(JSON.parse(cli.stdout)); + assertEvidence(evidence); + if (command === "search") + for (const name of [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + ]) + assert.ok( + evidence.normalized_result.some((value) => value.value === name), + ); + else { + const cliDossier = functionDossierSchema.parse( + evidence.normalized_result, + ); + assert.equal(cliDossier.procedure.address, dossier.procedure.address); + assert.ok(cliDossier.pseudocode.trim().length > 0); + } + } + assert.equal( + createHash("sha256") + .update(await readFile(fixture.path)) + .digest("hex"), + fixture.sha256, + ); + reports.push({ + ...fixture, + language: expectedLanguage, + procedures: procedures.length, + refusals, + cli: true, + stdio_mcp: true, + snapshot_replay: true, + original_unchanged: true, + }); + } +} catch (cause) { + failure = cause; +} +for (const close of [() => client.close(), () => transport.close()]) { + try { + await close(); + } catch (cause) { + failure = new AggregateError( + failure === undefined ? [cause] : [failure, cause], + "Verification or cleanup failed", + ); + } +} +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( + `RISC-V verification failed; retained workspace: ${workspace}; MCP stderr: ${stderr}`, + { cause: failure }, + ); +assert.deepEqual( + await readdir(runtime), + [], + `Runtime resources survived server shutdown; retained workspace: ${workspace}`, +); +await rm(workspace, { recursive: true, force: true }); +console.log( + JSON.stringify( + { + verifier_run: completed, + lane: "ghidra-riscv", + mocked: false, + host: { platform: process.platform, architecture: process.arch }, + provider: { id: "ghidra", version: installation.providerVersion }, + fixtures: reports, + cleanup: "complete", + }, + null, + 2, + ), +); diff --git a/src/domain/binaryTarget.riscv.test.ts b/src/domain/binaryTarget.riscv.test.ts new file mode 100644 index 0000000000..fcc77cf891 --- /dev/null +++ b/src/domain/binaryTarget.riscv.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { parseExecutableHeader } from "./binaryTarget.js"; + +const header = ( + little: boolean, + bits: 32 | 64 = 32, + flags = 0x5, + type = 2, + machine = 243, +): Buffer => { + const bytes = Buffer.alloc(bits === 32 ? 52 : 64); + bytes.set([0x7f, 0x45, 0x4c, 0x46, bits === 32 ? 1 : 2, little ? 1 : 2, 1]); + if (little) { + bytes.writeUInt16LE(type, 16); + bytes.writeUInt16LE(machine, 18); + bytes.writeUInt32LE(flags, bits === 32 ? 36 : 48); + } else { + bytes.writeUInt16BE(type, 16); + bytes.writeUInt16BE(machine, 18); + bytes.writeUInt32BE(flags, bits === 32 ? 36 : 48); + } + return bytes; +}; + +describe("RISC-V ELF header boundaries", () => { + it.each([ + [true, 32], + [false, 32], + [true, 64], + [false, 64], + ] as const)( + "retains class, endian, e_type and e_flags (little=%s, class=%s)", + (little, bits) => { + const parsed = parseExecutableHeader(header(little, bits), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: { + elfClass: bits, + byteOrder: little ? "little" : "big", + type: 2, + flags: 0x5, + }, + }); + }, + ); + + it("reads ELF64 flags at their own offset", () => { + const bytes = header(true, 64); + bytes.writeUInt32LE(0xdeadbeef, 36); + bytes.writeUInt32LE(0x00000014, 48); + const parsed = parseExecutableHeader(bytes, "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toMatchObject({ + architecture: "riscv", + riscv: { elfClass: 64, flags: 0x14 }, + }); + }); + + it("does not attach ELF metadata to a family that declares none", () => { + const parsed = parseExecutableHeader(header(true, 64, 0x5, 2, 62), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }); + }); + + it("rejects a truncated header before reading flags", () => { + const parsed = parseExecutableHeader(header(true).subarray(0, 39), "x64"); + expect(parsed).toEqual({ + ok: false, + error: + "truncated ELF header: an ELF32 header needs 52 bytes; the file has 39", + }); + }); + + it("does not turn invalid byte order into little-endian RISC-V", () => { + const bytes = header(true); + bytes[5] = 0; + expect(parseExecutableHeader(bytes, "x64")).toEqual({ + ok: false, + error: "unsupported ELF endianness", + }); + }); + + it("keeps an unknown e_machine outside the recognized family set", () => { + expect(parseExecutableHeader(header(true, 32, 0x5, 2, 244), "x64")).toEqual( + { + ok: false, + error: "unsupported ELF architecture", + }, + ); + }); +}); diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 79a9daefab..29818b674e 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -1,6 +1,9 @@ import type { BinaryArchitecture, + ElfFamilyMetadata, + HeaderArchitecture, MipsElfMetadata, + RiscvElfMetadata, UniversalMachOSlice, } from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; @@ -15,31 +18,28 @@ export type ExecutableMetadata = readonly availableArchitectures: readonly "mips"[]; readonly mips: MipsElfMetadata; } + | { + readonly format: "elf"; + readonly architecture: "riscv"; + readonly availableArchitectures: readonly "riscv"[]; + readonly riscv: RiscvElfMetadata; + } | { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly universalSlice?: never; } | { readonly format: "mach-o-universal"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly universalSlice: UniversalMachOSlice; } | { readonly format: "pe"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly executableRole: | "application" | "shared-library" @@ -115,9 +115,9 @@ const parseFatMachO = ( const entrySize = is64 ? 32 : 20; if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize) return err("truncated or invalid FAT architecture table"); - const architectures: Exclude[] = []; + const architectures: HeaderArchitecture[] = []; const slices = new Map< - Exclude, + HeaderArchitecture, { readonly offset: bigint; readonly size: bigint } >(); for (let index = 0; index < count; index += 1) { @@ -191,20 +191,31 @@ const parseElf = ( const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18); const architecture = elfArchitecture(machine); if (architecture === undefined) return err("unsupported ELF architecture"); - if (architecture === "mips") - return ok({ - format: "elf", - architecture, - availableArchitectures: [architecture], - mips: { - elfClass: bytes[4] === 1 ? 32 : 64, - byteOrder: little ? "little" : "big", - type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16), - flags: little - ? bytes.readUInt32LE(bytes[4] === 1 ? 36 : 48) - : bytes.readUInt32BE(bytes[4] === 1 ? 36 : 48), - }, - }); + if (architecture === "mips" || architecture === "riscv") { + const elfClass = bytes[4] === 1 ? 32 : 64; + const flagsOffset = elfClass === 32 ? 36 : 48; + const metadata: ElfFamilyMetadata = { + elfClass, + byteOrder: little ? "little" : "big", + type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16), + flags: little + ? bytes.readUInt32LE(flagsOffset) + : bytes.readUInt32BE(flagsOffset), + }; + return architecture === "mips" + ? ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + mips: metadata, + }) + : ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + riscv: metadata, + }); + } return ok({ format: "elf", architecture, @@ -299,9 +310,7 @@ const peManagedStatus = ( ); }; -const machArchitecture = ( - cpu: number, -): Exclude | undefined => { +const machArchitecture = (cpu: number): HeaderArchitecture | undefined => { switch (cpu) { case 7: return "x86"; @@ -327,6 +336,8 @@ const elfArchitecture = (machine: number): BinaryArchitecture | undefined => { return "arm"; case 183: return "arm64"; + case 243: // EM_RISCV: XLEN, byte order and e_flags remain separate ELF facts. + return "riscv"; } return undefined; }; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index e73d86cec6..b4d44c1101 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -5,15 +5,44 @@ export const BINARY_ARCHITECTURES = [ "arm", "arm64", "mips", + "riscv", ] as const; export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number]; -/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ -export interface MipsElfMetadata { +/** + * ELF header facts for a family whose ISA and ABI are not established by the + * family name alone. Consumers admit a lane from these declarations rather than + * re-reading the header. + */ +export interface ElfFamilyMetadata { readonly elfClass: 32 | 64; readonly byteOrder: "little" | "big"; readonly type: number; readonly flags: number; +} + +/** Families whose parsed target variant carries `ElfFamilyMetadata`. */ +export const ELF_METADATA_ARCHITECTURES = [ + "mips", + "riscv", +] as const satisfies readonly BinaryArchitecture[]; +export type ElfMetadataArchitecture = + (typeof ELF_METADATA_ARCHITECTURES)[number]; + +/** Families whose ISA and ABI need a separate ELF declaration to be admitted. */ +export const isElfMetadataArchitecture = ( + architecture: BinaryArchitecture, +): architecture is ElfMetadataArchitecture => + (ELF_METADATA_ARCHITECTURES as readonly string[]).includes(architecture); + +/** Recognized family with no ELF metadata member; the header alone is the fact. */ +export type HeaderArchitecture = Exclude< + BinaryArchitecture, + ElfMetadataArchitecture +>; + +/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ +export interface MipsElfMetadata extends ElfFamilyMetadata { /** Undefined until inspected; null means no ABI record was observed. */ readonly abiFlags?: MipsAbiFlags | null; /** Unavailable declaration sources; missing records there remain unknown. */ @@ -44,6 +73,12 @@ export interface MipsAbiFlags { readonly flags2: number; } +/** + * ELF class and flags retain the width and ABI declarations used by this lane. + * ISA extensions in `.riscv.attributes` are not inspected by this header parser. + */ +export type RiscvElfMetadata = ElfFamilyMetadata; + /** Byte range of the selected slice inside its universal Mach-O container. */ export interface UniversalMachOSlice { readonly offset: number; @@ -67,11 +102,8 @@ type NonExecutableMetadata = { type ExecutableTarget = BinaryTargetIdentity & { readonly kind: "executable"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; }; /** @@ -88,6 +120,15 @@ export type BinaryTarget = readonly executableRole?: never; readonly managed?: never; }) + | (BinaryTargetIdentity & { + readonly kind: "executable"; + readonly format: "elf"; + readonly architecture: "riscv"; + readonly availableArchitectures: readonly "riscv"[]; + readonly riscv: RiscvElfMetadata; + readonly executableRole?: never; + readonly managed?: never; + }) | (ExecutableTarget & { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; readonly universalSlice?: never; diff --git a/src/domain/peMachine.ts b/src/domain/peMachine.ts index 16a5ace371..18981c88c6 100644 --- a/src/domain/peMachine.ts +++ b/src/domain/peMachine.ts @@ -1,6 +1,6 @@ -import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { HeaderArchitecture } from "./binaryTargetTypes.js"; -export type PeArchitecture = Exclude; +export type PeArchitecture = HeaderArchitecture; /** A decoded COFF machine and whether it carried a ReadyToRun OS override. */ export interface PeMachine { diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts index d00af8d498..94fdf6ce71 100644 --- a/src/domain/rawBinary.ts +++ b/src/domain/rawBinary.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { + BinaryArchitecture, + HeaderArchitecture, +} from "./binaryTargetTypes.js"; import type { ExecutableFormatHint } from "./dosCom.js"; import { err, ok, type Result } from "./result.js"; @@ -55,7 +58,7 @@ export const validateRawBinaryLength = ( /** Admitted headerless load identity used by target resolution and profiles. */ export interface RawBinaryAdmission { - readonly architecture: Exclude; + readonly architecture: HeaderArchitecture; readonly entryPoint: string; readonly entryPointSource: string; } diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index a736cd0bf4..60edb0971b 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -17,6 +17,10 @@ import { ghidraMipsProfileParameters, ghidraMipsSharedLanguageOverride, } from "./GhidraMipsProfile.js"; +import { + ghidraRiscvLanguageOverride, + ghidraRiscvProfileParameters, +} from "./GhidraRiscvProfile.js"; import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; import { @@ -32,6 +36,17 @@ import { NATIVE_AOT_PROFILE_CONTRACT_REVISION, } from "../domain/native/nativeAotPe.js"; +/** + * The processor language REA pins for this target, or undefined when Ghidra may + * select from the header. The committed profile and the session readback both + * compare against this single owner rather than re-deriving a lane. + */ +export const ghidraPinnedLanguage = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + ghidraMipsSharedLanguageOverride(target) ?? + ghidraRiscvLanguageOverride(target); + /** Resolve version-bound, deterministic semantics before Ghidra imports a target. */ export const resolveGhidraAnalysisProfile = async ( target: BinaryTarget, @@ -136,22 +151,26 @@ export const resolveGhidraAnalysisProfile = async ( const dosMz = target.format === "dos-mz"; const dosCom = target.format === "dos-com"; const dos = dosMz || dosCom; - const mipsLanguage = ghidraMipsSharedLanguageOverride(target); + const pinnedLanguage = ghidraPinnedLanguage(target); if ( - mipsLanguage !== undefined && + pinnedLanguage !== undefined && languageOverride !== undefined && - (languageOverride.languageId !== mipsLanguage.languageId || + (languageOverride.languageId !== pinnedLanguage.languageId || (languageOverride.compilerSpecId !== undefined && - languageOverride.compilerSpecId !== mipsLanguage.compilerSpecId)) + languageOverride.compilerSpecId !== pinnedLanguage.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.", + `The caller-selected processor or compiler specification is outside the verified ${ + target.architecture === "riscv" + ? "generic little-endian RISC-V" + : "endian-matched MIPS32 shared-image" + } profile. The requested override has not been changed.`, ), ); - const selectedLanguage = languageOverride ?? mipsLanguage; + const selectedLanguage = languageOverride ?? pinnedLanguage; // DOS imports already force an admitted loader, language, and context. if (dos && languageOverride !== undefined) return Promise.resolve( @@ -227,7 +246,7 @@ export const resolveGhidraAnalysisProfile = async ( ? "auto-default" : (selectedLanguage.compilerSpecId ?? "language-default"), ...(languageOverride === undefined - ? mipsLanguage === undefined + ? pinnedLanguage === undefined ? {} : { language_selection: "provider-pinned-v1" } : { language_selection: "configured-v1" }), @@ -251,6 +270,7 @@ export const resolveGhidraAnalysisProfile = async ( : {}), ...analyzerParameters, ...ghidraMipsProfileParameters(target), + ...ghidraRiscvProfileParameters(target), ...(pspExtension?.ok === true ? { mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2", diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index aa50440797..c48f589576 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -66,6 +66,7 @@ const SUPPORTED_ARCHITECTURES = new Set([ "arm", "arm64", "mips", + "riscv", ]); /** Ghidra candidate backed by an isolated ephemeral headless import. */ diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index fc0297789b..39c89c46ae 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -14,6 +14,7 @@ import { ghidraSeedLimitations, } from "./GhidraAnalysisSeeds.js"; import { + ghidraPinnedLanguage, ghidraProfileDisabledAnalyzers, ghidraProfileLanguageOverride, sameGhidraDisabledAnalyzers, @@ -61,10 +62,10 @@ import { ghidraMipsSoftFloatFailure, ghidraMipsRelocatableLimitations, ghidraMipsSharedLimitations, - ghidraMipsSharedLanguageOverride, ghidraMipsSoftFloatLimitations, isGhidraMipsSoftFloatTarget, } from "./GhidraMipsProfile.js"; +import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; @@ -453,7 +454,7 @@ export const createGhidraProviderClient = (input: { if ( !sameGhidraLanguageOverride( languageOverride, - config.ghidraLanguageOverride ?? ghidraMipsSharedLanguageOverride(target), + config.ghidraLanguageOverride ?? ghidraPinnedLanguage(target), ) ) return unavailableClient( @@ -523,6 +524,7 @@ export const createGhidraProviderClient = (input: { : []), ...ghidraMipsRelocatableLimitations(target), ...ghidraMipsSharedLimitations(target), + ...ghidraRiscvLimitations(target), ]; const providerLimitations = installation.platform === "win32" diff --git a/src/ghidra/GhidraPspProfile.ts b/src/ghidra/GhidraPspProfile.ts index b1ee12fe55..17260d038e 100644 --- a/src/ghidra/GhidraPspProfile.ts +++ b/src/ghidra/GhidraPspProfile.ts @@ -1,5 +1,6 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js"; +import { ghidraRiscvUnsupportedReason } from "./GhidraRiscvProfile.js"; /** Allegrex's ELF machine declaration, not a filename or inferred instruction. */ export const isGhidraPspTarget = (target: BinaryTarget): boolean => @@ -13,6 +14,8 @@ export const isGhidraPspTarget = (target: BinaryTarget): boolean => export const ghidraProcessorUnsupportedReason = ( target: BinaryTarget, ): string | null => { + if (target.kind === "executable" && target.architecture === "riscv") + return ghidraRiscvUnsupportedReason(target); if ( target.kind !== "executable" || target.architecture !== "mips" || diff --git a/src/ghidra/GhidraRawBinaryLanguage.ts b/src/ghidra/GhidraRawBinaryLanguage.ts index 9a4f254b28..ea024bdbaa 100644 --- a/src/ghidra/GhidraRawBinaryLanguage.ts +++ b/src/ghidra/GhidraRawBinaryLanguage.ts @@ -1,4 +1,4 @@ -import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; +import type { HeaderArchitecture } from "../domain/binaryTargetTypes.js"; /** Bound Ghidra language for a caller-supplied raw-binary architecture. */ export interface GhidraRawBinaryLanguage { @@ -13,7 +13,7 @@ export interface GhidraRawBinaryLanguage { * Big-endian, Thumb-only, and Cortex-M variants are out of scope. */ export const ghidraRawBinaryLanguage = ( - architecture: Exclude, + architecture: HeaderArchitecture, ): GhidraRawBinaryLanguage => { switch (architecture) { case "x86": diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts new file mode 100644 index 0000000000..9bbb65cf32 --- /dev/null +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -0,0 +1,256 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { parseConfig } from "../config/parseConfig.js"; +import type { + BinaryTarget, + RiscvElfMetadata, +} from "../domain/binaryTargetTypes.js"; +import { silentLogger } from "../logger.js"; +import { HopperProvider } from "../hopper/HopperProvider.js"; +import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js"; +import { IdaProvider } from "../ida/IdaProvider.js"; +import { GhidraProvider } from "./GhidraProvider.js"; +import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; +import type { GhidraInstallationHost } from "./GhidraInstallation.js"; +import { + ghidraPinnedLanguage, + ghidraProfileLanguageOverride, + sameGhidraLanguageOverride, +} from "./GhidraAnalysisProfile.js"; + +// Abbreviated from Ghidra 12.1.4 Processors/RISCV/data/languages/riscv.ldefs. +// The release tree ships no big-endian or ELF-class-mismatched RISC-V language. +const RISCV_LDEFS = ` + + + RISC-V 64 little default + + + + RISC-V 32 little default + + + +`; + +// Fake installation inspection tests admission/profile contracts, not Ghidra. +const host: GhidraInstallationHost = { + platform: "linux", + architecture: "x64", + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.11", + major: 21, + home: "/jdk-21", + bits: 64, + runtime: "jdk", + }), +}; +const configOf = ( + installDir: string, + environment: Record = {}, +) => { + const parsed = parseConfig({ + GHIDRA_INSTALL_DIR: installDir, + ...environment, + }); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; +const target = (change: Partial = {}): BinaryTarget => ({ + path: "/fixture.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: { + elfClass: 64, + byteOrder: "little", + type: 2, + flags: 0x5, + ...change, + }, +}); + +let installDir = ""; +beforeAll(async () => { + installDir = await mkdtemp(join(tmpdir(), "rea-ghidra-riscv-languages-")); + const languages = join(installDir, "Ghidra/Processors/RISCV/data/languages"); + await mkdir(languages, { recursive: true }); + await writeFile(join(languages, "riscv.ldefs"), RISCV_LDEFS); +}); +afterAll(async () => { + await rm(installDir, { recursive: true, force: true }); +}); + +const ghidra = (environment: Record = {}) => + new GhidraProvider(configOf(installDir, environment), silentLogger, {}, host); + +describe("bounded Ghidra RISC-V admission", () => { + it("pins the generic language per width instead of accepting the header guess", async () => { + const provider = ghidra(); + const rv64 = target(); + const rv32 = target({ elfClass: 32, flags: 0x4 }); + expect(provider.inspectTargetSupport(rv64).status).toBe("supported"); + expect(provider.inspectTargetSupport(rv32).status).toBe("supported"); + expect(ghidraPinnedLanguage(rv64)).toEqual({ + languageId: "RISCV:LE:64:default", + compilerSpecId: "gcc", + }); + expect(ghidraPinnedLanguage(rv32)).toEqual({ + languageId: "RISCV:LE:32:default", + compilerSpecId: "gcc", + }); + const wide = await provider.resolveAnalysisProfile(rv64); + const narrow = await provider.resolveAnalysisProfile(rv32); + if (!wide.ok || !narrow.ok) + throw new Error("expected admitted RISC-V profiles"); + expect(wide.value.profile?.parameters.riscv_elf).toEqual({ + elf_class: 64, + byte_order: "little", + type: 2, + flags: 5, + float_abi_double: true, + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }); + expect(wide.value.profile?.parameters.riscv_support_lane).toBe( + "elf64-exec-le-double-float-generic-v1", + ); + expect(narrow.value.profile?.parameters.riscv_support_lane).toBe( + "elf32-exec-le-double-float-generic-v1", + ); + expect(narrow.value.profile?.parameters.riscv_elf).toMatchObject({ + elf_class: 32, + rvc_declared: false, + }); + expect(wide.value.profile?.digest).not.toBe(narrow.value.profile?.digest); + }); + + it("commits the pinned language the session readback expects", async () => { + // A pinned lane has one owner: the committed profile and the open_binary + // readback must agree without either re-deriving a different family's pin. + const provider = ghidra(); + for (const value of [target(), target({ elfClass: 32 })]) { + const resolved = await provider.resolveAnalysisProfile(value); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a RISC-V profile"); + expect( + sameGhidraLanguageOverride( + ghidraProfileLanguageOverride(resolved.value.profile.parameters), + ghidraPinnedLanguage(value), + ), + ).toBe(true); + } + }); + + it.each([ + [{ byteOrder: "big" }, "little-endian"], + [{ type: 3 }, "fixed-address ET_EXEC only"], + [{ type: 1 }, "fixed-address ET_EXEC only"], + [{ type: 0xffa0 }, "e_type 65440"], + [{ flags: 0xc }, "EF_RISCV_RVE"], + [{ flags: 0x14 }, "EF_RISCV_TSO"], + [{ flags: 0x1 }, "soft-float convention"], + [{ flags: 0x3 }, "single-float"], + [{ flags: 0x7 }, "quad-float"], + [{ flags: 0x8000_0005 }, "(0x80000000)"], + [{ flags: 0x24 }, "bits outside the verified lane"], + ] satisfies [Partial, string][])( + "refuses an unverified RISC-V interpretation %j", + async (change, reason) => { + const provider = ghidra(); + const value = target(change); + expect(provider.inspectTargetSupport(value)).toMatchObject({ + status: "unsupported", + code: "architecture_unsupported", + reason: expect.stringContaining(reason), + }); + expect(await provider.resolveAnalysisProfile(value)).toEqual( + expect.objectContaining({ ok: false }), + ); + expect(ghidraPinnedLanguage(value)).toBeUndefined(); + }, + ); + + it("refuses a caller-selected processor outside the verified generic lane", async () => { + const resolved = await ghidra({ + REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:32:AndeStar_v5", + }).resolveAnalysisProfile(target({ elfClass: 32 })); + expect(resolved.ok).toBe(false); + if (resolved.ok) throw new Error("expected a refusal"); + expect(resolved.error.message).toContain("generic little-endian RISC-V"); + }); + + it("keeps a caller-selected language that matches the pin as configured", async () => { + const resolved = await ghidra({ + REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:64:default", + REA_GHIDRA_COMPILER_SPEC_ID: "gcc", + }).resolveAnalysisProfile(target()); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a configured profile"); + expect(resolved.value.profile.parameters.language_selection).toBe( + "configured-v1", + ); + expect(resolved.value.profile.parameters.language_id).toBe( + "RISCV:LE:64:default", + ); + }); + + it("states the linkage claim boundary in the lane limitations", () => { + const notes = ghidraRiscvLimitations(target()); + expect(notes).toHaveLength(3); + expect(notes.join(" ")).toContain("does not establish static linkage"); + expect(notes[0]).toContain("RISCV:LE:64:default"); + expect( + ghidraRiscvLimitations({ + path: "/other.elf", + sha256: "b".repeat(64), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }), + ).toEqual([]); + }); + + it("keeps RISC-V out of unverified Hopper and IDA adapter lanes", () => { + const hopper = new HopperProvider( + configOf("/hopper"), + silentLogger, + {}, + "linux", + ); + expect(hopper.inspectTargetSupport(target()).status).toBe("unsupported"); + expect(hopperLoaderArgsForTarget(target()).ok).toBe(false); + expect(hopperLoaderArgsForTarget(target())).toEqual( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("RISC-V"), + }), + }), + ); + const ida = new IdaProvider(configOf("/ida"), {}); + expect(ida.inspectTargetSupport(target()).status).toBe("unsupported"); + }); +}); diff --git a/src/ghidra/GhidraRiscvProfile.ts b/src/ghidra/GhidraRiscvProfile.ts new file mode 100644 index 0000000000..3ccd5fc4cb --- /dev/null +++ b/src/ghidra/GhidraRiscvProfile.ts @@ -0,0 +1,129 @@ +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; + +/** + * Ghidra's generic RISC-V languages model the double-float ABI: the shipped + * `riscv.ldefs` maps `RISCV:LE:{32,64}:default` to the `riscv{32,64}-fp.cspec` + * compiler specification. Soft, single and quad ABI declarations therefore need + * their own verification rather than inheriting this lane. + */ +const EF_RISCV_RVC = 0x0001; +const EF_RISCV_FLOAT_ABI = 0x0006; +const EF_RISCV_FLOAT_ABI_DOUBLE = 0x0004; +const EF_RISCV_RVE = 0x0008; +const EF_RISCV_TSO = 0x0010; + +const ET_REL = 1; +const ET_EXEC = 2; +const ET_DYN = 3; + +/** + * Bits this lane reads. Anything else in `e_flags` — reserved today or defined by + * a newer psABI — is refused rather than assumed compatible. + */ +const EF_RISCV_DECLARED_BITS = EF_RISCV_RVC | EF_RISCV_FLOAT_ABI; + +/** Header declarations outside the verified generic-language lane. */ +const riscvUnsupportedFloatAbi = (abi: number): string => + abi === 0 + ? "unspecified (soft-float convention)" + : abi === 0x0002 + ? "single-float" + : "quad-float"; + +/** + * Keep family recognition separate from the deliberately bounded Ghidra lane. + * + * Ghidra 12.1.4 selects `RISCV:LE:32:AndeStar_v5` for an ordinary RV32 ELF: its + * `riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves + * the 32-bit one with the comment that the loader "must choose between 'default' + * and 'AndeStar' variants". The lane therefore pins the language instead of + * accepting the header-based guess. + */ +export const ghidraRiscvUnsupportedReason = ( + target: BinaryTarget, +): string | null => { + if (target.kind !== "executable" || target.architecture !== "riscv") + return null; + const metadata = target.riscv; + if (target.format !== "elf") + return "RISC-V analysis requires ELF header metadata; a family name alone does not select an ISA or ABI."; + if (metadata.byteOrder !== "little") + return "This Ghidra RISC-V lane supports little-endian images only; Ghidra 12.1.4 ships no big-endian RISC-V language."; + if (metadata.elfClass !== 32 && metadata.elfClass !== 64) + return "This Ghidra RISC-V lane requires ELF32 or ELF64; the class decides which generic language is pinned."; + if ((metadata.flags & EF_RISCV_RVE) !== 0) + return "EF_RISCV_RVE declares the embedded 15-register ABI, which needs its own processor specification and compiler verification."; + if ((metadata.flags & EF_RISCV_TSO) !== 0) + return "EF_RISCV_TSO declares the total-store-order memory model; this lane verifies the baseline model only."; + if (metadata.type !== ET_EXEC) + return `This Ghidra RISC-V lane supports fixed-address ET_EXEC only; ${ + metadata.type === ET_REL + ? "relocatable" + : metadata.type === ET_DYN + ? "shared and position-independent" + : `e_type ${metadata.type}` + } images need separate load verification. ET_EXEC alone does not establish static linkage, which this lane does not inspect.`; + const abi = metadata.flags & EF_RISCV_FLOAT_ABI; + if (abi !== EF_RISCV_FLOAT_ABI_DOUBLE) + return `This Ghidra RISC-V lane requires the EF_RISCV_FLOAT_ABI_DOUBLE declaration (${riscvUnsupportedFloatAbi( + abi, + )} observed); the pinned generic language uses the double-float compiler specification.`; + const unsupportedFlags = (metadata.flags & ~EF_RISCV_DECLARED_BITS) >>> 0; + if (unsupportedFlags !== 0) + return `RISC-V e_flags declares bits outside the verified lane (0x${unsupportedFlags.toString(16)}); reserved or newer declarations are not treated as compatible with this profile.`; + return null; +}; + +/** Pin a language only for declarations admitted by the same lane boundary. */ +export const ghidraRiscvLanguageOverride = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + target.kind === "executable" && + target.architecture === "riscv" && + ghidraRiscvUnsupportedReason(target) === null + ? { + languageId: `RISCV:LE:${target.riscv.elfClass}:default`, + compilerSpecId: "gcc", + } + : undefined; + +/** Commit source ELF interpretation without confusing target ISA with host CPU. */ +export const ghidraRiscvProfileParameters = ( + target: BinaryTarget, +): Readonly> => + target.kind === "executable" && + target.architecture === "riscv" && + target.riscv !== undefined + ? { + riscv_elf: { + elf_class: target.riscv.elfClass, + byte_order: target.riscv.byteOrder, + type: target.riscv.type, + flags: target.riscv.flags, + float_abi_double: + (target.riscv.flags & EF_RISCV_FLOAT_ABI) === + EF_RISCV_FLOAT_ABI_DOUBLE, + rvc_declared: (target.riscv.flags & EF_RISCV_RVC) !== 0, + rve_declared: (target.riscv.flags & EF_RISCV_RVE) !== 0, + tso_declared: (target.riscv.flags & EF_RISCV_TSO) !== 0, + }, + riscv_support_lane: + target.riscv.elfClass === 32 + ? "elf32-exec-le-double-float-generic-v1" + : "elf64-exec-le-double-float-generic-v1", + } + : {}; + +/** The pinned language is an analysis convention, not proof of the producer. */ +export const ghidraRiscvLimitations = ( + target: BinaryTarget, +): readonly string[] => + target.kind === "executable" && target.architecture === "riscv" + ? [ + `RISC-V ELF: REA pins Ghidra's generic \`RISCV:LE:${target.riscv.elfClass}:default\` language with the \`gcc\` compiler specification instead of accepting the loader's header-based choice, which selects \`RISCV:LE:32:AndeStar_v5\` for ordinary RV32 images on Ghidra 12.1.4.`, + "The pinned generic language models the double-float ABI declared by `e_flags`; the declaration is recorded as an ELF fact and is not evidence of the original compiler or of verified instruction semantics.", + "Admission reads `e_type` only: a fixed-address ET_EXEC declaration does not establish static linkage, and program headers (`PT_INTERP`, dynamic dependencies) are not inspected by this lane.", + ] + : []; diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 927f7289b2..299862a97e 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -8,8 +8,10 @@ import type { import { createAnalysisProfile } from "../domain/analysisProfile.js"; import { isMachOFormat, + isElfMetadataArchitecture, type BinaryTarget, - type BinaryArchitecture, + type ElfMetadataArchitecture, + type HeaderArchitecture, } from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, @@ -101,6 +103,15 @@ export const resolveHopperAnalysisProfile = async ( }); }; +/** Ghidra owns these lanes; Hopper names the family it declines rather than guessing. */ +const HOPPER_DECLINED_ARCHITECTURE_LABELS: Record< + ElfMetadataArchitecture, + string +> = { + mips: "MIPS", + riscv: "RISC-V", +}; + /** Derive Hopper's complete non-interactive CLI loader selection. */ export const hopperLoaderArgsForTarget = ( target: BinaryTarget, @@ -113,12 +124,12 @@ export const hopperLoaderArgsForTarget = ( if (target.kind !== "executable") return err(new ProviderAdapterError("hopper", "resolve_analysis_profile")); const architecture = target.architecture; - if (architecture === "mips") + if (isElfMetadataArchitecture(architecture)) return err( new AnalysisCapabilityUnavailableError( "hopper", "resolve_analysis_profile", - "REA's Hopper adapter does not admit MIPS targets. Select a verified Ghidra MIPS profile instead.", + `REA's Hopper adapter does not admit ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} targets. Select a verified Ghidra ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} profile instead.`, ), ); const flag = hopperArchitectureFlag(architecture); @@ -151,9 +162,7 @@ export const hopperLoaderArgsForTarget = ( } }; -const hopperArchitectureFlag = ( - architecture: Exclude, -): string => { +const hopperArchitectureFlag = (architecture: HeaderArchitecture): string => { switch (architecture) { case "x86": return "--intel-32"; diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 4995333161..20a53105c3 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { isMachOFormat, + isElfMetadataArchitecture, type BinaryTarget, } from "../domain/binaryTargetTypes.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; @@ -47,7 +48,10 @@ export const resolveHopperMachOImage = async ( ), ); if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); - if (!isMachOFormat(target.format) || target.architecture === "mips") + if ( + !isMachOFormat(target.format) || + isElfMetadataArchitecture(target.architecture) + ) return invalid("Hopper FAT64 image preparation requires a Mach-O target"); let file: FileHandle; try { diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index c52eefe280..aec8c20829 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -16,7 +16,10 @@ import type { } from "../application/AnalysisProvider.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { AppConfig } from "../config/types.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isElfMetadataArchitecture, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; @@ -118,11 +121,13 @@ export class HopperProvider implements AnalysisProviderCandidate { reason: null, diagnostics, }; - if (target.architecture === "mips") + if (isElfMetadataArchitecture(target.architecture)) return { status: "unsupported", code: "architecture_unsupported", - reason: "REA's Hopper adapter does not admit MIPS targets.", + reason: `REA's Hopper adapter does not admit ${ + target.architecture === "mips" ? "MIPS" : "RISC-V" + } targets.`, diagnostics, }; if (target.format === "raw-binary") diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 7fefce825a..c86ef5eb44 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -1,7 +1,10 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isElfMetadataArchitecture, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { err, ok } from "../domain/result.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { @@ -101,12 +104,16 @@ export class IdaProvider implements AnalysisProviderCandidate { target_kind: target.kind, target_format: target.format, }; - if (target.kind === "executable" && target.architecture === "mips") + if ( + target.kind === "executable" && + isElfMetadataArchitecture(target.architecture) + ) return { status: "unsupported", code: "architecture_unsupported", - reason: - "REA's IDA adapter has no verified MIPS lane. Select a supported Ghidra profile instead.", + reason: `REA's IDA adapter has no verified ${ + target.architecture === "mips" ? "MIPS" : "RISC-V" + } lane. Select a supported Ghidra profile instead.`, diagnostics, }; if (target.kind !== "executable") diff --git a/tests/conformance/c/riscv.c b/tests/conformance/c/riscv.c new file mode 100644 index 0000000000..c100c4f186 --- /dev/null +++ b/tests/conformance/c/riscv.c @@ -0,0 +1,18 @@ +/* Source-owned freestanding analysis fixture. Never execute this target. */ +volatile unsigned int rea_riscv_global = 7; +extern unsigned int rea_riscv_probe(unsigned int value); + +__attribute__((noinline)) unsigned int rea_riscv_leaf(unsigned int value) +{ + return value * 3u + rea_riscv_global; +} + +__attribute__((noinline)) double rea_riscv_float(double left, double right) +{ + return left + right; +} + +__attribute__((noinline)) unsigned int rea_riscv_entry(unsigned int selector) +{ + return rea_riscv_probe(rea_riscv_leaf(selector + 1u)); +} diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index ae9cc7b4c5..d2161e1ff3 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -3,6 +3,14 @@ These source-owned fixtures are compiled at verification time; generated binaries and Ghidra projects are never committed. +- `../c/riscv.c` and `riscv-probe.S` produce source-owned RV32/RV64 ELF images + with double-float ABI declarations and an independently known instruction. + `npm run verify:ghidra:riscv` exercises real CLI/MCP discovery, decompilation, + output schemas, profile and snapshot identity, refusal diagnostics, immutable + source bytes, and owned cleanup. It requires BYO Ghidra 12.1.4/JDK 21+ and + RISC-V Clang/LLD/LLVM readelf; `REA_RISCV_CLANG`, `REA_RISCV_LLD` and + `REA_RISCV_READELF` select existing tool commands. + - `inventory.c` produces separate x86-64 debug and stripped ELF executables. It covers imports, an external `puts` function, linker thunks, source symbols, direct calls, a targetless callback call, two referenced strings, and a diff --git a/tests/conformance/ghidra/riscv-fixture.mjs b/tests/conformance/ghidra/riscv-fixture.mjs new file mode 100644 index 0000000000..27b354094e --- /dev/null +++ b/tests/conformance/ghidra/riscv-fixture.mjs @@ -0,0 +1,97 @@ +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); + +/** Build and independently inspect a source-owned ELF; never execute it. */ +export async function buildRiscvFixture(directory, bits) { + assert.ok(bits === 32 || bits === 64); + const compiler = process.env.REA_RISCV_CLANG ?? "clang"; + const linker = process.env.REA_RISCV_LLD ?? "ld.lld"; + const reader = process.env.REA_RISCV_READELF ?? "llvm-readelf"; + const source = fileURLToPath(new URL("../c/riscv.c", import.meta.url)); + const probe = fileURLToPath(new URL("./riscv-probe.S", import.meta.url)); + const path = join(directory, `rv${bits}.elf`); + const args = [ + `--target=riscv${bits}-unknown-elf`, + `-march=rv${bits}imafdc`, + `-mabi=${bits === 32 ? "ilp32d" : "lp64d"}`, + `--ld-path=${linker}`, + "-nostdlib", + "-static", + "-fno-pic", + "-O0", + "-g", + "-Wl,-e,rea_riscv_entry", + "-Wl,--build-id=none", + source, + probe, + "-o", + path, + ]; + const options = { timeout: 10000, maxBuffer: 1024 * 1024 }; + const compilerVersion = await execute(compiler, ["--version"], options); + const linkerVersion = await execute(linker, ["--version"], options); + const readerVersion = await execute(reader, ["--version"], options); + await execute(compiler, args, { ...options, timeout: 60000 }); + const { stdout, stderr } = await execute(reader, ["-h", "-s", "-W", path], { + ...options, + env: { ...process.env, LC_ALL: "C" }, + }); + assert.equal(stderr.trim(), "", "Unexpected ELF reader diagnostics"); + const field = (expression, label) => { + const matches = [...stdout.matchAll(expression)]; + assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`); + return matches[0][1].trim(); + }; + assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), `ELF${bits}`); + assert.match(field(/^\s*Data:\s*(.+)$/gmu, "byte order"), /little endian/u); + assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u); + assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^RISC-V\b/u); + const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+).*$/gmu, "flags")); + assert.equal(flags, 5, "Compiler did not declare RVC and double-float ABI"); + const symbols = {}; + for (const name of [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + "rea_riscv_global", + ]) { + const value = field( + new RegExp( + `^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+(?:FUNC|OBJECT)\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`, + "gmu", + ), + name, + ); + symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`; + } + return { + path, + sha256: createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + riscv: { elfClass: bits, byteOrder: "little", type: 2, flags }, + symbols, + toolchain: { + compiler: { command: compiler, version: compilerVersion.stdout.trim() }, + linker: { command: linker, version: linkerVersion.stdout.trim() }, + reader: { command: reader, version: readerVersion.stdout.trim() }, + arguments: args, + }, + sources: await Promise.all( + [source, probe].map(async (sourcePath) => ({ + path: sourcePath, + sha256: createHash("sha256") + .update(await readFile(sourcePath)) + .digest("hex"), + })), + ), + }; +} diff --git a/tests/conformance/ghidra/riscv-probe.S b/tests/conformance/ghidra/riscv-probe.S new file mode 100644 index 0000000000..701ca9582d --- /dev/null +++ b/tests/conformance/ghidra/riscv-probe.S @@ -0,0 +1,9 @@ +/* Fixed instruction oracle, independent of the compiler's C code generation. */ +.section .text +.option norvc +.global rea_riscv_probe +.type rea_riscv_probe, @function +rea_riscv_probe: + addi a0, a0, 17 + ret +.size rea_riscv_probe, .-rea_riscv_probe