From df25bc8068b4d62c0e8db2b38648648ed831ae0e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:53:56 +0800 Subject: [PATCH] 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 --- bridge/ghidra/ReaGhidraBridge.java | 1 + docs/ghidra-provider.md | 5 + .../lib/real-ghidra-relocation-symbols.mjs | 92 +++++++++++++++++++ scripts/verify-real-ghidra-dos.mjs | 7 ++ scripts/verify-real-ghidra.mjs | 10 ++ .../analysisView/NativeLoadImageViews.test.ts | 32 +++++++ src/contracts/officialToolContracts.ts | 2 +- .../analysisView/nativeLoadImageView.ts | 14 ++- src/domain/native/nativeLoadImage.ts | 2 + src/ghidra/GhidraAnalysisProfile.ts | 2 +- src/ghidra/GhidraLoadImageValues.test.ts | 38 +++++++- src/ghidra/GhidraLoadImageValues.ts | 10 +- src/ghidra/GhidraRawProfileDigest.test.ts | 6 +- tests/conformance/ghidra/README.md | 3 + tests/conformance/ghidra/relocation-symbol.c | 7 ++ 15 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 scripts/lib/real-ghidra-relocation-symbols.mjs create mode 100644 tests/conformance/ghidra/relocation-symbol.c diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 99bf59c93d..0ebe47e0db 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -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))); diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index e4d2c6bff7..ad65004b3c 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -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` diff --git a/scripts/lib/real-ghidra-relocation-symbols.mjs b/scripts/lib/real-ghidra-relocation-symbols.mjs new file mode 100644 index 0000000000..2ce4522bcf --- /dev/null +++ b/scripts/lib/real-ghidra-relocation-symbols.mjs @@ -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(); + }, + ]); +} diff --git a/scripts/verify-real-ghidra-dos.mjs b/scripts/verify-real-ghidra-dos.mjs index 9c6590a611..7bad32a135 100644 --- a/scripts/verify-real-ghidra-dos.mjs +++ b/scripts/verify-real-ghidra-dos.mjs @@ -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) => ({ diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs index 2d4aa94e9b..a32a005b08 100644 --- a/scripts/verify-real-ghidra.mjs +++ b/scripts/verify-real-ghidra.mjs @@ -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", }; diff --git a/src/application/analysisView/NativeLoadImageViews.test.ts b/src/application/analysisView/NativeLoadImageViews.test.ts index 4c5b9565a1..c1616d57c2 100644 --- a/src/application/analysisView/NativeLoadImageViews.test.ts +++ b/src/application/analysisView/NativeLoadImageViews.test.ts @@ -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); diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index e8b7e23ef3..47df239305 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -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" }, ), diff --git a/src/domain/analysisView/nativeLoadImageView.ts b/src/domain/analysisView/nativeLoadImageView.ts index c4975e00dc..eb5b56af88 100644 --- a/src/domain/analysisView/nativeLoadImageView.ts +++ b/src/domain/analysisView/nativeLoadImageView.ts @@ -43,6 +43,7 @@ const defaultFields: Partial> = { offset: true, status: true, type: true, + symbol: true, values: true, }) .keyof().options, @@ -65,7 +66,7 @@ const request = (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, diff --git a/src/domain/native/nativeLoadImage.ts b/src/domain/native/nativeLoadImage.ts index 183d12866c..12857e89f6 100644 --- a/src/domain/native/nativeLoadImage.ts +++ b/src/domain/native/nativeLoadImage.ts @@ -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), ), diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 190a3c6c24..23ac5b1834 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -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", diff --git a/src/ghidra/GhidraLoadImageValues.test.ts b/src/ghidra/GhidraLoadImageValues.test.ts index f34cfca3c9..6b4c1f5efd 100644 --- a/src/ghidra/GhidraLoadImageValues.test.ts +++ b/src/ghidra/GhidraLoadImageValues.test.ts @@ -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); diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index b110770a1a..558da111eb 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -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( diff --git a/src/ghidra/GhidraRawProfileDigest.test.ts b/src/ghidra/GhidraRawProfileDigest.test.ts index f9b7ed5551..6562f5e346 100644 --- a/src/ghidra/GhidraRawProfileDigest.test.ts +++ b/src/ghidra/GhidraRawProfileDigest.test.ts @@ -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", }); diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index 419a3bc7d8..34ffb86a8d 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -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 diff --git a/tests/conformance/ghidra/relocation-symbol.c b/tests/conformance/ghidra/relocation-symbol.c new file mode 100644 index 0000000000..30b1fd4fe5 --- /dev/null +++ b/tests/conformance/ghidra/relocation-symbol.c @@ -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;