mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
1 parent
aa48d494e9
commit
df25bc8068
15 files changed
+222
-9
No files matched your search
@@ -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)));
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user