Compare commits

...
Author SHA1 Message Date
Kaoru0822 b9de5dad93 Merge remote-tracking branch 'origin/main' into fix/8257-elf-entry-label-identity 2026-10-31 16:04:37 +00:00
Kaoru0822andClaude Opus 5.5 6e68440bf0 fix(ghidra): report unexamined loader labels when label provenance is incomplete
Address review findings on #8327:
- Ghidra's DWARF import (and its external debug-file symbol copy) also names
  functions with SourceType.IMPORTED. When DWARFAnalyzer.isAlreadyImported is
  true, an unattested loader-convention name is `unexamined`, not
  `synthesized`.
- ElfHeader.parse reports truncated headers and unresolved tables through its
  error consumer without throwing, and reads past the file end as zeros. Any
  parser diagnostic, a symbol or string table outside the file, a skipped
  symbol table or an unresolved DT_SYMTAB now leaves the examination
  incomplete (`unexamined`).
- The CI fixture generator no longer writes to a caller-supplied path: it
  creates its own temporary directory, writes constant names and prints the
  directory (CodeQL js/path-injection).

The real-Ghidra CI probe adds truncated-section-header, oversized-symtab and
DWARF-only (auto-analysis) fixtures, all expecting `unexamined`, and pins the
DWARF case to Ghidra's import record.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:17:24 +00:00
Kaoru0822 302b8d14d5 Merge remote-tracking branch 'origin/main' into fix/8257-elf-entry-label-identity 2026-10-31 14:55:24 +00:00
Kaoru0822andClaude Opus 5.5 b819c9cae3 fix(ghidra): keep loader-synthesized ELF entry labels out of exact symbol identity
Ghidra's ELF loader names a header or dynamic entry that no symbol defines
(`entry`, `_DT_INIT`, `_INIT_<n>`, ...) and reports it as SourceType.IMPORTED,
the same source as artifact symbols. REA treated every imported name as an
artifact-supplied symbol, so two unrelated stripped ELF entry functions paired
as `exact-symbol` and compared as `method: symbol`.

The bridge now qualifies only these exact loader names on ELF programs with
`loader_role_label`, checked once per program against the artifact's own
`.symtab`/`.dynsym` names: `artifact-symbol` keeps a genuine `entry` symbol
named, `synthesized` becomes generated, and `unexamined` (unreadable tables or
undecoded MiniDebugInfo) stays unknown. The domain symbol-origin owner consumes
the fact, so matching, comparison identity and relationship endpoints share it;
independent complete body bytes still match. A real-Ghidra CI probe imports
source-owned ELF executables for all three outcomes.

Fixes #8257

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:55:24 +00:00
18 changed files with 876 additions and 26 deletions

No files matched your search

@@ -95,3 +95,29 @@ jobs:
decode cortex ARM:LE:32:Cortex mrs/4/msp bx/2/lr
decode v8m ARM:LE:32:v8-m mrs/4/msplim bx/2/lr
decode xtensa Xtensa:LE:32:default entry/3/a1 retw.n/2
# Ghidra's real ELF loader imports each source-owned executable; only the
# DWARF fixture runs auto-analysis. The bridge must classify the header
# `entry` label from the artifact's symbol tables and Ghidra's DWARF
# import record. The bytes are never executed.
- name: Classify real ELF loader entry labels through the bridge
shell: bash
run: |
java_home_variable="JAVA_HOME_${GHIDRA_JAVA_MAJOR}_X64"
export JAVA_HOME="${!java_home_variable}"
probe_root="$(node tests/conformance/ghidra/elf-role-label-fixture.mjs)"
test -d "${probe_root}"
for fixture in stripped:synthesized symbolized:artifact-symbol minidebug:unexamined \
truncated:unexamined oversized:unexamined dwarf:unexamined:dwarf-imported; do
IFS=: read -r name label dwarf <<< "${fixture}"
analysis=(-noanalysis)
probe=(0x400080 "${label}")
if [ -n "${dwarf}" ]; then analysis=(); probe+=("${dwarf}"); fi
mkdir -p "${probe_root}/${name}-project"
timeout 300s "${GHIDRA_INSTALL_DIR}/support/analyzeHeadless" "${probe_root}/${name}-project" ElfRole \
-import "${probe_root}/${name}.elf" "${analysis[@]}" -readOnly -deleteProject \
-scriptPath "${GITHUB_WORKSPACE}/tests/conformance/ghidra;${GITHUB_WORKSPACE}/bridge/ghidra" \
-postScript ReaElfRoleLabelProbe.java "${GITHUB_WORKSPACE}/bridge/ghidra" "${probe[@]}" \
> "${probe_root}/${name}.log" 2>&1 || { cat "${probe_root}/${name}.log"; exit 1; }
cat "${probe_root}/${name}.log"
grep -F "REA_ELF_ROLE_LABEL_OK ${label}" "${probe_root}/${name}.log"
done
+107
View File
@@ -29,6 +29,7 @@ import java.security.MessageDigest;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
@@ -37,6 +38,7 @@ import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
@@ -61,6 +63,16 @@ import ghidra.app.decompiler.ClangCaseToken;
import ghidra.app.decompiler.ClangNode;
import ghidra.framework.Application;
import ghidra.app.util.bin.format.pdb.PdbParserConstants;
import ghidra.app.util.bin.FileBytesProvider;
import ghidra.app.util.bin.format.elf.ElfDynamicTable;
import ghidra.app.util.bin.format.elf.ElfDynamicType;
import ghidra.app.util.bin.format.elf.ElfHeader;
import ghidra.app.util.bin.format.elf.ElfSectionHeader;
import ghidra.app.util.bin.format.elf.ElfSectionHeaderConstants;
import ghidra.app.util.bin.format.elf.ElfSymbol;
import ghidra.app.util.bin.format.elf.ElfSymbolTable;
import ghidra.app.util.opinion.ElfLoader;
import ghidra.app.plugin.core.analysis.DWARFAnalyzer;
import ghidra.framework.options.Options;
import ghidra.program.model.address.Address;
import ghidra.program.model.address.AddressIterator;
@@ -101,6 +113,8 @@ import ghidra.program.model.symbol.SourceType;
import ghidra.program.model.symbol.Symbol;
import ghidra.program.model.symbol.SymbolIterator;
import ghidra.program.model.symbol.SymbolTable;
import ghidra.program.model.symbol.SymbolType;
import ghidra.program.model.listing.Program;
import ghidra.program.model.pcode.FunctionPrototype;
import ghidra.program.model.pcode.HighFunction;
import ghidra.program.model.pcode.HighParam;
@@ -4847,9 +4861,102 @@ public final class ReaGhidraBridge extends HeadlessScript {
facts.addProperty("external", symbol.isExternal());
facts.addProperty("type", normalizedSymbolType(symbol));
facts.addProperty("source", normalizedSource(symbol.getSource()));
String roleLabel = elfLoaderRoleLabel(symbol);
if (roleLabel != null) facts.addProperty("loader_role_label", roleLabel);
return facts;
}
// Ghidra's ELF loader names each header and dynamic entry it finds without a
// symbol-defined function (ElfProgramBuilder.processEntryPoints), using
// SourceType.IMPORTED like artifact symbols. Only these exact names qualify.
private static final Pattern ELF_LOADER_ROLE_LABEL = Pattern.compile(
Pattern.quote(ElfLoader.ELF_ENTRY_FUNCTION_NAME) + "|" +
Pattern.quote("_" + ElfDynamicType.DT_INIT.name) + "|" +
Pattern.quote("_" + ElfDynamicType.DT_FINI.name) + "|" +
"_(?:PREINIT|INIT|FINI)_(?:0|[1-9][0-9]*)");
// Role names supplied by each program's immutable original ELF symbol tables;
// empty when those tables cannot be examined completely.
private static final Map<Program, Optional<Set<String>>> ELF_ARTIFACT_ROLE_NAMES =
Collections.synchronizedMap(new java.util.WeakHashMap<>());
private static String elfLoaderRoleLabel(Symbol symbol) {
if (symbol.getSource() != SourceType.IMPORTED ||
!ELF_LOADER_ROLE_LABEL.matcher(symbol.getName()).matches() ||
symbol.isExternal() || symbol.getSymbolType() != SymbolType.FUNCTION ||
!symbol.getParentNamespace().isGlobal()) {
return null;
}
Program program = symbol.getProgram();
if (!ElfLoader.ELF_NAME.equals(program.getExecutableFormat())) return null;
Optional<Set<String>> names =
ELF_ARTIFACT_ROLE_NAMES.computeIfAbsent(program, ReaGhidraBridge::elfArtifactRoleNames);
if (names.isEmpty()) return "unexamined";
if (names.get().contains(symbol.getName())) return "artifact-symbol";
// Ghidra's DWARF import, including its external debug-file symbol copy,
// also names functions with SourceType.IMPORTED from unexamined sources.
return DWARFAnalyzer.isAlreadyImported(program) ? "unexamined" : "synthesized";
}
private static Optional<Set<String>> elfArtifactRoleNames(Program program) {
List<FileBytes> files = program.getMemory().getAllFileBytes();
if (files.size() != 1 || files.get(0).getFileOffset() != 0) return Optional.empty();
try {
List<String> diagnostics = new ArrayList<>();
ElfHeader elf = new ElfHeader(new FileBytesProvider(files.get(0)), diagnostics::add);
elf.parse();
// The parser reports truncated headers and unresolved tables without
// throwing, and reads past the file end as zeros. The loader also
// imports compressed MiniDebugInfo symbols, which this check does not decode.
if (!diagnostics.isEmpty() || !elfSymbolTablesInFile(elf, files.get(0).getSize()) ||
elf.getSection(".gnu_debugdata") != null) {
return Optional.empty();
}
Set<String> names = new HashSet<>();
for (ElfSymbolTable table : elf.getSymbolTables()) {
for (ElfSymbol entry : table.getSymbols()) {
// The loader never labels a program address with these.
if (entry.isFile() || entry.isSection() || entry.isTLS() || entry.isExternal()) continue;
String name = entry.getNameAsString();
if (name != null && ELF_LOADER_ROLE_LABEL.matcher(name).matches()) names.add(name);
}
}
return Optional.of(Set.copyOf(names));
}
catch (Exception exception) {
return Optional.empty();
}
}
// Every symbol table and its string table lies within the file, and none was skipped.
private static boolean elfSymbolTablesInFile(ElfHeader elf, long fileSize) {
ElfSectionHeader[] sections = elf.getSections();
int tables = 0;
for (ElfSectionHeader section : sections) {
if (section.getType() != ElfSectionHeaderConstants.SHT_SYMTAB &&
section.getType() != ElfSectionHeaderConstants.SHT_DYNSYM) {
continue;
}
tables++;
int link = section.getLink();
if (!elfSectionInFile(section, fileSize) || link <= 0 || link >= sections.length ||
sections[link].getType() != ElfSectionHeaderConstants.SHT_STRTAB ||
!elfSectionInFile(sections[link], fileSize)) {
return false;
}
}
ElfDynamicTable dynamic = elf.getDynamicTable();
if (dynamic != null && dynamic.containsDynamicValue(ElfDynamicType.DT_SYMTAB) &&
elf.getDynamicSymbolTable() == null) {
return false;
}
return elf.getSymbolTables().length >= tables;
}
private static boolean elfSectionInFile(ElfSectionHeader section, long fileSize) {
return !section.isInvalidOffset() && section.getSize() >= 0 &&
section.getOffset() <= fileSize - section.getSize();
}
private static String normalizedSource(SourceType source) {
return source.name().toLowerCase(Locale.ROOT);
}
+15 -1
View File
@@ -66,7 +66,7 @@ request ceiling.
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Program identity | One `analyzeHeadless` import produces exactly one Program; `list_documents` therefore returns exactly one name. |
| Addresses | Default memory uses lowercase `0x` hexadecimal. Non-default and external spaces use `<percent-encoded-space>:0x<hex>` and remain round-trippable. The handshake commits image base and default address-space name. |
| Symbols | `list_names` includes address-bearing memory and external symbols, including dynamic symbols, while excluding variable and no-address namespace records. Each item reports primary, dynamic, external, symbol type, and source facts. |
| Symbols | `list_names` includes address-bearing memory and external symbols, including dynamic symbols, while excluding variable and no-address namespace records. Each item reports primary, dynamic, external, symbol type, and source facts. ELF loader entry labels add `loader_role_label`. |
| Procedures | Both non-external and external functions are listed. A local thunk remains distinct from its resolved target; exact and qualified name lookup fails on ambiguity rather than guessing. |
| Strings | Only Ghidra-defined string `Data` is observed. Items report charset, byte length, and whether a required null terminator is missing. The API cannot distinguish a present terminator from fixed/Pascal layouts when no terminator is missing, so that state is named `present_or_not_required`. |
| Memory | Memory-block end addresses are exclusive. Read/write/execute, initialization, overlay, address space, and image base are direct Ghidra observations. |
@@ -80,6 +80,20 @@ does not prove text is absent. Both list and search Evidence carry this
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
bytes and references.
Ghidra's ELF loader names a header or dynamic entry that no symbol defines
(`entry`, `_DT_INIT`, `_DT_FINI`, `_INIT_<n>`, `_PREINIT_<n>`, `_FINI_<n>`) and
reports it as `imported`, like an artifact symbol. For an imported global
function with one of these names, `loader_role_label` reports `artifact-symbol`
when a defined `.symtab`/`.dynsym` symbol supplies that exact name. Otherwise it
reports `unexamined` when those tables cannot be read completely (any ELF parser
diagnostic, a symbol or string table extending past the file, or undecoded
MiniDebugInfo) or when Ghidra's DWARF analysis imported debug information, which
also names functions as `imported`; it reports `synthesized` only when neither
applies. The check compares names, not addresses.
Matching and comparison treat `synthesized` as generated and `unexamined` as
unknown origin. Evidence recorded before this fact existed keeps its reported
`imported` origin.
## PE debug-symbol coverage
The post-analysis handshake reports Ghidra's PDB Program Information attributes:
+5 -4
View File
@@ -1703,10 +1703,11 @@ older or imported Evidence without a commitment leave coordinate identity
unknown. Unparseable coordinates also remain unknown.
Across different artifacts, endpoints use nonempty procedure names whose observed
symbol facts establish a named origin. Dynamic/default symbols and unknown origin
remain unknown. Ghidra emits the same source/dynamic symbol facts for inventories
and procedure identities; Hopper, IDA and older imported dossiers without verified
origin facts cannot establish correspondence from display labels. No name-spelling
symbol facts establish a named origin. Dynamic/default symbols, loader-synthesized
role labels and unknown origin remain unknown. Ghidra emits the same
source/dynamic/loader-role symbol facts for inventories and procedure
identities; Hopper, IDA and older imported dossiers without verified origin
facts cannot establish correspondence from display labels. No name-spelling
rule classifies `EntryPoint` or a `FUN_` prefix as generated. A name observed at
multiple procedure entries retains its label constraint but cannot establish equal
endpoints. Same-container records without a common coordinate context likewise
+8 -2
View File
@@ -25,8 +25,14 @@ Unknown or conflicting origin does not establish name correspondence; complete
independent body bytes remain eligible. Exact names establish name correspondence,
not unchanged code or original-source identity. Ghidra publishes its symbol facts;
Hopper, IDA and older imported dossiers retain unknown origin when no such facts
were observed. Display spelling alone, including `EntryPoint` or `FUN_` prefixes,
does not establish whether a name was generated.
were observed. Ghidra's ELF loader reports its own entry labels, such as `entry`
at a header entry that no symbol defines, with source `imported`; the
`loader_role_label` fact then marks a `synthesized` label as generated and an
`unexamined` one as unknown, so neither establishes name correspondence. Equal
complete body bytes still can. Ghidra's PE and Mach-O loaders also label their
header entry `entry`; those labels do not report this fact yet. Display spelling
alone, including `EntryPoint` or `FUN_` prefixes, does not establish whether a
name was generated.
Authentic Evidence selected for the wrong side, role, target, provider, profile
or document returns an input error naming the selected field or observation
+2 -1
View File
@@ -1394,7 +1394,8 @@ reject reserved `file-offset:` selectors before name lookup; source-file offset
selection belongs to the Ghidra import mapping.
Native function names carry observed symbol metadata where available. Ghidra uses
its function symbol's source and dynamic flags; Hopper and IDA leave origin
its function symbol's source and dynamic flags, qualified by `loader_role_label`
for its ELF loader's own entry labels; Hopper and IDA leave origin
unknown because this adapter has no verified origin API. Comparison and matching
cannot use unknown labels as cross-artifact identity, even when their spelling
looks meaningful. Original labels remain in Evidence. This qualification does not
+2
View File
@@ -610,6 +610,8 @@ const PATH_SCOPES = {
"scripts/verify-ghidra-bridge-sources.mjs",
"tests/conformance/ghidra/ReaArmContextProbe.java",
"tests/conformance/ghidra/ReaDecodeProbe.java",
"tests/conformance/ghidra/ReaElfRoleLabelProbe.java",
"tests/conformance/ghidra/elf-role-label-fixture.mjs",
".github/workflows/ghidra-bridge-sources.yml",
],
website: [
@@ -335,6 +335,15 @@ export function assertStrippedFixture(observed) {
throw new Error(
"Stripped Ghidra fixture did not preserve stripped semantics",
);
// Without .symtab, Ghidra's ELF loader names the header entry itself.
if (
observed.target.format === "elf" &&
findValue(observed.procedures, "entry")?.symbol?.loader_role_label !==
"synthesized"
)
throw new Error(
`Stripped ELF entry label lost its loader-synthesized origin: ${JSON.stringify(findValue(observed.procedures, "entry"))}`,
);
assertStrings(observed.strings);
assertSegments(observed.segments, observed.session.target.image_base);
}
+13 -6
View File
@@ -201,25 +201,28 @@ const reportedSymbol = (
): {
readonly value: string | null;
readonly origin: "known" | "unknown" | "conflicting";
readonly loaderSynthesized: boolean;
} => {
const names = [
...(row.symbol === undefined ? [] : [row]),
...(known?.values.filter((symbol) => symbol.value === row.value) ?? []),
];
const generated = names.some(
(symbol) => nativeSymbolOrigin(symbol.symbol) === "generated",
);
const named = names.some(
(symbol) => nativeSymbolOrigin(symbol.symbol) === "named",
const origins = new Set(
names.map((symbol) => nativeSymbolOrigin(symbol.symbol)),
);
const generated = origins.has("generated"),
named = origins.has("named");
return {
value: row.value.length === 0 || generated || !named ? null : row.value,
origin:
generated && named
? "conflicting"
: names.some((symbol) => symbol.symbol !== undefined)
: generated || named
? "known"
: "unknown",
loaderSynthesized: names.some(
(symbol) => symbol.symbol?.loader_role_label === "synthesized",
),
};
};
const functionFact = async (
@@ -257,6 +260,10 @@ const functionFact = async (
? "Conflicting procedure observations; body and dossier remain unknown."
: "No complete unambiguous bytes for the observed function body.",
);
if (symbol.loaderSynthesized)
limitations.push(
"Provider loader-synthesized role label; exact-name matching is unavailable. Independent complete body bytes remain eligible.",
);
if (symbol.origin === "unknown")
limitations.push(
"Symbol origin unavailable; exact-name matching is unavailable. Independent complete body bytes remain eligible.",
+25 -2
View File
@@ -1,5 +1,19 @@
import { z } from "zod";
/**
* A provider loader names some format roles by convention, such as Ghidra's
* `entry` at an ELF header entry that no symbol-defined function occupies.
* Present only on such convention names. It reports whether the artifact's
* examined symbol tables supply the same exact name (`artifact-symbol`), do
* not while no other provider name source applies (`synthesized`), or the
* label's origin could not be established (`unexamined`).
*/
const nativeLoaderRoleLabelSchema = z.enum([
"artifact-symbol",
"synthesized",
"unexamined",
]);
/** Native producer facts about the exact reported symbol, independent of display spelling. */
export const nativeSymbolMetadataSchema = z.object({
primary: z.boolean(),
@@ -7,17 +21,26 @@ export const nativeSymbolMetadataSchema = z.object({
external: z.boolean(),
type: z.string(),
source: z.enum(["default", "analysis", "ai", "imported", "user_defined"]),
loader_role_label: nativeLoaderRoleLabelSchema.exactOptional(),
});
export type NativeSymbolMetadata = z.infer<typeof nativeSymbolMetadataSchema>;
/**
* A provider source such as Ghidra's `imported` also covers loader-created role
* labels, so loader facts qualify it before a name counts as artifact-supplied.
*/
export const nativeSymbolOrigin = (
symbol: NativeSymbolMetadata | null | undefined,
): "generated" | "named" | "unknown" =>
symbol == null
? "unknown"
: symbol.dynamic || symbol.source === "default"
: symbol.dynamic ||
symbol.source === "default" ||
symbol.loader_role_label === "synthesized"
? "generated"
: "named";
: symbol.loader_role_label === "unexamined"
? "unknown"
: "named";
/** Unknown origin never makes an arbitrary display label a cross-artifact identity. */
export const hasStableNativeSymbol = (
+2 -8
View File
@@ -154,14 +154,8 @@ export const parseGhidraInventoryInput = (
return ok(parsed.data);
};
const symbolFacts = z
.object({
primary: z.boolean(),
dynamic: z.boolean(),
external: z.boolean(),
type: z.string().min(1),
source: z.enum(["default", "analysis", "ai", "imported", "user_defined"]),
})
const symbolFacts = nativeSymbolMetadataSchema
.extend({ type: z.string().min(1) })
.strict();
const procedureFacts = z
.object({
@@ -7,10 +7,15 @@ import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
nativeMatchingNamedInventory as nativeMatchingInventory,
nativeMatchingBody,
nativeMatchingDetails,
nativeMatchingTarget,
nativeMatchingProvider,
} from "../../fixtures/nativeFunctionMatching.js";
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js";
import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js";
import type { NativeSymbolMetadata } from "../../../src/domain/native/nativeSymbol.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { procedureInfoSchema } from "../../../src/domain/native/procedureInfo.js";
import {
@@ -335,6 +340,162 @@ cliTest.for([{ profile: "full" }, { profile: "compact" }] as const)(
},
);
const roleLabelDossier = (
side: "1" | "2",
address: string,
label: NativeSymbolMetadata["loader_role_label"],
) => {
const value = functionDossierSchema.parse(
FUNCTION_COMPARISON_EXAMPLE.left.normalized_result,
);
value.procedure = {
address,
name: "entry",
symbol: {
primary: true,
dynamic: false,
external: false,
type: "function",
source: "imported",
...(label === undefined ? {} : { loader_role_label: label }),
},
classification: null,
body: nativeMatchingBody(address, 1),
signature: null,
locals: [],
};
value.basic_blocks = [];
return createEvidence(nativeMatchingTarget(side), nativeMatchingProvider, {
operation: "analyze_function",
parameters: { procedure: address },
result: value,
relationshipCoverage: { status: "complete" },
});
};
cliTest(
"native matching and comparison keep loader-synthesized role labels apart from exact symbols through CLI and MCP",
{ timeout: 60000 },
async ({ cli }) => {
// Unrelated entry bodies, equal synthesized initializer bodies, an
// artifact-supplied role name, and a role name whose origin is unknown.
const left = nativeMatchingInventory("1", [
{ address: "0x10", value: "entry", loader_role_label: "synthesized" },
{ address: "0x20", value: "_DT_INIT", loader_role_label: "synthesized" },
{
address: "0x30",
value: "_INIT_0",
loader_role_label: "artifact-symbol",
},
{ address: "0x40", value: "_FINI_0", loader_role_label: "unexamined" },
]);
const right = nativeMatchingInventory("2", [
{ address: "0x50", value: "entry", loader_role_label: "synthesized" },
{ address: "0x60", value: "_DT_INIT", loader_role_label: "synthesized" },
{
address: "0x70",
value: "_INIT_0",
loader_role_label: "artifact-symbol",
},
{ address: "0x80", value: "_FINI_0", loader_role_label: "unexamined" },
]);
const leftObservations = [
...nativeMatchingDetails("1", "0x10", "e8000000c3"),
...nativeMatchingDetails("1", "0x20", "4883ec08c3"),
];
const rightObservations = [
...nativeMatchingDetails("2", "0x50", "b82a000000c3"),
...nativeMatchingDetails("2", "0x60", "4883ec08c3"),
];
const request = {
left,
right,
left_observations: leftObservations,
right_observations: rightObservations,
detail: "complete",
};
const { call } = await connectLocalToolsMcp();
const mcp = await call("match_native_functions", request);
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
const evidence = parseEvidence(mcp.structuredContent);
const report = nativeFunctionMatchingSchema.parse(
evidence.normalized_result,
);
const outcome = (name: string) =>
report.matches
.filter((match) =>
[...match.left, ...match.right].some(
(member) => member.name === name,
),
)
.map(({ state, basis }) => ({ state, basis }));
expect(outcome("entry")).toEqual([
{ state: "only_left", basis: null },
{ state: "only_right", basis: null },
]);
expect(outcome("_DT_INIT")).toEqual([
{ state: "matched", basis: "identical-bytes" },
]);
expect(outcome("_INIT_0")).toEqual([
{ state: "matched", basis: "exact-symbol" },
]);
expect(outcome("_FINI_0")).toEqual([
{ state: "only_left", basis: null },
{ state: "only_right", basis: null },
]);
const members = report.matches.flatMap((match) => [
...match.left,
...match.right,
]);
for (const member of members)
expect(
member.limitations.includes(
"Provider loader-synthesized role label; exact-name matching is unavailable. Independent complete body bytes remain eligible.",
),
member.name,
).toBe(member.name === "entry" || member.name === "_DT_INIT");
expect(
members
.filter((member) => member.name === "_FINI_0")
.every((member) =>
member.limitations.includes(
"Symbol origin unavailable; exact-name matching is unavailable. Independent complete body bytes remain eligible.",
),
),
).toBe(true);
// The CLI shares the matching implementation and Evidence identity.
const root = await createTestTempDirectory("rea-matching-role-labels-");
const requestPath = join(root, "request.json");
await writeFile(requestPath, JSON.stringify(request));
const cliResult = await cli.run({
arguments: ["match-native-functions", requestPath, "--json"],
});
expect(cliResult.exitCode, cliResult.stderr).toBe(0);
expect(parseEvidence(cliResult.json).evidence_id).toBe(
evidence.evidence_id,
);
// compare_functions (MCP-only) consumes the same symbol-origin owner.
for (const label of ["synthesized", "artifact-symbol"] as const) {
const compared = await call("compare_functions", {
left: roleLabelDossier("1", "0x10", label),
right: roleLabelDossier("2", "0x50", label),
});
expect(compared.isError, JSON.stringify(compared)).not.toBe(true);
expect(
functionComparisonResultSchema.parse(
parseEvidence(compared.structuredContent).normalized_result,
).function_match,
).toMatchObject(
label === "synthesized"
? { status: "ambiguous", method: "explicit" }
: { status: "matched", method: "symbol" },
);
}
},
);
cliTest(
"native matching rejects corrupt and misselected MCP sources without retaining them",
async () => {
@@ -745,6 +745,14 @@ it.each([
"tests/conformance/ghidra/ReaDecodeProbe.java",
{ ghidra_bridge: true, runtime: false, package: false },
],
[
"tests/conformance/ghidra/ReaElfRoleLabelProbe.java",
{ ghidra_bridge: true, runtime: false, package: false },
],
[
"tests/conformance/ghidra/elf-role-label-fixture.mjs",
{ ghidra_bridge: true, runtime: false, package: false },
],
[
"bridge/ghidra/Example.java",
{
@@ -0,0 +1,209 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { elf } from "../../../src/domain/binaryTarget.fixture.js";
import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js";
import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js";
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
import {
jsonValueSchema,
type JsonValue,
} from "../../../src/domain/jsonValue.js";
import { nativeFunctionMatchingSchema } from "../../../src/domain/native/functionMatching.js";
import type { NativeSymbolMetadata } from "../../../src/domain/native/nativeSymbol.js";
import { ok } from "../../../src/domain/result.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
type RoleLabel = NonNullable<NativeSymbolMetadata["loader_role_label"]>;
const symbol = (label?: RoleLabel | "invalid") => ({
primary: true,
dynamic: false,
external: false,
type: "function",
source: "imported",
...(label === undefined ? {} : { loader_role_label: label }),
});
const procedure = { external: false, thunk: false, thunk_target: null };
// Each side names its ELF header entry with Ghidra's loader label; the bodies
// at 0x201120 and 0x20113b are unrelated functions in the reported workflow.
const rows = (entry: string, label?: RoleLabel | "invalid") => [
{ address: entry, value: "entry", symbol: symbol(label), procedure },
{ address: "0x201200", value: "main", symbol: symbol(), procedure },
];
type ReportedLabel = RoleLabel | "invalid" | undefined;
const openTarget = async (side: "left" | "right", label: ReportedLabel) => {
const root = await createTestTempDirectory(`rea-ghidra-role-${side}-`);
const path = join(root, `${side}.elf`);
const header = elf(2, 1, 62);
header.writeUInt16LE(2, 16);
header.writeBigUInt64LE(side === "left" ? 0x201120n : 0x20113bn, 24);
await writeFile(path, header);
const entry = side === "left" ? "0x201120" : "0x20113b";
const harness = await connectGhidraMcp(
`ghidra-loader-role-${side}`,
(operation) => {
if (operation === "list_procedures")
return Promise.resolve(ok(jsonValueSchema.parse(rows(entry, label))));
if (operation === "analyze_function") {
const dossier = ghidraFunctionDossier() as {
procedure: Record<string, JsonValue>;
};
dossier.procedure.name = "entry";
dossier.procedure.symbol = jsonValueSchema.parse(symbol(label));
return Promise.resolve(
ok({ value: jsonValueSchema.parse(dossier), limitations: [] }),
);
}
throw new Error(`Unexpected operation: ${operation}`);
},
{ targetPath: path },
);
onTestFinished(harness.close);
const call = async (name: string, args: Record<string, unknown>) => {
const reply = await harness.mcp.callTool({ name, arguments: args });
if (reply.isError === true) throw new Error(JSON.stringify(reply));
return sessionEvidence(harness.session, reply.structuredContent);
};
return { harness, call };
};
const sides = async (label: ReportedLabel) => ({
left: await openTarget("left", label),
right: await openTarget("right", label),
});
const match = async (
right: Awaited<ReturnType<typeof openTarget>>,
leftInventory: Evidence,
rightInventory: Evidence,
) => {
const reply = await right.harness.mcp.callTool({
name: "match_native_functions",
arguments: {
left: leftInventory,
right: {
kind: "retained-evidence",
evidence_id: rightInventory.evidence_id,
},
detail: "complete",
},
});
if (reply.isError === true) throw new Error(JSON.stringify(reply));
return nativeFunctionMatchingSchema.parse(
parseEvidence(reply.structuredContent).normalized_result,
);
};
const groupFor = (
report: ReturnType<typeof nativeFunctionMatchingSchema.parse>,
name: string,
) =>
report.matches.filter((group) =>
[...group.left, ...group.right].some((member) => member.name === name),
);
// compare_functions consumes the same parsed fact from independent dossiers.
const compareEntries = async (
left: Awaited<ReturnType<typeof openTarget>>,
right: Awaited<ReturnType<typeof openTarget>>,
) => {
const dossier = { procedure: "entry", detail: "complete" };
const compared = await right.harness.mcp.callTool({
name: "compare_functions",
arguments: {
left: await left.call("analyze_function", dossier),
right: await right.call("analyze_function", dossier),
},
});
if (compared.isError === true) throw new Error(JSON.stringify(compared));
return functionComparisonResultSchema.parse(
parseEvidence(compared.structuredContent).normalized_result,
);
};
it("keeps Ghidra loader-synthesized ELF entry labels from proving exact symbol correspondence", async () => {
const { left, right } = await sides("synthesized");
const leftInventory = await left.call("list_procedures", {});
const rightInventory = await right.call("list_procedures", {});
// The provider fact survives the adapter into retained inventory Evidence.
expect(leftInventory.normalized_result).toMatchObject([
{
value: "entry",
symbol: { source: "imported", loader_role_label: "synthesized" },
},
{
value: "main",
symbol: expect.not.objectContaining({
loader_role_label: expect.anything(),
}),
},
]);
const report = await match(right, leftInventory, rightInventory);
expect(groupFor(report, "main")).toMatchObject([
{ state: "matched", basis: "exact-symbol" },
]);
const entries = groupFor(report, "entry");
expect(entries.map(({ state, basis }) => ({ state, basis }))).toEqual([
{ state: "only_left", basis: null },
{ state: "only_right", basis: null },
]);
for (const group of entries)
for (const member of [...group.left, ...group.right]) {
expect(member.name).toBe("entry");
expect(member.limitations).toContain(
"Provider loader-synthesized role label; exact-name matching is unavailable. Independent complete body bytes remain eligible.",
);
}
const comparison = await compareEntries(left, right);
expect(comparison.function_match).toEqual({
status: "ambiguous",
method: "explicit",
left_name: "entry",
right_name: "entry",
});
expect(
comparison.dimensions.find(({ dimension }) => dimension === "identity"),
).toMatchObject({ status: "unknown" });
});
it.each([
{ label: "artifact-symbol", state: "matched", basis: "exact-symbol" },
{ label: "unexamined", state: "only_left", basis: null },
] as const)(
"keeps Ghidra $label entry labels on their observed origin",
async ({ label, state, basis }) => {
const { left, right } = await sides(label);
const leftInventory = await left.call("list_procedures", {});
const rightInventory = await right.call("list_procedures", {});
const report = await match(right, leftInventory, rightInventory);
const [first] = groupFor(report, "entry");
expect(first).toMatchObject({ state, basis });
if (label === "unexamined")
expect(first!.left[0]!.limitations).toContain(
"Symbol origin unavailable; exact-name matching is unavailable. Independent complete body bytes remain eligible.",
);
expect((await compareEntries(left, right)).function_match).toMatchObject(
label === "artifact-symbol"
? { status: "matched", method: "symbol" }
: { status: "ambiguous", method: "explicit" },
);
},
);
it("rejects an unknown Ghidra loader role label before creating Evidence", async () => {
const left = await openTarget("left", "invalid");
const reply = await left.harness.mcp.callTool({
name: "list_procedures",
arguments: {},
});
expect(reply.isError).toBe(true);
expect(parseMcpToolError(reply)).toMatchObject({
error: { code: "unreadable_output" },
});
expect(parseMcpToolError(reply)).not.toHaveProperty("evidence_id");
});
+9
View File
@@ -19,6 +19,15 @@ binaries and Ghidra projects are never committed.
language's default context and checks each mnemonic, length and optional
complete operand. The `ghidra-bridge-sources` CI job runs it for `ARM:LE:32:Cortex`,
`ARM:LE:32:v8-m` and `Xtensa:LE:32:default`; the bytes are never executed.
- `elf-role-label-fixture.mjs` writes source-owned x86-64 ELF executables into
a new temporary directory and prints its path. Their header entry has no
symbol, a genuine global `FUNC entry` symbol, an undecoded `.gnu_debugdata`
section, truncated section headers, a symbol table extending past the file,
or only a DWARF `entry` subprogram. `ReaElfRoleLabelProbe.java` imports each
with Ghidra's ELF loader (auto-analysis only for the DWARF case) and checks
the production bridge's `loader_role_label` for the loader's `entry`
function. The `ghidra-bridge-sources` CI job runs it; the bytes are never
executed.
- `inventory.c` produces separate x86-64 debug and stripped ELF executables.
It covers imports, an external `puts` function, linker thunks, source symbols,
direct calls, a targetless callback call, two referenced strings, and a
@@ -0,0 +1,43 @@
//@category REA Verification
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import ghidra.app.plugin.core.analysis.DWARFAnalyzer;
import ghidra.app.script.GhidraScript;
import ghidra.app.script.GhidraScriptUtil;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Symbol;
import java.io.File;
import java.lang.reflect.Method;
/** Real ELF loader import; checks the production bridge's entry label fact. */
public class ReaElfRoleLabelProbe extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
require(args.length == 3 || (args.length == 4 && args[3].equals("dwarf-imported")),
"Expected the production bridge directory, entry address, label and optional dwarf-imported");
var source = GhidraScriptUtil.findScriptByName("ReaGhidraBridge.java");
require(source != null && new File(source.getAbsolutePath()).getCanonicalFile().equals(
new File(args[0], "ReaGhidraBridge.java").getCanonicalFile()), "Production bridge source");
GhidraScript bridge = GhidraScriptUtil.getProvider(source).getScriptInstance(source, errorWriter);
require(currentProgram.getExecutableFormat().equals("Executable and Linking Format (ELF)"),
"ELF loader import");
// Pins the reason for a DWARF fixture's label to Ghidra's own import record.
require(DWARFAnalyzer.isAlreadyImported(currentProgram) == (args.length == 4),
"DWARF import state");
Function function = getFunctionAt(toAddr(Long.decode(args[1])));
require(function != null && function.getName(true).equals("entry"), "Function at the ELF header entry");
Method metadata = bridge.getClass().getDeclaredMethod("symbolMetadata", Symbol.class);
metadata.setAccessible(true);
JsonObject facts = (JsonObject) metadata.invoke(null, function.getSymbol());
require(facts.get("source").getAsString().equals("imported"), "Ghidra imported source " + facts);
JsonElement label = facts.get("loader_role_label");
require(label != null && label.getAsString().equals(args[2]), "Loader role label " + facts);
println("REA_ELF_ROLE_LABEL_OK " + args[2]);
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}
@@ -0,0 +1,225 @@
#!/usr/bin/env node
// Write source-owned x86-64 ELF executables whose header entry Ghidra's ELF
// loader names `entry` into a new private directory and print its path. The
// bytes are never executed.
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
export const ELF_ROLE_ENTRY = 0x400080n;
const BASE = 0x400000n;
const CODE = Buffer.from("b82a000000c3", "hex"); // mov eax, 42; ret
const CODE_OFFSET = Number(ELF_ROLE_ENTRY - BASE);
const sectionHeader = (fields) => {
const header = Buffer.alloc(64);
header.writeUInt32LE(fields.name, 0);
header.writeUInt32LE(fields.type, 4);
header.writeBigUInt64LE(fields.flags ?? 0n, 8);
header.writeBigUInt64LE(fields.address ?? 0n, 16);
header.writeBigUInt64LE(BigInt(fields.offset), 24);
header.writeBigUInt64LE(BigInt(fields.size), 32);
header.writeUInt32LE(fields.link ?? 0, 40);
header.writeUInt32LE(fields.info ?? 0, 44);
header.writeBigUInt64LE(fields.align ?? 1n, 48);
header.writeBigUInt64LE(fields.entrySize ?? 0n, 56);
return header;
};
/**
* One PT_LOAD image; `sections` optionally adds headers and their contents. A
* section `size` may exceed its data, and `missingHeaders` declares headers
* beyond the end of the file.
*/
const executable = (sections, { missingHeaders = 0 } = {}) => {
const image = Buffer.alloc(CODE_OFFSET + CODE.length);
image.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]);
image.writeUInt16LE(2, 16); // ET_EXEC
image.writeUInt16LE(62, 18); // EM_X86_64
image.writeUInt32LE(1, 20);
image.writeBigUInt64LE(ELF_ROLE_ENTRY, 24);
image.writeBigUInt64LE(64n, 32);
image.writeUInt16LE(64, 52);
image.writeUInt16LE(56, 54);
image.writeUInt16LE(1, 56);
image.writeUInt16LE(64, 58);
image.writeUInt32LE(1, 64); // PT_LOAD
image.writeUInt32LE(5, 68); // R|X
image.writeBigUInt64LE(BASE, 80);
image.writeBigUInt64LE(BASE, 88);
image.writeBigUInt64LE(BigInt(image.length), 96);
image.writeBigUInt64LE(BigInt(image.length), 104);
image.writeBigUInt64LE(0x1000n, 112);
image.set(CODE, CODE_OFFSET);
if (sections.length === 0) return image;
const all = [
...sections,
{ name: ".shstrtab", type: 3, data: Buffer.alloc(0) },
];
const names = [0];
let shstrtab = Buffer.from([0]);
for (const section of all) {
names.push(shstrtab.length);
shstrtab = Buffer.concat([shstrtab, Buffer.from(`${section.name}\0`)]);
}
all.at(-1).data = shstrtab;
const parts = [image];
let offset = image.length;
const headers = [sectionHeader({ name: 0, type: 0, offset: 0, size: 0 })];
const align = () => {
const padding = (8 - (offset % 8)) % 8;
parts.push(Buffer.alloc(padding));
offset += padding;
};
all.forEach((section, index) => {
// Sections without an offset append their contents after the image.
if (section.offset === undefined) align();
headers.push(
sectionHeader({
...section,
name: names[index + 1],
offset: section.offset ?? offset,
size: section.size ?? section.data.length,
}),
);
if (section.offset === undefined) {
parts.push(section.data);
offset += section.data.length;
}
});
align();
const bytes = Buffer.concat([...parts, ...headers]);
bytes.writeBigUInt64LE(BigInt(offset), 40);
bytes.writeUInt16LE(headers.length + missingHeaders, 60);
bytes.writeUInt16LE(headers.length - 1, 62);
return bytes;
};
const text = {
name: ".text",
type: 1, // SHT_PROGBITS
flags: 6n, // SHF_ALLOC|SHF_EXECINSTR
address: ELF_ROLE_ENTRY,
offset: CODE_OFFSET,
data: CODE,
align: 16n,
};
const symbol = Buffer.alloc(48);
symbol.writeUInt32LE(1, 24); // "entry" in .strtab
symbol.writeUInt8(0x12, 28); // STB_GLOBAL, STT_FUNC
symbol.writeUInt16LE(1, 30); // .text
symbol.writeBigUInt64LE(ELF_ROLE_ENTRY, 32);
symbol.writeBigUInt64LE(BigInt(CODE.length), 40);
const symbolTable = (entries, size) => ({
name: ".symtab",
type: 2, // SHT_SYMTAB
data: entries,
size,
link: 3,
info: 1,
align: 8n,
entrySize: 24n,
});
const uleb = (value) => Buffer.from([value]); // every value here is below 0x80
const cstring = (value) => Buffer.from(`${value}\0`);
const little = (bytes, write) => {
const buffer = Buffer.alloc(bytes);
write(buffer);
return buffer;
};
const u16 = (value) => little(2, (buffer) => buffer.writeUInt16LE(value));
const u32 = (value) => little(4, (buffer) => buffer.writeUInt32LE(value));
const u64 = (value) => little(8, (buffer) => buffer.writeBigUInt64LE(value));
// DWARF 4: a C compile unit with one external subprogram `entry` at the entry.
const DW_FORM = { addr: 0x01, data2: 0x05, data4: 0x06, string: 0x08 };
const debugAbbrev = Buffer.concat([
...[1, 0x11, 1].map(uleb), // compile_unit, has children
...[0x25, DW_FORM.string, 0x13, DW_FORM.data2, 0x03, DW_FORM.string].map(
uleb,
),
...[0x11, DW_FORM.addr, 0x12, DW_FORM.data4, 0, 0].map(uleb),
...[2, 0x2e, 0].map(uleb), // subprogram, no children
...[0x3f, 0x19, 0x03, DW_FORM.string].map(uleb), // external, name
...[0x11, DW_FORM.addr, 0x12, DW_FORM.data4, 0, 0, 0].map(uleb),
]);
const units = Buffer.concat([
u16(4), // version
u32(0), // debug_abbrev offset
Buffer.from([8]), // address size
uleb(1),
cstring("REA source-owned fixture"),
u16(0x0c), // DW_LANG_C99
cstring("entry.c"),
u64(ELF_ROLE_ENTRY),
u32(CODE.length),
uleb(2),
cstring("entry"),
u64(ELF_ROLE_ENTRY),
u32(CODE.length),
uleb(0),
]);
const debugInfo = Buffer.concat([u32(units.length), units]);
/** Expected bridge `loader_role_label` for the function at the ELF header entry. */
export const ELF_ROLE_LABEL_FIXTURES = {
// No symbol table: the loader creates and names the entry function.
stripped: { label: "synthesized", bytes: executable([]) },
// A genuine global FUNC symbol `entry` defines the header entry function.
symbolized: {
label: "artifact-symbol",
bytes: executable([
text,
symbolTable(symbol),
{ name: ".strtab", type: 3, data: Buffer.from("\0entry\0") },
]),
},
// The parser reports missing section headers without throwing.
truncated: {
label: "unexamined",
bytes: executable([text], { missingHeaders: 2 }),
},
// A symbol table extending past the file end is read only partly.
oversized: {
label: "unexamined",
bytes: executable([
text,
symbolTable(Buffer.alloc(24), 24 * 0x1000),
{ name: ".strtab", type: 3, data: Buffer.from([0]) },
]),
},
// Without .symtab, Ghidra's DWARF analysis names the entry function from
// debug information with the same imported source. Requires auto-analysis.
dwarf: {
label: "unexamined",
analysis: true,
bytes: executable([
text,
{ name: ".debug_abbrev", type: 1, data: debugAbbrev },
{ name: ".debug_info", type: 1, data: debugInfo },
]),
},
// MiniDebugInfo can supply stripped symbols that the bridge does not decode.
minidebug: {
label: "unexamined",
bytes: executable([
text,
{ name: ".gnu_debugdata", type: 1, data: Buffer.from("not-xz") },
]),
},
};
if (process.argv[1] === fileURLToPath(import.meta.url)) {
if (process.argv.length > 2)
throw new Error("Usage: elf-role-label-fixture.mjs (takes no arguments)");
// Only constant fixture names are written inside the directory created here.
const directory = await mkdtemp(join(tmpdir(), "rea-elf-role-"));
for (const [name, fixture] of Object.entries(ELF_ROLE_LABEL_FIXTURES))
await writeFile(join(directory, `${name}.elf`), fixture.bytes, {
flag: "wx",
});
console.log(directory);
}
+7 -2
View File
@@ -30,13 +30,17 @@ export const nativeMatchingInventory = (
/** Explicit imported-origin fixture for tests that select exact-symbol correspondence. */
export const nativeMatchingNamedInventory = (
side: "1" | "2",
entries: readonly { address: string; value: string }[],
entries: readonly {
address: string;
value: string;
loader_role_label?: NativeSymbolMetadata["loader_role_label"];
}[],
evidenceLinks: readonly string[] = [],
) =>
createEvidence(nativeMatchingTarget(side), nativeMatchingProvider, {
operation: "list_procedures",
parameters: {},
result: entries.map((entry) => ({
result: entries.map(({ loader_role_label, ...entry }) => ({
...entry,
symbol: {
primary: true,
@@ -44,6 +48,7 @@ export const nativeMatchingNamedInventory = (
external: false,
type: "function",
source: "imported",
...(loader_role_label === undefined ? {} : { loader_role_label }),
} satisfies NativeSymbolMetadata,
})),
evidenceLinks: [...evidenceLinks],