mirror of
https://github.com/morluto/rea.git
synced 2026-10-29 10:55:57 +00:00
Correlate typed labels with recovered block entries and one indirect dispatch. Preserve ambiguous and nonexact integer associations as unknown with raw diagnostics. Expose defaults separately, collect backing load metadata, and invalidate old profiles. Add independent compiler-byte CLI/MCP acceptance and actual Ghidra model controls. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) Assisted-by: Suika (gensokyo-skills:suika-scatter-gather-planning)
546 lines
17 KiB
JavaScript
546 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { isAbsolute, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
|
import { resolveGhidraAnalysisProfile } from "../dist/ghidra/GhidraAnalysisProfile.js";
|
|
import { GhidraClient } from "../dist/ghidra/GhidraClient.js";
|
|
import {
|
|
inspectGhidraInstallation,
|
|
SUPPORTED_GHIDRA_VERSION,
|
|
} from "../dist/ghidra/GhidraInstallation.js";
|
|
import { GhidraHeadlessLauncher } from "../dist/ghidra/GhidraLauncher.js";
|
|
import { GHIDRA_PROVIDER_IDENTITY } from "../dist/ghidra/GhidraProvider.js";
|
|
|
|
import {
|
|
assertCleanup,
|
|
assertCrossFixture,
|
|
assertDebugFixture,
|
|
assertMalformedFixture,
|
|
assertRuntimeCoordinates,
|
|
assertSession,
|
|
assertStrippedFixture,
|
|
summary,
|
|
} from "./verify-real-ghidra-assertions.mjs";
|
|
import {
|
|
allItems,
|
|
call,
|
|
verifyInventoryOperations,
|
|
} from "./verify-real-ghidra-inventory.mjs";
|
|
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
|
import { hasTypedSwitchEvidence } from "./verify-real-ghidra-switch-assertions.mjs";
|
|
|
|
import {
|
|
verifyDenseDefaultReturn,
|
|
verifyNativeTypeLayout,
|
|
verifyRelativeSwitch,
|
|
verifyNativeValueTrace,
|
|
} from "./verify-real-ghidra-function.mjs";
|
|
const exec = promisify(execFile);
|
|
const verifierRun = createVerifierRun();
|
|
const installDir = process.env.GHIDRA_INSTALL_DIR;
|
|
if (installDir === undefined || !isAbsolute(installDir))
|
|
throw new Error(
|
|
"Set GHIDRA_INSTALL_DIR to the absolute root of an extracted Ghidra 12.1.4 release.",
|
|
);
|
|
const installation = inspectGhidraInstallation({
|
|
installDir,
|
|
...(process.env.JAVA_HOME === undefined
|
|
? {}
|
|
: { javaHome: process.env.JAVA_HOME }),
|
|
});
|
|
if (
|
|
installation.status !== "available" ||
|
|
installation.analyzeHeadlessPath === null
|
|
)
|
|
throw new Error(
|
|
`Ghidra installation is unavailable: ${JSON.stringify(installation)}`,
|
|
);
|
|
if (installation.providerVersion !== SUPPORTED_GHIDRA_VERSION)
|
|
throw new Error("Ghidra provider version commitment drifted");
|
|
|
|
const crossFormat = process.argv.includes("--cross-format");
|
|
const aarch64JumpTableOnly = process.argv.includes("--aarch64-jump-table");
|
|
const expectedNativeTarget = nativeFixtureTarget(
|
|
process.platform,
|
|
process.arch,
|
|
);
|
|
const fixtureRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-fixtures-"));
|
|
const sourcePath = fileURLToPath(
|
|
new URL("../tests/conformance/ghidra/inventory.c", import.meta.url),
|
|
);
|
|
const crossFormatSourcePath = fileURLToPath(
|
|
new URL("../tests/conformance/ghidra/cross-format.c", import.meta.url),
|
|
);
|
|
const debugPath = join(fixtureRoot, "rea-ghidra-inventory-debug");
|
|
const layoutPath = join(fixtureRoot, "rea-ghidra-layout.o");
|
|
const strippedPath = join(fixtureRoot, "rea-ghidra-inventory-stripped");
|
|
const arm64ElfPath = join(fixtureRoot, "rea-ghidra-cross-arm64");
|
|
const peObjectPath = join(fixtureRoot, "rea-ghidra-cross-x86_64.obj");
|
|
const pePath = join(fixtureRoot, "rea-ghidra-cross-x86_64.exe");
|
|
const peImportLibraryPath = join(fixtureRoot, "rea-ghidra-cross-x86_64.lib");
|
|
const machObjectPath = join(fixtureRoot, "rea-ghidra-cross-x86_64.o");
|
|
const malformedPath = join(fixtureRoot, "rea-ghidra-malformed");
|
|
const compiler = process.env.REA_CC ?? "cc";
|
|
const clang = process.env.REA_CLANG ?? "clang";
|
|
const lld = process.env.REA_LLD ?? "ld.lld";
|
|
const lldLink = process.env.REA_LLD_LINK ?? "lld-link";
|
|
const lane = crossFormat
|
|
? "cross-format"
|
|
: aarch64JumpTableOnly
|
|
? "AArch64 jump-table"
|
|
: "host-format";
|
|
const toolchain = new Map([[compiler, "host fixture compiler"]]);
|
|
if (crossFormat || aarch64JumpTableOnly)
|
|
toolchain.set(clang, "cross-format clang compiler");
|
|
if (crossFormat) {
|
|
toolchain.set(lld, "LLVM LLD linker");
|
|
toolchain.set(lldLink, "Windows PE linker");
|
|
}
|
|
const crossTarget = crossFormat
|
|
? "aarch64-linux-gnu, x86_64-pc-windows-msvc, x86_64-apple-darwin"
|
|
: aarch64JumpTableOnly
|
|
? "aarch64-linux-gnu"
|
|
: `${expectedNativeTarget.format}/${expectedNativeTarget.architecture}`;
|
|
for (const [command, role] of toolchain) {
|
|
try {
|
|
await exec(command, [command === lldLink ? "/?" : "--version"]);
|
|
} catch (cause) {
|
|
throw new Error(
|
|
`Ghidra ${lane} verification cannot run for target ${crossTarget}: required ${role} '${command}' is unavailable or failed its preflight.`,
|
|
{ cause },
|
|
);
|
|
}
|
|
}
|
|
try {
|
|
const common = ["-O0", "-g", "-fno-inline"];
|
|
if (expectedNativeTarget.format === "elf") common.push("-fno-pie", "-no-pie");
|
|
await exec(compiler, [...common, sourcePath, "-o", debugPath]);
|
|
await exec(compiler, [
|
|
"-O0",
|
|
"-g",
|
|
"-gdwarf-4",
|
|
"-c",
|
|
sourcePath,
|
|
"-o",
|
|
layoutPath,
|
|
]);
|
|
const strippedFlags = [...common, "-s"];
|
|
if (expectedNativeTarget.format === "mach-o")
|
|
strippedFlags.push("-fvisibility=hidden");
|
|
await exec(compiler, [...strippedFlags, sourcePath, "-o", strippedPath]);
|
|
const crossTargets = [];
|
|
if (aarch64JumpTableOnly) {
|
|
const relativeSource = fileURLToPath(
|
|
new URL("../tests/conformance/ghidra/relative-switch.S", import.meta.url),
|
|
);
|
|
for (const entrySize of [1, 2]) {
|
|
for (const format of [
|
|
"elf",
|
|
...(expectedNativeTarget.architecture === "arm64" ? ["mach-o"] : []),
|
|
]) {
|
|
const targetPath = join(
|
|
fixtureRoot,
|
|
`relative-${entrySize}-${format}.o`,
|
|
);
|
|
await exec(clang, [
|
|
...(format === "elf" ? ["--target=aarch64-linux-gnu"] : []),
|
|
`-DREA_ENTRY_BYTES=${entrySize}`,
|
|
"-c",
|
|
relativeSource,
|
|
"-o",
|
|
targetPath,
|
|
]);
|
|
crossTargets.push([
|
|
targetPath,
|
|
`relative-${entrySize}`,
|
|
{ format, architecture: "arm64" },
|
|
]);
|
|
}
|
|
}
|
|
|
|
await exec(clang, [
|
|
"--target=aarch64-linux-gnu",
|
|
"-O2",
|
|
"-g",
|
|
"-fno-inline",
|
|
"-fno-pie",
|
|
"-c",
|
|
crossFormatSourcePath,
|
|
"-o",
|
|
arm64ElfPath,
|
|
]);
|
|
crossTargets.push([
|
|
arm64ElfPath,
|
|
"aarch64-jump-table",
|
|
{ format: "elf", architecture: "arm64" },
|
|
]);
|
|
} else if (crossFormat) {
|
|
await exec(clang, [
|
|
"--target=aarch64-linux-gnu",
|
|
"-O2",
|
|
"-g",
|
|
"-fno-inline",
|
|
"-fno-pie",
|
|
"-nostdlib",
|
|
"-static",
|
|
"-fuse-ld=lld",
|
|
crossFormatSourcePath,
|
|
"-Wl,-e,rea_cross_start",
|
|
"-o",
|
|
arm64ElfPath,
|
|
]);
|
|
await exec(clang, [
|
|
"--target=x86_64-pc-windows-msvc",
|
|
"-O0",
|
|
"-gcodeview",
|
|
"-fno-inline",
|
|
"-c",
|
|
crossFormatSourcePath,
|
|
"-o",
|
|
peObjectPath,
|
|
]);
|
|
await exec(lldLink, [
|
|
"/entry:rea_cross_start",
|
|
"/subsystem:console",
|
|
"/nodefaultlib",
|
|
"/export:rea_cross_entry",
|
|
`/implib:${peImportLibraryPath}`,
|
|
`/out:${pePath}`,
|
|
peObjectPath,
|
|
]);
|
|
await exec(clang, [
|
|
"--target=x86_64-apple-darwin",
|
|
"-O0",
|
|
"-g",
|
|
"-fno-inline",
|
|
"-c",
|
|
crossFormatSourcePath,
|
|
"-o",
|
|
machObjectPath,
|
|
]);
|
|
crossTargets.push(
|
|
[
|
|
arm64ElfPath,
|
|
"cross-arm64-elf",
|
|
{ format: "elf", architecture: "arm64" },
|
|
],
|
|
[pePath, "cross-x86_64-pe", { format: "pe", architecture: "x86_64" }],
|
|
[
|
|
machObjectPath,
|
|
"cross-x86_64-mach-o",
|
|
{ format: "mach-o", architecture: "x86_64" },
|
|
],
|
|
);
|
|
}
|
|
await writeFile(malformedPath, Buffer.from("not-a-binary\n", "utf8"));
|
|
|
|
const debug = await verifyTarget(debugPath, "debug", expectedNativeTarget);
|
|
assertDebugFixture(debug);
|
|
const layout = await verifyTarget(
|
|
layoutPath,
|
|
"type-layout",
|
|
expectedNativeTarget,
|
|
);
|
|
const stripped = await verifyTarget(
|
|
strippedPath,
|
|
"stripped",
|
|
expectedNativeTarget,
|
|
);
|
|
assertStrippedFixture(stripped);
|
|
const crossResults = [];
|
|
for (const [targetPath, variant, expectedTarget] of crossTargets) {
|
|
const result = await verifyTarget(targetPath, variant, expectedTarget);
|
|
if (variant !== "aarch64-jump-table" && !variant.startsWith("relative-"))
|
|
assertCrossFixture(result);
|
|
crossResults.push(result);
|
|
}
|
|
await assertMalformedFixture(malformedPath);
|
|
|
|
const customPath = process.env.GHIDRA_TARGET_PATH;
|
|
const custom =
|
|
customPath === undefined ? null : await verifyTarget(customPath, "custom");
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
verifier_run: await completeVerifierRun(verifierRun),
|
|
ok: true,
|
|
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
|
|
verification_lane: aarch64JumpTableOnly
|
|
? "aarch64-jump-table"
|
|
: crossFormat
|
|
? "cross-format"
|
|
: "host-native",
|
|
fixture_sources:
|
|
crossFormat || aarch64JumpTableOnly
|
|
? [sourcePath, crossFormatSourcePath]
|
|
: [sourcePath],
|
|
fixtures: [debug, stripped, layout, ...crossResults].map(summary),
|
|
malformed_target: "rejected-before-provider-start",
|
|
native_api_cli: aarch64JumpTableOnly
|
|
? (crossResults[0]?.native_api_cli ?? null)
|
|
: debug.native_api_cli,
|
|
native_value_e2e: debug.native_values,
|
|
custom_target: custom === null ? null : summary(custom),
|
|
cleanup: "complete",
|
|
})}\n`,
|
|
);
|
|
} finally {
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
function nativeFixtureTarget(platform, architecture) {
|
|
const supportedTargets = {
|
|
"darwin-arm64": { format: "mach-o", architecture: "arm64" },
|
|
"darwin-x64": { format: "mach-o", architecture: "x86_64" },
|
|
"linux-x64": { format: "elf", architecture: "x86_64" },
|
|
};
|
|
const target = supportedTargets[`${platform}-${architecture}`];
|
|
if (target === undefined)
|
|
throw new Error(
|
|
`The Ghidra verifier does not support a host-native fixture on ${platform}/${architecture}; use a supported Linux x64 or macOS x64/arm64 host.`,
|
|
);
|
|
return target;
|
|
}
|
|
|
|
async function verifyNativeApiCli(
|
|
targetPath,
|
|
procedures,
|
|
{ requireDenseJumpTable, denseSwitchSymbol, client },
|
|
) {
|
|
const denseSwitch = procedures.find(({ value }) =>
|
|
value.endsWith(denseSwitchSymbol),
|
|
);
|
|
if (denseSwitch === undefined)
|
|
throw new Error("The Ghidra inventory omitted the dense switch fixture");
|
|
const { stdout } = await exec(
|
|
process.execPath,
|
|
[
|
|
"scripts/rea.mjs",
|
|
"inspect-native-api",
|
|
targetPath,
|
|
denseSwitch.address,
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
maxBuffer: 72 * 1024 * 1024,
|
|
},
|
|
);
|
|
const evidence = JSON.parse(stdout);
|
|
const boundary = evidence.normalized_result?.boundary;
|
|
const denseTable = boundary?.jump_tables?.find(
|
|
({ mappings }) => mappings.length > 32,
|
|
);
|
|
const denseCases = new Set(
|
|
denseTable?.mappings.flatMap(({ case_value }) =>
|
|
typeof case_value === "number" ? [case_value] : [],
|
|
) ?? [],
|
|
);
|
|
const expectedCases = Array.from({ length: 40 }, (_, index) => index);
|
|
const mappingsAreExact = expectedCases.every((caseValue) =>
|
|
denseTable?.mappings.some(
|
|
(mapping) =>
|
|
mapping.case_value === caseValue &&
|
|
mapping.confidence === "high" &&
|
|
(hasTypedSwitchEvidence(
|
|
mapping.evidence,
|
|
`Typed case value ${caseValue}`,
|
|
mapping.target_address,
|
|
denseTable.dispatch_address,
|
|
) ||
|
|
mapping.evidence.some(
|
|
({ kind, source, detail }) =>
|
|
kind === "jump-table" &&
|
|
typeof detail === "string" &&
|
|
detail.trim().length > 0 &&
|
|
((source === "ghidra-high-function" &&
|
|
detail.includes(
|
|
`case value ${caseValue} with target ${mapping.target_address}`,
|
|
)) ||
|
|
(source === "ghidra-instruction-and-memory" &&
|
|
detail.startsWith(`Case value ${caseValue} indexes byte `) &&
|
|
detail.includes(`reaching ${mapping.target_address}.`))),
|
|
)),
|
|
),
|
|
);
|
|
if (
|
|
evidence.operation !== "inspect_native_api" ||
|
|
evidence.provider?.id !== "rea-workflow" ||
|
|
evidence.analysis_profile?.provider?.id !== "rea-workflow" ||
|
|
boundary?.available !== true
|
|
)
|
|
throw new Error(
|
|
`The shipped inspect-native-api CLI did not return an available boundary: ${stdout}`,
|
|
);
|
|
if (
|
|
requireDenseJumpTable &&
|
|
(denseTable === undefined ||
|
|
denseCases.size !== expectedCases.length ||
|
|
denseTable.mappings.length !== expectedCases.length ||
|
|
denseTable.default_targets.length !== 1 ||
|
|
denseTable.default_targets[0].confidence !== "high" ||
|
|
!hasTypedSwitchEvidence(
|
|
denseTable.default_targets[0].evidence,
|
|
"Typed default label",
|
|
denseTable.default_targets[0].target_address,
|
|
denseTable.dispatch_address,
|
|
) ||
|
|
denseTable.mappings.some(
|
|
({ target_address }) =>
|
|
target_address === denseTable.default_targets[0].target_address,
|
|
) ||
|
|
expectedCases.some((caseValue) => !denseCases.has(caseValue)) ||
|
|
!mappingsAreExact ||
|
|
evidence.normalized_result?.residual_unknowns?.length !== 0)
|
|
)
|
|
throw new Error(
|
|
`The shipped inspect-native-api CLI did not preserve complete dense jump-table output: ${stdout}`,
|
|
);
|
|
const defaultOracle = requireDenseJumpTable
|
|
? await verifyDenseDefaultReturn(
|
|
client,
|
|
denseTable.default_targets[0].target_address,
|
|
)
|
|
: null;
|
|
return {
|
|
operation: evidence.operation,
|
|
provider: evidence.provider,
|
|
mappings_returned: denseTable?.mappings.length ?? null,
|
|
default_oracle: defaultOracle,
|
|
residual_unknowns: evidence.normalized_result.residual_unknowns.length,
|
|
};
|
|
}
|
|
|
|
async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
|
const parsedTarget = await parseBinaryTarget(targetPath);
|
|
if (!parsedTarget.ok) throw parsedTarget.error;
|
|
if (
|
|
expectedTarget !== null &&
|
|
(parsedTarget.value.format !== expectedTarget.format ||
|
|
parsedTarget.value.architecture !== expectedTarget.architecture)
|
|
)
|
|
throw new Error(
|
|
`Fixture header classification drifted for ${variant}: ${JSON.stringify(parsedTarget.value)}`,
|
|
);
|
|
const profile = await resolveGhidraAnalysisProfile(
|
|
parsedTarget.value,
|
|
GHIDRA_PROVIDER_IDENTITY,
|
|
installation,
|
|
);
|
|
if (!profile.ok || profile.value.profile === null)
|
|
throw new Error("Ghidra analysis profile could not be committed");
|
|
|
|
const client = new GhidraClient({
|
|
launcher: new GhidraHeadlessLauncher({
|
|
analyzeHeadlessPath: installation.analyzeHeadlessPath,
|
|
...(process.env.JAVA_HOME === undefined
|
|
? {}
|
|
: { javaHome: process.env.JAVA_HOME }),
|
|
bridgeScriptPath: fileURLToPath(
|
|
new URL("../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
|
|
),
|
|
platform: installation.platform,
|
|
}),
|
|
targetPath: parsedTarget.value.path,
|
|
targetSha256: parsedTarget.value.sha256,
|
|
providerVersion: SUPPORTED_GHIDRA_VERSION,
|
|
profileDigest: profile.value.profile.digest,
|
|
});
|
|
|
|
let runtimeCoordinates;
|
|
try {
|
|
const started = await client.start();
|
|
if (!started.ok) throw started.error;
|
|
assertSession(
|
|
started.value,
|
|
profile.value.profile.digest,
|
|
parsedTarget.value.sha256,
|
|
);
|
|
const pinged = await client.ping();
|
|
if (!pinged.ok) throw pinged.error;
|
|
assertSession(
|
|
pinged.value,
|
|
profile.value.profile.digest,
|
|
parsedTarget.value.sha256,
|
|
);
|
|
runtimeCoordinates = client.diagnostics();
|
|
assertRuntimeCoordinates(runtimeCoordinates);
|
|
|
|
const documents = await call(client, "list_documents", {});
|
|
const segments = await call(client, "list_segments", { document: null });
|
|
const procedures = await allItems(client, "list_procedures", {
|
|
document: null,
|
|
});
|
|
const names = await allItems(client, "list_names", {
|
|
document: null,
|
|
address: null,
|
|
});
|
|
const strings = await allItems(client, "list_strings", {
|
|
document: null,
|
|
address: null,
|
|
});
|
|
const crossArm64Elf =
|
|
variant === "cross-arm64-elf" || variant === "aarch64-jump-table";
|
|
const nativeApiCli =
|
|
variant === "debug" || crossArm64Elf
|
|
? await verifyNativeApiCli(parsedTarget.value.path, procedures, {
|
|
requireDenseJumpTable:
|
|
crossArm64Elf ||
|
|
(expectedNativeTarget.format === "elf" &&
|
|
expectedNativeTarget.architecture === "x86_64"),
|
|
denseSwitchSymbol: crossArm64Elf
|
|
? "rea_cross_dense_switch"
|
|
: "rea_ghidra_inventory_dense_switch",
|
|
client,
|
|
})
|
|
: null;
|
|
const probes = variant.startsWith("relative-")
|
|
? await verifyRelativeSwitch(
|
|
client,
|
|
procedures,
|
|
Number(variant.slice(-1)),
|
|
)
|
|
: variant === "type-layout"
|
|
? await verifyNativeTypeLayout(client, names)
|
|
: variant === "custom" || variant === "aarch64-jump-table"
|
|
? null
|
|
: await verifyInventoryOperations({
|
|
client,
|
|
variant,
|
|
procedures,
|
|
names,
|
|
strings,
|
|
});
|
|
const nativeValues =
|
|
variant === "debug"
|
|
? await verifyNativeValueTrace(client, procedures, parsedTarget.value)
|
|
: null;
|
|
return {
|
|
variant,
|
|
target: parsedTarget.value,
|
|
profile: profile.value.profile,
|
|
session: pinged.value,
|
|
documents,
|
|
segments,
|
|
procedures,
|
|
names,
|
|
strings,
|
|
probes,
|
|
native_api_cli: nativeApiCli,
|
|
native_values: nativeValues,
|
|
};
|
|
} finally {
|
|
await client.close();
|
|
if (runtimeCoordinates !== undefined)
|
|
await assertCleanup(runtimeCoordinates);
|
|
}
|
|
}
|