mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
* fix(ghidra): prevent loader relocations from constant folding Ghidra imports loader relocation destinations with their file bytes. A destination in a non-writable loaded block can be populated by the runtime loader instead, for example a copy-relocated musl `stderr` in RELRO, so the decompiler folded the on-disk zero and showed `fprintf(NULL, ...)`. Before initializing the decompiler, mark each loaded, initialized, non-writable block containing a relocation destination volatile in the ephemeral database, and add a limitation to procedures that read such a destination. ELF shared objects and PIEs are included; relocatable objects and core files are not. Permissions and source FileBytes are unchanged. Sessions without database-mutation authority (Windows P0 and existing caller projects) record the limitation but do not mark blocks volatile, and the analysis profile versions the two behaviors separately. A real Linux Ghidra ELF probe runs in the Ghidra bridge CI lane. Fixes #4177 Squashed from #4198 and rebased onto main. Rebase adaptations: the read-only gate, the verifier renamed to verify-ghidra-loader-relocation so it stays out of the manual-verifier glob, docs moved to docs/testing-lanes.md and docs/providers/ghidra-provider.md. Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ghidra): await installation inspection in the relocation verifier inspectGhidraInstallation became async on main after #4198's base, so the verifier asserted on a pending Promise. Also wait for provider output to close before reading it, like the sibling Ghidra verifiers, and keep the workflow reference inside the docs site. Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ghidra): address relocation review findings - Version the existing-project analysis profile with the read-only relocation semantics, since those sessions now add the limitation. - Inspect the installation through the verifier helper that owns the file-reader resources. - Build the fixture with -fPIC: with -fPIE, GCC 12 emits a copy relocation of rea_external_value into writable .bss, so the probe's GOT check could not hold. - Run probe cleanup through withVerifierCleanup so a cleanup failure does not hide the probe failure, and keep the workspace when owned Ghidra cleanup is incomplete. - Link the workflow with a full GitHub URL, per the docs guide. Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ghidra): reach the external data through the GOT with a weak extern Building the whole fixture with -fPIC also turned the reader's direct load of rea_loader_pointer into a GOT-relaxed lea plus two loads, and the real-Ghidra lane then reported that the baseline no longer folds the file constant. Keep -fPIE and declare only rea_external_value weak: GCC and clang must reach a possibly absent symbol through a GOT entry, which lands as R_X86_64_GLOB_DAT inside GNU_RELRO (checked with readelf for GCC 12.2 and clang/lld). Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ghidra): protect only relocations Ghidra could not apply Running the real-Ghidra lane locally (Ghidra 12.1.4, JDK 21, GCC 12) showed that marking every non-writable block that holds a relocation volatile also marks full-RELRO .got. External calls then decompile as opaque pointer calls (`(*pcVar2)(param_1)` through `PTR_rea_external_...` instead of `rea_external(param_1)`), which the probe's GOT-stability check caught. Ghidra applies GLOB_DAT and RELATIVE relocations, so those bytes already hold the loader's value and folding them is correct. The #4177 case is a copy relocation, which Ghidra imports as UNSUPPORTED with the file's zero in place. Protect only destinations whose relocation status is not APPLIED or APPLIED_OTHER. The fixture now reads a provider `const` object that the linker copies into .data.rel.ro, the #4177 shape. The probe requires that relocation to be unapplied and requires applied GOT relocations to stay non-volatile. Ghidra 12.1.4 already shows this x86-64 read by name without protection, so the probe records whether the baseline folds and compares before and after only when it does. The profile value becomes volatile-unapplied-relocation-blocks-v1. Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Arslan Basharat <arslanbasharat414@gmail.com> Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
247 lines
7.9 KiB
JavaScript
247 lines
7.9 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, mkdtemp, 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 {
|
|
ProviderProcessSupervisor,
|
|
spawnOwnedProviderProcess,
|
|
} from "../dist/process/ProviderProcess.js";
|
|
import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js";
|
|
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
|
|
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
|
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.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 workspace = await mkdtemp(
|
|
join(tmpdir(), "rea-ghidra-loader-relocation-"),
|
|
);
|
|
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 workspace is released only after that proof.
|
|
let workspaceReleasable = true;
|
|
|
|
await withVerifierCleanup(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",
|
|
};
|
|
}, [
|
|
async () => {
|
|
if (supervisor === undefined) return;
|
|
const stopped = await supervisor.stop();
|
|
supervisor.dispose();
|
|
workspaceReleasable = stopped.status !== "incomplete";
|
|
assert.ok(
|
|
workspaceReleasable,
|
|
`Owned Ghidra cleanup incomplete; retained ${workspace}: ${JSON.stringify(stopped)}`,
|
|
);
|
|
},
|
|
async () => {
|
|
if (workspaceReleasable)
|
|
await rm(workspace, { recursive: true, force: true });
|
|
},
|
|
]);
|
|
|
|
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");
|
|
}
|