fix(ghidra): retain relocation symbol observations (#8531)

* fix(ghidra): retain relocation symbol observations

* test(ghidra): verify named relocation symbols through CLI

* fix(ghidra): clarify verification and supervise test commands

* test(ghidra): share load verification guidance
This commit is contained in:
morluto authored and GitHub committed 2026-11-01 23:53:56 +08:00
1 parent aa48d494e9
commit df25bc8068
15 files changed
+222 -9

No files matched your search

+1
View File
@@ -693,6 +693,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
row.addProperty("status", relocation.getStatus().toString());
row.addProperty("type", relocation.getType());
row.add("symbol", GSON.toJsonTree(relocation.getSymbolName()));
row.add("values", GSON.toJsonTree(relocation.getValues() == null ? new long[0] : relocation.getValues()));
byte[] original = relocation.getBytes();
row.add("original_bytes_hex", original == null ? JsonNull.INSTANCE : GSON.toJsonTree(bytesHex(original)));
+5
View File
@@ -172,6 +172,11 @@ have no file backing. ELF mappings are observed
without independent format-specific load-image verification, as the result's
status and limitations state.
Relocation rows retain Ghidra's `symbol` value, including an empty string or
null when returned by Ghidra. It is a provider observation, not proof of the
runtime-resolved address. Older Evidence may not have recorded this field;
a relocation page requesting it reports that uncertainty.
For large relocation inventories, select MCP `inspect_native_load_image` with
`detail: "summary"`. It returns verification state, observed identity and counts,
while retaining complete Evidence. Use `normalized_result.parent_evidence_id`
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseEvidence } from "../../dist/domain/evidence.js";
import { PrivateRuntimeRoot } from "../../dist/process/PrivateRuntimeRoot.js";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
import { createVerifierCommands } from "./verify-commands.mjs";
const source = fileURLToPath(
new URL(
"../../tests/conformance/ghidra/relocation-symbol.c",
import.meta.url,
),
);
/** Verify named and unnamed ELF relocations through the shipped CLI and Ghidra. */
export async function verifyGhidraRelocationSymbols({ clang, lld, env }) {
const commands = await createVerifierCommands();
let root;
let commandsClosed = false;
return withVerifierCleanup(async () => {
root = await PrivateRuntimeRoot.create({ prefix: "rea-relocation-proof-" });
const object = join(root.path, "relocation-symbol.o");
const target = join(root.path, "relocation-symbol.so");
const commandEnv = {
...env,
TMPDIR: root.path,
TMP: root.path,
TEMP: root.path,
};
const run = (command, args) =>
commands.exec(command, args, {
env: commandEnv,
timeout: 120000,
maxBuffer: 16 * 1024 * 1024,
});
await run(clang, [
"--target=x86_64-linux-gnu",
"-fPIC",
"-O0",
"-c",
source,
"-o",
object,
]);
await run(lld, ["-shared", object, "-o", target]);
const hash = async () =>
createHash("sha256")
.update(await readFile(target))
.digest("hex");
const before = await hash();
const { stdout } = await run(process.execPath, [
"scripts/rea.mjs",
"inspect-native-load-image",
target,
"--provider",
"ghidra",
"--json",
]);
const evidence = parseEvidence(JSON.parse(stdout));
assert.equal(evidence.subject?.digest.sha256, before);
assert.equal(evidence.provider.id, "ghidra");
const report = evidence.normalized_result;
assert.equal(report.status, "unsupported");
const relocations = report.observations.relocations;
assert.ok(
relocations.some(({ symbol }) => symbol === "rea_external"),
"The Ghidra relocation must retain its imported symbol",
);
assert.ok(
relocations.some(({ symbol }) => symbol === ""),
"The provider's empty symbol must remain distinct from an unrecorded field",
);
assert.equal(await hash(), before);
return {
source,
target_sha256: before,
named_symbol: "rea_external",
empty_symbol: true,
};
}, [
async () => {
await commands.close();
commandsClosed = true;
},
async () => {
if (commandsClosed && root !== undefined) await root.close();
},
]);
}
+7
View File
@@ -125,6 +125,13 @@ try {
assert.equal(image.module_bytes, fixture.module_bytes);
assert.equal(image.entry.linear_address, expected.entry);
assert.ok(image.checks.every((check) => check.matched));
assert.ok(image.observations.relocations.length > 0);
assert.ok(
image.observations.relocations.every(
({ symbol }) => symbol === null || typeof symbol === "string",
),
"Each real Ghidra relocation must record its symbol observation",
);
const cliImage = await cliEvidence("inspect-native-load-image");
// Snapshot filenames are session-specific. Every measured byte/range/check is stable.
const stableImage = (value) => ({
+10
View File
@@ -32,6 +32,7 @@ import {
} from "./verify-real-ghidra-inventory.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { hasTypedSwitchEvidence } from "./verify-real-ghidra-switch-assertions.mjs";
import { verifyGhidraRelocationSymbols } from "./lib/real-ghidra-relocation-symbols.mjs";
import {
verifyDenseDefaultReturn,
@@ -281,6 +282,14 @@ try {
}
await writeFile(malformedPath, Buffer.from("not-a-binary\n", "utf8"));
const relocationSymbols = crossFormat
? await verifyGhidraRelocationSymbols({
clang,
lld,
env: process.env,
})
: null;
const debug = await verifyTarget(debugPath, "debug", expectedNativeTarget);
assertDebugFixture(debug);
const layout = await verifyTarget(
@@ -331,6 +340,7 @@ try {
? (crossResults[0]?.native_api_cli ?? null)
: jumpTable.native_api_cli,
native_value_e2e: debug.native_values,
relocation_symbols: relocationSymbols,
custom_target: custom === null ? null : summary(custom),
cleanup: "complete",
};
@@ -67,6 +67,38 @@ const view = async (report: NativeLoadImage, request: unknown) => {
return analysisViewResultSchema.parse(result.value.normalized_result);
};
it("distinguishes recorded, absent, and historical unrecorded relocation symbols", async () => {
const report = source();
report.observations.relocations[0]!.symbol = "external_symbol";
report.observations.relocations[1]!.symbol = null;
const request = { kind: "native-load-image", collection: "relocations" };
const current = await view(report, request);
if (current.kind !== "native-load-image") throw new Error("Expected page");
if (current.items.collection !== "relocations")
throw new Error("Expected relocations");
expect(current.items.rows).toMatchObject([
{ record: { symbol: "external_symbol" } },
{ record: { symbol: null } },
]);
expect(current.unknowns.join(" ")).not.toContain("symbol was not recorded");
report.observations.relocations[1]!.symbol = "";
const emptyName = await view(report, request);
if (emptyName.kind !== "native-load-image") throw new Error("Expected page");
if (emptyName.items.collection !== "relocations")
throw new Error("Expected relocations");
expect(emptyName.items.rows?.[1]?.record).toMatchObject({ symbol: "" });
expect(emptyName.unknowns.join(" ")).not.toContain("symbol was not recorded");
delete report.observations.relocations[0]!.symbol;
const historical = await view(report, request);
if (historical.kind !== "native-load-image") throw new Error("Expected page");
expect(historical.items.rows?.[0]?.record).not.toHaveProperty("symbol");
expect(historical.unknowns.join(" ")).toContain("symbol was not recorded");
const omitted = await view(report, { ...request, fields: ["address"] });
expect(omitted.unknowns.join(" ")).not.toContain("symbol was not recorded");
});
it("summarizes measured ELF facts without upgrading unsupported verification", async () => {
const report = source();
const parent = evidence(report);
+1 -1
View File
@@ -55,7 +55,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"inspect_native_load_image",
"Inspect the provider's measured source digests, memory mappings, relocations, entry points and entry register context. Independent snapshot verification supports DOS MZ, explicitly selected COM, raw binaries and GameCube DOL; other formats retain their observations with unsupported verification. Overlay and uninitialized coverage remain explicit. Select detail summary for verification state and counts, retaining complete Evidence for inspect_analysis_view kind native-load-image: collection source_files, mappings, relocations, entry_points, entry_context, checks or sections, with indices, fields and offset/limit paging. Relocation bytes and check expected/observed payloads require explicit fields; defaults keep metadata. Null collections are unrecorded or inapplicable. Does not execute or modify the target.",
"Inspect the provider's measured source digests, memory mappings, relocations, entry points and entry register context. Ghidra relocation symbols are provider observations, not runtime binding proof; older Evidence may not record them. Independent snapshot verification supports DOS MZ, explicitly selected COM, raw binaries and GameCube DOL; other formats retain their observations with unsupported verification. Overlay and uninitialized coverage remain explicit. Select detail summary for verification state and counts, retaining complete Evidence for inspect_analysis_view kind native-load-image: collection source_files, mappings, relocations, entry_points, entry_context, checks or sections, with indices, fields and offset/limit paging. Relocation bytes and check expected/observed payloads require explicit fields; defaults keep metadata. Null collections are unrecorded or inapplicable. Does not execute or modify the target.",
z.strictObject({ detail: analysisDetailSchema }),
{ evidenceParameters: "omit-detail" },
),
+13 -1
View File
@@ -43,6 +43,7 @@ const defaultFields: Partial<Record<keyof typeof rows, readonly string[]>> = {
offset: true,
status: true,
type: true,
symbol: true,
values: true,
})
.keyof().options,
@@ -65,7 +66,7 @@ const request = <Name extends keyof typeof rows>(name: Name) =>
.optional()
.describe(
name === "relocations"
? "Defaults to address, segment, offset, status, type and values. Select original_bytes_hex or memory_bytes_hex explicitly."
? "Defaults to address, segment, offset, status, type, symbol and values. Select original_bytes_hex or memory_bytes_hex explicitly."
: name === "checks"
? "Defaults to name, matched, file_offset and address. Select expected or observed explicitly; these can contain complete comparison inventories."
: "Omit to return every recorded field in each selected row.",
@@ -290,8 +291,19 @@ export const projectNativeLoadImageView = (
: undefined
: observed[view.collection];
const page = selectRows(selected ?? [], view);
const symbolUnrecorded =
view.collection === "relocations" &&
(view.fields === undefined ||
view.fields.some((field: string) => field === "symbol")) &&
page.rows.some(({ record }) => !("symbol" in record));
return ok({
...shared,
unknowns: symbolUnrecorded
? [
...shared.unknowns,
"The relocation symbol was not recorded for at least one selected row.",
]
: shared.unknowns,
kind: "native-load-image" as const,
verification_status: report.status,
view,
+2
View File
@@ -50,6 +50,8 @@ export const nativeLoadImageObservationSchema = z.strictObject({
offset: integer.max(0xffff).nullable(),
status: z.string().min(1),
type: z.int(),
// Missing on retained observations made before the provider recorded it.
symbol: z.string().nullable().exactOptional(),
values: z.array(
z.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER),
),
+1 -1
View File
@@ -55,7 +55,7 @@ import {
/** Shared analysis commitments for raw and header-selected imports. */
const sharedAnalysisSemantics = {
annotation_policy: "atomic-function-entry-metadata-v1",
load_image_observations: "source-mappings-entry-context-v2",
load_image_observations: "source-mappings-entry-context-relocation-symbol-v3",
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
call_relationship_coverage: "function-body-call-coverage-v5",
+37 -1
View File
@@ -12,7 +12,43 @@ import {
} from "../domain/native/nativeLoadImage.js";
import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js";
import { parseGhidraInventoryResult } from "./GhidraInventoryValues.js";
import { prepareGhidraDosLoadImage } from "./GhidraLoadImageValues.js";
import {
GHIDRA_DOS_LOAD_VERIFICATION_LIMITATION,
prepareGhidraDosLoadImage,
} from "./GhidraLoadImageValues.js";
it("retains the provider symbol without claiming DOS verification of it", () => {
const fixture = fixtureDosLoadImage();
fixture.observation.relocations[0]!.symbol = "provider_name";
const result = compareDosFixture(
fixture.bytes,
fixture.sha256,
fixture.observation,
);
if (!result.ok) throw new Error(result.error);
expect(result.value.status).toBe("verified");
expect(result.value.observations.relocations[0]?.symbol).toBe(
"provider_name",
);
expect(result.value.limitations).toContain(
GHIDRA_DOS_LOAD_VERIFICATION_LIMITATION,
);
});
it("preserves Ghidra relocation symbol values at the inventory boundary", () => {
for (const symbol of ["audit_external", "", null] as const) {
const observation = structuredClone(fixtureDosLoadImage().observation);
observation.relocations[0]!.symbol = symbol;
const parsed = parseGhidraInventoryResult(
"inspect_native_load_image",
jsonValueSchema.parse(observation),
);
expect(parsed).toMatchObject({
ok: true,
value: { value: { relocations: [{ symbol }] } },
});
}
});
it("accepts DOS header padding after a complete relocation table", () => {
const bytes = Buffer.alloc(128);
+7 -3
View File
@@ -15,8 +15,10 @@ import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
const sha256 = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
const address = (offset: number): string => `0x${offset.toString(16)}`;
export const GHIDRA_DOS_LOAD_VERIFICATION_LIMITATION =
"Verification covers original/modified FileBytes, every initialized target-backed header/module mapping, relocation records and the external entry point. Ghidra relocation symbol names are retained observations, not independently verified. Mapping ends are inclusive.";
const limitations = [
"Verification covers original/modified FileBytes, every initialized target-backed header/module mapping, relocation records and the external entry point. Mapping ends are inclusive.",
GHIDRA_DOS_LOAD_VERIFICATION_LIMITATION,
"Appended overlay bytes are identified by file extent and included in FileBytes identity; they must not be mapped as initialized header/module memory. Uninitialized allocations are reported but their contents and DOS runtime behavior are not verified.",
"The fixed load segment 0x1000 describes Ghidra's analysis image, not a running DOS process. Verified import does not establish original source ownership or recovered program behavior.",
"A mismatching memory digest identifies the reported source range; it does not locate the first differing byte inside that range.",
@@ -160,8 +162,10 @@ export const attestGhidraDosLoadImage = (
// A multiset preserves duplicate relocations and aliases; iteration order is not semantic.
const rows = (values: NativeLoadImageObservation["relocations"]): string[] =>
values
.map(({ segment: _segment, offset: _offset, ...value }) =>
JSON.stringify(value),
// The independent DOS check does not attest Ghidra's symbol name.
.map(
({ segment: _segment, offset: _offset, symbol: _symbol, ...value }) =>
JSON.stringify(value),
)
.sort();
check(
+4 -2
View File
@@ -9,9 +9,9 @@ import { GhidraProvider } from "./GhidraProvider.js";
// Digests commit to the current shared policy, including the instruction status
// revision that prevents reuse of snapshots with the previous ambiguous result.
const RAW_DIGESTS = {
"6502": "8e33776d1cc5c5d77d32923d5978c05ec2d989d00920dd458cc2dafb4c9c0cad",
"6502": "bf79f425a94feb3d4150b6fe31f446dfb299af974c123cf3d4eaebdfe02319d6",
"x86-disabled-analyzer":
"d17bddc8ac110c891d30fb989fc7906df421bd9bea5df4aac05a950f50c7bdaf",
"a89f116ac233a91a8a7fae5e7a24cc72126ee1c33219c79bb20e95cc440b2fae",
} as const;
const configFor = (environment: NodeJS.ProcessEnv) => {
@@ -44,6 +44,8 @@ describe("raw-binary analysis profile digest", () => {
language_id: "6502:LE:16:default",
language_selection: "caller-supplied-v1",
load_image_evidence: "independent-raw-mapping-context-v4",
load_image_observations:
"source-mappings-entry-context-relocation-symbol-v3",
instruction_status: "unlisted-address-without-decode-v1",
analyzer_preset: "ghidra-default",
});
+3
View File
@@ -30,6 +30,9 @@ binaries and Ghidra projects are never committed.
x86-64 PE, and x86-64 Mach-O targets from the same semantics. It preserves an
exported entry, direct and indirect calls, a volatile string reference, and a
multi-block branch across loaders.
- `relocation-symbol.c` builds an x86-64 ELF with named and unnamed dynamic
relocations. The cross-format verifier checks their Ghidra observations through
the public CLI without executing the artifact.
- `no-return.c` and `ReaNoReturnProbe.java` inject incorrect import flags into a
real ELF or Mach-O analysis database, verify caller recovery, propagated flag
repair and signature preservation, and retain genuine/unknown no-return calls
@@ -0,0 +1,7 @@
/* Static ELF fixture: no target code is executed by the verifier. */
extern int rea_external(int);
int (*volatile rea_external_slot)(int) = rea_external;
__attribute__((visibility("hidden"), noinline, used)) int rea_local(int value) {
return value + 1;
}
int (*volatile rea_local_slot)(int) = rea_local;