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:
parent
f36c10f9e8
commit
6fbdbd6820
|
|
@ -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 +
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
Loading…
Reference in New Issue