diff --git a/.github/workflows/ghidra-bridge-sources.yml b/.github/workflows/ghidra-bridge-sources.yml index 40a024be90..2bf3378852 100644 --- a/.github/workflows/ghidra-bridge-sources.yml +++ b/.github/workflows/ghidra-bridge-sources.yml @@ -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: | diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 12329c1a5d..d9ab04b34a 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -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 targetSourceIdentity = new IdentityHashMap<>(); + private final NavigableMap loaderInitializedRelocations = + new TreeMap<>(); private String admittedTargetSha256; private static final Set 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 discovered = new TreeMap<>(); + Set blocks = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + Iterator 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 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 limitations = new ArrayList<>(); private boolean inferredNoReturnCall; + private final Set
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
requesters, List
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 aliases) { boolean matched() { diff --git a/docs/providers/ghidra-provider.md b/docs/providers/ghidra-provider.md index 0eb12989e0..3cff500317 100644 --- a/docs/providers/ghidra-provider.md +++ b/docs/providers/ghidra-provider.md @@ -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 diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index a7846af0ec..6cc436529a 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -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 diff --git a/package.json b/package.json index 6f6d3cfc5a..d1256a70e9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index eafcec83d5..9e9f54b193 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.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: [ diff --git a/scripts/verify-ghidra-loader-relocation.mjs b/scripts/verify-ghidra-loader-relocation.mjs new file mode 100644 index 0000000000..b04f1fc1e1 --- /dev/null +++ b/scripts/verify-ghidra-loader-relocation.mjs @@ -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"); +} diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index f56aa7adbb..16ac39dd53 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -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", diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index bb1941ff46..8a902bdb7e 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -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 diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index d3b35e28d3..01bec4bd68 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -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 }, diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index 7a994902c8..14669bea00 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -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 diff --git a/tests/conformance/ghidra/ReaLoaderRelocationProbe.java b/tests/conformance/ghidra/ReaLoaderRelocationProbe.java new file mode 100644 index 0000000000..612a5331c6 --- /dev/null +++ b/tests/conformance/ghidra/ReaLoaderRelocationProbe.java @@ -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 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 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 snapshotFileBytes(List sources) throws Exception { + List 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 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 sources = memory.getAllFileBytes(); + if (sources.isEmpty()) throw new IllegalStateException("Imported Program has no FileBytes"); + FileBytes targetSource = sources.get(0); + List 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 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 observedLimitations = (List)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 writerLimitations = (List)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 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); + } +} diff --git a/tests/conformance/ghidra/loader-relocation-provider.c b/tests/conformance/ghidra/loader-relocation-provider.c new file mode 100644 index 0000000000..4d351bbdaa --- /dev/null +++ b/tests/conformance/ghidra/loader-relocation-provider.c @@ -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; diff --git a/tests/conformance/ghidra/loader-relocation.c b/tests/conformance/ghidra/loader-relocation.c new file mode 100644 index 0000000000..4efe82a7da --- /dev/null +++ b/tests/conformance/ghidra/loader-relocation.c @@ -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