Files
reverse-engineering-anything/scripts/verify-ghidra-loader-relocation.mjs
T
8e2853dd7b fix(ghidra): prevent loader relocations from constant folding (#10483)
* 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>
2026-11-14 09:32:48 +01:00

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");
}