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.
This commit is contained in:
parent
86e44bef08
commit
be19c3fd12
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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<GhidraSessionInfo, Error> => {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<GhidraTargetSnapshot> => {
|
||||
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";
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue