From 6fbdbd682038d012b04f0fe92c470cf1789fb101 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:19:07 +0800 Subject: [PATCH] fix(ghidra): resolve secondary symbols at function entries (#1195) * fix(ghidra): resolve secondary symbols at function entries * test(ghidra): derive interior bytes from decoded instructions --- bridge/ghidra/ReaGhidraBridge.java | 15 ++- docs/native-investigation.md | 3 + scripts/lib/real-ghidra-boundaries.mjs | 29 ++++- scripts/lib/real-ghidra-entry-aliases.mjs | 126 ++++++++++++++++++++++ src/ghidra/GhidraAnalysisProfile.ts | 2 +- tests/conformance/ghidra/README.md | 3 + tests/conformance/ghidra/entry-aliases.c | 19 ++++ 7 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 scripts/lib/real-ghidra-entry-aliases.mjs create mode 100644 tests/conformance/ghidra/entry-aliases.c diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 31805ad75..b64955a2e 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -1629,9 +1629,20 @@ public final class ReaGhidraBridge extends HeadlessScript { Function function = entry.function; Symbol symbol = function.getSymbol(); String qualified = symbol == null ? function.getName() : symbol.getName(true); - if (function.getName().equals(value) || qualified.equals(value)) { - matches.add(function); + boolean matchesName = function.getName().equals(value) || qualified.equals(value); + if (!matchesName) { + // Imported labels (for example Mach-O _main) can share a + // function entry without becoming its primary symbol. Only + // exact entries qualify; an interior label is not a selector. + for (Symbol entrySymbol : currentProgram.getSymbolTable().getSymbols(function.getEntryPoint())) { + monitor.checkCancelled(); + if (entrySymbol.getName().equals(value) || entrySymbol.getName(true).equals(value)) { + matchesName = true; + break; + } + } } + if (matchesName) matches.add(function); } if (matches.size() > 1) throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value + diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 96433fc51..902831169 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -128,6 +128,9 @@ an explicit `0x` or address-space prefix selects an address; otherwise an exact database symbol name takes precedence over a bare hexadecimal address. Thus a function renamed to `dead` remains selectable by name. Use the names returned by the inventory, including their namespaces and any platform symbol prefix. +Ghidra also accepts secondary symbols at a function entry, such as an imported +`_main` label. A label inside a function is not a procedure-name selector; use +its address when selecting the containing function. Overloads can share a fully qualified name; ambiguity errors return every matching entry address so the caller can select the intended function directly. diff --git a/scripts/lib/real-ghidra-boundaries.mjs b/scripts/lib/real-ghidra-boundaries.mjs index 17ddff340..47e90e60e 100644 --- a/scripts/lib/real-ghidra-boundaries.mjs +++ b/scripts/lib/real-ghidra-boundaries.mjs @@ -13,6 +13,7 @@ import { verifyLegacyGhidraReferenceSnapshot } from "./ghidra-reference-snapshot import { verifyGhidraSnapshotLifecycle } from "./real-ghidra-snapshot-lifecycle.mjs"; import { verifyGhidraTargetAdmission } from "./real-ghidra-target-admission.mjs"; import { verifyGhidraLargeResults } from "./real-ghidra-large-results.mjs"; +import { verifyGhidraEntryAliases } from "./real-ghidra-entry-aliases.mjs"; import { verifyGhidraNamespaceAnnotations } from "./real-ghidra-namespace-annotations.mjs"; /** Probe real Ghidra location, annotation and error contracts through public adapters. */ @@ -278,7 +279,25 @@ export async function verifyGhidraBoundaries( await cli("inspect-native-instruction", address.toUpperCase()), baseline, ); - const interior = `0x${(BigInt(address) + 1n).toString(16)}`; + // x86 function entries can begin with a one-byte PUSH. Locate an observed + // multi-byte instruction instead of assuming entry + 1 is an interior byte. + let multiByteInstruction; + for (const range of original.procedure.body.ranges) { + let cursor = BigInt(range.start); + while (cursor <= BigInt(range.end)) { + const instruction = await call("inspect_native_instruction", { + address: `0x${cursor.toString(16)}`, + }); + if (instruction.status === "decoded" && instruction.length > 1) { + multiByteInstruction = instruction; + break; + } + cursor += BigInt(instruction.length ?? 1); + } + if (multiByteInstruction !== undefined) break; + } + assert.ok(multiByteInstruction, "Fixture lacks a multi-byte instruction"); + const interior = `0x${(BigInt(multiByteInstruction.address) + 1n).toString(16)}`; assert.equal( (await call("inspect_native_instruction", { address: interior })).status, "not-instruction-boundary", @@ -693,6 +712,13 @@ export async function verifyGhidraBoundaries( entrypoint, env, }); + await verifyGhidraEntryAliases({ + call, + reject: invalid, + target, + entrypoint, + env, + }); await verifyGhidraLargeResults({ call, reject: invalid, @@ -741,6 +767,7 @@ export async function verifyGhidraBoundaries( imported_source_identity_retained: true, equivalent_instruction_address_spellings: true, qualified_annotation_name_roundtrip: true, + imported_entry_alias_selection: true, oversized_result_retention_and_complete_export: true, long_selector_rejection_and_provider_recovery: true, source_immutable: true, diff --git a/scripts/lib/real-ghidra-entry-aliases.mjs b/scripts/lib/real-ghidra-entry-aliases.mjs new file mode 100644 index 000000000..3230ed6d1 --- /dev/null +++ b/scripts/lib/real-ghidra-entry-aliases.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { 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"; + +/** Verify imported entry aliases through CLI/MCP without admitting interior labels. */ +export async function verifyGhidraEntryAliases({ + call, + reject, + target, + entrypoint, + env, +}) { + const exec = promisify(execFile); + const compiler = process.env.REA_CC ?? "cc"; + const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-entry-aliases-")); + const source = fileURLToPath( + new URL("../../tests/conformance/ghidra/entry-aliases.c", import.meta.url), + ); + const path = join(workspace, "entry-aliases"); + const prefix = process.platform === "darwin" ? "_" : ""; + try { + await exec(compiler, ["-O0", "-g", "-fno-inline", source, "-o", path], { + env, + timeout: 30000, + }); + const bytes = await readFile(path); + await call("close_binary"); + await call("open_binary", { path, provider_id: "ghidra" }); + const names = await call("list_names"); + const alias = names.find( + ({ value }) => value === `${prefix}rea_entry_alias`, + ); + assert.ok(alias, "Fixture entry alias was not imported"); + assert.equal( + alias.symbol.primary, + false, + "The regression must exercise a secondary entry symbol", + ); + assert.equal(alias.symbol.source, "imported"); + const canonical = (await call("list_procedures")).find( + ({ address }) => address === alias.address, + ); + assert.ok(canonical, "Fixture alias is not at a function entry"); + for (const procedure of [ + alias.value, + "dead", + canonical.value, + alias.address, + ]) { + assert.equal( + await call("procedure_address", { procedure }), + alias.address, + ); + assert.equal( + (await call("analyze_function", { procedure })).procedure.address, + alias.address, + ); + assert.equal( + await call("procedure_pseudo_code", { procedure }), + await call("procedure_pseudo_code", { procedure: alias.address }), + ); + } + // Leaf and qualified forms of the primary name must not duplicate a match. + assert.equal( + (await call("procedure_info", { procedure: alias.value })).entrypoint, + alias.address, + ); + const interior = names.find( + ({ value }) => value === `${prefix}rea_interior`, + ); + assert.ok(interior); + assert.notEqual(interior.address, alias.address); + const failure = await reject("procedure_address", { + procedure: interior.value, + }); + assert.equal(failure.code, "invalid_request"); + assert.match( + JSON.stringify(failure.details.issues), + /Unknown Ghidra procedure name or address/u, + ); + assert.equal( + await call("procedure_address", { procedure: interior.address }), + alias.address, + "Explicit interior addresses keep their existing containment semantics", + ); + const edited = await call("annotate_native_function", { + procedure: alias.value, + comment: "Selected by imported entry alias", + }); + assert.equal(edited.annotations.address, alias.address); + assert.equal( + edited.annotations.comment, + "Selected by imported entry alias", + ); + assert.equal( + await call("procedure_address", { procedure: alias.value }), + alias.address, + ); + await call("close_binary"); + const { stdout } = await exec( + process.execPath, + [ + entrypoint, + "function", + path, + alias.value, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + assert.equal( + JSON.parse(stdout).normalized_result.procedure.address, + alias.address, + ); + assert.deepEqual(await readFile(path), bytes); + await call("open_binary", { path: target.path, provider_id: "ghidra" }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +} diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 418136f74..57fc2f1f2 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -43,7 +43,7 @@ export const resolveGhidraAnalysisProfile = ( load_image_observations: "source-mappings-entry-context-v2", function_body_evidence: "complete-inclusive-ranges-v1", function_references: "complete-body-and-entry-reference-manager-v2", - location_resolution: "explicit-address-exact-name-first-v2", + location_resolution: "explicit-address-exact-entry-symbol-first-v3", process_launch: installation.platform === "win32" ? "official-headless-script-v1" diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index b99acb644..8f9148d4b 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -7,6 +7,9 @@ binaries and Ghidra projects are never committed. It covers imports, an external `puts` function, linker thunks, source symbols, direct calls, a targetless callback call, two referenced strings, and a multi-block branch. +- `entry-aliases.c` preserves secondary imported entry labels, a bare-hex + alias, and an interior label. The native CLI/MCP lane verifies entry selection, + annotation, interior-label rejection, and unchanged source bytes. - `cross-format.c` is freestanding so the verifier can produce AArch64 ELF, 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 diff --git a/tests/conformance/ghidra/entry-aliases.c b/tests/conformance/ghidra/entry-aliases.c new file mode 100644 index 000000000..3f9982044 --- /dev/null +++ b/tests/conformance/ghidra/entry-aliases.c @@ -0,0 +1,19 @@ +// Keep secondary imported entry labels and a non-entry control in one function. +#if defined(__APPLE__) +#define REA_SYMBOL(name) "_" name +#else +#define REA_SYMBOL(name) name +#endif + +__attribute__((noinline, used)) int rea_alias_target(void) { + __asm__ volatile(".globl " REA_SYMBOL("rea_interior") "\n" + REA_SYMBOL("rea_interior") ":"); + return 17; +} + +__asm__(".globl " REA_SYMBOL("rea_entry_alias") "\n" + ".set " REA_SYMBOL("rea_entry_alias") ", " REA_SYMBOL("rea_alias_target") "\n" + // A bare hexadecimal identifier must still resolve as an entry label. + ".globl dead\n.set dead, " REA_SYMBOL("rea_alias_target") "\n"); + +int main(void) { return rea_alias_target(); }