mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9de5dad93 | ||
|
|
6e68440bf0 | ||
|
|
302b8d14d5 | ||
|
|
b819c9cae3 |
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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user