Files
reverse-engineering-anything/scripts/verify-ghidra-loader-relocation.mjs
T
morluto 94c260017a refactor(verify): give the remaining verifiers' roots to VerifierFixture (#11184)
* refactor(verify): give the remaining verifiers' roots to VerifierFixture

* fix(verify): let the loader-relocation supervisor dispose itself so a retried stop can observe its exit

* test(verify): give the isolated bridge-sources and packaged-wasm runs the fixture owner they now import

* test(verify): observe the Bun and Go verifier roots where VerifierFixture loads them
2026-11-16 00:04:05 +08:00

243 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
} from "../dist/process/ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js";
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import {
VerifierFixture,
withVerifierFixture,
} from "./lib/verifier-fixture.mjs";
const exec = promisify(execFile);
const expectedChecks = [
"bridge_source_bound",
"relocation_protection_precedes_decompiler",
"fixture_is_elf_shared_object",
"dynamic_elf_relocation_table_is_relocatable",
"importer_relocation_present",
"relocation_not_applied_by_importer",
"reader_references_relocation",
"writer_references_relocation",
"got_function_relocation_present",
"got_data_relocation_present",
"got_caller_references_loader_relocation",
"destination_is_loaded_initialized",
"destination_is_file_backed",
"destination_is_non_writable",
"destination_not_volatile_before_protection",
"destination_block_is_volatile",
"destination_permissions_preserved",
"source_filebytes_unchanged",
"evidence_limitation_represents_loader_uncertainty",
"non_read_relocation_reference_not_reported_as_read",
"relocation_lookup_covers_last_affected_byte",
"applied_got_relocations_not_volatile",
"native_decompiler_available",
"baseline_got_pseudocode_resolves_external_symbols",
"protected_got_pseudocode_keeps_external_symbols",
"full_relro_got_caller_pseudocode_is_stable",
];
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-ghidra-loader-relocation.mjs");
if (process.platform !== "linux")
throw new Error(
"Loader-relocation verification requires a Linux Ghidra host with an ELF linker.",
);
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
);
const installation = await inspectVerifierGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
assert.ok(
installation.analyzeHeadlessPath !== null &&
(installation.status === "available" ||
installation.rejection?.code === "executable_missing"),
JSON.stringify(installation),
);
const run = createVerifierRun();
const workspaceFixture = await VerifierFixture.open({
prefix: "rea-ghidra-loader-relocation-",
private: false,
});
const workspace = workspaceFixture.path;
const project = join(workspace, "project");
const target = join(workspace, "rea-loader-relocation.elf");
const provider = join(workspace, "librea-loader-provider.so");
const fixture = fileURLToPath(
new URL("../tests/conformance/ghidra/loader-relocation.c", import.meta.url),
);
const providerFixture = fileURLToPath(
new URL(
"../tests/conformance/ghidra/loader-relocation-provider.c",
import.meta.url,
),
);
const bridgeDirectory = fileURLToPath(
new URL("../bridge/ghidra", import.meta.url),
);
const fixtureDirectory = fileURLToPath(
new URL("../tests/conformance/ghidra", import.meta.url),
);
const bridge = join(bridgeDirectory, "ReaGhidraBridge.java");
const compiler = process.env.REA_CC ?? "cc";
let supervisor;
let report;
// Owned Ghidra processes may still use the workspace until cleanup proves
// they are gone, so the fixture removes it only after that proof.
workspaceFixture.releaseAfter(async () => {
if (supervisor === undefined) return;
const stopped = await supervisor.stop();
assert.ok(
stopped.status !== "incomplete",
`Owned Ghidra cleanup incomplete; retained ${workspace}: ${JSON.stringify(stopped)}`,
);
});
await withVerifierFixture(workspaceFixture, async () => {
await mkdir(project);
await buildProvider(compiler, providerFixture, provider);
await buildFixture(compiler, fixture, provider, target);
const targetDigest = digest(await readFile(target));
const launch = await spawnOwnedProviderProcess({
command: installation.analyzeHeadlessPath,
arguments: [
project,
"LoaderRelocation",
"-import",
target,
"-readOnly",
"-deleteProject",
"-scriptPath",
`${bridgeDirectory};${fixtureDirectory}`,
"-postScript",
"ReaLoaderRelocationProbe.java",
bridge,
],
runId: run.run_id,
expectedCommand: null,
});
supervisor = new ProviderProcessSupervisor({
...launch,
ownsProcessLifetime: true,
cleanup: () => cleanupOwnedProcessGroup(launch.ownership),
});
assert.ok(
await supervisor.waitForExit(180_000),
"Ghidra loader-relocation probe exceeded its 180-second deadline",
);
assert.ok(
await supervisor.waitForOutputClose(1000),
"Ghidra loader-relocation probe output did not close",
);
const snapshot = supervisor.snapshot();
const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`;
assert.equal(snapshot.exitCode, 0, output);
const matches = [
...output.matchAll(/REA_LOADER_RELOCATION_PROBE_JSON (\{[^\r\n]*\})/gu),
];
assert.equal(matches.length, 1, output);
report = JSON.parse(matches[0][1]);
assert.equal(report.status, "passed");
assert.equal(report.ghidra_version, installation.providerVersion);
assert.deepEqual(
expectedChecks.every((check) => report.checks.includes(check)),
true,
);
assert.equal(report.source_filebytes_unchanged, true);
assert.equal(report.permissions, "read=true,write=false,execute=false");
assert.ok(
report.limitations.some(
(value) =>
value.includes("non-writable block") &&
value.includes("file-backed value may differ") &&
value.includes("not authoritative"),
),
);
assert.equal(report.decompiler_verified, true);
assert.ok(report.checks.includes("pseudocode_does_not_use_file_constant"));
// Ghidra 12.1.4 shows this x86-64 copy-relocated read by name even
// unprotected, so the before/after difference is checked only when the
// baseline folds the file-backed zero.
assert.equal(typeof report.baseline_folds_file_constant, "boolean");
if (report.baseline_folds_file_constant)
assert.ok(
report.checks.includes("protected_pseudocode_is_not_baseline_constant"),
);
assert.ok(
report.checks.includes("full_relro_got_caller_pseudocode_is_stable"),
);
assert.equal(digest(await readFile(target)), targetDigest);
report = {
...report,
target_sha256: targetDigest,
decompiler_scope: report.baseline_folds_file_constant
? "native Ghidra baseline folded the file constant; protected pseudocode does not"
: "native Ghidra baseline did not fold the file constant on this toolchain; protected pseudocode does not use it and GOT callers are unchanged",
};
});
assert.ok(report);
console.log(
JSON.stringify({
...report,
cleanup: "complete",
verifier_run: await completeVerifierRun(run),
}),
);
async function buildProvider(compiler, source, output) {
const arguments_ = [
"-O0",
"-fPIC",
"-shared",
"-nostdlib",
source,
"-o",
output,
];
await exec(compiler, arguments_, { timeout: 60_000 });
}
async function buildFixture(compiler, source, provider, output) {
const arguments_ = [
"-O0",
"-fPIE",
"-fno-plt",
"-pie",
"-nostdlib",
"-Wl,-z,relro,-z,now",
"-Wl,-e,rea_loader_start",
"-Wl,-rpath,$ORIGIN",
source,
provider,
"-o",
output,
];
await exec(compiler, arguments_, { timeout: 60_000 });
}
function digest(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}