mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 10:43:30 +00:00
fix(ghidra): prevent loader relocations from constant folding (#10483)
* fix(ghidra): prevent loader relocations from constant folding Ghidra imports loader relocation destinations with their file bytes. A destination in a non-writable loaded block can be populated by the runtime loader instead, for example a copy-relocated musl `stderr` in RELRO, so the decompiler folded the on-disk zero and showed `fprintf(NULL, ...)`. Before initializing the decompiler, mark each loaded, initialized, non-writable block containing a relocation destination volatile in the ephemeral database, and add a limitation to procedures that read such a destination. ELF shared objects and PIEs are included; relocatable objects and core files are not. Permissions and source FileBytes are unchanged. Sessions without database-mutation authority (Windows P0 and existing caller projects) record the limitation but do not mark blocks volatile, and the analysis profile versions the two behaviors separately. A real Linux Ghidra ELF probe runs in the Ghidra bridge CI lane. Fixes #4177 Squashed from #4198 and rebased onto main. Rebase adaptations: the read-only gate, the verifier renamed to verify-ghidra-loader-relocation so it stays out of the manual-verifier glob, docs moved to docs/testing-lanes.md and docs/providers/ghidra-provider.md. Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ghidra): await installation inspection in the relocation verifier inspectGhidraInstallation became async on main after #4198's base, so the verifier asserted on a pending Promise. Also wait for provider output to close before reading it, like the sibling Ghidra verifiers, and keep the workflow reference inside the docs site. Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ghidra): address relocation review findings - Version the existing-project analysis profile with the read-only relocation semantics, since those sessions now add the limitation. - Inspect the installation through the verifier helper that owns the file-reader resources. - Build the fixture with -fPIC: with -fPIE, GCC 12 emits a copy relocation of rea_external_value into writable .bss, so the probe's GOT check could not hold. - Run probe cleanup through withVerifierCleanup so a cleanup failure does not hide the probe failure, and keep the workspace when owned Ghidra cleanup is incomplete. - Link the workflow with a full GitHub URL, per the docs guide. Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ghidra): reach the external data through the GOT with a weak extern Building the whole fixture with -fPIC also turned the reader's direct load of rea_loader_pointer into a GOT-relaxed lea plus two loads, and the real-Ghidra lane then reported that the baseline no longer folds the file constant. Keep -fPIE and declare only rea_external_value weak: GCC and clang must reach a possibly absent symbol through a GOT entry, which lands as R_X86_64_GLOB_DAT inside GNU_RELRO (checked with readelf for GCC 12.2 and clang/lld). Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ghidra): protect only relocations Ghidra could not apply Running the real-Ghidra lane locally (Ghidra 12.1.4, JDK 21, GCC 12) showed that marking every non-writable block that holds a relocation volatile also marks full-RELRO .got. External calls then decompile as opaque pointer calls (`(*pcVar2)(param_1)` through `PTR_rea_external_...` instead of `rea_external(param_1)`), which the probe's GOT-stability check caught. Ghidra applies GLOB_DAT and RELATIVE relocations, so those bytes already hold the loader's value and folding them is correct. The #4177 case is a copy relocation, which Ghidra imports as UNSUPPORTED with the file's zero in place. Protect only destinations whose relocation status is not APPLIED or APPLIED_OTHER. The fixture now reads a provider `const` object that the linker copies into .data.rel.ro, the #4177 shape. The probe requires that relocation to be unapplied and requires applied GOT relocations to stay non-volatile. Ghidra 12.1.4 already shows this x86-64 read by name without protection, so the probe records whether the baseline folds and compares before and after only when it does. The profile value becomes volatile-unapplied-relocation-blocks-v1. Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Arslan Basharat <arslanbasharat414@gmail.com> Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
14 files changed
+998
-1
No files matched your search
@@ -14,7 +14,7 @@ jobs:
|
||||
ghidra-bridge-sources:
|
||||
name: Compile packaged Ghidra bridge sources
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
GHIDRA_RELEASE_URL: https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.1.4_build/ghidra_12.1.4_PUBLIC_20260921.zip
|
||||
GHIDRA_RELEASE_SHA256: ddac49f903da9d5bac833e5cc79395098b9c33cfd3279be5f31bd00387d2d4db
|
||||
@@ -46,6 +46,14 @@ jobs:
|
||||
java_home_variable="JAVA_HOME_${GHIDRA_JAVA_MAJOR}_X64"
|
||||
test -n "${!java_home_variable}"
|
||||
JAVA_HOME="${!java_home_variable}" npm run verify:ghidra:bridge-sources -- --verified-sdk
|
||||
- name: Install locked dependencies for loader relocation regression
|
||||
run: npm ci
|
||||
- name: Verify loader relocation handling with the native decompiler
|
||||
shell: bash
|
||||
run: |
|
||||
java_home_variable="JAVA_HOME_${GHIDRA_JAVA_MAJOR}_X64"
|
||||
export JAVA_HOME="${!java_home_variable}"
|
||||
npm run verify:ghidra:loader-relocation
|
||||
- name: Inspect real ARM, Thumb and MIPS instruction context through the bridge
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -38,6 +38,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.NavigableMap;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
@@ -61,6 +62,7 @@ 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.opinion.ElfLoader;
|
||||
import ghidra.framework.options.Options;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.address.AddressIterator;
|
||||
@@ -126,6 +128,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private static final int DEFAULT_HIGH_PCODE_DEF_USE_EDGES = 12000;
|
||||
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
||||
private final Map<FileBytes, Boolean> targetSourceIdentity = new IdentityHashMap<>();
|
||||
private final NavigableMap<Address, LoaderInitializedRelocation> loaderInitializedRelocations =
|
||||
new TreeMap<>();
|
||||
private String admittedTargetSha256;
|
||||
private static final Set<String> DESCRIPTOR_KEYS = Set.of(
|
||||
"transport",
|
||||
@@ -257,6 +261,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
if (!descriptor.transport.equals("unix-socket") && descriptor.analysisExtensions.size() > 0)
|
||||
throw new IOException("Windows Ghidra P0 does not admit metadata recovery or database mutation.");
|
||||
analysisExtensions.analyze(descriptor.analysisExtensions, currentProgram, monitor);
|
||||
// Read-only sessions keep the discovered limitation but cannot mark blocks volatile.
|
||||
protectLoaderInitializedRelocations(
|
||||
!descriptor.existingProject && descriptor.transport.equals("unix-socket"));
|
||||
initializeDecompiler();
|
||||
checkpointModification = currentProgram.getModificationNumber();
|
||||
serve(descriptor);
|
||||
@@ -1046,6 +1053,89 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghidra applies most loader relocations, but one it cannot apply (for
|
||||
* example an ELF copy relocation in RELRO, whose value comes from another
|
||||
* object) keeps its on-disk bytes while the runtime loader supplies a
|
||||
* different value. Mark a non-writable loaded block holding such a
|
||||
* destination volatile before opening the decompiler so those bytes cannot
|
||||
* be constant-propagated as if they were runtime values.
|
||||
*
|
||||
* This changes only the ephemeral analysis database: source FileBytes and
|
||||
* the original target remain untouched. MemoryBlock exposes volatility for
|
||||
* a whole block, so the mitigation is intentionally conservative within a
|
||||
* block while preserving its read/write/execute permissions.
|
||||
*
|
||||
* Without database-mutation authority (Windows P0 or a caller's existing
|
||||
* project) the relocations are still recorded so affected procedures carry
|
||||
* the limitation, but no block is marked volatile.
|
||||
*/
|
||||
private void protectLoaderInitializedRelocations(boolean markVolatile) throws Exception {
|
||||
String elfFileType = currentProgram.getOptions(
|
||||
ghidra.program.model.listing.Program.PROGRAM_INFO
|
||||
).getString(ElfLoader.ELF_FILE_TYPE_PROPERTY, "");
|
||||
// Ghidra also marks ELF ET_DYN and ET_CORE programs relocatable.
|
||||
if (currentProgram.getRelocationTable().isRelocatable() &&
|
||||
!elfFileType.equals("shared object")) return;
|
||||
Map<Address, LoaderInitializedRelocation> discovered = new TreeMap<>();
|
||||
Set<MemoryBlock> blocks = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Iterator<Relocation> iterator = currentProgram.getRelocationTable().getRelocations();
|
||||
while (iterator.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
Relocation relocation = iterator.next();
|
||||
// An applied relocation already holds the value the loader writes
|
||||
// (for example GLOB_DAT or RELATIVE), so folding it is correct and
|
||||
// keeping its block foldable preserves GOT-call pseudocode.
|
||||
Relocation.Status status = relocation.getStatus();
|
||||
if (status == Relocation.Status.APPLIED || status == Relocation.Status.APPLIED_OTHER)
|
||||
continue;
|
||||
Address location = relocation.getAddress();
|
||||
MemoryBlock block = currentProgram.getMemory().getBlock(location);
|
||||
if (block == null || !block.isLoaded() || !block.isInitialized() || block.isWrite())
|
||||
continue;
|
||||
long lengthInBytes = relocation.getLength();
|
||||
int addressableUnitSize = location.getAddressSpace().getAddressableUnitSize();
|
||||
long lengthInAddressableUnits = Math.max(
|
||||
1L,
|
||||
(lengthInBytes + addressableUnitSize - 1L) / addressableUnitSize
|
||||
);
|
||||
discovered.put(
|
||||
location,
|
||||
new LoaderInitializedRelocation(
|
||||
location,
|
||||
lengthInAddressableUnits,
|
||||
block.getName(),
|
||||
relocation.getSymbolName()
|
||||
)
|
||||
);
|
||||
blocks.add(block);
|
||||
}
|
||||
if (discovered.isEmpty()) return;
|
||||
loaderInitializedRelocations.putAll(discovered);
|
||||
if (!markVolatile) return;
|
||||
|
||||
int transaction = currentProgram.startTransaction("REA loader relocation semantics");
|
||||
boolean commit = false;
|
||||
try {
|
||||
for (MemoryBlock block : blocks) {
|
||||
monitor.checkCancelled();
|
||||
if (!block.isVolatile()) block.setVolatile(true);
|
||||
}
|
||||
commit = true;
|
||||
}
|
||||
finally {
|
||||
currentProgram.endTransaction(transaction, commit);
|
||||
}
|
||||
}
|
||||
|
||||
private LoaderInitializedRelocation loaderInitializedRelocationAt(Address address) {
|
||||
Map.Entry<Address, LoaderInitializedRelocation> entry =
|
||||
loaderInitializedRelocations.floorEntry(address);
|
||||
if (entry == null) return null;
|
||||
LoaderInitializedRelocation relocation = entry.getValue();
|
||||
return relocation.contains(address) ? relocation : null;
|
||||
}
|
||||
|
||||
private DecompileOptions decompileOptions(Integer payloadMiB) {
|
||||
DecompileOptions options = new DecompileOptions();
|
||||
options.setMaxInstructions(Integer.MAX_VALUE);
|
||||
@@ -3990,6 +4080,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private final Function function;
|
||||
private final List<String> limitations = new ArrayList<>();
|
||||
private boolean inferredNoReturnCall;
|
||||
private final Set<Address> observedLoaderRelocations = new HashSet<>();
|
||||
private Instruction beforePrevious;
|
||||
private Instruction previous;
|
||||
|
||||
@@ -4004,6 +4095,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
limitations.add(terminal.limitation());
|
||||
if (terminal.inferredNoReturn()) inferredNoReturnCall = true;
|
||||
}
|
||||
for (Reference reference : instruction.getReferencesFrom()) {
|
||||
if (!reference.getReferenceType().isRead()) continue;
|
||||
LoaderInitializedRelocation relocation = loaderInitializedRelocationAt(
|
||||
reference.getToAddress()
|
||||
);
|
||||
if (relocation == null || !observedLoaderRelocations.add(relocation.address()))
|
||||
continue;
|
||||
limitations.add(loaderInitializedRelocationLimitation(relocation));
|
||||
}
|
||||
if (beforePrevious != null && previous != null) {
|
||||
String seh = borlandSehHandlerLimitation(
|
||||
function, beforePrevious, previous, instruction);
|
||||
@@ -4050,6 +4150,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return bookmark != null && "Non-Returning Function Found".equals(bookmark.getComment());
|
||||
}
|
||||
|
||||
private static String loaderInitializedRelocationLimitation(
|
||||
LoaderInitializedRelocation relocation) {
|
||||
String symbol = relocation.symbolName();
|
||||
String symbolDetail = symbol == null || symbol.isBlank()
|
||||
? "an unnamed relocation"
|
||||
: "the relocation for " + symbol;
|
||||
return "This procedure reads bytes affected by the loader relocation at " +
|
||||
canonicalAddress(relocation.address()) + " in non-writable block " +
|
||||
relocation.blockName() +
|
||||
", which contains " + symbolDetail +
|
||||
"; the file-backed value may differ from the loader-initialized runtime value, " +
|
||||
"so constants derived from this location are not authoritative.";
|
||||
}
|
||||
|
||||
private String borlandSehHandlerLimitation(
|
||||
Function function, Instruction handlerPush, Instruction framePush,
|
||||
Instruction frameLink) {
|
||||
@@ -5484,6 +5598,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
}
|
||||
private record AddressNameConflict(Namespace namespace, String leaf, List<Address> requesters, List<Address> holders) {}
|
||||
private record AddressNameFailure(String reason, AddressNameConflict conflict) {}
|
||||
private record LoaderInitializedRelocation(
|
||||
Address address, long lengthInAddressableUnits, String blockName, String symbolName
|
||||
) {
|
||||
private boolean contains(Address candidate) {
|
||||
if (!candidate.hasSameAddressSpace(address) || candidate.compareTo(address) < 0)
|
||||
return false;
|
||||
long displacement = candidate.subtract(address);
|
||||
return displacement >= 0 && displacement < lengthInAddressableUnits;
|
||||
}
|
||||
}
|
||||
private record FunctionEntry(Function function, InventoryItem item) {}
|
||||
private record ProcedureNameMatch(boolean primary, List<String> aliases) {
|
||||
boolean matched() {
|
||||
|
||||
@@ -362,6 +362,21 @@ flag and affected callers are repaired in the same pass. Unknown imports and
|
||||
genuine no-return imports keep their flags. Analysis profiles version this repair
|
||||
so snapshots produced before it are not reused. Windows P0 does not run it.
|
||||
|
||||
Ghidra applies most loader relocations, but it cannot apply some, such as an
|
||||
ELF copy relocation into RELRO (for example musl's `stderr`). Those destinations
|
||||
keep their file bytes while the runtime loader writes another object's value.
|
||||
Before decompiling, a fresh non-Windows import marks each loaded, initialized,
|
||||
non-writable block that holds such an unapplied relocation destination volatile
|
||||
in the ephemeral database, so the decompiler cannot fold the on-disk value into
|
||||
a constant. Applied relocations such as GOT entries stay foldable, so external
|
||||
calls keep their names. Permissions and source `FileBytes` are unchanged; the
|
||||
whole block is affected because Ghidra sets volatility per block. A procedure
|
||||
that reads an unapplied destination adds a limitation naming the relocation.
|
||||
Windows P0 and existing projects (`REA_GHIDRA_PROJECT_PATH`) have no
|
||||
database-mutation authority: they add the same limitation, but their pseudocode
|
||||
may still show the file-backed value. ELF relocatable objects and core files are
|
||||
excluded. Analysis profiles version this behavior.
|
||||
|
||||
`npm run verify:ghidra` compiles the versioned C oracle into debug and stripped
|
||||
host-native targets (x86-64/AArch64 ELF on Linux or Mach-O on macOS), plus a native
|
||||
DWARF 4 type-layout object. It proves the admitted operations, external functions, resolved thunks, exports, stripped-name
|
||||
|
||||
@@ -549,6 +549,26 @@ does not call CLI `search-bytes`, and does not page an overlay search across a
|
||||
snapshot reopen. AArch64 remains covered by deterministic tests rather than this
|
||||
real-provider lane. See [raw binary analysis](providers/raw-binary.md).
|
||||
|
||||
`npm run verify:ghidra:loader-relocation` runs
|
||||
`tests/conformance/ghidra/ReaLoaderRelocationProbe.java` inside a real Linux
|
||||
Ghidra ELF import. The source-owned PIE fixture reads a `const` object from a
|
||||
provider library, which the linker copies into `.data.rel.ro` with a copy
|
||||
relocation that Ghidra imports as unapplied, the shape of #4177. The probe
|
||||
requires that relocation and the reader's read reference to it, a non-writable
|
||||
file-backed block, and the bridge's volatile marking and limitation. Source
|
||||
`FileBytes` and block permissions must be unchanged. Applied GOT relocations
|
||||
must stay non-volatile, and a full-RELRO `-fno-plt` GOT caller must decompile
|
||||
identically before and after protection. Ghidra 12.1.4 already shows this
|
||||
x86-64 read by name without protection, so the report records whether the
|
||||
baseline folded the file-backed zero and compares before and after only when it
|
||||
did. The decompiler effect on the original MIPS case was observed on the
|
||||
reporter's binary, not in this lane. The lane never executes the fixture and
|
||||
needs Ghidra/JDK with the native decompiler and a Linux C compiler. The
|
||||
change-selected [Ghidra bridge CI lane](https://github.com/morluto/rea/blob/main/.github/workflows/ghidra-bridge-sources.yml)
|
||||
runs it with its checksum-verified Ghidra distribution. Read-only sessions
|
||||
(Windows P0 and caller-owned existing projects) record the same limitation but
|
||||
do not mark blocks volatile; no real-provider lane covers them.
|
||||
|
||||
`npm run verify:ghidra:address-context` runs on Linux with a caller-installed
|
||||
Ghidra, full JDK, and an x86-64 ELF GCC toolchain (`CC` selects another compatible
|
||||
compiler). It builds
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
|
||||
"verify:kuna": "npm run build:cached && node scripts/verify-real-kuna.mjs",
|
||||
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
|
||||
"verify:ghidra:loader-relocation": "npm run build:cached && node scripts/verify-ghidra-loader-relocation.mjs",
|
||||
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
|
||||
"verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs",
|
||||
"verify:ghidra:address-context": "npm run build:cached && node scripts/verify-real-ghidra-address-context.mjs",
|
||||
|
||||
@@ -640,6 +640,9 @@ const PATH_SCOPES = {
|
||||
"tests/conformance/ghidra/ReaInstructionContextProbe.java",
|
||||
"tests/conformance/ghidra/ReaComputedCallCoverageProbe.java",
|
||||
"tests/conformance/ghidra/ReaDecodeProbe.java",
|
||||
"scripts/verify-ghidra-loader-relocation.mjs",
|
||||
"tests/conformance/ghidra/ReaLoaderRelocationProbe.java",
|
||||
"tests/conformance/ghidra/loader-relocation*.c",
|
||||
".github/workflows/ghidra-bridge-sources.yml",
|
||||
],
|
||||
website: [
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../dist/process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js";
|
||||
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const expectedChecks = [
|
||||
"bridge_source_bound",
|
||||
"relocation_protection_precedes_decompiler",
|
||||
"fixture_is_elf_shared_object",
|
||||
"dynamic_elf_relocation_table_is_relocatable",
|
||||
"importer_relocation_present",
|
||||
"relocation_not_applied_by_importer",
|
||||
"reader_references_relocation",
|
||||
"writer_references_relocation",
|
||||
"got_function_relocation_present",
|
||||
"got_data_relocation_present",
|
||||
"got_caller_references_loader_relocation",
|
||||
"destination_is_loaded_initialized",
|
||||
"destination_is_file_backed",
|
||||
"destination_is_non_writable",
|
||||
"destination_not_volatile_before_protection",
|
||||
"destination_block_is_volatile",
|
||||
"destination_permissions_preserved",
|
||||
"source_filebytes_unchanged",
|
||||
"evidence_limitation_represents_loader_uncertainty",
|
||||
"non_read_relocation_reference_not_reported_as_read",
|
||||
"relocation_lookup_covers_last_affected_byte",
|
||||
"applied_got_relocations_not_volatile",
|
||||
"native_decompiler_available",
|
||||
"baseline_got_pseudocode_resolves_external_symbols",
|
||||
"protected_got_pseudocode_keeps_external_symbols",
|
||||
"full_relro_got_caller_pseudocode_is_stable",
|
||||
];
|
||||
|
||||
if (process.argv.length !== 2)
|
||||
throw new Error("Usage: node scripts/verify-ghidra-loader-relocation.mjs");
|
||||
if (process.platform !== "linux")
|
||||
throw new Error(
|
||||
"Loader-relocation verification requires a Linux Ghidra host with an ELF linker.",
|
||||
);
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error(
|
||||
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
|
||||
);
|
||||
|
||||
const installation = await inspectVerifierGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir: process.env.GHIDRA_INSTALL_DIR,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { javaHome: process.env.JAVA_HOME }),
|
||||
});
|
||||
assert.ok(
|
||||
installation.analyzeHeadlessPath !== null &&
|
||||
(installation.status === "available" ||
|
||||
installation.rejection?.code === "executable_missing"),
|
||||
JSON.stringify(installation),
|
||||
);
|
||||
|
||||
const run = createVerifierRun();
|
||||
const workspace = await mkdtemp(
|
||||
join(tmpdir(), "rea-ghidra-loader-relocation-"),
|
||||
);
|
||||
const project = join(workspace, "project");
|
||||
const target = join(workspace, "rea-loader-relocation.elf");
|
||||
const provider = join(workspace, "librea-loader-provider.so");
|
||||
const fixture = fileURLToPath(
|
||||
new URL("../tests/conformance/ghidra/loader-relocation.c", import.meta.url),
|
||||
);
|
||||
const providerFixture = fileURLToPath(
|
||||
new URL(
|
||||
"../tests/conformance/ghidra/loader-relocation-provider.c",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const bridgeDirectory = fileURLToPath(
|
||||
new URL("../bridge/ghidra", import.meta.url),
|
||||
);
|
||||
const fixtureDirectory = fileURLToPath(
|
||||
new URL("../tests/conformance/ghidra", import.meta.url),
|
||||
);
|
||||
const bridge = join(bridgeDirectory, "ReaGhidraBridge.java");
|
||||
const compiler = process.env.REA_CC ?? "cc";
|
||||
let supervisor;
|
||||
let report;
|
||||
// Owned Ghidra processes may still use the workspace until cleanup proves
|
||||
// they are gone, so the workspace is released only after that proof.
|
||||
let workspaceReleasable = true;
|
||||
|
||||
await withVerifierCleanup(async () => {
|
||||
await mkdir(project);
|
||||
await buildProvider(compiler, providerFixture, provider);
|
||||
await buildFixture(compiler, fixture, provider, target);
|
||||
const targetDigest = digest(await readFile(target));
|
||||
const launch = await spawnOwnedProviderProcess({
|
||||
command: installation.analyzeHeadlessPath,
|
||||
arguments: [
|
||||
project,
|
||||
"LoaderRelocation",
|
||||
"-import",
|
||||
target,
|
||||
"-readOnly",
|
||||
"-deleteProject",
|
||||
"-scriptPath",
|
||||
`${bridgeDirectory};${fixtureDirectory}`,
|
||||
"-postScript",
|
||||
"ReaLoaderRelocationProbe.java",
|
||||
bridge,
|
||||
],
|
||||
runId: run.run_id,
|
||||
expectedCommand: null,
|
||||
});
|
||||
supervisor = new ProviderProcessSupervisor({
|
||||
...launch,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup: () => cleanupOwnedProcessGroup(launch.ownership),
|
||||
});
|
||||
assert.ok(
|
||||
await supervisor.waitForExit(180_000),
|
||||
"Ghidra loader-relocation probe exceeded its 180-second deadline",
|
||||
);
|
||||
assert.ok(
|
||||
await supervisor.waitForOutputClose(1000),
|
||||
"Ghidra loader-relocation probe output did not close",
|
||||
);
|
||||
const snapshot = supervisor.snapshot();
|
||||
const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`;
|
||||
assert.equal(snapshot.exitCode, 0, output);
|
||||
const matches = [
|
||||
...output.matchAll(/REA_LOADER_RELOCATION_PROBE_JSON (\{[^\r\n]*\})/gu),
|
||||
];
|
||||
assert.equal(matches.length, 1, output);
|
||||
report = JSON.parse(matches[0][1]);
|
||||
assert.equal(report.status, "passed");
|
||||
assert.equal(report.ghidra_version, installation.providerVersion);
|
||||
assert.deepEqual(
|
||||
expectedChecks.every((check) => report.checks.includes(check)),
|
||||
true,
|
||||
);
|
||||
assert.equal(report.source_filebytes_unchanged, true);
|
||||
assert.equal(report.permissions, "read=true,write=false,execute=false");
|
||||
assert.ok(
|
||||
report.limitations.some(
|
||||
(value) =>
|
||||
value.includes("non-writable block") &&
|
||||
value.includes("file-backed value may differ") &&
|
||||
value.includes("not authoritative"),
|
||||
),
|
||||
);
|
||||
assert.equal(report.decompiler_verified, true);
|
||||
assert.ok(report.checks.includes("pseudocode_does_not_use_file_constant"));
|
||||
// Ghidra 12.1.4 shows this x86-64 copy-relocated read by name even
|
||||
// unprotected, so the before/after difference is checked only when the
|
||||
// baseline folds the file-backed zero.
|
||||
assert.equal(typeof report.baseline_folds_file_constant, "boolean");
|
||||
if (report.baseline_folds_file_constant)
|
||||
assert.ok(
|
||||
report.checks.includes("protected_pseudocode_is_not_baseline_constant"),
|
||||
);
|
||||
assert.ok(
|
||||
report.checks.includes("full_relro_got_caller_pseudocode_is_stable"),
|
||||
);
|
||||
assert.equal(digest(await readFile(target)), targetDigest);
|
||||
report = {
|
||||
...report,
|
||||
target_sha256: targetDigest,
|
||||
decompiler_scope: report.baseline_folds_file_constant
|
||||
? "native Ghidra baseline folded the file constant; protected pseudocode does not"
|
||||
: "native Ghidra baseline did not fold the file constant on this toolchain; protected pseudocode does not use it and GOT callers are unchanged",
|
||||
};
|
||||
}, [
|
||||
async () => {
|
||||
if (supervisor === undefined) return;
|
||||
const stopped = await supervisor.stop();
|
||||
supervisor.dispose();
|
||||
workspaceReleasable = stopped.status !== "incomplete";
|
||||
assert.ok(
|
||||
workspaceReleasable,
|
||||
`Owned Ghidra cleanup incomplete; retained ${workspace}: ${JSON.stringify(stopped)}`,
|
||||
);
|
||||
},
|
||||
async () => {
|
||||
if (workspaceReleasable)
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
},
|
||||
]);
|
||||
|
||||
assert.ok(report);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
...report,
|
||||
cleanup: "complete",
|
||||
verifier_run: await completeVerifierRun(run),
|
||||
}),
|
||||
);
|
||||
|
||||
async function buildProvider(compiler, source, output) {
|
||||
const arguments_ = [
|
||||
"-O0",
|
||||
"-fPIC",
|
||||
"-shared",
|
||||
"-nostdlib",
|
||||
source,
|
||||
"-o",
|
||||
output,
|
||||
];
|
||||
await exec(compiler, arguments_, { timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function buildFixture(compiler, source, provider, output) {
|
||||
const arguments_ = [
|
||||
"-O0",
|
||||
"-fPIE",
|
||||
"-fno-plt",
|
||||
"-pie",
|
||||
"-nostdlib",
|
||||
"-Wl,-z,relro,-z,now",
|
||||
"-Wl,-e,rea_loader_start",
|
||||
"-Wl,-rpath,$ORIGIN",
|
||||
source,
|
||||
provider,
|
||||
"-o",
|
||||
output,
|
||||
];
|
||||
await exec(compiler, arguments_, { timeout: 60_000 });
|
||||
}
|
||||
|
||||
function digest(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
@@ -76,6 +76,9 @@ const sharedAnalysisSemantics = {
|
||||
byte_search_coverage: "separate-loaded-overlays-v1",
|
||||
function_boundary_observations: "ghidra-function-boundary-limitations-v5",
|
||||
} as const;
|
||||
const loaderRelocationSemantics = "volatile-unapplied-relocation-blocks-v1";
|
||||
// Read-only sessions record relocation limitations without marking blocks volatile.
|
||||
const readOnlyLoaderRelocationSemantics = "read-only-relocation-limitations-v1";
|
||||
|
||||
/**
|
||||
* The processor language REA pins for this target, or undefined when Ghidra may
|
||||
@@ -324,6 +327,11 @@ export const resolveGhidraAnalysisProfile = async (
|
||||
}
|
||||
: {}),
|
||||
...sharedAnalysisSemantics,
|
||||
// Windows P0 has no database-mutation authority: limitations only.
|
||||
loader_relocation_semantics:
|
||||
installation.platform === "win32"
|
||||
? readOnlyLoaderRelocationSemantics
|
||||
: loaderRelocationSemantics,
|
||||
no_return_repair:
|
||||
installation.platform === "win32"
|
||||
? "disabled-windows-p0"
|
||||
@@ -491,6 +499,7 @@ export const existingGhidraAnalysisProfile = (
|
||||
...sharedAnalysisSemantics,
|
||||
selected_project_relationship_coverage: "prior-analysis-unknown-v1",
|
||||
annotation_policy: "read-only-existing-project-v1",
|
||||
loader_relocation_semantics: readOnlyLoaderRelocationSemantics,
|
||||
existing_project: project,
|
||||
original_analysis: { completeness: "unknown", settings: "unknown" },
|
||||
no_return_repair: "disabled-existing-project",
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
functionCoordinateContextsEqual,
|
||||
} from "../domain/native/functionCoordinateContext.js";
|
||||
import { createAnalysisProfile } from "../domain/analysisProfile.js";
|
||||
import { existingGhidraAnalysisProfile } from "./GhidraAnalysisProfile.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisOperation,
|
||||
@@ -855,6 +856,33 @@ describe("Ghidra platform support", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("versions loader relocation semantics by database-mutation authority", async () => {
|
||||
const semantics = async (platform: NodeJS.Platform) => {
|
||||
const resolved = await provider(
|
||||
installationHost(platform),
|
||||
).resolveAnalysisProfile(peTarget("x86_64"));
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
return resolved.value.profile?.parameters.loader_relocation_semantics;
|
||||
};
|
||||
// Windows P0 cannot mark blocks volatile, so its snapshots must not replay
|
||||
// as volatile-protected pseudocode.
|
||||
expect(await semantics("win32")).toBe(
|
||||
"read-only-relocation-limitations-v1",
|
||||
);
|
||||
expect(await semantics("linux")).toBe(
|
||||
"volatile-unapplied-relocation-blocks-v1",
|
||||
);
|
||||
// A caller's existing project is read-only too, so its saved results must
|
||||
// not match a profile that predates the relocation limitation.
|
||||
expect(
|
||||
existingGhidraAnalysisProfile(
|
||||
peTarget("x86_64"),
|
||||
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
|
||||
{ project_name: "fixture" },
|
||||
).parameters.loader_relocation_semantics,
|
||||
).toBe("read-only-relocation-limitations-v1");
|
||||
});
|
||||
|
||||
it("commits the PE role and managed classification to profile identity", async () => {
|
||||
const ghidra = provider(installationHost("win32"));
|
||||
const application = peTarget("x86_64");
|
||||
@@ -1455,6 +1483,7 @@ describe("Ghidra client projection", () => {
|
||||
call_relationship_coverage: "observed-call-inventory-and-discovery-v7",
|
||||
function_dossier_coverage: "observed-facets-and-call-relationships-v1",
|
||||
native_value_flow_projection: "accepted-runtime-budgets-v1",
|
||||
loader_relocation_semantics: "volatile-unapplied-relocation-blocks-v1",
|
||||
});
|
||||
|
||||
const result = await ghidra
|
||||
|
||||
@@ -768,6 +768,18 @@ it.each([
|
||||
"scripts/verify-ghidra-bridge-sources.mjs",
|
||||
{ code: true, ghidra_bridge: true, runtime: false, package: false },
|
||||
],
|
||||
[
|
||||
"scripts/verify-ghidra-loader-relocation.mjs",
|
||||
{ code: true, ghidra_bridge: true, runtime: false, package: false },
|
||||
],
|
||||
[
|
||||
"tests/conformance/ghidra/ReaLoaderRelocationProbe.java",
|
||||
{ code: true, ghidra_bridge: true, runtime: false, package: false },
|
||||
],
|
||||
[
|
||||
"tests/conformance/ghidra/loader-relocation-provider.c",
|
||||
{ code: true, ghidra_bridge: true, runtime: false, package: false },
|
||||
],
|
||||
[
|
||||
"src/ghidra/GhidraBridgeSources.ts",
|
||||
{ code: true, ghidra_bridge: true, runtime: true, package: true },
|
||||
|
||||
@@ -91,6 +91,19 @@ no-overwrite CLI delivery. It checks the target and project files for changes.
|
||||
from the worst-case line size before allocating the source. Larger or
|
||||
invalid counts are rejected before installation inspection or compilation.
|
||||
|
||||
- `loader-relocation.c`, `loader-relocation-provider.c` and
|
||||
`ReaLoaderRelocationProbe.java` exercise the production `ReaGhidraBridge`
|
||||
against a real imported Linux full-RELRO PIE ELF and a small shared library.
|
||||
The fixture's `.data.rel.ro` pointer is populated by an importer-created
|
||||
relocation. The probe proves that the `ET_DYN` program is marked relocatable
|
||||
by Ghidra but is still protected, checks the relocation byte range and
|
||||
read/write reference handling, and compares baseline/protected pseudocode.
|
||||
It also checks that a `-fno-plt` GOT caller retains identical pseudocode when
|
||||
its relocation blocks become volatile. Source `FileBytes` and block
|
||||
permissions must remain unchanged. `npm run
|
||||
verify:ghidra:loader-relocation` requires Linux, BYO Ghidra/JDK with its
|
||||
native decompiler, and a C compiler; the fixture is never executed.
|
||||
|
||||
`scripts/verify-real-ghidra.mjs` uses `cc`, `clang`, and `lld-link` by default;
|
||||
`REA_CC`, `REA_CLANG`, and `REA_LLD_LINK` can select alternate commands. The
|
||||
verifier requires the verified bring-your-own Ghidra 12.1.4 installation through
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
//@category REA Verification
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import generic.jar.ResourceFile;
|
||||
import ghidra.app.decompiler.DecompInterface;
|
||||
import ghidra.app.util.opinion.ElfLoader;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.app.script.GhidraScriptProvider;
|
||||
import ghidra.app.script.GhidraScriptUtil;
|
||||
import ghidra.program.database.mem.FileBytes;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.FunctionIterator;
|
||||
import ghidra.program.model.listing.Instruction;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.model.mem.Memory;
|
||||
import ghidra.program.model.mem.MemoryBlock;
|
||||
import ghidra.program.model.mem.MemoryBlockSourceInfo;
|
||||
import ghidra.program.model.reloc.Relocation;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
import ghidra.program.model.symbol.SymbolIterator;
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Exercises the production loader-relocation boundary on a real imported
|
||||
* Linux ELF Ghidra Program. The relocation and instruction reference must
|
||||
* come from the importer; the source file and its FileBytes are never changed.
|
||||
*/
|
||||
public final class ReaLoaderRelocationProbe extends GhidraScript {
|
||||
private record FileSnapshot(FileBytes source, byte[] original, byte[] modified) {}
|
||||
|
||||
private final JsonArray checks = new JsonArray();
|
||||
|
||||
private void require(String name, boolean condition) {
|
||||
if (!condition) throw new IllegalStateException("Loader relocation assertion failed: " + name);
|
||||
checks.add(name);
|
||||
}
|
||||
|
||||
private Function function(String name) {
|
||||
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
|
||||
Function found = null;
|
||||
while (functions.hasNext()) {
|
||||
Function candidate = functions.next();
|
||||
if (!candidate.getName().equals(name) && !candidate.getName().equals("_" + name)) continue;
|
||||
require("unique_function_" + name, found == null);
|
||||
found = candidate;
|
||||
}
|
||||
require("function_present_" + name, found != null);
|
||||
return found;
|
||||
}
|
||||
|
||||
private static byte[] bytes(FileBytes fileBytes, boolean original) throws Exception {
|
||||
byte[] result = new byte[Math.toIntExact(fileBytes.getSize())];
|
||||
int read = original
|
||||
? fileBytes.getOriginalBytes(0, result)
|
||||
: fileBytes.getModifiedBytes(0, result, 0, result.length);
|
||||
if (read != result.length) throw new IllegalStateException("Incomplete FileBytes capture");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String digest(byte[] value) throws Exception {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
|
||||
}
|
||||
|
||||
private static Method privateMethod(Class<?> owner, String name, Class<?>... types)
|
||||
throws Exception {
|
||||
Method method = owner.getDeclaredMethod(name, types);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
}
|
||||
|
||||
private static Object invoke(Method method, Object target, Object... arguments) throws Exception {
|
||||
try {
|
||||
return method.invoke(target, arguments);
|
||||
}
|
||||
catch (InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Production bridge method failed: " + method.getName(), failure.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private static void setField(Object target, String name, Object value) throws Exception {
|
||||
for (Class<?> owner = target.getClass(); owner != null; owner = owner.getSuperclass()) {
|
||||
try {
|
||||
Field field = owner.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
return;
|
||||
}
|
||||
catch (NoSuchFieldException ignored) {
|
||||
// Continue through HeadlessScript's inherited state.
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Missing bridge field: " + name);
|
||||
}
|
||||
|
||||
private static JsonObject procedureQuery(String document, String procedure) {
|
||||
JsonObject query = new JsonObject();
|
||||
query.addProperty("document", document);
|
||||
query.addProperty("procedure", procedure);
|
||||
return query;
|
||||
}
|
||||
|
||||
private Address symbolAddress(String name) {
|
||||
SymbolIterator symbols = currentProgram.getSymbolTable().getSymbols(name);
|
||||
Address found = null;
|
||||
while (symbols.hasNext()) {
|
||||
Address address = symbols.next().getAddress();
|
||||
if (found != null && !found.equals(address))
|
||||
throw new IllegalStateException("Multiple addresses for fixture symbol " + name);
|
||||
found = address;
|
||||
}
|
||||
if (found == null) throw new IllegalStateException("Missing fixture symbol " + name);
|
||||
return found;
|
||||
}
|
||||
|
||||
private Relocation relocationAt(Address address) {
|
||||
Iterator<Relocation> relocations = currentProgram.getRelocationTable().getRelocations();
|
||||
Relocation found = null;
|
||||
while (relocations.hasNext()) {
|
||||
Relocation candidate = relocations.next();
|
||||
if (!candidate.getAddress().equals(address)) continue;
|
||||
if (found != null) throw new IllegalStateException("Multiple relocations at " + canonical(address));
|
||||
found = candidate;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private Relocation relocationForSymbol(String name) {
|
||||
Iterator<Relocation> relocations = currentProgram.getRelocationTable().getRelocations();
|
||||
while (relocations.hasNext()) {
|
||||
Relocation candidate = relocations.next();
|
||||
if (name.equals(candidate.getSymbolName())) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean targetBacked(MemoryBlock block, FileBytes target) {
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos())
|
||||
if (source.getFileBytes().orElse(null) == target) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private Reference readerReference(Function reader, Address target) {
|
||||
for (Instruction instruction : currentProgram.getListing().getInstructions(reader.getBody(), true)) {
|
||||
for (Reference reference : instruction.getReferencesFrom()) {
|
||||
if (reference.getToAddress().equals(target) && reference.getReferenceType().isRead())
|
||||
return reference;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Reference writerReference(Function writer, Address target) {
|
||||
for (Instruction instruction : currentProgram.getListing().getInstructions(writer.getBody(), true)) {
|
||||
for (Reference reference : instruction.getReferencesFrom()) {
|
||||
if (reference.getToAddress().equals(target) && reference.getReferenceType().isWrite())
|
||||
return reference;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Reference readReference(Function function, Address target) {
|
||||
for (Instruction instruction : currentProgram.getListing().getInstructions(function.getBody(), true)) {
|
||||
for (Reference reference : instruction.getReferencesFrom()) {
|
||||
if (reference.getToAddress().equals(target) && reference.getReferenceType().isRead())
|
||||
return reference;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<FileSnapshot> snapshotFileBytes(List<FileBytes> sources) throws Exception {
|
||||
List<FileSnapshot> snapshots = new java.util.ArrayList<>();
|
||||
for (FileBytes source : sources)
|
||||
snapshots.add(new FileSnapshot(source, bytes(source, true), bytes(source, false)));
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
private static boolean fileBytesUnchanged(List<FileSnapshot> snapshots) throws Exception {
|
||||
for (FileSnapshot snapshot : snapshots) {
|
||||
if (!java.util.Arrays.equals(snapshot.original(), bytes(snapshot.source(), true)) ||
|
||||
!java.util.Arrays.equals(snapshot.modified(), bytes(snapshot.source(), false))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The copy destination's file bytes are zero; the loader writes 0x11111111. */
|
||||
private static boolean containsFileConstant(String pseudocode) {
|
||||
return java.util.regex.Pattern.compile("\\breturn (0|0x0);").matcher(pseudocode).find();
|
||||
}
|
||||
|
||||
private static void disposeDecompiler(Object bridge) throws Exception {
|
||||
Object value = getField(bridge, "decompiler");
|
||||
if (value instanceof DecompInterface interface_) {
|
||||
interface_.dispose();
|
||||
setField(bridge, "decompiler", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static String permissions(MemoryBlock block) {
|
||||
return "read=" + block.isRead() + ",write=" + block.isWrite() + ",execute=" + block.isExecute();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
String[] arguments = getScriptArgs();
|
||||
if (arguments.length != 1) throw new IllegalArgumentException("Expected exact bridge source path");
|
||||
File expectedBridge = new File(arguments[0]).getCanonicalFile();
|
||||
ResourceFile resource = GhidraScriptUtil.findScriptByName("ReaGhidraBridge.java");
|
||||
if (resource == null) throw new IllegalStateException("ReaGhidraBridge.java is absent from scriptPath");
|
||||
File actualBridge = new File(resource.getAbsolutePath()).getCanonicalFile();
|
||||
require("bridge_source_bound", expectedBridge.equals(actualBridge));
|
||||
String source = Files.readString(actualBridge.toPath());
|
||||
int protectionCall = source.indexOf("protectLoaderInitializedRelocations(");
|
||||
int decompilerCall = source.indexOf("initializeDecompiler();");
|
||||
require("relocation_protection_precedes_decompiler", protectionCall >= 0 && protectionCall < decompilerCall);
|
||||
|
||||
GhidraScript bridge = GhidraScriptUtil.getProvider(resource).getScriptInstance(resource, errorWriter);
|
||||
bridge.set(getState(), getControls());
|
||||
setField(bridge, "sessionDefaultAddressSpace", currentProgram.getAddressFactory().getDefaultAddressSpace());
|
||||
setField(bridge, "admittedTargetSha256", currentProgram.getExecutableSHA256().toLowerCase());
|
||||
|
||||
Memory memory = currentProgram.getMemory();
|
||||
List<FileBytes> sources = memory.getAllFileBytes();
|
||||
if (sources.isEmpty()) throw new IllegalStateException("Imported Program has no FileBytes");
|
||||
FileBytes targetSource = sources.get(0);
|
||||
List<FileSnapshot> sourceBefore = snapshotFileBytes(sources);
|
||||
|
||||
Function reader = function("rea_loader_reader");
|
||||
Function writer = function("rea_loader_writer");
|
||||
Function gotCaller = function("rea_loader_got_caller");
|
||||
// The copy-relocation destination Ghidra cannot apply (#4177's shape).
|
||||
Address pointer = symbolAddress("rea_external_const");
|
||||
Relocation relocation = relocationAt(pointer);
|
||||
String elfFileType = currentProgram.getOptions(Program.PROGRAM_INFO)
|
||||
.getString(ElfLoader.ELF_FILE_TYPE_PROPERTY, "");
|
||||
require("fixture_is_elf_shared_object", elfFileType.equals("shared object"));
|
||||
require("dynamic_elf_relocation_table_is_relocatable",
|
||||
currentProgram.getRelocationTable().isRelocatable());
|
||||
require("importer_relocation_present",
|
||||
relocation != null && currentProgram.getRelocationTable().hasRelocation(pointer));
|
||||
if (relocation == null) throw new IllegalStateException("Importer did not create copy relocation");
|
||||
|
||||
require("relocation_not_applied_by_importer",
|
||||
relocation.getStatus() != Relocation.Status.APPLIED &&
|
||||
relocation.getStatus() != Relocation.Status.APPLIED_OTHER);
|
||||
Reference reference = readerReference(reader, pointer);
|
||||
require("reader_references_relocation", reference != null);
|
||||
require("writer_references_relocation", writerReference(writer, pointer) != null);
|
||||
|
||||
Relocation functionGotRelocation = relocationForSymbol("rea_external");
|
||||
Relocation dataGotRelocation = relocationForSymbol("rea_external_value");
|
||||
require("got_function_relocation_present", functionGotRelocation != null);
|
||||
require("got_data_relocation_present", dataGotRelocation != null);
|
||||
require("got_caller_references_loader_relocation",
|
||||
readReference(gotCaller, functionGotRelocation.getAddress()) != null ||
|
||||
readReference(gotCaller, dataGotRelocation.getAddress()) != null);
|
||||
|
||||
MemoryBlock block = memory.getBlock(pointer);
|
||||
if (block == null) throw new IllegalStateException("Pointer relocation has no loaded memory block");
|
||||
boolean readable = block.isRead();
|
||||
boolean writable = block.isWrite();
|
||||
boolean executable = block.isExecute();
|
||||
require("destination_is_loaded_initialized", block.isLoaded() && block.isInitialized());
|
||||
require("destination_is_file_backed", targetBacked(block, targetSource));
|
||||
require("destination_is_non_writable", !writable);
|
||||
require("destination_not_volatile_before_protection", !block.isVolatile());
|
||||
|
||||
boolean decompilerAvailable = false;
|
||||
String baselinePseudocode = null;
|
||||
// Observed, not required: Ghidra 12.1.4 shows this x86-64 read by name.
|
||||
boolean baselineFolds = false;
|
||||
String protectedPseudocode = null;
|
||||
String baselineGotPseudocode = null;
|
||||
String protectedGotPseudocode = null;
|
||||
List<String> limitations = null;
|
||||
Method initialize = privateMethod(bridge.getClass(), "initializeDecompiler");
|
||||
Method pseudocodeMethod = privateMethod(bridge.getClass(), "procedurePseudocode", JsonObject.class);
|
||||
try {
|
||||
try {
|
||||
invoke(initialize, bridge);
|
||||
decompilerAvailable = true;
|
||||
}
|
||||
catch (IllegalStateException unavailable) {
|
||||
throw new IllegalStateException(
|
||||
"The Linux loader-relocation probe requires Ghidra's native decompiler",
|
||||
unavailable.getCause()
|
||||
);
|
||||
}
|
||||
require("native_decompiler_available", decompilerAvailable);
|
||||
JsonObject baseline = (JsonObject)invoke(
|
||||
pseudocodeMethod,
|
||||
bridge,
|
||||
procedureQuery(currentProgram.getName(), reader.getName())
|
||||
);
|
||||
baselinePseudocode = baseline.get("value").getAsString();
|
||||
baselineFolds = containsFileConstant(baselinePseudocode);
|
||||
JsonObject baselineGot = (JsonObject)invoke(
|
||||
pseudocodeMethod,
|
||||
bridge,
|
||||
procedureQuery(currentProgram.getName(), gotCaller.getName())
|
||||
);
|
||||
baselineGotPseudocode = baselineGot.get("value").getAsString();
|
||||
require("baseline_got_pseudocode_resolves_external_symbols",
|
||||
baselineGotPseudocode.contains("rea_external") &&
|
||||
baselineGotPseudocode.contains("rea_external_value"));
|
||||
|
||||
disposeDecompiler(bridge);
|
||||
Method protect = privateMethod(
|
||||
bridge.getClass(), "protectLoaderInitializedRelocations", boolean.class);
|
||||
invoke(protect, bridge, true);
|
||||
require("destination_block_is_volatile", block.isVolatile());
|
||||
require("destination_permissions_preserved",
|
||||
block.isRead() == readable && block.isWrite() == writable && block.isExecute() == executable);
|
||||
require("source_filebytes_unchanged", fileBytesUnchanged(sourceBefore));
|
||||
|
||||
Method limitationsMethod = privateMethod(
|
||||
bridge.getClass(), "functionBoundaryLimitations", Function.class, Iterable.class
|
||||
);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> observedLimitations = (List<String>)invoke(
|
||||
limitationsMethod,
|
||||
bridge,
|
||||
reader,
|
||||
currentProgram.getListing().getInstructions(reader.getBody(), true)
|
||||
);
|
||||
limitations = observedLimitations;
|
||||
require("evidence_limitation_represents_loader_uncertainty",
|
||||
limitations.stream().anyMatch(value ->
|
||||
value.contains("non-writable block " + block.getName()) &&
|
||||
value.contains("file-backed value may differ") &&
|
||||
value.contains("not authoritative")));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> writerLimitations = (List<String>)invoke(
|
||||
limitationsMethod,
|
||||
bridge,
|
||||
writer,
|
||||
currentProgram.getListing().getInstructions(writer.getBody(), true)
|
||||
);
|
||||
require("non_read_relocation_reference_not_reported_as_read",
|
||||
writerLimitations.stream().noneMatch(value ->
|
||||
value.contains("file-backed value may differ")));
|
||||
|
||||
Method relocationLookup = privateMethod(
|
||||
bridge.getClass(), "loaderInitializedRelocationAt", Address.class
|
||||
);
|
||||
int addressableUnitSize = pointer.getAddressSpace().getAddressableUnitSize();
|
||||
long relocationLengthInAddressableUnits = Math.max(
|
||||
1L,
|
||||
(relocation.getLength() + (long)addressableUnitSize - 1L) / addressableUnitSize
|
||||
);
|
||||
Address lastAffectedAddress = pointer.add(relocationLengthInAddressableUnits - 1L);
|
||||
require("relocation_lookup_covers_last_affected_byte",
|
||||
invoke(relocationLookup, bridge, lastAffectedAddress) != null);
|
||||
|
||||
MemoryBlock functionGotBlock = memory.getBlock(functionGotRelocation.getAddress());
|
||||
MemoryBlock dataGotBlock = memory.getBlock(dataGotRelocation.getAddress());
|
||||
// Applied GOT relocations already hold the loader's value; protecting
|
||||
// them would turn external calls into opaque pointer calls.
|
||||
require("applied_got_relocations_not_volatile",
|
||||
functionGotBlock != null && !functionGotBlock.isVolatile() &&
|
||||
dataGotBlock != null && !dataGotBlock.isVolatile());
|
||||
|
||||
invoke(initialize, bridge);
|
||||
JsonObject protectedResult = (JsonObject)invoke(
|
||||
pseudocodeMethod,
|
||||
bridge,
|
||||
procedureQuery(currentProgram.getName(), reader.getName())
|
||||
);
|
||||
protectedPseudocode = protectedResult.get("value").getAsString();
|
||||
require("pseudocode_does_not_use_file_constant", !containsFileConstant(protectedPseudocode));
|
||||
if (baselineFolds)
|
||||
require("protected_pseudocode_is_not_baseline_constant",
|
||||
!protectedPseudocode.equals(baselinePseudocode));
|
||||
JsonObject protectedGot = (JsonObject)invoke(
|
||||
pseudocodeMethod,
|
||||
bridge,
|
||||
procedureQuery(currentProgram.getName(), gotCaller.getName())
|
||||
);
|
||||
protectedGotPseudocode = protectedGot.get("value").getAsString();
|
||||
require("protected_got_pseudocode_keeps_external_symbols",
|
||||
protectedGotPseudocode.contains("rea_external") &&
|
||||
protectedGotPseudocode.contains("rea_external_value"));
|
||||
require("full_relro_got_caller_pseudocode_is_stable",
|
||||
protectedGotPseudocode.equals(baselineGotPseudocode));
|
||||
}
|
||||
finally {
|
||||
disposeDecompiler(bridge);
|
||||
}
|
||||
JsonObject report = new JsonObject();
|
||||
report.addProperty("status", "passed");
|
||||
report.addProperty("ghidra_version", ghidra.framework.Application.getApplicationVersion());
|
||||
report.addProperty("bridge_source_sha256", digest(Files.readAllBytes(actualBridge.toPath())));
|
||||
report.addProperty("target_source_sha256", digest(sourceBefore.get(0).original()));
|
||||
report.addProperty("relocation_address", canonical(pointer));
|
||||
report.addProperty("relocation_type", relocation.getType());
|
||||
report.addProperty("relocation_status", relocation.getStatus().toString());
|
||||
report.addProperty("block", block.getName());
|
||||
report.addProperty("reader_reference", canonical(reference.getFromAddress()));
|
||||
report.addProperty("source_filebytes_unchanged", fileBytesUnchanged(sourceBefore));
|
||||
report.addProperty("permissions", permissions(block));
|
||||
report.addProperty("decompiler_verified", decompilerAvailable);
|
||||
report.addProperty("baseline_folds_file_constant", baselineFolds);
|
||||
report.addProperty("baseline_pseudocode", baselinePseudocode);
|
||||
report.addProperty("protected_pseudocode", protectedPseudocode);
|
||||
report.addProperty("baseline_got_caller_pseudocode", baselineGotPseudocode);
|
||||
report.addProperty("protected_got_caller_pseudocode", protectedGotPseudocode);
|
||||
if (limitations == null) throw new IllegalStateException("Loader limitation probe did not run");
|
||||
report.add("limitations", GSON_ARRAY(limitations));
|
||||
report.add("checks", checks);
|
||||
report.addProperty("scope", "real Linux ELF importer relocation and production bridge private methods; no target execution and no source FileBytes mutation");
|
||||
println("REA_LOADER_RELOCATION_PROBE_JSON " + report);
|
||||
}
|
||||
|
||||
private static JsonArray GSON_ARRAY(List<String> values) {
|
||||
JsonArray result = new JsonArray();
|
||||
for (String value : values) result.add(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Object getField(Object target, String name) throws Exception {
|
||||
for (Class<?> owner = target.getClass(); owner != null; owner = owner.getSuperclass()) {
|
||||
try {
|
||||
Field field = owner.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field.get(target);
|
||||
}
|
||||
catch (NoSuchFieldException ignored) { }
|
||||
}
|
||||
throw new IllegalStateException("Missing bridge field: " + name);
|
||||
}
|
||||
|
||||
private static String canonical(Address address) {
|
||||
return address.getAddressSpace().getName() + ":0x" + Long.toUnsignedString(address.getOffset(), 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
typedef unsigned int rea_loader_word;
|
||||
|
||||
__attribute__((visibility("default")))
|
||||
rea_loader_word rea_external(int value) {
|
||||
return (rea_loader_word)value + 3u;
|
||||
}
|
||||
|
||||
__attribute__((visibility("default")))
|
||||
rea_loader_word rea_external_value = 0x22222222u;
|
||||
|
||||
__attribute__((visibility("default")))
|
||||
const rea_loader_word rea_external_const = 0x11111111u;
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Source-owned Linux ELF fixture for the Ghidra loader-relocation boundary. */
|
||||
typedef unsigned int rea_loader_word;
|
||||
|
||||
/* This file-backed value is the constant the unprotected decompiler may fold. */
|
||||
__attribute__((used, visibility("default")))
|
||||
const rea_loader_word rea_loader_value = 0x11111111u;
|
||||
|
||||
#if defined(__ELF__)
|
||||
#define REA_LOADER_POINTER_SECTION ".data.rel.ro"
|
||||
#elif defined(__APPLE__)
|
||||
#define REA_LOADER_POINTER_SECTION "__DATA_CONST,__const"
|
||||
#else
|
||||
#error "The loader-relocation fixture requires an ELF or Mach-O compiler"
|
||||
#endif
|
||||
|
||||
/* The loader fills this pointer before its RELRO block is sealed read-only. */
|
||||
__attribute__((used, visibility("default"), section(REA_LOADER_POINTER_SECTION)))
|
||||
const rea_loader_word *volatile const rea_loader_pointer = &rea_loader_value;
|
||||
|
||||
/* A const object defined by the provider. In a PIE, GCC and the linker copy
|
||||
* it into RELRO with a copy relocation that Ghidra cannot apply, so the file
|
||||
* holds zero where the loader writes the provider's value: the #4177 shape. */
|
||||
extern const rea_loader_word rea_external_const;
|
||||
|
||||
__attribute__((noinline, used, visibility("default")))
|
||||
rea_loader_word rea_loader_reader(void) {
|
||||
return rea_external_const;
|
||||
}
|
||||
|
||||
__attribute__((noinline, used, visibility("default")))
|
||||
void rea_loader_writer(void) {
|
||||
*(volatile rea_loader_word *)(void *)&rea_external_const = 0;
|
||||
}
|
||||
|
||||
/* An applied relocation in the same RELRO segment; it is not protected. */
|
||||
__attribute__((noinline, used, visibility("default")))
|
||||
rea_loader_word rea_loader_pointer_reader(void) {
|
||||
return *rea_loader_pointer;
|
||||
}
|
||||
|
||||
extern rea_loader_word rea_external(int value);
|
||||
/* Weak so -fPIE code reads it through a RELRO GOT entry. A strong reference
|
||||
* lets GCC use a copy relocation into writable .bss instead. */
|
||||
extern rea_loader_word rea_external_value __attribute__((weak));
|
||||
|
||||
__attribute__((noinline, used, visibility("default")))
|
||||
rea_loader_word rea_loader_got_caller(rea_loader_word value) {
|
||||
return rea_external((int)value) + rea_external_value;
|
||||
}
|
||||
|
||||
__attribute__((used, visibility("default"))) void rea_loader_start(void) {
|
||||
volatile rea_loader_word result = rea_loader_reader();
|
||||
(void)result;
|
||||
for (;;) {
|
||||
}
|
||||
}
|
||||
|
||||
#undef REA_LOADER_POINTER_SECTION
|
||||
Reference in new issue
Block a user