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
This commit is contained in:
Ysopwnable 2026-10-09 12:19:07 +08:00 committed by GitHub
parent f36c10f9e8
commit 6fbdbd6820
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 193 additions and 4 deletions

View File

@ -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 +

View File

@ -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.

View File

@ -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,

View File

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

View File

@ -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"

View File

@ -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

View File

@ -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(); }