feat(native): admit bounded MIPS ELF analysis through Ghidra (#1362)

* feat(native): admit bounded MIPS ELF analysis through Ghidra

Retain MIPS ELF identity and interpretation through Evidence and snapshots.
Keep provider admission bounded to ELF32 ET_EXEC o32 MIPS32r2, without
implicitly widening Hopper, IDA or Windows P0 coverage. Add source-owned
fixtures and an optional real-provider verifier; real Ghidra verification
remains pending in this contribution environment.

Refs #718

* fix(ghidra): bound MIPS floating-point mode admission

Reject explicit EF_MIPS_FP64 and EF_MIPS_NAN2008 declarations until their
semantics have a real-provider verification lane. Add PSP PRX/Allegrex,
o64/EABI and floating-point-mode refusal regressions. Document that the
header gate is not a full .MIPS.abiflags check and advance the profile lane
identity to invalidate older interpretation bindings.

Validation: 24 standalone checks against the transpiled production profile
module passed; the two new flag cases failed before this change. Clang 17
and LLD compiled the source-owned fixture for both endian targets. The
complete Vitest/typecheck suite and real Ghidra verification remain pending.

Refs morluto/rea#718; PSP support remains follow-up morluto/rea#1330.

* fix(native): validate MIPS ABI records before Ghidra admission

Read bounded ELF32 PT_MIPS_ABIFLAGS and SHT_MIPS_ABIFLAGS records through
the already-open target handle. Preserve declared ABI fields, reject
conflicting/duplicate/truncated records, and keep missing records unknown.
Wire inspection into target resolution before provider selection. Require
compatible declarations for the deliberately narrow Ghidra lane and bind
all ABI fields into profile identity (v3) for snapshot invalidation.

Clang 17 produced eight source-owned fixtures spanning both byte orders
and FPXX/FP32/FP64/soft-float modes with identical ELF header flags. The
new reader and policy distinguish them. 24 focused regression test bodies
passed after TypeScript transpilation using node:test in place of Vitest;
strict isolated TypeScript checking of the reader and target/result types
passed. These are limited checks, not full Vitest/project typechecking or
real-Ghidra verification, which remain pending.

Advances morluto/rea#718. PSP/Allegrex remains morluto/rea#1330 follow-up.

* fix(native): repair MIPS integration boundaries and test resolver behavior

Keep ELF targets out of Hopper Mach-O preparation and scope existing PE fixture types correctly. Move ABI tests into the filesystem boundary lane, use shared cleanup, add resolver regressions, apply the pinned formatter, and register the real MIPS verifier through the existing package-command pattern. Validated with repository-pinned Node/npm, check:ci, actual focused Vitest, discovery and docs:check. Real Ghidra conformance remains pending.

* fix(test): honor caller-selected JDK in real MIPS verification

* test(native): strengthen MIPS Ghidra conformance

Cross-check the source-owned fixture with bounded GNU readelf inspection, compare production ELF/ABI interpretation and independent symbol addresses, and verify effective Ghidra language/compiler identity through existing public operations. Add a source-owned fixed-encoding probe for immediate and conditional branch checks plus global/string byte assertions.

Add 16 assertion/producer-boundary regression cases without widening production admission, changing provider contracts, or introducing proprietary dependencies. Local focused suite: 99 passed, one existing root-permission test skipped. Typecheck, lint/module boundaries, formatting and 700-file discovery pass; full hosted checks and real Ghidra verification remain pending for this checkpoint.

* fix(ghidra): preserve native-tool architecture checks after MIPS integration

The conflict-free upstream merge failed TypeScript compilation because includes on the union of MIPS-only and other architecture arrays requires never. Use equality-based membership without casts or changing accepted native hosts. Extend the existing native-decompiler rejection table with a MIPS executable at a Linux x64 tool path; retain upstream FAT and unknown-header behavior. Hosted verification follows this commit; no success is claimed yet.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Jay-Alexander Elliot
2026-10-10 23:04:27 +08:00
committed by GitHub
co-authored by github-actions[bot]
parent 96fd98cce0
commit bddddccdee
32 changed files with 2028 additions and 35 deletions
+100
View File
@@ -0,0 +1,100 @@
# MIPS ELF analysis through Ghidra
This lane extends native ELF analysis; it does not execute MIPS code or rehost
firmware. It advances the generic-MIPS portion of
[#718](https://github.com/morluto/rea/issues/718), not the whole firmware roadmap.
PSP/Allegrex remains a separate specialization tracked in
[#1330](https://github.com/morluto/rea/issues/1330).
## Admission boundary
ELF `EM_MIPS` is retained as the provider-neutral `mips` family. The target also
retains class, byte order, `e_type`, raw `e_flags`, and inspected ABI declarations.
Recognition does not mean every provider or MIPS variant is supported. Existing
PE, managed and Mach-O architecture sets are unchanged.
The first Ghidra lane admits ELF32 `ET_EXEC`, little or big endian, with explicit
o32 and `EF_MIPS_ARCH_32R2` flags and no machine-specific or ASE encoding flags.
These are header declarations, not proof that every instruction conforms to
that ISA. ELF64, n32/n64, unspecified ABI, MIPS16/microMIPS, machine-specific
variants, shared/relocatable objects and PSP PRX are not covered by this lane.
Their family may be identified, but provider selection returns the specific
unsupported constraint rather than guessing a compatible profile.
Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their
absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and
soft-float targets. Target resolution also reads the 24-byte ABI flags record
identified by `PT_MIPS_ABIFLAGS` and/or `SHT_MIPS_ABIFLAGS`, without relying on
section names or the record being inside the initial 4 KiB header probe.
Program and section declarations must agree; duplicate, conflicting, truncated
or out-of-file records are rejected. Stripped section tables and section-only
records are supported. Extended table numbering is explicitly unsupported.
The field layout follows
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
Provider admission requires record version 0, MIPS32r2, 32-bit GPR/CPR1, no CPR2,
and a double-precision or FPXX ABI declaration with no ISA extension, ASE or
unknown general flags. Missing records remain unknown rather than defaulting
to a compatible ABI, and this initial lane refuses them. Unknown record values
are retained for diagnosis but do not gain provider support. These checks bind
the declared interpretation; the integer-only fixture does not establish full
floating-point behavioral conformance.
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
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
## Use
With a caller-installed Ghidra and compatible JDK on a supported Linux/macOS host:
```bash
rea analyze /absolute/path/to/mips.elf --provider ghidra --json
rea function /absolute/path/to/mips.elf function_name --provider ghidra --json
```
MCP uses the existing tools, including `open_binary` with an absolute path and
`provider_id: "ghidra"`. No MIPS-specific tool catalog or emulator is introduced.
Inspect instructions, caller relationships and data alongside pseudocode. In
particular, delay-slot semantics are supplied by Ghidra, not reconstructed by
REA's ELF parser.
## Verification
The source-owned freestanding fixture is `tests/conformance/c/mips.c`. The
optional cross-target lane requires a caller-supplied Clang with MIPS targets,
LLD, GNU `readelf`, Ghidra and its compatible JDK; ordinary host-native checks
do not acquire these tools. `REA_MIPS_CLANG` selects Clang and
`REA_MIPS_READELF` selects GNU readelf (for example `greadelf` on macOS).
These are test-time prerequisites, not REA runtime providers.
```bash
GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra npm run verify:ghidra:mips
```
The lane compiles both byte orders and never executes the targets. GNU readelf
independently supplies header/ABI facts and symbol addresses, compared with the
production REA target resolver before provider startup. Its version, bounded
command output and selected tool path are retained in the verification report.
The parser deliberately recognizes only this fixture's GNU output, rejecting
missing, ambiguous or unexpected fields rather than substituting defaults.
Existing public MCP operations check Ghidra's **observed** language/compiler
specification and loaded-file hashes. The source-owned `mips-probe.S` supplies
fixed ADDIU and conditional-branch encodings: verification checks bytes, length,
immediate and direct destination, independent of decompiler variable names.
Global and string bytes are checked at independently reported symbol addresses.
ELF load-image results retain observations even though REA's independent
load-image comparison currently covers DOS, not ELF; this lane checks those
observations and does not relabel the upstream result as verified.
CLI/MCP discovery and decompilation, direct calls, target/profile/Evidence
identity, snapshot round-trip and final process cleanup remain covered.
Pseudocode presence is a liveness check, not proof of semantic equivalence.
Instruction decoding and reader agreement likewise do not establish runtime,
delay-slot execution or complete floating-point semantics. Assertion regressions
reject incorrect byte order, constants, destinations and loaded identities.
Parser/profile tests are narrower checks, not substitutes for this real-provider
run. Report the actual lanes run and leave unexecuted verification pending.
+1
View File
@@ -150,6 +150,7 @@
"verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs",
"verify:adb": "npm run build:cached && node scripts/verify-adb.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import {
inspectMipsReadelf,
assertMipsLoadedImage,
assertMipsProbe,
assertMipsGlobal,
} from "../tests/conformance/ghidra/mips-checks.mjs";
import { parseEvidence } from "../dist/domain/evidence.js";
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
import { functionDossierSchema } from "../dist/domain/hopperValues.js";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { buildMipsFixture } from "../tests/conformance/ghidra/mips-fixture.mjs";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
// Optional cross-target lane. No tool acquisition and no target execution.
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-mips.mjs");
if (!["linux", "darwin"].includes(process.platform))
throw new Error(
"MIPS verification requires a supported Linux/macOS Ghidra host",
);
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR");
const installation = inspectGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
if (installation.status !== "available")
throw new Error(
`Ghidra prerequisite unavailable: ${JSON.stringify(installation)}`,
);
const execute = promisify(execFile);
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const run = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-mips-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
TMPDIR: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
GHIDRA_INSTALL_DIR: installation.installDir,
JAVA_HOME: installation.javaHome,
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
};
const reports = [];
let primaryFailure;
try {
for (const byteOrder of ["little", "big"]) {
const fixture = await buildMipsFixture(workspace, byteOrder);
const independent = await inspectMipsReadelf(fixture.path, byteOrder);
const resolved = await parseBinaryTarget(fixture.path);
if (!resolved.ok) throw resolved.error;
assert.deepEqual(
resolved.value.mips,
independent.mips,
"REA ELF/ABI interpretation disagrees with GNU readelf",
);
assert.equal(resolved.value.sha256, fixture.sha256);
const snapshotPath = join(workspace, `${byteOrder}.snapshot.json`);
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-mips-proof", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
undefined,
{ timeout: 180000 },
);
return requireMcpOperationResult(reply, name);
};
try {
await client.connect(transport);
const target = await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
});
assert.equal(target.architecture, "mips");
assert.equal(target.sha256, fixture.sha256);
// ELF load-image verification is not the DOS comparison lane. Its retained
// observations still expose the actual Ghidra language and loaded bytes.
const image = await call("inspect_native_load_image");
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
const procedures = await call("list_procedures");
const entry = procedures.find((p) => p.value === "rea_mips_entry");
const leaf = procedures.find((p) => p.value === "rea_mips_leaf");
assert.ok(entry, "Entry function missing from real Ghidra inventory");
assert.ok(leaf, "Leaf function missing from real Ghidra inventory");
assert.equal(
BigInt(entry.address),
BigInt(independent.symbols.rea_mips_entry),
);
assert.equal(
BigInt(leaf.address),
BigInt(independent.symbols.rea_mips_leaf),
);
const probe = independent.symbols.rea_mips_probe;
assert.ok(
procedures.some(
(p) =>
p.value === "rea_mips_probe" && BigInt(p.address) === BigInt(probe),
),
);
const move = await call("inspect_native_instruction", { address: probe });
const branch = await call("inspect_native_instruction", {
address: `0x${(BigInt(probe) + 4n).toString(16)}`,
});
assertMipsProbe(move, branch, byteOrder, probe);
const global = await call("read_bytes", {
address: independent.symbols.rea_mips_global,
length: 4,
});
assert.equal(global.returned_bytes, 4);
assertMipsGlobal(global.bytes_hex, byteOrder);
const marker = await call("read_bytes", {
address: independent.symbols.rea_mips_marker,
length: 30,
});
assert.equal(
marker.bytes_hex,
Buffer.from("rea-mips-source-owned-fixture\0").toString("hex"),
);
const callees = await call("procedure_callees", {
procedure: entry.address,
});
assert.ok(
callees.includes(leaf.address),
"Expected direct call not recovered",
);
const assembly = await call("procedure_assembly", {
procedure: entry.address,
});
assert.ok(assembly.trim().length > 0);
const dossier = functionDossierSchema.parse(
await call("analyze_function", { procedure: entry.address }),
);
assert.ok(dossier.pseudocode.trim().length > 0);
const mapping = await call("address_to_file_offset", {
address: entry.address,
});
const memory = await call("read_bytes", {
address: entry.address,
length: 16,
});
const sourceBytes = await readFile(fixture.path);
assert.equal(memory.returned_bytes, 16);
assert.equal(
memory.bytes_hex,
sourceBytes
.subarray(mapping.file_offset, mapping.file_offset + 16)
.toString("hex"),
);
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, "mips");
assert.equal(
snapshot.binding.analysis_profile.parameters.mips_elf.byte_order,
byteOrder,
);
assert.ok(snapshot.evidence_bundle.records.length > 0);
for (const evidence of snapshot.evidence_bundle.records)
assert.equal(parseEvidence(evidence).subject?.architecture, "mips");
await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
assert.deepEqual(await call("list_procedures"), procedures);
await call("close_binary");
// Same public operation through CLI, not a private injected provider.
const cli = await execute(
process.execPath,
[
entrypoint,
"function",
fixture.path,
"rea_mips_entry",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const evidence = parseEvidence(JSON.parse(cli.stdout));
assert.equal(evidence.subject?.architecture, "mips");
assert.equal(evidence.subject?.digest.sha256, fixture.sha256);
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,
"Analysis modified the source executable",
);
reports.push({
...fixture,
independent_reader: independent,
effective_language: image.observations.language_id,
effective_compiler_spec: image.observations.compiler_spec_id,
checks: [
"independent ELF/ABI and symbols",
"loaded Ghidra identity",
"fixed instruction bytes and immediate",
"conditional branch destination",
"global word and marker bytes",
"CLI/MCP and snapshot identity",
],
procedures: procedures.length,
status: "passed",
});
} catch (cause) {
throw new Error(
`MIPS ${byteOrder} verification failed; MCP stderr: ${stderr}`,
{ cause },
);
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
}
} catch (cause) {
primaryFailure = cause;
}
const completed = await completeVerifierRun(run);
if (
completed.process_lineage.status !== "verified" ||
completed.process_lineage.descendants.length !== 0
)
throw new Error(
`Cleanup unverified; retained diagnostic workspace: ${workspace}`,
{ cause: primaryFailure },
);
if (primaryFailure !== undefined)
throw new Error(
`MIPS verification failed; retained workspace: ${workspace}`,
{
cause: primaryFailure,
},
);
await rm(workspace, { recursive: true, force: true });
console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2));
+16 -1
View File
@@ -17,6 +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 {
resolveAppBundleExecutable,
type ResolvedAppBundle,
@@ -352,7 +353,21 @@ const readExecutableMetadata = async (
}
}
}
return parseExecutableHeader(bytes, hostArchitecture, fileSize);
const parsed = parseExecutableHeader(bytes, hostArchitecture, fileSize);
if (
!parsed.ok ||
parsed.value.architecture !== "mips" ||
parsed.value.mips.elfClass !== 32
)
return parsed;
const abiFlags = await readMipsElfAbiFlags(handle, parsed.value.mips, () =>
throwIfTargetResolutionCancelled(signal),
);
if (!abiFlags.ok) return abiFlags;
return ok({
...parsed.value,
mips: { ...parsed.value.mips, abiFlags: abiFlags.value },
});
};
const readPeMetadata = async (
+153
View File
@@ -0,0 +1,153 @@
import type { FileHandle } from "node:fs/promises";
import type {
MipsAbiFlags,
MipsElfMetadata,
} from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
/**
* Read ELF32 MIPS ABI 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 (
handle: FileHandle,
metadata: MipsElfMetadata,
checkCancelled: () => void,
): Promise<Result<MipsAbiFlags | null, string>> => {
checkCancelled();
if (metadata.elfClass !== 32)
return err("MIPS ABI record inspection currently supports ELF32 only");
const fileSize = (await handle.stat()).size;
checkCancelled();
const read = async (
offset: number,
length: number,
label: string,
): Promise<Result<Buffer, string>> => {
checkCancelled();
if (
!Number.isSafeInteger(fileSize) ||
offset < 0 ||
length < 0 ||
offset > fileSize - length
)
return err(`MIPS ${label} lies outside the file`);
const bytes = Buffer.alloc(length);
let total = 0;
while (total < length) {
checkCancelled();
const observed = await handle.read(
bytes,
total,
length - total,
offset + total,
);
checkCancelled();
if (observed.bytesRead === 0) return err(`truncated MIPS ${label}`);
total += observed.bytesRead;
}
return ok(bytes);
};
const header = await read(0, 52, "ELF32 header");
if (!header.ok) return header;
const bytes = header.value;
const little = metadata.byteOrder === "little";
const u16 = (data: Buffer, offset: number): number =>
little ? data.readUInt16LE(offset) : data.readUInt16BE(offset);
const u32 = (data: Buffer, offset: number): number =>
little ? data.readUInt32LE(offset) : data.readUInt32BE(offset);
if (
bytes.toString("hex", 0, 4) !== "7f454c46" ||
bytes[4] !== 1 ||
bytes[5] !== (little ? 1 : 2) ||
u16(bytes, 18) !== 8 ||
u16(bytes, 16) !== metadata.type ||
u32(bytes, 36) !== metadata.flags
)
return err("MIPS ELF header no longer matches the resolved identity");
if (bytes[6] !== 1 || u32(bytes, 20) !== 1 || u16(bytes, 40) !== 52)
return err("invalid MIPS ELF32 version or header size");
const tables = [
{
label: "program header table",
offset: u32(bytes, 28),
count: u16(bytes, 44),
stride: u16(bytes, 42),
entrySize: 32,
typeOffset: 0,
dataOffset: 4,
sizeOffset: 16,
recordType: 0x70000003, // PT_MIPS_ABIFLAGS
},
{
label: "section header table",
offset: u32(bytes, 32),
count: u16(bytes, 48),
stride: u16(bytes, 46),
entrySize: 40,
typeOffset: 4,
dataOffset: 16,
sizeOffset: 20,
recordType: 0x7000002a, // SHT_MIPS_ABIFLAGS
},
];
// Extended numbering needs a separate section-zero reader. Refuse it rather
// than silently treating an unexamined table as having no ABI declarations.
if (u16(bytes, 44) === 0xffff || u16(bytes, 48) >= 0xff00)
return err("extended or reserved MIPS ELF table numbering is unsupported");
let record: Buffer | undefined;
for (const table of tables) {
if (table.count === 0) {
if (table.offset !== 0)
return err(`unsupported extended or inconsistent MIPS ${table.label}`);
continue;
}
if (table.offset < 52 || table.stride !== table.entrySize)
return err(`invalid MIPS ${table.label} offset or entry size`);
// Counts are bounded by the ELF16 fields and strides by their exact ELF32
// structure sizes. Never allocate according to an untrusted section size.
const contents = await read(
table.offset,
table.count * table.entrySize,
table.label,
);
if (!contents.ok) return contents;
let found = false;
for (let i = 0; i < table.count; i += 1) {
checkCancelled();
const position = i * table.entrySize;
if (u32(contents.value, position + table.typeOffset) !== table.recordType)
continue;
if (found) return err(`duplicate MIPS ABI records in ${table.label}`);
found = true;
const offset = u32(contents.value, position + table.dataOffset);
const size = u32(contents.value, position + table.sizeOffset);
if (size !== 24)
return err("unsupported MIPS ABI record size; expected 24 bytes");
const candidate = await read(offset, 24, "ABI record");
if (!candidate.ok) return candidate;
if (record !== undefined && !record.equals(candidate.value))
return err("conflicting MIPS program/section ABI declarations");
record = candidate.value;
}
}
if (record === undefined) return ok(null);
// Preserve declarations verbatim, including unknown versions/values. Provider
// admission, not parsing, decides which interpretations have been verified.
return ok({
version: u16(record, 0),
isaLevel: record.readUInt8(2),
isaRevision: record.readUInt8(3),
gprSize: record.readUInt8(4),
cpr1Size: record.readUInt8(5),
cpr2Size: record.readUInt8(6),
fpAbi: record.readUInt8(7),
isaExtension: u32(record, 8),
ases: u32(record, 12),
flags1: u32(record, 16),
flags2: u32(record, 20),
});
};
@@ -2,7 +2,11 @@ import { z } from "zod";
import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
createEvidence,
type Evidence,
type EvidenceSubjectTarget,
} from "../../domain/evidence.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import {
managedApplicationGraphResultSchema,
@@ -68,14 +72,7 @@ const createManagedApplicationGraphEvidence = (
const subjectTarget = (
input: ProjectManagedApplicationGraphInput,
):
| {
readonly path: string;
readonly sha256: string;
readonly format: "pe";
readonly architecture?: "x86" | "x86_64" | "arm" | "arm64";
}
| undefined => {
): EvidenceSubjectTarget | undefined => {
const subject =
input.managed_artifact?.subject ??
input.managed_members?.subject ??
+3 -2
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js";
import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunctionAnnotations.js";
import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js";
import { nativeUiResultSchema } from "../domain/native/nativeUiObservation.js";
@@ -415,7 +416,7 @@ export const sessionOutputSchemas = {
format: targetFormatSchema,
kind: targetKindSchema,
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
}),
),
close_binary: lifecycleResultOf(
@@ -456,7 +457,7 @@ export const sessionOutputSchemas = {
format: targetFormatSchema,
kind: targetKindSchema,
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
}),
]),
]),
+5 -2
View File
@@ -11,7 +11,10 @@ import {
committedProviderSchema,
type AnalysisProfileCommitment,
} from "./analysisProfile.js";
import type { BinaryTarget } from "./binaryTargetTypes.js";
import {
BINARY_ARCHITECTURES,
type BinaryTarget,
} from "./binaryTargetTypes.js";
import {
evidenceBundleForTarget,
evidenceBundleSchema,
@@ -31,7 +34,7 @@ import {
import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
const architectureSchema = z.enum(["x86", "x86_64", "arm", "arm64"]);
const architectureSchema = z.enum(BINARY_ARCHITECTURES);
const formatSchema = z.enum([
"analysis-database",
"mach-o",
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { parseExecutableHeader } from "./binaryTarget.js";
const header = (little: boolean, bits: 32 | 64 = 32): 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(2, 16);
bytes.writeUInt16LE(8, 18);
bytes.writeUInt32LE(0x70001001, bits === 32 ? 36 : 48);
} else {
bytes.writeUInt16BE(2, 16);
bytes.writeUInt16BE(8, 18);
bytes.writeUInt32BE(0x70001001, bits === 32 ? 36 : 48);
}
return bytes;
};
describe("MIPS ELF header boundaries", () => {
it.each([true, false])(
"retains source endian and ABI/ISA flags (little=%s)",
(little) => {
const parsed = parseExecutableHeader(header(little), "x64");
if (!parsed.ok) throw new Error(parsed.error);
expect(parsed.value).toEqual({
format: "elf",
architecture: "mips",
availableArchitectures: ["mips"],
mips: {
elfClass: 32,
byteOrder: little ? "little" : "big",
type: 2,
flags: 0x70001001,
},
});
},
);
it("reads ELF64 flags at their own offset without claiming o32 support", () => {
const bytes = header(false, 64);
bytes.writeUInt32BE(0xfeedface, 36);
bytes.writeUInt32BE(0x80000001, 48);
const parsed = parseExecutableHeader(bytes, "x64");
if (!parsed.ok) throw new Error(parsed.error);
expect(parsed.value).toMatchObject({
architecture: "mips",
mips: { elfClass: 64, byteOrder: "big", flags: 0x80000001 },
});
});
it("retains a PSP declaration without claiming Allegrex provider support", () => {
const bytes = header(true);
bytes.writeUInt32LE(0x10a23001, 36);
const parsed = parseExecutableHeader(bytes, "x64");
if (!parsed.ok) throw new Error(parsed.error);
expect(parsed.value).toMatchObject({
architecture: "mips",
mips: { flags: 0x10a23001 },
});
});
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 MIPS", () => {
const bytes = header(true);
bytes[5] = 0;
expect(parseExecutableHeader(bytes, "x64")).toEqual({
ok: false,
error: "unsupported ELF endianness",
});
});
});
+43 -8
View File
@@ -1,18 +1,33 @@
import type { BinaryArchitecture } from "./binaryTargetTypes.js";
import type {
BinaryArchitecture,
MipsElfMetadata,
} from "./binaryTargetTypes.js";
import { err, ok, type Result } from "./result.js";
import { mzWindowsHeaderOffset, parseDosMzHeader } from "./dosMz.js";
/** Format and architecture facts recovered from an executable header. */
export type ExecutableMetadata =
| {
readonly format: "elf";
readonly architecture: "mips";
readonly availableArchitectures: readonly "mips"[];
readonly mips: MipsElfMetadata;
}
| {
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly architecture: BinaryArchitecture;
readonly availableArchitectures: readonly BinaryArchitecture[];
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
}
| {
readonly format: "pe";
readonly architecture: BinaryArchitecture;
readonly availableArchitectures: readonly BinaryArchitecture[];
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
readonly executableRole:
| "application"
| "shared-library"
@@ -88,7 +103,7 @@ 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: BinaryArchitecture[] = [];
const architectures: Exclude<BinaryArchitecture, "mips">[] = [];
const sliceEnds = new Map<BinaryArchitecture, bigint>();
for (let index = 0; index < count; index += 1) {
const entry = 8 + index * entrySize;
@@ -148,6 +163,20 @@ 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),
},
});
return ok({
format: "elf",
architecture,
@@ -239,7 +268,9 @@ const peManagedStatus = (
);
};
const machArchitecture = (cpu: number): BinaryArchitecture | undefined => {
const machArchitecture = (
cpu: number,
): Exclude<BinaryArchitecture, "mips"> | undefined => {
switch (cpu) {
case 7:
return "x86";
@@ -259,6 +290,8 @@ const elfArchitecture = (machine: number): BinaryArchitecture | undefined => {
return "x86";
case 62:
return "x86_64";
case 8: // EM_MIPS: ISA, ABI and byte order remain separate ELF facts.
return "mips";
case 40:
return "arm";
case 183:
@@ -267,7 +300,9 @@ const elfArchitecture = (machine: number): BinaryArchitecture | undefined => {
return undefined;
};
const peArchitecture = (machine: number): BinaryArchitecture | undefined => {
const peArchitecture = (
machine: number,
): Exclude<BinaryArchitecture, "mips"> | undefined => {
switch (machine) {
case 0x14c:
return "x86";
+47 -3
View File
@@ -1,5 +1,37 @@
/** Provider-neutral CPU families detected from supported executable headers. */
export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64";
export const BINARY_ARCHITECTURES = [
"x86",
"x86_64",
"arm",
"arm64",
"mips",
] as const;
export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number];
/** Raw ELF facts; family recognition does not establish ISA or ABI support. */
export interface MipsElfMetadata {
readonly elfClass: 32 | 64;
readonly byteOrder: "little" | "big";
readonly type: number;
readonly flags: number;
/** Undefined until inspected; null means neither typed table has a record. */
readonly abiFlags?: MipsAbiFlags | null;
}
/** Declared fields of the 24-byte MIPS ABI flags record, not inferred semantics. */
export interface MipsAbiFlags {
readonly version: number;
readonly isaLevel: number;
readonly isaRevision: number;
readonly gprSize: number;
readonly cpr1Size: number;
readonly cpr2Size: number;
readonly fpAbi: number;
readonly isaExtension: number;
readonly ases: number;
readonly flags1: number;
readonly flags2: number;
}
interface BinaryTargetIdentity {
readonly path: string;
@@ -18,8 +50,11 @@ type NonExecutableMetadata = {
type ExecutableTarget = BinaryTargetIdentity & {
readonly kind: "executable";
readonly architecture: BinaryArchitecture;
readonly availableArchitectures: readonly BinaryArchitecture[];
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
};
/**
@@ -27,6 +62,15 @@ type ExecutableTarget = BinaryTargetIdentity & {
* Each variant carries only metadata established for that exact file kind.
*/
export type BinaryTarget =
| (BinaryTargetIdentity & {
readonly kind: "executable";
readonly format: "elf";
readonly architecture: "mips";
readonly availableArchitectures: readonly "mips"[];
readonly mips: MipsElfMetadata;
readonly executableRole?: never;
readonly managed?: never;
})
| (ExecutableTarget & {
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly executableRole?: never;
+7 -3
View File
@@ -7,7 +7,11 @@ import {
analysisProfileSchema,
type AnalysisProfileCommitment,
} from "./analysisProfile.js";
import type { BinaryTarget } from "./binaryTargetTypes.js";
import {
BINARY_ARCHITECTURES,
type BinaryArchitecture,
type BinaryTarget,
} from "./binaryTargetTypes.js";
import {
jsonObjectSchema,
jsonValueSchema,
@@ -57,7 +61,7 @@ const subjectSchema = z.object({
"javascript-bundle",
"entitlements",
]),
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
local_path: z.string(),
});
/** Source location attached to an evidence observation. */
@@ -171,7 +175,7 @@ export interface EvidenceSubjectTarget {
readonly path: string;
readonly sha256: string;
readonly format: z.infer<typeof subjectSchema>["format"];
readonly architecture?: "x86" | "x86_64" | "arm" | "arm64";
readonly architecture?: BinaryArchitecture;
}
type EvidenceAuthority = z.infer<typeof evidenceAuthoritySchema>;
type ExecutionEnvironment = z.infer<typeof executionEnvironmentSchema>;
+20 -1
View File
@@ -4,11 +4,18 @@ import type {
} from "../application/AnalysisProvider.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
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,
ghidraMipsUnsupportedReason,
} from "./GhidraMipsProfile.js";
/** Resolve version-bound, deterministic semantics before Ghidra imports a target. */
export const resolveGhidraAnalysisProfile = (
@@ -25,6 +32,17 @@ export const resolveGhidraAnalysisProfile = (
return Promise.resolve(
err(new ProviderAdapterError(identity.id, "resolve_analysis_profile")),
);
const mipsReason = ghidraMipsUnsupportedReason(target);
if (mipsReason !== null)
return Promise.resolve(
err(
new AnalysisUnsupportedTargetError(
"resolve_analysis_profile",
target.path,
mipsReason,
),
),
);
const provider = { ...identity, version: installation.providerVersion };
const dosMz = target.format === "dos-mz";
const dosCom = target.format === "dos-com";
@@ -90,6 +108,7 @@ export const resolveGhidraAnalysisProfile = (
}
: {}),
analyzer_preset: "ghidra-default",
...ghidraMipsProfileParameters(target),
}),
}),
);
+13
View File
@@ -569,6 +569,19 @@ describe("Ghidra FAT native decompiler admission", () => {
describe("Ghidra native decompiler admission", () => {
it.each([
{
name: "a MIPS decompiler at the Linux x64 path",
options: {
environment: {},
installDir: INSTALL,
platform: "linux" as const,
architecture: "x64" as const,
},
executableHeader: (path: string) =>
path === LINUX_DECOMPILER
? { bytes: elf(1, 1, 8), size: 52 }
: undefined,
},
{
name: "a Linux x86-64 decompiler at the macOS arm64 path",
options: {
+3 -1
View File
@@ -116,7 +116,9 @@ const assessNativeDecompiler = (
const alternative = parseExecutableHeader(probe.bytes, node, probe.size);
if (
alternative.ok &&
!alternative.value.availableArchitectures.includes(expectedArchitecture)
!alternative.value.availableArchitectures.some(
(available) => available === expectedArchitecture,
)
) {
parsed = alternative;
break;
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { parseConfig } from "../config/parseConfig.js";
import type {
BinaryTarget,
MipsElfMetadata,
} 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 type { GhidraInstallationHost } from "./GhidraInstallation.js";
// 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 parsed = parseConfig({ GHIDRA_INSTALL_DIR: "/ghidra" });
if (!parsed.ok) throw parsed.error;
const config = parsed.value;
const target = (change: Partial<MipsElfMetadata> = {}): BinaryTarget => ({
path: "/fixture.elf",
sha256: "a".repeat(64),
kind: "executable",
format: "elf",
architecture: "mips",
availableArchitectures: ["mips"],
mips: {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x70001001,
abiFlags: {
version: 0,
isaLevel: 32,
isaRevision: 2,
gprSize: 1,
cpr1Size: 1,
cpr2Size: 0,
fpAbi: 5,
isaExtension: 0,
ases: 0,
flags1: 0,
flags2: 0,
},
...change,
},
});
describe("bounded Ghidra MIPS admission", () => {
it("binds endian and flags without widening Windows P0", async () => {
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
const little = target();
const big = target({ byteOrder: "big" });
expect(ghidra.inspectTargetSupport(little).status).toBe("supported");
expect(ghidra.inspectTargetSupport(big).status).toBe("supported");
const left = await ghidra.resolveAnalysisProfile(little);
const right = await ghidra.resolveAnalysisProfile(big);
if (!left.ok) throw left.error;
if (!right.ok) throw right.error;
expect(left.value.profile?.parameters.mips_elf).toEqual({
elf_class: 32,
byte_order: "little",
type: 2,
flags: 0x70001001,
abi_flags: {
version: 0,
isa_level: 32,
isa_revision: 2,
gpr_size: 1,
cpr1_size: 1,
cpr2_size: 0,
fp_abi: 5,
isa_extension: 0,
ases: 0,
flags1: 0,
flags2: 0,
},
});
expect(left.value.profile?.digest).not.toBe(right.value.profile?.digest);
const windows = new GhidraProvider(
config,
silentLogger,
{},
{
...host,
platform: "win32",
},
);
expect(windows.inspectTargetSupport(little).status).toBe("unsupported");
});
it.each([
[{ elfClass: 64 }, "ELF32"],
[{ type: 1 }, "ET_EXEC"],
[{ type: 3 }, "ET_EXEC"],
[{ type: 0xffa0 }, "ET_EXEC"],
[{ flags: 0x10a23001 }, "Machine-specific"],
[{ flags: 0x70001021 }, "o32"],
[{ flags: 0x70000001 }, "o32"],
[{ flags: 0x70002001 }, "o32"],
[{ flags: 0x70003001 }, "o32"],
[{ flags: 0x70004001 }, "o32"],
[{ flags: 0x72001001 }, "ASE"],
[{ flags: 0x74001001 }, "ASE"],
[{ flags: 0x70911001 }, "Machine-specific"],
[{ flags: 0x60001001 }, "32R2"],
[{ flags: 0x70001201 }, "EF_MIPS_FP64"],
[{ flags: 0x70001401 }, "EF_MIPS_NAN2008"],
[{ abiFlags: null }, "inspected ABI"],
] satisfies [Partial<MipsElfMetadata>, string][])(
"refuses an unverified MIPS interpretation %j",
async (change, reason) => {
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
const value = target(change);
expect(ghidra.inspectTargetSupport(value)).toMatchObject({
status: "unsupported",
reason: expect.stringContaining(reason),
});
const resolved = await ghidra.resolveAnalysisProfile(value);
expect(resolved.ok).toBe(false);
},
);
it("keeps MIPS out of unverified Hopper and IDA adapter lanes", () => {
const hopper = new HopperProvider(config, silentLogger, {}, "linux");
expect(hopper.inspectTargetSupport(target()).status).toBe("unsupported");
expect(hopperLoaderArgsForTarget(target()).ok).toBe(false);
const ida = new IdaProvider(config, {});
expect(ida.inspectTargetSupport(target()).status).toBe("unsupported");
});
});
+86
View File
@@ -0,0 +1,86 @@
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
/** Keep family recognition separate from the deliberately bounded Ghidra lane. */
export const ghidraMipsUnsupportedReason = (
target: BinaryTarget,
): string | null => {
if (target.kind !== "executable" || target.architecture !== "mips")
return null;
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.";
if (metadata.elfClass !== 32)
return "This Ghidra MIPS lane supports ELF32 only; MIPS64 and n64 remain unsupported.";
if (metadata.type !== 2)
return "This Ghidra MIPS lane supports ET_EXEC only; relocatable objects, shared images and PSP PRX need separate load verification.";
if ((metadata.flags & 0x0f000000) !== 0)
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.";
if ((metadata.flags & 0xf0000000) !== 0x70000000)
return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2; other ISA revisions need separate verification.";
if (
(metadata.flags & 0x0000f000) !== 0x00001000 ||
(metadata.flags & 0x20) !== 0
)
return "This Ghidra MIPS lane requires an explicit o32 ABI; unspecified, n32 and EABI targets remain unsupported.";
if ((metadata.flags & 0x00000200) !== 0)
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.";
const abi = metadata.abiFlags;
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.";
if (abi.version !== 0)
return "This Ghidra MIPS lane requires ABI flags version 0; newer record semantics are unsupported.";
if (abi.isaLevel !== 32 || abi.isaRevision !== 2)
return "MIPS ABI flags must agree with the declared MIPS32r2 ISA.";
if (abi.gprSize !== 1 || abi.cpr1Size !== 1 || abi.cpr2Size !== 0)
return "This Ghidra MIPS lane requires 32-bit GPR/CPR1 and no CPR2; other register modes need separate verification.";
if (abi.fpAbi !== 1 && abi.fpAbi !== 5)
return "This Ghidra MIPS lane admits double-precision or FPXX ABI declarations only; soft-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.flags1 & ~1) !== 0 || abi.flags2 !== 0)
return "MIPS ABI flags contain unsupported general flags.";
return null;
};
/** Commit source ELF interpretation without confusing target ISA with host CPU. */
export const ghidraMipsProfileParameters = (
target: BinaryTarget,
): Readonly<Record<string, JsonValue>> => {
if (
target.kind !== "executable" ||
target.architecture !== "mips" ||
target.mips === undefined
)
return {};
const abi = target.mips.abiFlags;
return {
mips_elf: {
elf_class: target.mips.elfClass,
byte_order: target.mips.byteOrder,
type: target.mips.type,
flags: target.mips.flags,
abi_flags:
abi === undefined || abi === null
? null
: {
version: abi.version,
isa_level: abi.isaLevel,
isa_revision: abi.isaRevision,
gpr_size: abi.gprSize,
cpr1_size: abi.cpr1Size,
cpr2_size: abi.cpr2Size,
fp_abi: abi.fpAbi,
isa_extension: abi.isaExtension,
ases: abi.ases,
flags1: abi.flags1,
flags2: abi.flags2,
},
},
mips_support_lane: "elf32-exec-o32-arch32r2-standard-v3",
};
};
+2 -2
View File
@@ -752,7 +752,7 @@ const sessionInfo = () => ({
const executableTarget = (
format: "mach-o" | "elf" | "pe",
architecture: NonNullable<BinaryTarget["architecture"]>,
architecture: Extract<BinaryTarget, { format: "pe" }>["architecture"],
): BinaryTarget =>
format === "pe"
? peTarget(architecture)
@@ -766,7 +766,7 @@ const executableTarget = (
};
const peTarget = (
architecture: NonNullable<BinaryTarget["architecture"]>,
architecture: Extract<BinaryTarget, { format: "pe" }>["architecture"],
): Extract<BinaryTarget, { format: "pe" }> => ({
path: "/tmp/fixture",
sha256: "a".repeat(64),
+17 -2
View File
@@ -30,6 +30,7 @@ import {
type GhidraInstallationInspection,
} from "./GhidraInstallation.js";
import { resolveGhidraAnalysisProfile } from "./GhidraAnalysisProfile.js";
import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js";
import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js";
import {
CAPABILITIES,
@@ -47,7 +48,13 @@ import {
windowsNativeCapabilities,
} from "../process/WindowsAuthority.js";
const SUPPORTED_ARCHITECTURES = new Set(["x86", "x86_64", "arm", "arm64"]);
const SUPPORTED_ARCHITECTURES = new Set([
"x86",
"x86_64",
"arm",
"arm64",
"mips",
]);
/** Ghidra candidate backed by an isolated ephemeral headless import. */
export class GhidraProvider implements AnalysisProviderCandidate {
@@ -135,6 +142,14 @@ export class GhidraProvider implements AnalysisProviderCandidate {
};
if (hostPlatform === "win32")
return inspectWindowsP0TargetSupport(target, diagnostics);
const mipsReason = ghidraMipsUnsupportedReason(target);
if (mipsReason !== null)
return {
status: "unsupported",
code: "architecture_unsupported",
reason: mipsReason,
diagnostics,
};
if (target.format === "mach-o" && target.availableArchitectures.length > 1)
return {
status: "unsupported",
@@ -148,7 +163,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
status: "unsupported",
code: "architecture_unsupported",
reason:
"Ghidra v1 requires a concrete x86, x86_64, arm, or arm64 target architecture.",
"Ghidra v1 requires a supported x86, x86_64, arm, arm64, or MIPS target profile.",
diagnostics,
};
return {
+11 -1
View File
@@ -110,6 +110,14 @@ export const hopperLoaderArgsForTarget = (
if (target.kind !== "executable")
return err(new ProviderAdapterError("hopper", "resolve_analysis_profile"));
const architecture = target.architecture;
if (architecture === "mips")
return err(
new AnalysisCapabilityUnavailableError(
"hopper",
"resolve_analysis_profile",
"REA's Hopper adapter does not admit MIPS targets. Select a verified Ghidra MIPS profile instead.",
),
);
const flag = hopperArchitectureFlag(architecture);
switch (target.format) {
case "mach-o":
@@ -138,7 +146,9 @@ export const hopperLoaderArgsForTarget = (
}
};
const hopperArchitectureFlag = (architecture: BinaryArchitecture): string => {
const hopperArchitectureFlag = (
architecture: Exclude<BinaryArchitecture, "mips">,
): string => {
switch (architecture) {
case "x86":
return "--intel-32";
+22
View File
@@ -35,3 +35,25 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
}
},
);
it("rejects an ELF target before attempting FAT64 source preparation", async () => {
const directory = await createTestTempDirectory("rea-hopper-mips-boundary-");
// No file is created: format validation must precede any filesystem lookup.
const path = join(directory, "mips.elf");
const result = await resolveHopperMachOImage({
path,
sha256: "0".repeat(64),
kind: "executable",
format: "elf",
architecture: "mips",
availableArchitectures: ["mips"],
mips: { elfClass: 32, byteOrder: "little", type: 2, flags: 0x70001001 },
});
if (result.ok)
throw new Error("An ELF target entered Mach-O image preparation");
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
path,
reason: "Hopper FAT64 image preparation requires a Mach-O target",
});
});
+2
View File
@@ -44,6 +44,8 @@ export const resolveHopperMachOImage = async (
),
);
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
if (target.format !== "mach-o")
return invalid("Hopper FAT64 image preparation requires a Mach-O target");
let file: FileHandle;
try {
file = await open(target.path, "r");
+7
View File
@@ -116,6 +116,13 @@ export class HopperProvider implements AnalysisProviderCandidate {
reason: null,
diagnostics,
};
if (target.architecture === "mips")
return {
status: "unsupported",
code: "architecture_unsupported",
reason: "REA's Hopper adapter does not admit MIPS targets.",
diagnostics,
};
if (target.format === "dos-mz" || target.format === "dos-com")
return {
status: "unsupported",
+8
View File
@@ -101,6 +101,14 @@ export class IdaProvider implements AnalysisProviderCandidate {
target_kind: target.kind,
target_format: target.format,
};
if (target.kind === "executable" && target.architecture === "mips")
return {
status: "unsupported",
code: "architecture_unsupported",
reason:
"REA's IDA adapter has no verified MIPS lane. Select a supported Ghidra profile instead.",
diagnostics,
};
return target.kind === "executable"
? { status: "supported", code: null, reason: null, diagnostics }
: {
@@ -0,0 +1,309 @@
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 { 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 {
ghidraMipsProfileParameters,
ghidraMipsUnsupportedReason,
} from "../../../src/ghidra/GhidraMipsProfile.js";
const metadata = (little = true): MipsElfMetadata => ({
elfClass: 32,
byteOrder: little ? "little" : "big",
type: 2,
flags: 0x70001001,
});
const abiOffset = 8192; // Deliberately outside BinaryTargetResolver's 4 KiB probe.
const fixture = (little = true): Buffer => {
const bytes = Buffer.alloc(abiOffset + 48);
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(2, 16);
u16(8, 18);
u32(1, 20);
u32(52, 28);
u32(128, 32);
u32(0x70001001, 36);
u16(52, 40);
u16(32, 42);
u16(1, 44);
u16(40, 46);
u16(2, 48);
u32(0x70000003, 52);
u32(abiOffset, 56);
u32(24, 68);
u32(24, 72);
u32(0x7000002a, 172);
u32(abiOffset, 184);
u32(24, 188);
bytes.set([0, 0, 32, 2, 1, 1, 0, 5], abiOffset);
return bytes;
};
const inspect = async (
bytes: Buffer,
little = true,
checkCancelled: () => void = () => {},
) => {
const directory = await createTestTempDirectory("rea-mips-abi-test-");
const path = join(directory, "fixture.elf");
await writeFile(path, bytes);
const handle = await open(path, "r");
try {
return await readMipsElfAbiFlags(handle, metadata(little), checkCancelled);
} finally {
await handle.close();
}
};
const target = (mips: MipsElfMetadata) => ({
path: "/fixture.elf",
sha256: "a".repeat(64),
kind: "executable" as const,
format: "elf" as const,
architecture: "mips" as const,
availableArchitectures: ["mips"] as const,
mips,
});
for (const little of [true, false]) {
it(`reads a remote ABI record through both ELF tables (${little ? "LE" : "BE"})`, async () => {
const bytes = fixture(little);
const result = await inspect(bytes, little);
assert.ok(result.ok);
assert.deepEqual(result.value, {
version: 0,
isaLevel: 32,
isaRevision: 2,
gprSize: 1,
cpr1Size: 1,
cpr2Size: 0,
fpAbi: 5,
isaExtension: 0,
ases: 0,
flags1: 0,
flags2: 0,
});
const resolved = target({ ...metadata(little), abiFlags: result.value });
assert.equal(ghidraMipsUnsupportedReason(resolved), null);
const before = ghidraMipsProfileParameters(resolved);
bytes[abiOffset + 5] = 2;
bytes[abiOffset + 7] = 6;
const fp64 = await inspect(bytes, little);
assert.ok(fp64.ok);
const changed = target({ ...metadata(little), abiFlags: fp64.value });
assert.match(ghidraMipsUnsupportedReason(changed) ?? "", /register modes/u);
assert.notDeepEqual(ghidraMipsProfileParameters(changed), before);
});
}
it("supports stripped section tables using PT_MIPS_ABIFLAGS", async () => {
const bytes = fixture();
bytes.writeUInt32LE(0, 32);
bytes.writeUInt16LE(0, 48);
const result = await inspect(bytes);
assert.ok(result.ok);
assert.equal(result.value?.fpAbi, 5);
});
it("supports a section-only ABI record without assuming its name", async () => {
const bytes = fixture();
bytes.writeUInt32LE(0, 28);
bytes.writeUInt16LE(0, 44);
const result = await inspect(bytes);
assert.ok(result.ok);
assert.equal(result.value?.fpAbi, 5);
});
it("keeps absent ABI declarations unknown and refuses provider admission", async () => {
const bytes = fixture();
bytes.writeUInt32LE(0, 52);
bytes.writeUInt32LE(0, 172);
const result = await inspect(bytes);
assert.ok(result.ok);
assert.equal(result.value, null);
assert.match(
ghidraMipsUnsupportedReason(target({ ...metadata(), abiFlags: null })) ??
"",
/inspected ABI/u,
);
assert.match(
ghidraMipsUnsupportedReason(target(metadata())) ?? "",
/inspected ABI/u,
);
});
it("rejects contradictory program and section ABI declarations", async () => {
const bytes = fixture();
bytes.copy(bytes, abiOffset + 24, abiOffset, abiOffset + 24);
bytes[abiOffset + 24 + 7] = 6;
bytes.writeUInt32LE(abiOffset + 24, 184);
const result = await inspect(bytes);
assert.ok(!result.ok);
assert.match(result.error, /conflicting/u);
});
it("rejects duplicate ABI declarations in one table", async () => {
const bytes = fixture();
bytes.copy(bytes, 84, 52, 84);
bytes.writeUInt16LE(2, 44);
const result = await inspect(bytes);
assert.ok(!result.ok);
assert.match(result.error, /duplicate/u);
});
const malformed: [string, (bytes: Buffer) => void, RegExp][] = [
["record outside file", (b) => b.writeUInt32LE(0xfffffff0, 56), /outside/u],
["table outside file", (b) => b.writeUInt32LE(0xfffffff0, 28), /outside/u],
["short record", (b) => b.writeUInt32LE(23, 68), /record size/u],
["oversized record", (b) => b.writeUInt32LE(0xffffffff, 68), /record size/u],
["invalid table stride", (b) => b.writeUInt16LE(31, 42), /entry size/u],
["extended program count", (b) => b.writeUInt16LE(0xffff, 44), /numbering/u],
["extended section count", (b) => b.writeUInt16LE(0, 48), /extended/u],
["changed identity", (b) => b.writeUInt32LE(0x60001001, 36), /identity/u],
["invalid ELF version", (b) => b.writeUInt32LE(0, 20), /version/u],
];
for (const [name, change, reason] of malformed) {
it(`rejects ${name} instead of using a guessed ABI`, async () => {
const bytes = fixture();
change(bytes);
const result = await inspect(bytes);
assert.ok(!result.ok);
assert.match(result.error, reason);
});
}
const unsupported: [string, (bytes: Buffer) => void, RegExp][] = [
["new record version", (b) => b.writeUInt16LE(1, abiOffset), /version 0/u],
[
"conflicting ISA",
(b) => {
b[abiOffset + 3] = 6;
},
/MIPS32r2/u,
],
[
"soft-float",
(b) => {
b[abiOffset + 5] = 0;
b[abiOffset + 7] = 3;
},
/register modes/u,
],
[
"unspecified FP ABI",
(b) => {
b[abiOffset + 7] = 0;
},
/unspecified/u,
],
["ISA extension", (b) => b.writeUInt32LE(1, abiOffset + 8), /extension/u],
["ASE", (b) => b.writeUInt32LE(0x800, abiOffset + 12), /ASE/u],
[
"reserved flags",
(b) => b.writeUInt32LE(2, abiOffset + 16),
/general flags/u,
],
];
for (const [name, change, reason] of unsupported) {
it(`retains ${name} while rejecting its unverified provider interpretation`, async () => {
const bytes = fixture();
change(bytes);
const result = await inspect(bytes);
assert.ok(result.ok);
assert.ok(result.value !== null);
assert.match(
ghidraMipsUnsupportedReason(
target({ ...metadata(), abiFlags: result.value }),
) ?? "",
reason,
);
});
}
it("preserves the caller's cancellation error during table inspection", async () => {
const cancelled = new Error("caller cancellation sentinel");
let checks = 0;
await assert.rejects(
inspect(fixture(), true, () => {
checks += 1;
if (checks === 12) throw cancelled;
}),
(error) => error === cancelled,
);
});
for (const little of [true, false]) {
it(`resolves full-file MIPS ABI identity before provider selection (${little ? "LE" : "BE"})`, async () => {
const directory = await createTestTempDirectory("rea-mips-resolution-");
const path = join(directory, "fixture.elf");
const bytes = fixture(little);
await writeFile(path, bytes);
const result = await parseBinaryTarget(path);
if (!result.ok) throw result.error;
assert.equal(result.value.architecture, "mips");
assert.equal(result.value.format, "elf");
assert.equal(
result.value.sha256,
createHash("sha256").update(bytes).digest("hex"),
);
if (
result.value.kind !== "executable" ||
result.value.architecture !== "mips"
)
throw new Error("Expected a resolved MIPS executable");
assert.equal(result.value.mips.byteOrder, little ? "little" : "big");
assert.equal(result.value.mips.abiFlags?.fpAbi, 5);
assert.equal(ghidraMipsUnsupportedReason(result.value), null);
assert.deepEqual(await readFile(path), bytes);
});
}
it("keeps missing ABI data unknown through the public target resolver", async () => {
const directory = await createTestTempDirectory("rea-mips-resolution-");
const path = join(directory, "fixture.elf");
const bytes = fixture();
bytes.writeUInt32LE(0, 52);
bytes.writeUInt32LE(0, 172);
await writeFile(path, bytes);
const result = await parseBinaryTarget(path);
if (!result.ok) throw result.error;
if (
result.value.kind !== "executable" ||
result.value.architecture !== "mips"
)
throw new Error("Expected a resolved MIPS executable");
assert.equal(result.value.mips.abiFlags, null);
assert.match(
ghidraMipsUnsupportedReason(result.value) ?? "",
/inspected ABI/u,
);
});
it("projects a malformed out-of-probe ABI record as a target error", async () => {
const directory = await createTestTempDirectory("rea-mips-resolution-");
const path = join(directory, "fixture.elf");
const bytes = fixture();
bytes.writeUInt32LE(0xfffffff0, 56);
await writeFile(path, bytes);
const result = await parseBinaryTarget(path);
if (result.ok) throw new Error("Malformed ABI extent was accepted");
assert.equal(result.error._tag, "BinaryTargetError");
assert.match(result.error.message, /ABI record lies outside the file/u);
assert.deepEqual(await readFile(path), bytes);
});
it("preserves cancellation instead of turning it into malformed target data", async () => {
const directory = await createTestTempDirectory("rea-mips-resolution-");
const path = join(directory, "fixture.elf");
await writeFile(path, fixture());
const controller = new AbortController();
controller.abort();
const result = await parseBinaryTarget(path, { signal: controller.signal });
if (result.ok) throw new Error("Cancelled target resolution succeeded");
assert.ok(result.error instanceof AnalysisCancelledError);
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import {
createAnalysisSnapshotEntry,
parseAnalysisSnapshot,
serializeAnalysisSnapshot,
snapshotBinding,
snapshotTarget,
snapshotMatchesProfile,
} from "../../../src/domain/analysisSnapshot.js";
import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js";
const target: BinaryTarget = {
path: "/source-owned.elf",
sha256: "a".repeat(64),
kind: "executable",
format: "elf",
architecture: "mips",
availableArchitectures: ["mips"],
mips: { elfClass: 32, byteOrder: "little", type: 2, flags: 0x70001001 },
};
const provider = { id: "ghidra", name: "Ghidra", version: "12.1.4" };
const profile = createAnalysisProfile(provider, {
mips_elf: { elf_class: 32, byte_order: "little", type: 2, flags: 0x70001001 },
});
describe("MIPS identity through lifecycle, Evidence and snapshot consumers", () => {
it("round-trips query bindings without relaxing integrity", () => {
const result = [{ address: "0x20150", value: "rea_mips_entry" }];
const parameters = {};
const evidence = createEvidence(target, provider, {
operation: "list_procedures",
parameters,
result,
analysisProfile: profile,
});
const binding = snapshotBinding(profile);
const entry = createAnalysisSnapshotEntry({
target: snapshotTarget(target),
binding,
operation: "list_procedures",
parameters,
execution: {
result,
rawResult: null,
provider,
analysisProfile: profile,
limitations: [],
locations: [],
subject: target,
},
});
const snapshot = {
target: snapshotTarget(target),
binding,
entries: [entry],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([evidence]),
};
const restored = parseAnalysisSnapshot(
JSON.parse(serializeAnalysisSnapshot(snapshot)),
);
expect(restored.target.architecture).toBe("mips");
expect(restored.evidence_bundle.records[0]?.subject?.architecture).toBe(
"mips",
);
expect(restored.entries[0]?.execution.subject?.architecture).toBe("mips");
expect(
sessionOutputSchemas.open_binary.parse({ result: target }).result
.architecture,
).toBe("mips");
expect(() =>
parseEvidence({
...evidence,
subject: { ...evidence.subject, architecture: "x86" },
}),
).toThrow("semantic identifier");
const changed = createAnalysisProfile(provider, {
mips_elf: {
elf_class: 32,
byte_order: "big",
type: 2,
flags: 0x70001001,
},
});
expect(snapshotMatchesProfile(binding, changed)).toBe(false);
});
});
+14
View File
@@ -0,0 +1,14 @@
/* Freestanding, source-owned static-analysis fixture. Never executed by REA. */
volatile unsigned int rea_mips_global = 7;
const char rea_mips_marker[] = "rea-mips-source-owned-fixture";
__attribute__((noinline)) unsigned int rea_mips_leaf(unsigned int value)
{
return value * 3u + rea_mips_global;
}
__attribute__((noinline)) unsigned int rea_mips_entry(unsigned int selector)
{
unsigned int adjusted = selector > 10u ? selector : selector + 1u;
return rea_mips_leaf(adjusted) + (unsigned char)rea_mips_marker[0];
}
@@ -0,0 +1,35 @@
import type { MipsElfMetadata } from "../../../src/domain/binaryTargetTypes.js";
export interface MipsReadelfFacts {
mips: MipsElfMetadata;
symbols: Record<
| "rea_mips_entry"
| "rea_mips_leaf"
| "rea_mips_probe"
| "rea_mips_global"
| "rea_mips_marker",
string
>;
}
export function parseMipsReadelf(
text: string,
byteOrder: string,
): MipsReadelfFacts;
export function inspectMipsReadelf(
path: string,
byteOrder: string,
): Promise<
MipsReadelfFacts & { command: string; version: string; raw: string }
>;
export function assertMipsLoadedImage(
observations: unknown,
byteOrder: string,
sha256: string,
): void;
export function assertMipsProbe(
move: unknown,
branch: unknown,
byteOrder: string,
address: string,
): void;
export function assertMipsGlobal(bytes: string, byteOrder: string): void;
+180
View File
@@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execute = promisify(execFile);
const language = (byteOrder) => {
assert.ok(byteOrder === "little" || byteOrder === "big");
return `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`;
};
const wordHex = (value, byteOrder) => {
language(byteOrder);
const bytes = Buffer.alloc(4);
if (byteOrder === "little") bytes.writeUInt32LE(value);
else bytes.writeUInt32BE(value);
return bytes.toString("hex");
};
/** Parse only the documented GNU readelf fields needed by our tiny fixture. */
export function parseMipsReadelf(text, byteOrder) {
language(byteOrder);
const field = (expression, label) => {
const matches = [...text.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"), "ELF32");
assert.equal(
field(/^\s*Data:.*?,\s*(little|big) endian\s*$/gmu, "byte order"),
byteOrder,
);
assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u);
assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^MIPS\b/u);
const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+),.*$/gmu, "flags"));
assert.equal(flags, 0x70001001, "Fixture ELF flags differ from build intent");
assert.equal(field(/^ISA:\s*(.+)$/gmu, "ISA"), "MIPS32r2");
const registerSize = (name) => {
const bits = Number(
field(new RegExp(`^${name} size:\\s*(\\d+)\\s*$`, "gmu"), name),
);
assert.ok(bits === 0 || bits === 32, `Unexpected ${name} width`);
return bits === 32 ? 1 : 0;
};
const fp = field(/^FP ABI:\s*(.+)$/gmu, "FP ABI");
const fpAbi = new Map([
["Hard float (double precision)", 1],
["Hard float (32-bit CPU, Any FPU)", 5],
]).get(fp);
assert.ok(fpAbi !== undefined, `Unrecognized fixture FP ABI: ${fp}`);
assert.equal(field(/^ISA Extension:\s*(.+)$/gmu, "ISA extension"), "None");
assert.equal(field(/^ASEs:\s*\n[ \t]*(.+)$/gmu, "ASEs"), "None");
const abiFlags = {
version: Number(
field(/^MIPS ABI Flags Version:\s*(\d+)\s*$/gmu, "ABI version"),
),
isaLevel: 32,
isaRevision: 2,
gprSize: registerSize("GPR"),
cpr1Size: registerSize("CPR1"),
cpr2Size: registerSize("CPR2"),
fpAbi,
isaExtension: 0,
ases: 0,
flags1: Number.parseInt(
field(/^FLAGS 1:\s*([\da-f]+)\s*$/gmu, "flags1"),
16,
),
flags2: Number.parseInt(
field(/^FLAGS 2:\s*([\da-f]+)\s*$/gmu, "flags2"),
16,
),
};
const symbols = {};
for (const name of [
"rea_mips_entry",
"rea_mips_leaf",
"rea_mips_probe",
"rea_mips_global",
"rea_mips_marker",
]) {
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",
),
`symbol ${name}`,
);
symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`;
}
return {
mips: { elfClass: 32, byteOrder, type: 2, flags, abiFlags },
symbols,
};
}
/** Independent reader is a bounded test prerequisite, never an REA provider. */
export async function inspectMipsReadelf(path, byteOrder) {
const command = process.env.REA_MIPS_READELF ?? "readelf";
const options = {
env: { ...process.env, LC_ALL: "C" },
timeout: 10000,
maxBuffer: 1024 * 1024,
};
const version = await execute(command, ["--version"], options);
assert.match(
version.stdout,
/^GNU readelf\b/u,
"This lane requires GNU readelf",
);
const result = await execute(
command,
["-h", "-A", "-s", "-W", path],
options,
);
assert.equal(
result.stderr.trim(),
"",
`readelf diagnostics: ${result.stderr}`,
);
return {
...parseMipsReadelf(result.stdout, byteOrder),
command,
version: version.stdout.split("\n")[0],
raw: result.stdout,
};
}
/** Check effective Ghidra observations, not the requested analysis profile. */
export function assertMipsLoadedImage(observations, byteOrder, sha256) {
assert.equal(observations.language_id, language(byteOrder));
assert.equal(observations.compiler_spec_id, "default");
assert.ok(
observations.source_files.some(
(file) =>
file.original_sha256 === sha256 && file.modified_sha256 === sha256,
),
"Ghidra loaded source identity changed",
);
}
/** Verify the exact constant/conditional destination in mips-probe.S. */
export function assertMipsProbe(move, branch, byteOrder, address) {
const start = BigInt(address);
for (const [instruction, offset, word] of [
[move, 0n, 0x24821234],
[branch, 4n, 0x10800003],
]) {
assert.equal(instruction.status, "decoded");
assert.equal(BigInt(instruction.address), start + offset);
assert.equal(instruction.architecture, language(byteOrder));
assert.equal(instruction.length, 4);
assert.equal(instruction.bytes, wordHex(word, byteOrder));
}
assert.match(move.mnemonic, /^ADDIU$/iu);
assert.ok(
move.operands.some(
(operand) =>
operand.index === 2 &&
operand.components.some(
(part) =>
part.kind === "immediate" &&
part.value !== null &&
BigInt(part.value) === 0x1234n,
),
),
"ADDIU immediate was not recovered",
);
assert.match(branch.mnemonic, /^BEQZ?$/iu);
assert.equal(branch.flow.kind, "jump");
assert.equal(branch.flow.conditional, true);
assert.equal(branch.flow.computed, false);
assert.deepEqual(
branch.flow.direct_destinations.map((value) => BigInt(value)),
[start + 20n],
);
}
/** Four bytes must encode the known source-owned global in the declared order. */
export function assertMipsGlobal(bytes, byteOrder) {
assert.equal(bytes, wordHex(7, byteOrder));
}
@@ -0,0 +1,229 @@
import { describe, expect, it } from "vitest";
import {
assertMipsGlobal,
assertMipsLoadedImage,
assertMipsProbe,
parseMipsReadelf,
} from "./mips-checks.mjs";
// GNU readelf 2.40 output for the source-owned Clang 17 / LLD fixture.
// This tests the reader boundary, not Ghidra; the real lane rebuilds both orders.
const readelf = `ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: MIPS R3000
Version: 0x1
Entry point address: 0x201b0
Start of program headers: 52 (bytes into file)
Start of section headers: 2996 (bytes into file)
Flags: 0x70001001, noreorder, o32, mips32r2
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 7
Size of section headers: 40 (bytes)
Number of section headers: 24
Section header string table index: 22
Symbol table '.symtab' contains 9 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 FILE LOCAL DEFAULT ABS mips.c
2: 00020254 0 NOTYPE LOCAL DEFAULT 4 .Lzero
3: 00038250 0 NOTYPE LOCAL HIDDEN 6 _gp
4: 00020170 64 FUNC GLOBAL DEFAULT 4 rea_mips_leaf
5: 00030268 4 OBJECT GLOBAL DEFAULT 7 rea_mips_global
6: 000201b0 136 FUNC GLOBAL DEFAULT 4 rea_mips_entry
7: 00010148 30 OBJECT GLOBAL DEFAULT 3 rea_mips_marker
8: 00020240 32 FUNC GLOBAL DEFAULT 4 rea_mips_probe
MIPS ABI Flags Version: 0
ISA: MIPS32r2
GPR size: 32
CPR1 size: 32
CPR2 size: 0
FP ABI: Hard float (32-bit CPU, Any FPU)
ISA Extension: None
ASEs:
None
FLAGS 1: 00000000
FLAGS 2: 00000000
`;
const sha256 = "a".repeat(64);
const image = {
language_id: "MIPS:LE:32:default",
compiler_spec_id: "default",
source_files: [{ original_sha256: sha256, modified_sha256: sha256 }],
};
const move = {
address: "0x20240",
status: "decoded",
architecture: "MIPS:LE:32:default",
length: 4,
bytes: "34128224",
mnemonic: "addiu",
operands: [
{ index: 2, components: [{ kind: "immediate", value: "0x1234" }] },
],
};
const branch = {
address: "0x20244",
status: "decoded",
architecture: "MIPS:LE:32:default",
length: 4,
bytes: "03008010",
mnemonic: "beqz",
flow: {
kind: "jump",
conditional: true,
computed: false,
direct_destinations: ["0x20254"],
},
};
describe("MIPS real-verifier independent expectations", () => {
it("reads GNU header, ABI and defined symbols without a Ghidra-derived oracle", () => {
const parsed = parseMipsReadelf(readelf, "little");
expect(parsed.mips).toMatchObject({
elfClass: 32,
byteOrder: "little",
flags: 0x70001001,
abiFlags: {
isaLevel: 32,
isaRevision: 2,
gprSize: 1,
cpr1Size: 1,
fpAbi: 5,
},
});
expect(parsed.symbols.rea_mips_probe).toBe("0x20240");
expect(
parseMipsReadelf(readelf.replace("little endian", "big endian"), "big")
.mips.byteOrder,
).toBe("big");
});
it.each([
["wrong class", "ELF32", "ELF64"],
["wrong byte order", "little endian", "big endian"],
["wrong machine", "MIPS R3000", "AArch64"],
["wrong object type", "EXEC (Executable file)", "DYN (Shared object file)"],
["wrong flags", "0x70001001", "0x60001001"],
["wrong ISA", "ISA: MIPS32r2", "ISA: MIPS32r6"],
["unknown FP ABI", "Hard float (32-bit CPU, Any FPU)", "unknown"],
["unknown register width", "GPR size: 32", "GPR size: 64"],
["missing symbol", "rea_mips_probe", "unrelated_probe"],
["missing ABI", "MIPS ABI Flags Version: 0", "ABI omitted"],
])(
"rejects %s instead of returning a guessed fixture interpretation",
(_name, from, to) => {
expect(() =>
parseMipsReadelf(readelf.replace(from, to), "little"),
).toThrow();
},
);
it("rejects ambiguous producer fields", () => {
expect(() =>
parseMipsReadelf(`${readelf}\nClass: ELF32\n`, "little"),
).toThrow(/ambiguous/u);
});
it("checks the effective loaded image, not requested profile metadata", () => {
assertMipsLoadedImage(image, "little", sha256);
expect(() =>
assertMipsLoadedImage(
{ ...image, language_id: "MIPS:BE:32:default" },
"little",
sha256,
),
).toThrow();
expect(() =>
assertMipsLoadedImage(
{ ...image, compiler_spec_id: "other" },
"little",
sha256,
),
).toThrow();
expect(() =>
assertMipsLoadedImage(
{
...image,
source_files: [
{ original_sha256: sha256, modified_sha256: "b".repeat(64) },
],
},
"little",
sha256,
),
).toThrow();
});
it("checks exact probe bytes, immediate and conditional destination in both orders", () => {
assertMipsProbe(move, branch, "little", "0x20240");
assertMipsProbe(
{ ...move, architecture: "MIPS:BE:32:default", bytes: "24821234" },
{ ...branch, architecture: "MIPS:BE:32:default", bytes: "10800003" },
"big",
"0x20240",
);
});
it("rejects plausible nonempty but incorrect instruction observations", () => {
expect(() =>
assertMipsProbe(
{ ...move, bytes: "35128224" },
branch,
"little",
"0x20240",
),
).toThrow();
expect(() =>
assertMipsProbe({ ...move, length: 2 }, branch, "little", "0x20240"),
).toThrow();
expect(() =>
assertMipsProbe(
{
...move,
operands: [
{ index: 2, components: [{ kind: "immediate", value: "0x1235" }] },
],
},
branch,
"little",
"0x20240",
),
).toThrow();
expect(() =>
assertMipsProbe(
move,
{
...branch,
flow: { ...branch.flow, direct_destinations: ["0x20250"] },
},
"little",
"0x20240",
),
).toThrow();
expect(() =>
assertMipsProbe(
move,
{ ...branch, flow: { ...branch.flow, conditional: false } },
"little",
"0x20240",
),
).toThrow();
});
it("checks all four global bytes in the declared order", () => {
assertMipsGlobal("07000000", "little");
assertMipsGlobal("00000007", "big");
expect(() => assertMipsGlobal("00000007", "little")).toThrow();
expect(() => assertMipsGlobal("0700", "little")).toThrow();
});
});
+74
View File
@@ -0,0 +1,74 @@
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 a source-owned freestanding ELF; never execute its target code. */
export async function buildMipsFixture(directory, byteOrder) {
if (byteOrder !== "little" && byteOrder !== "big")
throw new Error("MIPS fixture byte order must be little or big");
const compiler = process.env.REA_MIPS_CLANG ?? "clang";
const target = byteOrder === "little" ? "mipsel-linux-gnu" : "mips-linux-gnu";
const source = fileURLToPath(new URL("../c/mips.c", import.meta.url));
const probe = fileURLToPath(new URL("./mips-probe.S", import.meta.url));
const path = join(directory, `${target}.elf`);
const args = [
`--target=${target}`,
"-march=mips32r2",
"-mabi=32",
"-mno-abicalls",
"-fno-pic",
"-fuse-ld=lld",
"-nostdlib",
"-static",
"-O0",
"-g",
"-Wl,-e,rea_mips_entry",
"-Wl,--build-id=none",
source,
probe,
"-o",
path,
];
const version = await execute(compiler, ["--version"], { timeout: 10000 });
await execute(compiler, args, { timeout: 60000 });
const bytes = await readFile(path);
const read16 =
byteOrder === "little"
? bytes.readUInt16LE.bind(bytes)
: bytes.readUInt16BE.bind(bytes);
const read32 =
byteOrder === "little"
? bytes.readUInt32LE.bind(bytes)
: bytes.readUInt32BE.bind(bytes);
if (
bytes.length < 52 ||
bytes.toString("hex", 0, 4) !== "7f454c46" ||
bytes[4] !== 1 ||
bytes[5] !== (byteOrder === "little" ? 1 : 2) ||
read16(16) !== 2 ||
read16(18) !== 8 ||
read32(36) !== 0x70001001
)
throw new Error(
"Compiler did not produce the declared ELF32/o32/MIPS32r2 fixture",
);
return {
path,
byte_order: byteOrder,
flags: read32(36),
sha256: createHash("sha256").update(bytes).digest("hex"),
source_sha256: createHash("sha256")
.update(await readFile(source))
.digest("hex"),
probe_sha256: createHash("sha256")
.update(await readFile(probe))
.digest("hex"),
compiler: version.stdout.trim(),
arguments: args,
};
}
+24
View File
@@ -0,0 +1,24 @@
/* Source-owned standard MIPS32 instructions with fixed, reviewable encodings.
* No target execution. The branch at +4 targets +20; its delay slot is a NOP.
*/
.set push
.set noreorder
.text
.p2align 2
.globl rea_mips_probe
.type rea_mips_probe, @function
.ent rea_mips_probe
rea_mips_probe:
addiu $2, $4, 0x1234
beq $4, $0, .Lzero
nop
jr $31
nop
.Lzero:
addiu $2, $0, 7
jr $31
nop
.end rea_mips_probe
.size rea_mips_probe, .-rea_mips_probe
.set pop
.section .note.GNU-stack,"",@progbits