diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 0931834de..0d2bed04a 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -67,7 +67,7 @@ import ghidra.program.model.symbol.Symbol; import ghidra.program.model.symbol.SymbolIterator; public final class ReaGhidraBridge extends HeadlessScript { - private static final int BRIDGE_VERSION = 3; + private static final int BRIDGE_VERSION = 4; private static final int MAX_DESCRIPTOR_BYTES = 16 * 1024; private static final int MAX_REQUEST_CHARACTERS = 256 * 1024; private static final int MAX_RESPONSE_BYTES = 1024 * 1024; @@ -88,6 +88,7 @@ public final class ReaGhidraBridge extends HeadlessScript { "socket_path", "token", "run_id", + "target_sha256", "provider_version", "profile_digest" ); @@ -143,6 +144,16 @@ public final class ReaGhidraBridge extends HeadlessScript { if (!Application.getApplicationVersion().equals(descriptor.providerVersion)) { throw new IllegalStateException("Ghidra provider version does not match the session"); } + String importedSha256 = currentProgram.getExecutableSHA256(); + if (importedSha256 == null || + !constantTimeEquals( + importedSha256.toLowerCase(Locale.ROOT), + descriptor.targetSha256 + )) { + throw new IllegalStateException( + "Ghidra imported-byte digest does not match the admitted target" + ); + } try { initializeDecompiler(); serve(descriptor); @@ -270,6 +281,7 @@ public final class ReaGhidraBridge extends HeadlessScript { "default_address_space", currentProgram.getAddressFactory().getDefaultAddressSpace().getName() ); + target.addProperty("sha256", descriptor.targetSha256); JsonObject result = new JsonObject(); result.addProperty("name", "REA Ghidra bridge"); @@ -1593,6 +1605,7 @@ public final class ReaGhidraBridge extends HeadlessScript { requireString(object, "socket_path"), requireString(object, "token"), requireString(object, "run_id"), + requireSha256(object, "target_sha256"), requireString(object, "provider_version"), requireString(object, "profile_digest") ); @@ -1662,6 +1675,14 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + private static String requireSha256(JsonObject object, String name) { + String value = requireString(object, name); + if (!value.matches("[a-f0-9]{64}")) { + throw new IllegalArgumentException(name + " must be lowercase SHA-256"); + } + return value; + } + private static int requireInteger(JsonObject object, String name) { JsonElement value = object.get(name); if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { @@ -1798,6 +1819,7 @@ public final class ReaGhidraBridge extends HeadlessScript { String socketPath, String token, String runId, + String targetSha256, String providerVersion, String profileDigest ) {} diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs index 8508d5094..f76fbb0ed 100644 --- a/scripts/verify-real-ghidra.mjs +++ b/scripts/verify-real-ghidra.mjs @@ -189,6 +189,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) { ), }), targetPath: parsedTarget.value.path, + targetSha256: parsedTarget.value.sha256, providerVersion: SUPPORTED_GHIDRA_VERSION, profileDigest: profile.value.profile.digest, requestTimeoutMs: GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS, diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 2d4059197..83f186ea0 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -41,6 +41,7 @@ import { parseGhidraSessionInfo, type GhidraSessionInfo, } from "./GhidraSessionValues.js"; +import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js"; import { attachGhidraSocket, connectGhidraSocketOnce, @@ -77,6 +78,7 @@ export class GhidraClient { #launch: GhidraLaunch | undefined; #process: ProviderProcessSupervisor | undefined; #runtimeRoot: PrivateRuntimeRoot | undefined; + #snapshotPath: string | undefined; #token: string | undefined; #runId: string | undefined; #nextId = 1; @@ -232,6 +234,23 @@ export class GhidraClient { } if (deadline.signal.aborted) return this.#startupInterrupted(deadline); const socketPath = join(this.#runtimeRoot.path, "bridge.sock"); + try { + const snapshot = await createGhidraTargetSnapshot( + this.#options.targetPath, + this.#runtimeRoot.path, + this.#options.targetSha256, + ); + this.#snapshotPath = snapshot.path; + } catch (cause: unknown) { + const failure = this.#failure( + "start", + "Ghidra target snapshot failed admission", + cause, + ); + await this.#cleanup(); + return err(failure); + } + if (deadline.signal.aborted) return this.#startupInterrupted(deadline); this.#token = randomBytes(32).toString("hex"); this.#runId = randomUUID(); const launched = await this.#options.launcher @@ -241,7 +260,8 @@ export class GhidraClient { socketPath, token: this.#token, runId: this.#runId, - targetPath: this.#options.targetPath, + targetPath: this.#snapshotPath, + targetSha256: this.#options.targetSha256, providerVersion: this.#options.providerVersion, profileDigest: this.#options.profileDigest, }, @@ -401,6 +421,7 @@ export class GhidraClient { runId, providerVersion: this.#options.providerVersion, profileDigest: this.#options.profileDigest, + targetSha256: this.#options.targetSha256, }); if (parsed.ok) return parsed; return err( @@ -477,6 +498,7 @@ export class GhidraClient { const runtimeRoot = this.#runtimeRoot; this.#runtimeRoot = undefined; await runtimeRoot?.close(); + this.#snapshotPath = undefined; this.#token = undefined; this.#runId = undefined; this.#responseBuffer.reset(); @@ -542,6 +564,7 @@ export class GhidraClient { const snapshot = this.#process?.snapshot() ?? this.#processSnapshot; return createGhidraDiagnostics({ targetPath: this.#options.targetPath, + targetSha256: this.#options.targetSha256, providerVersion: this.#options.providerVersion, profileDigest: this.#options.profileDigest, ...(this.#runtimeRoot === undefined diff --git a/src/ghidra/GhidraClientTypes.ts b/src/ghidra/GhidraClientTypes.ts index 9f53e34b9..c6cdda222 100644 --- a/src/ghidra/GhidraClientTypes.ts +++ b/src/ghidra/GhidraClientTypes.ts @@ -33,6 +33,7 @@ export type GhidraDiagnostic = export interface GhidraClientOptions { readonly launcher: GhidraLauncher; readonly targetPath: string; + readonly targetSha256: string; readonly providerVersion: string; readonly profileDigest: string; readonly requestTimeoutMs?: number; diff --git a/src/ghidra/GhidraDefaults.ts b/src/ghidra/GhidraDefaults.ts index 4b66336e8..ea98b3b24 100644 --- a/src/ghidra/GhidraDefaults.ts +++ b/src/ghidra/GhidraDefaults.ts @@ -17,4 +17,4 @@ export const GHIDRA_MAX_QUEUED_REQUESTS = 32; /** Maximum encoded response line accepted from the Java bridge. */ export const GHIDRA_MAX_LINE_BYTES = 1024 * 1024; /** Version of REA's Java bridge handshake and request protocol. */ -export const GHIDRA_BRIDGE_VERSION = 3; +export const GHIDRA_BRIDGE_VERSION = 4; diff --git a/src/ghidra/GhidraDiagnostics.ts b/src/ghidra/GhidraDiagnostics.ts index 48400bdbd..147f49c0f 100644 --- a/src/ghidra/GhidraDiagnostics.ts +++ b/src/ghidra/GhidraDiagnostics.ts @@ -5,6 +5,7 @@ import type { GhidraLaunch } from "./GhidraLauncher.js"; /** Inputs needed to project bounded diagnostics for one Ghidra runtime. */ export interface GhidraDiagnosticsOptions { readonly targetPath: string; + readonly targetSha256: string; readonly providerVersion: string; readonly profileDigest: string; readonly runtimeRoot?: string; @@ -30,6 +31,7 @@ export const createGhidraDiagnostics = ( : value.replaceAll(options.token, "[REDACTED]"); return { target_path: options.targetPath, + target_sha256: options.targetSha256, provider_version: options.providerVersion, profile_digest: options.profileDigest, ...(options.runtimeRoot === undefined diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index accd68ab2..c2316da1a 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -25,6 +25,7 @@ export interface GhidraLaunchSession { readonly token: string; readonly runId: string; readonly targetPath: string; + readonly targetSha256: string; readonly providerVersion: string; readonly profileDigest: string; } @@ -89,6 +90,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { socket_path: session.socketPath, token: session.token, run_id: session.runId, + target_sha256: session.targetSha256, provider_version: session.providerVersion, profile_digest: session.profileDigest, })}\n`, diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index cc3fb3c6f..d20cb61cf 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -356,6 +356,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { ), }), targetPath: target.path, + targetSha256: target.sha256, providerVersion: prerequisites.value.providerVersion, profileDigest: committedProfile.digest, logger: this.logger.child({ layer: "ghidra-bridge" }), diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index a1143b4a3..a08ac111b 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -41,6 +41,7 @@ const sessionInfoSchema = z compiler_spec_id: z.string().min(1), image_base: z.string().regex(/^0x[0-9a-f]+$/u), default_address_space: z.string().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), }) .strict(), }) @@ -56,6 +57,7 @@ export const parseGhidraSessionInfo = ( readonly runId: string; readonly providerVersion: string; readonly profileDigest: string; + readonly targetSha256: string; }, ): Result => { const parsed = sessionInfoSchema.safeParse(value); @@ -64,6 +66,7 @@ export const parseGhidraSessionInfo = ( parsed.data.run_id !== expected.runId || parsed.data.provider.version !== expected.providerVersion || parsed.data.profile_digest !== expected.profileDigest || + parsed.data.target.sha256 !== expected.targetSha256 || new Set(parsed.data.capabilities).size !== GHIDRA_SESSION_CAPABILITIES.length || GHIDRA_SESSION_CAPABILITIES.some( diff --git a/src/ghidra/GhidraTargetSnapshot.ts b/src/ghidra/GhidraTargetSnapshot.ts new file mode 100644 index 000000000..e2b678600 --- /dev/null +++ b/src/ghidra/GhidraTargetSnapshot.ts @@ -0,0 +1,46 @@ +import { createHash } from "node:crypto"; +import { constants, createReadStream } from "node:fs"; +import { chmod, copyFile, rm } from "node:fs/promises"; +import { extname, join } from "node:path"; + +/** Immutable target copy admitted to one ephemeral Ghidra import. */ +export interface GhidraTargetSnapshot { + readonly path: string; + readonly sha256: string; +} + +/** Copy one target and require its bytes to match the parsed target identity. */ +export const createGhidraTargetSnapshot = async ( + sourcePath: string, + runtimeRoot: string, + expectedSha256: string, +): Promise => { + if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) + throw new TypeError("Ghidra target SHA-256 commitment is invalid"); + const suffix = safeExtension(sourcePath); + const snapshotPath = join( + runtimeRoot, + `target-${expectedSha256.slice(0, 12)}${suffix}`, + ); + try { + await copyFile(sourcePath, snapshotPath, constants.COPYFILE_EXCL); + await chmod(snapshotPath, 0o600); + const hash = createHash("sha256"); + for await (const chunk of createReadStream(snapshotPath)) + hash.update(chunk); + const observedSha256 = hash.digest("hex"); + if (observedSha256 !== expectedSha256) + throw new Error( + `Ghidra target snapshot digest mismatch: expected ${expectedSha256}, observed ${observedSha256}`, + ); + return { path: snapshotPath, sha256: observedSha256 }; + } catch (cause: unknown) { + await rm(snapshotPath, { force: true }).catch(() => undefined); + throw cause; + } +}; + +const safeExtension = (path: string): string => { + const extension = extname(path); + return /^\.[A-Za-z0-9]{1,12}$/u.test(extension) ? extension : ".bin"; +}; diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index 9de4ffef6..d16937c7f 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -7,6 +7,7 @@ const [ runId, providerVersion, profileDigest, + targetSha256, mode = "success", ] = process.argv.slice(2); @@ -15,7 +16,8 @@ if ( token === undefined || runId === undefined || providerVersion === undefined || - profileDigest === undefined + profileDigest === undefined || + targetSha256 === undefined ) { process.exit(64); } @@ -79,6 +81,7 @@ else { providerVersion: mode === "wrong_identity" ? "0.0.0" : providerVersion, profileDigest, + targetSha256, timedOut: mode === "analysis_timeout", }); const response = `${JSON.stringify({ id: request.id, ok: true, result })}\n`; @@ -132,10 +135,11 @@ const sessionInfo = ({ runId: sessionRunId, providerVersion: version, profileDigest: digest, + targetSha256: sha256, timedOut, }) => ({ name: "REA Ghidra bridge", - bridge_version: 3, + bridge_version: 4, run_id: sessionRunId, profile_digest: digest, provider: { id: "ghidra", version }, @@ -170,6 +174,7 @@ const sessionInfo = ({ compiler_spec_id: "gcc", image_base: "0x1000", default_address_space: "ram", + sha256, }, }); diff --git a/tests/ghidraClient.test.ts b/tests/ghidraClient.test.ts index c90c8325e..249d7fcd3 100644 --- a/tests/ghidraClient.test.ts +++ b/tests/ghidraClient.test.ts @@ -1,5 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; import { getEventListeners } from "node:events"; +import { readFileSync } from "node:fs"; import { access, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -22,6 +24,9 @@ const fixturePath = fileURLToPath( ); const PROFILE_DIGEST = "a".repeat(64); const PROVIDER_VERSION = "12.1.2"; +const TARGET_SHA256 = createHash("sha256") + .update(readFileSync(fixturePath)) + .digest("hex"); type FixtureMode = | "success" @@ -62,6 +67,7 @@ class FixtureLauncher implements GhidraLauncher { session.runId, session.providerVersion, session.profileDigest, + session.targetSha256, this.mode, ], { stdio: ["ignore", "pipe", "pipe"] }, @@ -88,7 +94,8 @@ const clientFor = ( ): GhidraClient => { const client = new GhidraClient({ launcher, - targetPath: "/tmp/fixture", + targetPath: fixturePath, + targetSha256: TARGET_SHA256, providerVersion: PROVIDER_VERSION, profileDigest: PROFILE_DIGEST, startupTimeoutMs: options.startupTimeoutMs ?? 1_000, @@ -382,7 +389,7 @@ describe("GhidraClient", () => { if (result.ok) return; const encoded = JSON.stringify(result.error.diagnostics); - expect(encoded).toContain("/tmp/fixture"); + expect(encoded).toContain(fixturePath); expect(encoded).toContain(PROFILE_DIGEST); expect(encoded).not.toContain(launcher.tokens[0] ?? "missing-token"); expect(events).toContainEqual( diff --git a/tests/ghidraLauncher.test.ts b/tests/ghidraLauncher.test.ts index 55e1aeb2b..7cf2e1039 100644 --- a/tests/ghidraLauncher.test.ts +++ b/tests/ghidraLauncher.test.ts @@ -76,6 +76,7 @@ describe("Ghidra headless launcher", () => { token, runId: "d6fcbb66-e829-4ff6-a535-0035aec63139", targetPath: "/tmp/fixture", + targetSha256: "b".repeat(64), providerVersion: "12.1.2", profileDigest: "a".repeat(64), }); diff --git a/tests/ghidraMcp.test.ts b/tests/ghidraMcp.test.ts index 30d665f44..802ed547e 100644 --- a/tests/ghidraMcp.test.ts +++ b/tests/ghidraMcp.test.ts @@ -31,7 +31,10 @@ describe("Ghidra MCP and shared CLI composition", () => { it("preserves provider evidence, composed parity, and capability routing", async () => { const calls: GhidraOperation[] = []; const factory: GhidraProviderClientFactory = (options) => ({ - start: () => Promise.resolve(ok(sessionInfo(options.profileDigest))), + start: () => + Promise.resolve( + ok(sessionInfo(options.profileDigest, options.targetSha256)), + ), callTool: (operation, input) => { calls.push(operation); return Promise.resolve(ok(resultFor(operation, input))); @@ -301,9 +304,9 @@ const installationHost = (): GhidraInstallationHost => ({ }), }); -const sessionInfo = (profileDigest: string) => ({ +const sessionInfo = (profileDigest: string, targetSha256: string) => ({ name: "REA Ghidra bridge" as const, - bridge_version: 3 as const, + bridge_version: 4 as const, run_id: "11111111-1111-4111-8111-111111111111", profile_digest: profileDigest, provider: { id: "ghidra" as const, version: "12.1.2" }, @@ -317,6 +320,7 @@ const sessionInfo = (profileDigest: string) => ({ compiler_spec_id: "gcc", image_base: "0x400000", default_address_space: "ram", + sha256: targetSha256, }, }); diff --git a/tests/ghidraProvider.test.ts b/tests/ghidraProvider.test.ts index 4ad5c9409..41a68999f 100644 --- a/tests/ghidraProvider.test.ts +++ b/tests/ghidraProvider.test.ts @@ -370,7 +370,7 @@ describe("Ghidra provider", () => { const sessionInfo = () => ({ name: "REA Ghidra bridge" as const, - bridge_version: 3 as const, + bridge_version: 4 as const, run_id: "11111111-1111-4111-8111-111111111111", profile_digest: "a".repeat(64), provider: { id: "ghidra" as const, version: "12.1.2" }, @@ -384,6 +384,7 @@ const sessionInfo = () => ({ compiler_spec_id: "gcc", image_base: "0x1000", default_address_space: "ram", + sha256: "a".repeat(64), }, }); diff --git a/tests/ghidraTargetSnapshot.test.ts b/tests/ghidraTargetSnapshot.test.ts new file mode 100644 index 000000000..ea78c04ef --- /dev/null +++ b/tests/ghidraTargetSnapshot.test.ts @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createGhidraTargetSnapshot } from "../src/ghidra/GhidraTargetSnapshot.js"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("Ghidra target snapshot", () => { + it("copies an exact digest-bound target into the private runtime", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-ghidra-snapshot-")); + roots.push(root); + const source = join(root, "source.exe"); + const bytes = Buffer.from("native PE fixture"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await writeFile(source, bytes); + + const snapshot = await createGhidraTargetSnapshot(source, root, sha256); + + expect(snapshot).toEqual({ + path: join(root, `target-${sha256.slice(0, 12)}.exe`), + sha256, + }); + await expect(readFile(snapshot.path)).resolves.toEqual(bytes); + }); + + it("removes a snapshot whose digest differs from admission", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-ghidra-snapshot-")); + roots.push(root); + const source = join(root, "source with unsafe extension.%PATH%"); + await writeFile(source, "changed target"); + const expected = "a".repeat(64); + const snapshotPath = join(root, `target-${expected.slice(0, 12)}.bin`); + + await expect( + createGhidraTargetSnapshot(source, root, expected), + ).rejects.toThrow(/digest mismatch/u); + await expect(access(snapshotPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +});