feat(ghidra): admit verified MIPS16 shared ELF images

Admit the verified ELF32 o32 MIPS32r2 MIPS16 shared-image profile after complete source load and relocation inspection. Preserve caller-selected bytes, pin the endian-matched language, and report per-address decoder context. Refuse unverified relocation shapes with explicit reasons.

Verified on Linux x64 with Ghidra 12.1.4: eight source-owned public CLI/MCP controls, original-artifact instruction/pseudocode/export replay, snapshot restore and owned cleanup. 152 integrated focused tests, static checks and documentation checks passed.

Fixes #1851

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
Ysopwnable 2026-10-12 07:50:31 +08:00 committed by GitHub
parent 6395e8d300
commit e9fe6437a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1460 additions and 37 deletions

View File

@ -908,6 +908,19 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("procedure", function == null ? JsonNull.INSTANCE : GSON.toJsonTree(canonicalAddress(function.getEntryPoint())));
result.addProperty("architecture", currentProgram.getLanguageID().getIdAsString());
result.addProperty("mode", currentProgram.getLanguage().getLanguageDescription().getVariant());
if (currentProgram.getLanguageID().getIdAsString().startsWith("MIPS:")) {
JsonArray decoderContext = new JsonArray();
var modeRegister = currentProgram.getRegister("ISA_MODE");
if (modeRegister != null) {
var modeValue = currentProgram.getProgramContext().getValue(modeRegister, address, false);
JsonObject context = new JsonObject();
context.addProperty("register", modeRegister.getName());
context.add("value", modeValue == null ? JsonNull.INSTANCE : GSON.toJsonTree(modeValue.toString()));
context.addProperty("source", "ghidra-program-context");
decoderContext.add(context);
}
result.add("decoder_context", decoderContext);
}
JsonArray operands = new JsonArray();
JsonArray references = new JsonArray();
JsonArray destinations = new JsonArray();

View File

@ -16,9 +16,10 @@ PE, managed and Mach-O architecture sets are unchanged.
The Ghidra lane admits ELF32 `ET_EXEC` and `ET_REL`, little or big endian, with
explicit o32 and `EF_MIPS_ARCH_32R2` flags and no machine-specific or ASE
encoding flags. These are header declarations, not proof that every instruction
conforms to that ISA. ELF64, n32/n64, unspecified ABI, MIPS16/microMIPS,
machine-specific variants, shared objects and PSP PRX are not covered by this
lane. Their family may be identified, but provider selection returns the
conforms to that ISA. ELF64, n32/n64, unspecified ABI, microMIPS,
machine-specific variants and PSP PRX are not covered by this standard-instruction
lane. MIPS16 shared images have the separate bounded profile below; other shared
or compressed images remain unsupported. Their family may be identified, but provider selection returns the
specific unsupported constraint rather than guessing a compatible profile.
Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their
@ -49,6 +50,63 @@ values are retained for diagnosis but do not gain provider support. These checks
bind the declared interpretation; the fixtures do not establish full
floating-point behavioral conformance.
### MIPS16 shared images
A separate profile admits ELF32 `ET_DYN`, either byte order, declaring explicit
o32, MIPS32r2 and exactly the MIPS16 ASE. Machine variants, n32, FP64 and NAN2008
remain refused. Source inspection must examine the complete dynamic relocation
declarations through uniquely mapped file-backed `PT_LOAD` ranges. This profile
verifies `R_MIPS_NONE` and symbol-index-zero `R_MIPS_REL32`; section declarations
must describe that same dynamic table. Unavailable section headers and additional
section relocation tables (including `--emit-relocs` output) remain unsupported.
Symbol-bearing REL32,
other relocation types, RELA and PLT declarations remain unsupported. A real
negative control shows that Ghidra can normalize a symbol-bearing MIPS16 function
pointer to an even address, losing the source mode tag. REA refuses that shape
rather than rewriting the source or silently repairing the analysis result.
The profile pins the endian-matched `MIPS:<LE|BE>:32:default` processor and default
compiler specification, and checks the bridge-reported language after import.
The generic processor preserves mixed MIPS32/MIPS16 code through per-address
`ISA_MODE` context; a global `16e` processor would impose one mode across the
image. A caller-selected incompatible processor or compiler specification is
explicitly refused without replacing the requested choice.
An observed ABI flags record must agree with the header and MIPS16 ASE, and
must declare the same verified floating-point combinations as the standard
lane. With no observed record, every ABI declaration table must have been
completely examined. The header still declares o32, but floating-point ABI
semantics remain unknown and are stated in Evidence; selecting the default
compiler specification does not establish floating-point storage. Partial
section tables do not prove record absence. The existing soft-float prototype
policy applies when the source explicitly declares soft-float.
Returned addresses are analysis coordinates, not runtime load addresses.
`inspect_native_load_image` preserves original relocation bytes, current loaded
memory bytes and provider status. External GOT bindings remain unverified
without their defining images. The provider's `modified_sha256` describes its
ephemeral relocated image and can differ from `original_sha256`; it does not
mean that the caller's artifact was written. `inspect_native_instruction` retains the program
language variant in `mode` and reports actual per-address `ISA_MODE` separately
in `decoder_context`, including null when unavailable. Artifact bytes and digests
remain unchanged; this workflow does not execute the shared library.
The optional real-provider check builds eight source-owned GNU controls, covering
both byte orders, mixed MIPS32/MIPS16 code, declared and absent ABI records, and
the rejected symbol-bearing and additional section relocations. It compares source instruction bytes,
function symbols and the local function-pointer tag with actual CLI/MCP results,
checks context through a snapshot round trip, and verifies process cleanup.
It requires caller-supplied GNU MIPS cross-binutils and Ghidra; no fixture target
is executed or patched after linking. Floating-point behavior of compressed
functions is not established by these integer controls.
```bash
REA_MIPS_AS=/absolute/path/to/mips-linux-gnu-as \
REA_MIPS_LD=/absolute/path/to/mips-linux-gnu-ld \
GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra \
npm run verify:ghidra:mips-shared
```
MIPS header and ABI record facts are committed in the analysis profile so
interpretation changes invalidate profile-bound snapshots. The support-lane
identity is versioned independently of file identity. Evidence, lifecycle

View File

@ -202,8 +202,14 @@ rea inspect-native-data-type <binary> --address <typed-data-address> --provider
Instruction facts include bytes, length, decoder text, ordered register/scalar/
address tokens, typed references and flow destinations. Mid-instruction, data,
outside-memory and undecodable addresses have separate outcomes. Effective
memory base/index/displacement roles and per-instruction context mode remain
unavailable; `mode` is the program language variant.
memory base/index/displacement roles remain unavailable. Ghidra reports its program
language variant in `mode`; other providers retain their own mode label. Ghidra
MIPS results additionally expose `decoder_context`
with the observed `ISA_MODE` register at the queried address and its provider
source. A null value is unavailable, not mode zero; an empty array reports no
available context register, and omission preserves older captures or providers
that do not expose this facet. The context is an analysis observation, not proof
of a runtime processor state.
`inspect_native_instruction` also returns `incoming_references` to the exact
queried address, including data, interior-instruction and undecodable addresses.

View File

@ -165,6 +165,7 @@
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
"verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs",
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",

View File

@ -0,0 +1,338 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
import { buildMips16SharedFixture } from "../tests/conformance/ghidra/mips16-shared-fixture.mjs";
import {
requireMcpOperationResult,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
if (
process.env.GHIDRA_INSTALL_DIR === undefined ||
!["linux", "darwin"].includes(process.platform)
)
throw new Error(
"This optional lane requires BYO Ghidra on Linux/macOS and GNU MIPS cross-binutils.",
);
const execute = promisify(execFile);
const run = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-mips-shared-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
TMPDIR: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { JAVA_HOME: process.env.JAVA_HOME }),
...(process.env.GHIDRA_HEADLESS_MAXMEM === undefined
? {}
: { GHIDRA_HEADLESS_MAXMEM: process.env.GHIDRA_HEADLESS_MAXMEM }),
};
const reports = [];
let failure;
try {
for (const byteOrder of ["big", "little"]) {
for (const variant of [
"declared",
"recordless",
"symbol-relative",
"section-relocations",
]) {
const fixture = await buildMips16SharedFixture(
workspace,
byteOrder,
variant,
);
const resolved = await parseBinaryTarget(fixture.path);
if (!resolved.ok) throw resolved.error;
assert.equal(resolved.value.sha256, fixture.sha256);
assert.equal(resolved.value.architecture, "mips");
if (variant === "section-relocations") {
assert.equal(resolved.value.mips.sharedLoad.status, "unavailable");
assert.match(
resolved.value.mips.sharedLoad.reason,
/section relocation declarations/u,
);
} else {
assert.equal(resolved.value.mips.sharedLoad.status, "complete");
assert.deepEqual(
resolved.value.mips.sharedLoad.relocationTypes,
[0, 3],
);
assert.equal(
resolved.value.mips.sharedLoad.symbolRelativeCount,
variant === "symbol-relative" ? 1 : 0,
);
}
if (variant === "recordless")
assert.equal(resolved.value.mips.abiFlags, null);
else {
assert.equal(resolved.value.mips.abiFlags.fpAbi, 5);
assert.equal(resolved.value.mips.abiFlags.ases, 0x400);
}
const transport = new StdioClientTransport({
command: process.execPath,
args: ["scripts/rea.mjs", "mcp"],
cwd: process.cwd(),
env,
stderr: "pipe",
});
const client = new Client({
name: "rea-mips16-shared-conformance",
version: "1",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-262144);
});
let calls = 0;
const reply = async (name, args = {}) => {
const response = await client.callTool(
{ name, arguments: args },
{ timeout: 180000 },
);
await writeFile(
join(workspace, `${byteOrder}-${variant}-${++calls}-${name}.json`),
JSON.stringify(response, null, 2),
);
return response;
};
const call = async (name, args) =>
requireMcpOperationResult(await reply(name, args), name);
const original = await readFile(fixture.path);
try {
await client.connect(transport);
await client.listTools();
if (
variant === "symbol-relative" ||
variant === "section-relocations"
) {
const refusal = requireMcpToolError(
await reply("open_binary", {
path: fixture.path,
provider_id: "ghidra",
}),
);
assert.equal(refusal.category, "unsupported_provider");
assert.match(
JSON.stringify(refusal),
variant === "symbol-relative"
? /Symbol-bearing R_MIPS_REL32/u
: /section relocation declarations/u,
);
assert.deepEqual(await readFile(fixture.path), original);
await client.ping();
reports.push({ ...fixture, status: "refused", ping: true });
continue;
}
await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
});
const loadedReply = await reply("inspect_native_load_image");
const loaded = requireMcpOperationResult(
loadedReply,
"inspect_native_load_image",
);
const evidence = parseEvidence(
JSON.parse(
loadedReply.content.find((item) => item.type === "text").text,
),
);
const language = `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`;
assert.equal(loaded.observations.language_id, language);
assert.equal(loaded.observations.compiler_spec_id, "default");
assert(
loaded.observations.source_files.some(
(file) => file.original_sha256 === fixture.sha256,
),
);
assert(
evidence.limitations.some((item) =>
item.includes("External GOT bindings"),
),
);
if (variant === "recordless")
assert(
evidence.limitations.some((item) =>
item.includes("does not establish the floating-point ABI"),
),
);
const base = BigInt(loaded.observations.image_base);
const address = (source, clearTag = false) =>
`0x${(base + (clearTag ? BigInt(source) & ~1n : BigInt(source))).toString(16)}`;
const compressed = address(fixture.symbols.rea_mips16_probe, true);
const standard = address(fixture.symbols.rea_mips32_probe);
const procedures = await call("list_procedures");
assert(
procedures.some(
(item) =>
item.address === compressed && item.value === "rea_mips16_probe",
),
);
assert(
procedures.some(
(item) =>
item.address === standard && item.value === "rea_mips32_probe",
),
);
let firstInstruction;
const expected =
byteOrder === "little"
? ["5a6a", "8eea", "074a", "a0e8"]
: ["6a5a", "ea8e", "4a07", "e8a0"];
for (let i = 0; i < 4; i += 1) {
const instruction = await call("inspect_native_instruction", {
address: `0x${(BigInt(compressed) + BigInt(2 * i)).toString(16)}`,
});
assert.equal(instruction.status, "decoded");
assert.equal(instruction.mode, "default");
assert.equal(instruction.length, 2);
assert.equal(instruction.bytes, expected[i]);
assert.deepEqual(instruction.decoder_context, [
{
register: "ISA_MODE",
value: "1",
source: "ghidra-program-context",
},
]);
if (i === 0) firstInstruction = instruction;
}
const standardInstruction = await call("inspect_native_instruction", {
address: standard,
});
assert.equal(standardInstruction.length, 4);
assert.equal(
standardInstruction.bytes,
byteOrder === "little" ? "03008224" : "24820003",
);
assert.equal(
standardInstruction.decoder_context[0].register,
"ISA_MODE",
);
assert(
[null, "0"].includes(standardInstruction.decoder_context[0].value),
);
const pointer = await call("read_bytes", {
address: address(fixture.symbols.rea_mips16_pointer),
length: 4,
});
const pointerBytes = Buffer.from(pointer.bytes_hex, "hex");
const pointerValue =
byteOrder === "little"
? pointerBytes.readUInt32LE(0)
: pointerBytes.readUInt32BE(0);
assert.equal(
BigInt(pointerValue),
base + BigInt(fixture.symbols.rea_mips16_probe),
);
const dossier = await call("analyze_function", {
procedure: compressed,
});
assert.match(dossier.pseudocode, /\^\s*0x5a/u);
assert.match(dossier.pseudocode, /\+\s*(?:7|0x7)\b/u);
const standardDossier = await call("analyze_function", {
procedure: standard,
});
assert.match(standardDossier.pseudocode, /\+\s*(?:3|0x3)\b/u);
const snapshotPath = join(
workspace,
`${byteOrder}-${variant}.snapshot.json`,
);
await call("close_binary", { snapshot_path: snapshotPath });
const snapshot = parseAnalysisSnapshot(
JSON.parse(await readFile(snapshotPath, "utf8")),
);
assert.equal(
snapshot.binding.analysis_profile.parameters.language_selection,
"provider-pinned-v1",
);
assert.equal(
snapshot.binding.analysis_profile.parameters.language_id,
language,
);
await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
assert.deepEqual(
await call("inspect_native_instruction", { address: compressed }),
firstInstruction,
);
await call("close_binary");
await client.ping();
const cli = await execute(
process.execPath,
[
"scripts/rea.mjs",
"inspect-native-instruction",
fixture.path,
compressed,
"--provider",
"ghidra",
"--format",
"json",
],
{ env, timeout: 180000, maxBuffer: 8 * 1024 * 1024 },
);
assert.deepEqual(
parseEvidence(JSON.parse(cli.stdout)).normalized_result,
firstInstruction,
);
assert.deepEqual(await readFile(fixture.path), original);
reports.push({
...fixture,
status: "passed",
language_id: language,
mixed_mode: true,
standard_decoder_context: standardInstruction.decoder_context,
pointer_tag_preserved: true,
snapshot_context_preserved: true,
cli_mcp_parity: true,
source_unchanged: true,
ping: true,
});
} finally {
await client.close();
await transport.close();
await writeFile(
join(workspace, `${byteOrder}-${variant}.stderr`),
stderr,
);
}
}
}
} catch (cause) {
failure = cause;
}
const completed = await completeVerifierRun(run);
if (
completed.process_lineage.status !== "verified" ||
completed.process_lineage.descendants.length !== 0
)
throw new Error(`Cleanup unverified; retained workspace: ${workspace}`, {
cause: failure,
});
if (failure !== undefined)
throw new Error(
`MIPS16 shared verification failed; retained workspace: ${workspace}`,
{ cause: failure },
);
await rm(workspace, { recursive: true, force: true });
console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2));

View File

@ -17,7 +17,7 @@ import {
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
import { readMipsElfAbiFlags } from "./MipsElfAbiFlags.js";
import { readMipsElfMetadata } from "./MipsElfMetadata.js";
import {
resolveAppBundleExecutable,
type ResolvedAppBundle,
@ -430,18 +430,23 @@ const readExecutableMetadata = async (
parsed.value.mips.elfClass !== 32
)
return parsed;
const abiFlags = await readMipsElfAbiFlags(handle, parsed.value.mips, () =>
throwIfTargetResolutionCancelled(signal),
const declarations = await readMipsElfMetadata(
handle,
parsed.value.mips,
() => throwIfTargetResolutionCancelled(signal),
);
if (!abiFlags.ok) return abiFlags;
if (!declarations.ok) return declarations;
return ok({
...parsed.value,
mips: {
...parsed.value.mips,
abiFlags: abiFlags.value.abiFlags,
...(abiFlags.value.limitations.length === 0
abiFlags: declarations.value.abiFlags,
...(declarations.value.sharedLoad === undefined
? {}
: { limitations: abiFlags.value.limitations }),
: { sharedLoad: declarations.value.sharedLoad }),
...(declarations.value.limitations.length === 0
? {}
: { limitations: declarations.value.limitations }),
},
});
};

View File

@ -4,24 +4,29 @@ import type {
MipsElfMetadata,
} from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
import {
readMipsElfSharedLoad,
type MipsElfRelocationSection,
} from "./MipsElfSharedLoad.js";
/** Observed ABI declarations and coverage of their source tables. */
export interface MipsAbiFlagsInspection {
/** Observed ABI and load declarations, retaining unavailable source coverage. */
export interface MipsElfInspection {
readonly abiFlags: MipsAbiFlags | null;
readonly limitations: readonly string[];
readonly sharedLoad?: MipsElfMetadata["sharedLoad"];
}
/**
* Read ELF32 MIPS ABI declarations from the caller's already-open file.
* Read ELF32 MIPS ABI and shared-load declarations from the caller's already-open file.
* Inspect both typed tables (not section names), including records beyond the
* initial header probe. A missing record is unknown, not a default FP ABI.
* The caller supplies its cancellation check so its tagged error is preserved.
*/
export const readMipsElfAbiFlags = async (
export const readMipsElfMetadata = async (
handle: FileHandle,
metadata: MipsElfMetadata,
checkCancelled: () => void,
): Promise<Result<MipsAbiFlagsInspection, string>> => {
): Promise<Result<MipsElfInspection, string>> => {
checkCancelled();
if (metadata.elfClass !== 32)
return err("MIPS ABI record inspection currently supports ELF32 only");
@ -107,6 +112,9 @@ export const readMipsElfAbiFlags = async (
if (u16(bytes, 44) === 0xffff || u16(bytes, 48) >= 0xff00)
return err("extended or reserved MIPS ELF table numbering is unsupported");
let record: Buffer | undefined;
let sharedLoad: MipsElfMetadata["sharedLoad"];
let programHeaders: Buffer | undefined;
const relocationSections: MipsElfRelocationSection[] = [];
const limitations: string[] = [];
for (const table of tables) {
if (table.count === 0) {
@ -135,12 +143,20 @@ export const readMipsElfAbiFlags = async (
table.label,
);
if (!contents.ok) return contents;
if (!table.optional && metadata.type === 3) programHeaders = contents.value;
let found = false;
for (let i = 0; i < count; i += 1) {
checkCancelled();
const position = i * table.entrySize;
if (u32(contents.value, position + table.typeOffset) !== table.recordType)
continue;
const type = u32(contents.value, position + table.typeOffset);
if (metadata.type === 3 && table.optional && (type === 4 || type === 9))
relocationSections.push({
type,
offset: u32(contents.value, position + 16),
size: u32(contents.value, position + 20),
entrySize: u32(contents.value, position + 36),
});
if (type !== table.recordType) continue;
if (found) return err(`duplicate MIPS ABI records in ${table.label}`);
found = true;
const offset = u32(contents.value, position + table.dataOffset);
@ -154,7 +170,37 @@ export const readMipsElfAbiFlags = async (
record = candidate.value;
}
}
if (record === undefined) return ok({ abiFlags: null, limitations });
if (metadata.type === 3 && programHeaders !== undefined) {
if (limitations.length > 0)
sharedLoad = {
status: "unavailable",
reason: `MIPS shared-image relocation declaration sources are incomplete. ${limitations.join(" ")}`,
};
else {
const shared = await readMipsElfSharedLoad(
programHeaders,
little,
fileSize,
read,
checkCancelled,
relocationSections,
);
sharedLoad = shared.ok
? shared.value
: { status: "unavailable", reason: shared.error };
}
}
const shared =
metadata.type === 3
? {
sharedLoad: sharedLoad ?? {
status: "unavailable" as const,
reason: "MIPS shared image has no inspected program header table",
},
}
: {};
if (record === undefined)
return ok({ abiFlags: null, limitations, ...shared });
// Preserve declarations verbatim, including unknown versions/values. Provider
// admission, not parsing, decides which interpretations have been verified.
const abiFlags: MipsAbiFlags = {
@ -170,5 +216,5 @@ export const readMipsElfAbiFlags = async (
flags1: u32(record, 16),
flags2: u32(record, 20),
};
return ok({ abiFlags, limitations });
return ok({ abiFlags, limitations, ...shared });
};

View File

@ -0,0 +1,220 @@
import type { MipsElfMetadata } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
type ReadRange = (
offset: number,
length: number,
label: string,
) => Promise<Result<Buffer, string>>;
export interface MipsElfRelocationSection {
readonly type: number;
readonly offset: number;
readonly size: number;
readonly entrySize: number;
}
type SharedLoadFacts = Extract<
NonNullable<MipsElfMetadata["sharedLoad"]>,
{ status: "complete" }
>;
/** Source ELF facts only; the provider owns interpretation and admission. */
export const readMipsElfSharedLoad = async (
programHeaders: Buffer,
little: boolean,
fileSize: number,
read: ReadRange,
checkCancelled: () => void,
sections: readonly MipsElfRelocationSection[],
): Promise<Result<SharedLoadFacts, string>> => {
const u32 = (bytes: Buffer, offset: number): number =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const loads: {
offset: number;
address: number;
size: number;
memorySize: number;
}[] = [];
let dynamic: { offset: number; address: number; size: number } | undefined;
for (let position = 0; position < programHeaders.length; position += 32) {
checkCancelled();
const type = u32(programHeaders, position);
const offset = u32(programHeaders, position + 4);
const size = u32(programHeaders, position + 16);
if (type === 1) {
const address = u32(programHeaders, position + 8);
const memorySize = u32(programHeaders, position + 20);
if (
offset > fileSize - size ||
size > memorySize ||
address + memorySize > 0x100000000
)
return err("invalid or incomplete MIPS PT_LOAD range");
loads.push({ offset, address, size, memorySize });
}
if (type === 2) {
if (dynamic !== undefined) return err("duplicate MIPS PT_DYNAMIC tables");
dynamic = { offset, address: u32(programHeaders, position + 8), size };
}
}
if (dynamic === undefined)
return err("MIPS shared image has no PT_DYNAMIC table");
if (dynamic.size === 0 || dynamic.size % 8 !== 0)
return err("invalid MIPS ELF32 dynamic table size");
if (dynamic.offset > fileSize - dynamic.size)
return err("MIPS PT_DYNAMIC range extends beyond the file");
const ranges = loads
.filter((load) => load.memorySize > 0)
.sort((left, right) => left.address - right.address);
for (let i = 1; i < ranges.length; i += 1) {
checkCancelled();
const previous = ranges[i - 1]!;
if (ranges[i]!.address < previous.address + previous.memorySize)
return err(
"overlapping MIPS PT_LOAD memory ranges require separate mapping verification",
);
}
// One normalized index prevents relocation count * program-header count work.
const mapped = (address: number, length: number, fileBacked: boolean) => {
let low = 0;
let high = ranges.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (ranges[middle]!.address <= address) low = middle + 1;
else high = middle;
}
const range = ranges[low - 1];
return range !== undefined &&
address - range.address <=
(fileBacked ? range.size : range.memorySize) - length
? range
: undefined;
};
const dynamicMapping = mapped(dynamic.address, dynamic.size, true);
if (
dynamicMapping === undefined ||
dynamicMapping.offset + dynamic.address - dynamicMapping.address !==
dynamic.offset
)
return err(
"MIPS PT_DYNAMIC has no unique matching file-backed PT_LOAD range",
);
// Bound temporary storage, not admitted artifact size. ELF32 records are
// exact eight-byte structures; every declared record is examined or refused.
const chunkBytes = 64 * 1024;
const relevant = new Set([2, 7, 8, 9, 17, 18, 19, 20, 23]);
const tags = new Map<number, number>();
let terminated = false;
for (
let offset = 0;
offset < dynamic.size && !terminated;
offset += chunkBytes
) {
const chunk = await read(
dynamic.offset + offset,
Math.min(chunkBytes, dynamic.size - offset),
"dynamic table",
);
if (!chunk.ok) return chunk;
for (let position = 0; position < chunk.value.length; position += 8) {
checkCancelled();
const tag = u32(chunk.value, position);
if (tag === 0) {
terminated = true;
break;
}
if (!relevant.has(tag)) continue;
if (tags.has(tag)) return err(`duplicate MIPS dynamic tag ${tag}`);
tags.set(tag, u32(chunk.value, position + 4));
}
}
if (!terminated) return err("MIPS dynamic table has no DT_NULL terminator");
if ([2, 7, 8, 9, 23].some((tag) => (tags.get(tag) ?? 0) !== 0))
return err(
"MIPS shared RELA or PLT relocation declarations require separate verification",
);
const address = tags.get(17);
const size = tags.get(18);
const stride = tags.get(19);
const checkSections = (
offset: number,
length: number,
): string | undefined => {
let observed = 0;
for (const section of sections) {
checkCancelled();
if (section.size === 0) continue;
if (
section.type !== 9 ||
section.entrySize !== 8 ||
section.offset !== offset ||
section.size !== length
)
return "MIPS section relocation declarations outside the dynamic REL table require separate verification";
observed += 1;
}
return observed > 1
? "duplicate MIPS section relocation declarations require separate verification"
: undefined;
};
const emptyRelocations = (): Result<SharedLoadFacts, string> => {
const reason = checkSections(0, 0);
return reason === undefined
? ok({
status: "complete",
relocationCount: 0,
relocationTypes: [],
symbolRelativeCount: 0,
})
: err(reason);
};
if (address === undefined && size === undefined && stride === undefined)
return emptyRelocations();
if (
address === undefined ||
size === undefined ||
stride !== 8 ||
size % 8 !== 0
)
return err(
"incomplete or invalid MIPS DT_REL/DT_RELSZ/DT_RELENT declaration",
);
if (size === 0) return emptyRelocations();
const mapping = mapped(address, size, true);
if (mapping === undefined)
return err(
"MIPS DT_REL range has no unique complete file-backed PT_LOAD mapping",
);
const fileOffset = mapping.offset + address - mapping.address;
const sectionReason = checkSections(fileOffset, size);
if (sectionReason !== undefined) return err(sectionReason);
let symbolRelativeCount = 0;
const types = new Set<number>();
for (let offset = 0; offset < size; offset += chunkBytes) {
const chunk = await read(
fileOffset + offset,
Math.min(chunkBytes, size - offset),
"dynamic REL records",
);
if (!chunk.ok) return chunk;
for (let position = 0; position < chunk.value.length; position += 8) {
checkCancelled();
const info = u32(chunk.value, position + 4);
const type = info & 0xff;
const relocationAddress = u32(chunk.value, position);
if (type === 3 && mapped(relocationAddress, 4, false) === undefined)
return err(
"MIPS REL32 destination has no unique complete PT_LOAD memory range",
);
types.add(type);
if (type === 3 && info >>> 8 !== 0) symbolRelativeCount += 1;
}
}
return ok({
status: "complete",
relocationCount: size / 8,
relocationTypes: [...types].sort((left, right) => left - right),
symbolRelativeCount,
});
};

View File

@ -18,6 +18,15 @@ export interface MipsElfMetadata {
readonly abiFlags?: MipsAbiFlags | null;
/** Unavailable declaration sources; missing records there remain unknown. */
readonly limitations?: readonly string[];
/** Complete dynamic REL declarations, or the reason they could not be inspected. */
readonly sharedLoad?:
| {
readonly status: "complete";
readonly relocationCount: number;
readonly relocationTypes: readonly number[];
readonly symbolRelativeCount: number;
}
| { readonly status: "unavailable"; readonly reason: string };
}
/** Declared fields of the 24-byte MIPS ABI flags record, not inferred semantics. */

View File

@ -53,7 +53,23 @@ export const nativeInstructionSchema = z.strictObject({
]),
procedure: z.string().nullable(),
architecture: z.string(),
mode: z.string(),
mode: z
.string()
.describe(
"Provider-reported mode label. Per-address decoder context, when available, is reported separately; a program-wide label does not establish runtime encoding mode.",
),
decoder_context: z
.array(
z.strictObject({
register: z.string().min(1),
value: z.string().min(1).nullable(),
source: z.string().min(1),
}),
)
.optional()
.describe(
"Observed per-address decoder context. Null means no value was available; omission means the provider or older capture does not expose this facet. The separate mode field retains the program language variant.",
),
bytes: z.string().nullable(),
length: z.number().int().positive().nullable(),
mnemonic: z.string().nullable(),

View File

@ -13,7 +13,10 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import type { GhidraInstallationInspection } from "./GhidraInstallation.js";
import { ghidraMipsProfileParameters } from "./GhidraMipsProfile.js";
import {
ghidraMipsProfileParameters,
ghidraMipsSharedLanguageOverride,
} from "./GhidraMipsProfile.js";
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
import {
@ -132,6 +135,22 @@ export const resolveGhidraAnalysisProfile = async (
const dosMz = target.format === "dos-mz";
const dosCom = target.format === "dos-com";
const dos = dosMz || dosCom;
const mipsLanguage = ghidraMipsSharedLanguageOverride(target);
if (
mipsLanguage !== undefined &&
languageOverride !== undefined &&
(languageOverride.languageId !== mipsLanguage.languageId ||
(languageOverride.compilerSpecId !== undefined &&
languageOverride.compilerSpecId !== mipsLanguage.compilerSpecId))
)
return err(
new AnalysisUnsupportedTargetError(
"resolve_analysis_profile",
target.path,
"The caller-selected processor or compiler specification is outside the verified endian-matched MIPS32 shared-image profile. The requested override has not been changed.",
),
);
const selectedLanguage = languageOverride ?? mipsLanguage;
// DOS imports already force an admitted loader, language, and context.
if (dos && languageOverride !== undefined)
return Promise.resolve(
@ -199,14 +218,16 @@ export const resolveGhidraAnalysisProfile = async (
: "auto-from-header",
language_id: dos
? "x86:LE:16:Real Mode"
: (languageOverride?.languageId ?? "auto-from-header"),
: (selectedLanguage?.languageId ?? "auto-from-header"),
compiler_spec_id: dos
? "default"
: languageOverride === undefined
: selectedLanguage === undefined
? "auto-default"
: (languageOverride.compilerSpecId ?? "language-default"),
: (selectedLanguage.compilerSpecId ?? "language-default"),
...(languageOverride === undefined
? {}
? mipsLanguage === undefined
? {}
: { language_selection: "provider-pinned-v1" }
: { language_selection: "configured-v1" }),
...(dos
? {
@ -246,7 +267,11 @@ export const resolveGhidraAnalysisProfile = async (
export const ghidraProfileLanguageOverride = (
parameters: Readonly<Record<string, unknown>>,
): GhidraLanguageOverride | undefined => {
if (parameters.language_selection !== "configured-v1") return undefined;
if (
parameters.language_selection !== "configured-v1" &&
parameters.language_selection !== "provider-pinned-v1"
)
return undefined;
const languageId = parameters.language_id;
const compilerSpecId = parameters.compiler_spec_id;
if (typeof languageId !== "string" || typeof compilerSpecId !== "string")

View File

@ -13,6 +13,7 @@ import { GhidraProvider } from "./GhidraProvider.js";
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
import type { GhidraMipsSoftFloatReport } from "./GhidraMipsProfile.js";
import { ghidraProfileLanguageOverride } from "./GhidraAnalysisProfile.js";
// Fake installation inspection tests admission/profile contracts, not Ghidra.
const host: GhidraInstallationHost = {
@ -217,6 +218,130 @@ const session = async (
};
};
describe("MIPS16 shared-image profile", () => {
const shared = (change: Partial<MipsElfMetadata> = {}) =>
target({
type: 3,
flags: 0x74001005,
abiFlags: null,
sharedLoad: {
status: "complete",
relocationCount: 14,
relocationTypes: [0, 3],
symbolRelativeCount: 0,
},
...change,
});
it.each(["little", "big"] as const)(
"pins a generic 32-bit %s language and retains the source ABI uncertainty",
async (byteOrder) => {
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
const value = shared({ byteOrder });
expect(ghidra.inspectTargetSupport(value).status).toBe("supported");
const resolved = await ghidra.resolveAnalysisProfile(value);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("expected shared profile");
const parameters = resolved.value.profile.parameters;
expect(parameters).toMatchObject({
language_id: `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
compiler_spec_id: "default",
language_selection: "provider-pinned-v1",
mips_support_lane: "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1",
mips_elf: { abi_flags: null },
});
expect(ghidraProfileLanguageOverride(parameters)).toEqual({
languageId: parameters.language_id,
compilerSpecId: "default",
});
},
);
it("retains shared-image and unknown floating-point limitations on the session", async () => {
const { client } = await session(shared(), undefined);
const health = await client.execute("health", {});
if (!health.ok) throw health.error;
expect(health.value.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("External GOT bindings"),
expect.stringContaining("does not establish the floating-point ABI"),
]),
);
});
it("continues to admit a declared FPXX record only when its MIPS16 ASE agrees", () => {
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
expect(
ghidra.inspectTargetSupport(shared(abi({ ases: 0x400 }))).status,
).toBe("supported");
expect(ghidra.inspectTargetSupport(shared(abi({ ases: 0 }))).status).toBe(
"unsupported",
);
});
it.each([
{ flags: 0x74001045 },
{ limitations: ["Unexamined section declarations"] },
{
sharedLoad: {
status: "complete",
relocationCount: 1,
relocationTypes: [3],
symbolRelativeCount: 1,
},
},
] satisfies Partial<MipsElfMetadata>[])(
"refuses incomplete or unverified source declarations %j",
(change) => {
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
expect(ghidra.inspectTargetSupport(shared(change)).status).toBe(
"unsupported",
);
},
);
it.each(["sharedLoad", "abiFlags"] as const)(
"refuses an uninspected %s declaration",
(declaration) => {
const value = shared();
if (value.kind !== "executable" || value.architecture !== "mips")
throw new Error("expected MIPS target");
const facts = { ...value.mips };
delete facts[declaration];
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
expect(
ghidra.inspectTargetSupport({ ...value, mips: facts }),
).toMatchObject({
status: "unsupported",
reason: expect.stringContaining(
declaration === "abiFlags"
? "inspected ABI flags"
: "have not been inspected",
),
});
},
);
it("refuses an endian pin committed for a different target", async () => {
const { counts, client } = await session(
shared({ byteOrder: "big" }),
undefined,
shared(),
);
const health = await client.execute("health", {});
expect(health.ok).toBe(false);
expect(counts.starts).toBe(0);
});
it("rejects a caller-selected global MIPS16 processor without replacing the request", async () => {
const parsed = parseConfig({
GHIDRA_INSTALL_DIR: "/ghidra",
REA_GHIDRA_LANGUAGE_ID: "MIPS:LE:32:16e",
});
if (!parsed.ok) throw parsed.error;
const ghidra = new GhidraProvider(parsed.value, silentLogger, {}, host);
const resolved = await ghidra.resolveAnalysisProfile(shared());
expect(resolved.ok).toBe(false);
if (!resolved.ok)
expect(resolved.error.message).toContain(
"requested override has not been changed",
);
});
});
describe("soft-float MIPS storage", () => {
it("admits soft-float o32 and commits its storage policy only for it", async () => {
const soft = target(softFloat);

View File

@ -1,9 +1,30 @@
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
const MIPS_FP_ABI_SOFT = 3;
const MIPS_ET_REL = 1;
const MIPS_ET_EXEC = 2;
const MIPS_ET_DYN = 3;
const MIPS16_HEADER_ASE = 0x04000000;
/** A distinct compressed shared-image shape; admission still checks every declaration. */
const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean =>
target.kind === "executable" &&
target.architecture === "mips" &&
target.mips.type === MIPS_ET_DYN &&
(target.mips.flags & 0x0f000000) === MIPS16_HEADER_ASE;
/** Generic MIPS32 permits per-function ISA_MODE; a global 16e processor does not. */
export const ghidraMipsSharedLanguageOverride = (
target: BinaryTarget,
): GhidraLanguageOverride | undefined =>
isGhidraMips16SharedTarget(target) && target.architecture === "mips"
? {
languageId: `MIPS:${target.mips.byteOrder === "little" ? "LE" : "BE"}:32:default`,
compilerSpecId: "default",
}
: undefined;
/** Keep family recognition separate from the deliberately bounded Ghidra lane. */
export const ghidraMipsUnsupportedReason = (
@ -14,11 +35,16 @@ export const ghidraMipsUnsupportedReason = (
const metadata = target.mips;
if (target.format !== "elf" || metadata === undefined)
return "MIPS analysis requires ELF header metadata; a family name alone does not select an ISA or ABI.";
const shared = isGhidraMips16SharedTarget(target);
if (metadata.elfClass !== 32)
return "This Ghidra MIPS lane supports ELF32 only; MIPS64 and n64 remain unsupported.";
if (metadata.type !== MIPS_ET_EXEC && metadata.type !== MIPS_ET_REL)
if (
metadata.type !== MIPS_ET_EXEC &&
metadata.type !== MIPS_ET_REL &&
!shared
)
return "This Ghidra MIPS lane supports ET_EXEC and ET_REL only; shared images and PSP PRX need separate load verification.";
if ((metadata.flags & 0x0f000000) !== 0)
if ((metadata.flags & 0x0f000000) !== 0 && !shared)
return "MIPS16, microMIPS and other ASE encodings are outside this standard-instruction MIPS lane.";
if ((metadata.flags & 0x00ff0000) !== 0)
return "Machine-specific MIPS variants, including PSP Allegrex, need a separately supported processor profile.";
@ -33,7 +59,20 @@ export const ghidraMipsUnsupportedReason = (
return "EF_MIPS_FP64 requires separate verification of 64-bit floating-point register semantics in this Ghidra lane.";
if ((metadata.flags & 0x00000400) !== 0)
return "EF_MIPS_NAN2008 requires separate verification of NaN encoding semantics in this Ghidra lane.";
if (shared) {
if ((metadata.flags & 0x40) !== 0)
return "EF_MIPS_ABI_ON32 declares an unverified ABI for the MIPS16 shared-image lane.";
const load = metadata.sharedLoad;
if (load === undefined || load.status !== "complete")
return `MIPS16 shared images require complete dynamic relocation inspection. ${load?.reason ?? "The source declarations have not been inspected."}`;
if (load.relocationTypes.some((type) => type !== 0 && type !== 3))
return "This MIPS16 shared-image lane verifies R_MIPS_NONE and R_MIPS_REL32 only; other dynamic relocation types require separate verification.";
if (load.symbolRelativeCount !== 0)
return "Symbol-bearing R_MIPS_REL32 is unsupported: Ghidra normalizes MIPS16 function addresses and can lose the source function-pointer mode tag. Symbol-index-zero REL32 is verified separately.";
}
const abi = metadata.abiFlags;
if (shared && abi === null && (metadata.limitations?.length ?? 0) === 0)
return null; // Inspected absence leaves FP semantics unknown, never inferred.
if (abi === undefined || abi === null)
return [
"This Ghidra MIPS lane requires an inspected ABI flags record; header flags alone do not establish the floating-point ABI.",
@ -49,8 +88,8 @@ export const ghidraMipsUnsupportedReason = (
const softFloat = abi.cpr1Size === 0 && abi.fpAbi === MIPS_FP_ABI_SOFT;
if (!hardFloat && !softFloat)
return "This Ghidra MIPS lane admits double-precision or FPXX ABI declarations with 32-bit CPR1, or soft-float without CPR1; single-float, FP64 and unspecified modes need separate verification.";
if (abi.isaExtension !== 0 || abi.ases !== 0)
return "MIPS ABI flags declare an ISA extension or ASE outside this standard-instruction lane.";
if (abi.isaExtension !== 0 || abi.ases !== (shared ? 0x400 : 0))
return "MIPS ABI flags declare an ISA extension or ASE outside this profile's verified instruction declarations.";
// MIPS_AFL_FLAGS1_ODDSPREG (bit 0) is the GCC and LLVM default for MIPS32 o32
// whatever the FP ABI; soft-float objects from both carry it. With no CPR1 it
// selects nothing, so it is accepted for soft-float as well as hard-float.
@ -116,6 +155,22 @@ export const ghidraMipsRelocatableLimitations = (
]
: [];
/** Shared-image results are analysis observations, not dynamic-loader execution. */
export const ghidraMipsSharedLimitations = (
target: BinaryTarget,
): readonly string[] =>
isGhidraMips16SharedTarget(target) && target.architecture === "mips"
? [
"MIPS16 shared ELF: the endian-matched generic MIPS32 language uses the provider's per-address ISA_MODE context. The program language variant is not the instruction's encoding mode.",
"Shared-image addresses are Ghidra analysis coordinates, not runtime addresses. The source bytes remain unchanged; inspect_native_load_image retains original relocation bytes, loaded memory bytes and provider relocation status. External GOT bindings remain unverified without their defining images.",
...(target.mips.abiFlags === null
? [
"No MIPS ABI flags record was found in the completely examined declaration tables. The header declares o32 but does not establish the floating-point ABI; the default compiler specification does not verify floating-point argument or return storage.",
]
: []),
]
: [];
/** Commit source ELF interpretation without confusing target ISA with host CPU. */
export const ghidraMipsProfileParameters = (
target: BinaryTarget,
@ -152,12 +207,33 @@ export const ghidraMipsProfileParameters = (
flags1: abi.flags1,
flags2: abi.flags2,
},
...(target.mips.sharedLoad === undefined
? {}
: {
shared_load:
target.mips.sharedLoad.status === "complete"
? {
status: "complete",
relocation_count: target.mips.sharedLoad.relocationCount,
relocation_types: [
...target.mips.sharedLoad.relocationTypes,
],
symbol_relative_count:
target.mips.sharedLoad.symbolRelativeCount,
}
: {
status: "unavailable",
reason: target.mips.sharedLoad.reason,
},
}),
},
...(isGhidraMipsSoftFloatTarget(target)
? { mips_soft_float_storage: GHIDRA_MIPS_SOFT_FLOAT_STORAGE }
: {}),
mips_support_lane:
target.mips.type === MIPS_ET_REL
instruction_context_evidence: "per-address-isa-mode-v1",
mips_support_lane: isGhidraMips16SharedTarget(target)
? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1"
: target.mips.type === MIPS_ET_REL
? "elf32-rel-o32-arch32r2-standard-v1"
: "elf32-exec-o32-arch32r2-standard-v3",
};

View File

@ -60,6 +60,8 @@ import {
GHIDRA_MIPS_SOFT_FLOAT_STORAGE,
ghidraMipsSoftFloatFailure,
ghidraMipsRelocatableLimitations,
ghidraMipsSharedLimitations,
ghidraMipsSharedLanguageOverride,
ghidraMipsSoftFloatLimitations,
isGhidraMipsSoftFloatTarget,
} from "./GhidraMipsProfile.js";
@ -444,7 +446,10 @@ export const createGhidraProviderClient = (input: {
committedProfile.parameters,
);
if (
!sameGhidraLanguageOverride(languageOverride, config.ghidraLanguageOverride)
!sameGhidraLanguageOverride(
languageOverride,
config.ghidraLanguageOverride ?? ghidraMipsSharedLanguageOverride(target),
)
)
return unavailableClient(
new ProviderAdapterError("ghidra", "open_binary", {
@ -512,6 +517,7 @@ export const createGhidraProviderClient = (input: {
? (target.mips.limitations ?? [])
: []),
...ghidraMipsRelocatableLimitations(target),
...ghidraMipsSharedLimitations(target),
];
const providerLimitations =
installation.platform === "win32"

View File

@ -7,7 +7,7 @@ import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import type { MipsElfMetadata } from "../../../src/domain/binaryTargetTypes.js";
import { readMipsElfAbiFlags } from "../../../src/application/MipsElfAbiFlags.js";
import { readMipsElfMetadata } from "../../../src/application/MipsElfMetadata.js";
import { ok } from "../../../src/domain/result.js";
import {
ghidraMipsProfileParameters,
@ -85,7 +85,7 @@ const inspect = async (
const path = await writeFixture(bytes);
const handle = await open(path, "r");
try {
const result = await readMipsElfAbiFlags(
const result = await readMipsElfMetadata(
handle,
metadata(little),
checkCancelled,

View File

@ -0,0 +1,260 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { open, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { it } from "vitest";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { readMipsElfMetadata } from "../../../src/application/MipsElfMetadata.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import {
ghidraMipsProfileParameters,
ghidraMipsUnsupportedReason,
} from "../../../src/ghidra/GhidraMipsProfile.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const relOffset = 4096;
const fixture = (little: boolean, count = 2): Buffer => {
const bytes = Buffer.alloc(relOffset + count * 8 + 16);
bytes.set([0x7f, 0x45, 0x4c, 0x46, 1, little ? 1 : 2, 1]);
const u16 = (value: number, offset: number) =>
little
? bytes.writeUInt16LE(value, offset)
: bytes.writeUInt16BE(value, offset);
const u32 = (value: number, offset: number) =>
little
? bytes.writeUInt32LE(value, offset)
: bytes.writeUInt32BE(value, offset);
u16(3, 16);
u16(8, 18);
u32(1, 20);
u32(52, 28);
u32(0x74001005, 36);
u16(52, 40);
u16(32, 42);
u16(2, 44);
u32(1, 52);
u32(bytes.length, 68);
u32(bytes.length, 72);
u32(2, 84);
u32(256, 88);
u32(256, 92);
u32(32, 100);
u32(32, 104);
u32(17, 256);
u32(relOffset, 260);
u32(18, 264);
u32(count * 8, 268);
u32(19, 272);
u32(8, 276);
for (let i = 1; i < count; i += 1) {
u32(512, relOffset + 8 * i);
u32(3, relOffset + 8 * i + 4);
}
return bytes;
};
const resolve = async (bytes: Buffer) => {
const directory = await createTestTempDirectory("rea-mips-shared-test-");
const path = join(directory, "shared.elf");
await writeFile(path, bytes);
const result = await parseBinaryTarget(path);
assert.ok(result.ok);
assert.equal(result.value.kind, "executable");
assert.equal(result.value.architecture, "mips");
if (
result.value.kind !== "executable" ||
result.value.architecture !== "mips"
)
throw new Error("expected MIPS ELF");
assert.equal(
result.value.sha256,
createHash("sha256").update(bytes).digest("hex"),
);
assert.deepEqual(await readFile(path), bytes);
return result.value;
};
for (const little of [true, false]) {
it(`retains complete dynamic declarations beyond the initial probe (${little ? "LE" : "BE"})`, async () => {
const target = await resolve(fixture(little));
assert.equal(target.mips.abiFlags, null);
assert.deepEqual(target.mips.sharedLoad, {
status: "complete",
relocationCount: 2,
relocationTypes: [0, 3],
symbolRelativeCount: 0,
});
assert.equal(ghidraMipsUnsupportedReason(target), null);
assert.deepEqual(ghidraMipsProfileParameters(target).mips_elf, {
elf_class: 32,
byte_order: little ? "little" : "big",
type: 3,
flags: 0x74001005,
abi_flags: null,
shared_load: {
status: "complete",
relocation_count: 2,
relocation_types: [0, 3],
symbol_relative_count: 0,
},
});
});
it(`retains and refuses symbol-bearing REL32 without repairing the mode tag (${little ? "LE" : "BE"})`, async () => {
const bytes = fixture(little);
if (little) bytes.writeUInt32LE(0x103, relOffset + 12);
else bytes.writeUInt32BE(0x103, relOffset + 12);
const target = await resolve(bytes);
assert.equal(target.mips.sharedLoad?.status, "complete");
assert.match(
ghidraMipsUnsupportedReason(target) ?? "",
/Symbol-bearing R_MIPS_REL32/u,
);
});
}
it("examines relocation records beyond a read chunk before deciding completeness", async () => {
const count = 8194;
const bytes = fixture(true, count);
bytes.writeUInt32LE(0x103, relOffset + (count - 1) * 8 + 4);
const target = await resolve(bytes);
assert.deepEqual(target.mips.sharedLoad, {
status: "complete",
relocationCount: count,
relocationTypes: [0, 3],
symbolRelativeCount: 1,
});
assert.match(ghidraMipsUnsupportedReason(target) ?? "", /mode tag/u);
});
it("does not treat uncaptured ABI declaration headers as an inspected absence", async () => {
const bytes = fixture(true);
bytes.writeUInt32LE(bytes.length + 1, 32);
bytes.writeUInt16LE(40, 46);
bytes.writeUInt16LE(1, 48);
const target = await resolve(bytes);
assert.equal(target.mips.abiFlags, null);
assert.equal(target.mips.sharedLoad?.status, "unavailable");
assert.match(
ghidraMipsUnsupportedReason(target) ?? "",
/unavailable headers remain unknown/u,
);
});
const malformed: [string, (bytes: Buffer) => void, RegExp][] = [
[
"overlapping load ranges",
(b) => {
b.writeUInt16LE(3, 44);
b.writeUInt32LE(1, 116);
b.writeUInt32LE(256, 124);
b.writeUInt32LE(16, 132);
b.writeUInt32LE(16, 136);
},
/overlapping MIPS PT_LOAD/u,
],
[
"inconsistent dynamic address",
(b) => b.writeUInt32LE(0x10000, 92),
/matching file-backed PT_LOAD/u,
],
["missing dynamic table", (b) => b.writeUInt32LE(0, 84), /no PT_DYNAMIC/u],
["repeated dynamic tables", (b) => b.writeUInt32LE(2, 52), /duplicate/u],
[
"declared dynamic tail outside file",
(b) => b.writeUInt32LE(b.length, 100),
/beyond the file/u,
],
[
"unavailable load bytes",
(b) => b.writeUInt32LE(b.length + 1, 68),
/PT_LOAD range/u,
],
["missing terminator", (b) => b.writeUInt32LE(1, 280), /DT_NULL/u],
[
"repeated scalar tags",
(b) => b.writeUInt32LE(17, 272),
/duplicate MIPS dynamic tag/u,
],
["invalid REL entry size", (b) => b.writeUInt32LE(16, 276), /DT_RELENT/u],
["truncated REL record", (b) => b.writeUInt32LE(17, 268), /DT_RELENT/u],
[
"unmapped REL table",
(b) => b.writeUInt32LE(0x10000, 260),
/PT_LOAD mapping/u,
],
[
"unmapped REL destination",
(b) => b.writeUInt32LE(0x10000, relOffset + 8),
/PT_LOAD memory range/u,
],
[
"RELA declaration",
(b) => {
b.writeUInt32LE(7, 272);
b.writeUInt32LE(4096, 276);
},
/RELA or PLT/u,
],
];
it.each(malformed)(
"preserves useful partial header facts for %s",
async (_name, mutate, reason) => {
const bytes = fixture(true);
mutate(bytes);
const target = await resolve(bytes);
assert.equal(target.mips.flags, 0x74001005);
assert.equal(target.mips.abiFlags, null);
assert.equal(target.mips.sharedLoad?.status, "unavailable");
assert.match(ghidraMipsUnsupportedReason(target) ?? "", reason);
},
);
it("retains unverified relocation type instead of claiming every examined record is supported", async () => {
const bytes = fixture(true);
bytes.writeUInt32LE(5, relOffset + 12);
const target = await resolve(bytes);
assert.deepEqual(target.mips.sharedLoad, {
status: "complete",
relocationCount: 2,
relocationTypes: [0, 5],
symbolRelativeCount: 0,
});
assert.match(
ghidraMipsUnsupportedReason(target) ?? "",
/other dynamic relocation types/u,
);
});
it("does not equate complete dynamic REL coverage with coverage of another section relocation table", async () => {
const bytes = fixture(true);
bytes.writeUInt32LE(1024, 32);
bytes.writeUInt16LE(40, 46);
bytes.writeUInt16LE(1, 48);
bytes.writeUInt32LE(9, 1028);
bytes.writeUInt32LE(2048, 1040);
bytes.writeUInt32LE(8, 1044);
bytes.writeUInt32LE(8, 1060);
const target = await resolve(bytes);
assert.equal(target.mips.sharedLoad?.status, "unavailable");
assert.match(
ghidraMipsUnsupportedReason(target) ?? "",
/section relocation declarations/u,
);
});
it("preserves the caller cancellation error during a bounded relocation scan", async () => {
const target = await resolve(fixture(true, 8194));
const handle = await open(target.path, "r");
const cancellation = new AnalysisCancelledError("open_binary");
let checks = 0;
try {
await assert.rejects(
readMipsElfMetadata(handle, target.mips, () => {
checks += 1;
if (checks === 100) throw cancellation;
}),
(error) => error === cancellation,
);
} finally {
await handle.close();
}
});

View File

@ -0,0 +1,22 @@
/* Build an older-style shared image from source without ABI declarations. */
PHDRS {
headers PT_PHDR PHDRS;
text PT_LOAD FILEHDR PHDRS FLAGS(5);
data PT_LOAD FLAGS(6);
dynamic PT_DYNAMIC FLAGS(6);
}
SECTIONS {
. = SIZEOF_HEADERS;
.hash : { *(.hash) } :text
.gnu.hash : { *(.gnu.hash) } :text
.dynsym : { *(.dynsym) } :text
.dynstr : { *(.dynstr) } :text
.rel.dyn : { *(.rel.*) } :text
.text : { *(.text*) } :text
. = ALIGN(0x10000);
.dynamic : { *(.dynamic) } :data :dynamic
.got : { *(.got*) } :data
.data : { *(.data*) } :data
.bss : { *(.bss*) *(COMMON) } :data
/DISCARD/ : { *(.MIPS.abiflags) *(.reginfo) *(.pdr) *(.mdebug.*) *(.comment) *(.note.*) }
}

View File

@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execute = promisify(execFile);
const source = fileURLToPath(new URL("./mips16-shared.S", import.meta.url));
const linkerScript = fileURLToPath(
new URL("./mips16-recordless.ld", import.meta.url),
);
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
/** BYO GNU cross-binutils; generated controls are never executed or byte-patched. */
export async function buildMips16SharedFixture(directory, byteOrder, variant) {
assert(["little", "big"].includes(byteOrder));
assert(
[
"declared",
"recordless",
"symbol-relative",
"section-relocations",
].includes(variant),
);
const assembler = process.env.REA_MIPS_AS ?? "mips-linux-gnu-as";
const linker = process.env.REA_MIPS_LD ?? "mips-linux-gnu-ld";
const endian = byteOrder === "little" ? "-EL" : "-EB";
const prefix = join(directory, `${byteOrder}-${variant}`);
const object = `${prefix}.o`;
const path = `${prefix}.so`;
const assemblerVersion = (
await execute(assembler, ["--version"], { timeout: 10000 })
).stdout;
const linkerVersion = (
await execute(linker, ["--version"], { timeout: 10000 })
).stdout;
assert(
assemblerVersion.includes("GNU assembler"),
"Use GNU's MIPS assembler: other assemblers have different compressed-instruction behavior.",
);
assert(linkerVersion.includes("GNU ld"));
await execute(
assembler,
[
"-32",
"-mips32r2",
endian,
...(variant === "symbol-relative"
? ["--defsym", "REA_MIPS_SYMBOL_RELATIVE=1"]
: []),
source,
"-o",
object,
],
{ timeout: 60000 },
);
await execute(
linker,
[
endian,
"-shared",
...(variant === "section-relocations" ? ["--emit-relocs"] : []),
...(variant === "recordless" ? ["-T", linkerScript] : []),
object,
"-o",
path,
],
{ timeout: 60000 },
);
const bytes = await readFile(path);
const little = byteOrder === "little";
const u16 = (offset) =>
little ? bytes.readUInt16LE(offset) : bytes.readUInt16BE(offset);
const u32 = (offset) =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
assert.equal(
bytes.subarray(0, 6).toString("hex"),
little ? "7f454c460101" : "7f454c460102",
);
assert.equal(u16(16), 3);
assert.equal(u16(18), 8);
assert.equal(u32(36), 0x74001007); // noreorder/PIC/CPIC plus o32/MIPS16/MIPS32r2.
const headers = Array.from(
{ length: u16(44) },
(_, i) => u32(28) + i * u16(42),
);
const fileOffset = (address, size) => {
const mappings = headers.filter(
(offset) =>
u32(offset) === 1 &&
address >= u32(offset + 8) &&
address - u32(offset + 8) <= u32(offset + 16) - size,
);
assert.equal(mappings.length, 1);
return u32(mappings[0] + 4) + address - u32(mappings[0] + 8);
};
const readelf = await execute(
process.env.REA_MIPS_READELF ?? "readelf",
["-W", "-h", "-l", "-s", "-r", path],
{ timeout: 10000 },
);
const symbols = {};
for (const name of [
"rea_mips16_probe",
"rea_mips32_probe",
"rea_mips16_pointer",
]) {
const addresses = new Set(
readelf.stdout
.split("\n")
.map((line) => line.trim().split(/\s+/u))
.filter(
(fields) =>
fields[0]?.endsWith(":") === true &&
["FUNC", "OBJECT"].includes(fields[3]) &&
fields.at(-1) === name,
)
.map((fields) => Number.parseInt(fields[1], 16)),
);
assert.equal(addresses.size, 1, `Missing or conflicting ${name}`);
symbols[name] = [...addresses][0];
}
assert.equal(symbols.rea_mips16_probe & 1, 1);
assert.equal(symbols.rea_mips32_probe & 1, 0);
const compressedOffset = fileOffset(symbols.rea_mips16_probe - 1, 8);
assert.equal(
bytes.subarray(compressedOffset, compressedOffset + 8).toString("hex"),
little ? "5a6a8eea074aa0e8" : "6a5aea8e4a07e8a0",
);
const standardOffset = fileOffset(symbols.rea_mips32_probe, 12);
assert.equal(
bytes.subarray(standardOffset, standardOffset + 12).toString("hex"),
little ? "030082240800e00300000000" : "2482000303e0000800000000",
);
const pointerOffset = fileOffset(symbols.rea_mips16_pointer, 4);
const rawPointer = u32(pointerOffset);
if (variant !== "symbol-relative")
assert.equal(rawPointer, symbols.rea_mips16_probe);
return {
path,
sha256: hash(bytes),
byte_order: byteOrder,
variant,
elf_flags: u32(36),
symbols,
raw_pointer: rawPointer,
source_sha256: hash(await readFile(source)),
linker_script_sha256:
variant === "recordless" ? hash(await readFile(linkerScript)) : null,
assembler_version: assemblerVersion.split("\n")[0],
linker_version: linkerVersion.split("\n")[0],
readelf_output: readelf.stdout,
};
}

View File

@ -0,0 +1,41 @@
/* Source-owned mixed-instruction shared-image control. */
.abicalls
.module fp=xx
.module nooddspreg
.text
.set noreorder
.set nomicromips
.set mips16
.globl rea_mips16_probe
.type rea_mips16_probe, @function
.ent rea_mips16_probe
rea_mips16_probe:
li $2, 90
xor $2, $4
addiu $2, 7
jrc $31
.end rea_mips16_probe
.size rea_mips16_probe, . - rea_mips16_probe
.ifndef REA_MIPS_SYMBOL_RELATIVE
.hidden rea_mips16_probe
.endif
.set nomips16
.globl rea_mips32_probe
.type rea_mips32_probe, @function
.ent rea_mips32_probe
rea_mips32_probe:
addiu $2, $4, 3
jr $31
nop
.end rea_mips32_probe
.size rea_mips32_probe, . - rea_mips32_probe
.data
.balign 4
.globl rea_mips16_pointer
.type rea_mips16_pointer, @object
.size rea_mips16_pointer, 4
rea_mips16_pointer:
.word rea_mips16_probe
.section .note.GNU-stack, "", @progbits