From be19c3fd12da55df84d92c5ec2641fe739df0438 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Fri, 17 Jul 2026 00:41:18 +0800 Subject: [PATCH] feat(ghidra): bind imports to admitted target bytes Copy each selected target into the ephemeral Ghidra runtime and reject the snapshot unless its SHA-256 matches the provider-neutral target identity. Advance the Java bridge protocol to v4, require Ghidra's imported executable digest to match again, and include that digest in the authenticated session handshake and diagnostics. Preserve bounded streaming hashes and remove mismatched snapshots before launch. --- bridge/ghidra/ReaGhidraBridge.java | 24 +++++++++++++- scripts/verify-real-ghidra.mjs | 1 + src/ghidra/GhidraClient.ts | 25 ++++++++++++++- src/ghidra/GhidraClientTypes.ts | 1 + src/ghidra/GhidraDefaults.ts | 2 +- src/ghidra/GhidraDiagnostics.ts | 2 ++ src/ghidra/GhidraLauncher.ts | 2 ++ src/ghidra/GhidraProvider.ts | 1 + src/ghidra/GhidraSessionValues.ts | 3 ++ src/ghidra/GhidraTargetSnapshot.ts | 46 +++++++++++++++++++++++++++ tests/fixtures/fakeGhidra.mjs | 9 ++++-- tests/ghidraClient.test.ts | 11 +++++-- tests/ghidraLauncher.test.ts | 1 + tests/ghidraMcp.test.ts | 10 ++++-- tests/ghidraProvider.test.ts | 3 +- tests/ghidraTargetSnapshot.test.ts | 51 ++++++++++++++++++++++++++++++ 16 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 src/ghidra/GhidraTargetSnapshot.ts create mode 100644 tests/ghidraTargetSnapshot.test.ts 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", + }); + }); +});