Files
rea/scripts/verify-real-ghidra-raw.mjs
T
2a78763e20 feat(native): search analyzed memory for masked byte patterns (#2341)
* feat(native): search analyzed memory for masked byte patterns

The diff adds search_bytes to the CLI, the shared MCP contract, Ghidra
inventory, and the headless bridge, and advertises the same operation on
Hopper as unavailable until that provider is verified. Patterns are
hexadecimal byte pairs with optional ?? wildcards and an optional
equal-length bit mask; the TypeScript boundary and the bridge both
require a nonempty pattern, equal lengths, and at least one constrained
bit. Ghidra walks initialized blocks in address order within inclusive
bounds, keeps overlapping matches inside a single block, and returns
each hit with its segment, unique source file offset, and containing
procedure, continuing through a cursor bound to the target, profile,
pattern, mask, and bounds. Each page reports the inclusive ranges
examined and the skipped uninitialized blocks; cancellation returns a
typed error and discards the active Ghidra database.
address_to_file_offset now uses the same unique-offset helper, which
rejects a mapping that is missing, ambiguous, or larger than a safe
integer.

* fix(native): limit unbounded byte search to loaded memory

Without start or end, search_bytes walked every Ghidra block, including
non-loaded file parts such as .symtab and overlay blocks, which the docs
call out of scope. Unbounded searches now skip those blocks and list them
in searched.skipped_not_loaded; explicit bounds in their address space
still search them. Document that a match must end at or before end.

* fix(native): preserve implicit address spaces in range consumers

* fix(native): reject present malformed byte search state

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 23:41:45 +08:00

360 lines
12 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } 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 { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-raw.mjs");
if (!["linux", "darwin"].includes(process.platform))
throw new Error(
"Raw-binary verification requires a Linux or macOS Ghidra host.",
);
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
);
const installation = inspectGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
assert.equal(installation.status, "available", JSON.stringify(installation));
const run = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-raw-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const bytes = Buffer.from("1eff2fe1", "hex"); // ARM little-endian bx lr.
const sha256 = createHash("sha256").update(bytes).digest("hex");
const path = join(workspace, "image.bin");
await writeFile(path, bytes);
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
TMPDIR: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { JAVA_HOME: process.env.JAVA_HOME }),
...(process.env.GHIDRA_HEADLESS_MAXMEM === undefined
? {}
: { GHIDRA_HEADLESS_MAXMEM: process.env.GHIDRA_HEADLESS_MAXMEM }),
...(process.env.REA_GHIDRA_DISABLED_ANALYZERS === undefined
? {}
: {
REA_GHIDRA_DISABLED_ANALYZERS:
process.env.REA_GHIDRA_DISABLED_ANALYZERS,
}),
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-raw-proof", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
try {
await client.connect(transport);
const profiles = [];
const sessions = [];
for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) {
// Omit provider_id to exercise reuse of the current provider route.
const opened = await call("open_binary", {
path,
format: "raw-binary",
architecture: "arm",
entry_point: entryPoint,
});
const entry = `0x${BigInt(entryPoint).toString(16)}`;
assert.equal(opened.entry_point, entry);
const image = await call("inspect_native_load_image");
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image),
);
assert.equal(image.normalized_result.base_address, entry);
assert.equal(image.normalized_result.entry.address, entry);
assert.equal(
image.normalized_result.observations.language_id,
"ARM:LE:32:v7",
);
assert.ok(
image.normalized_result.observations.entry_points.includes(entry),
);
assert.equal(image.analysis_profile.parameters.entry_point, entry);
profiles.push(image.analysis_profile.digest);
const status = await call("binary_session");
assert.equal(status.entry_point, entry);
sessions.push(status.analysis_run.run_id);
const memory = await call("read_bytes", {
address: entry,
length: bytes.length,
});
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const signature = await call("search_bytes", {
pattern: "1e ?? 2f e1",
limit: 1000,
});
assert.equal(signature.normalized_result.complete, true);
assert.equal(signature.normalized_result.next_cursor, null);
assert.equal(signature.normalized_result.searched.bytes, bytes.length);
assert.deepEqual(
signature.normalized_result.matches.map((match) => match.address),
[entry],
);
assert.equal(signature.normalized_result.matches[0].file_offset, 0);
assert.equal(
signature.normalized_result.matches[0].procedure.address,
entry,
);
const procedures = await call("list_procedures");
assert.ok(
procedures.normalized_result.some(
(procedure) => procedure.address === entry,
),
);
const instruction = await call("inspect_native_instruction", {
address: entry,
});
assert.equal(instruction.normalized_result.address, entry);
}
assert.equal(profiles[0], profiles[1]);
assert.notEqual(profiles[1], profiles[2]);
assert.equal(sessions[0], sessions[1]);
assert.notEqual(sessions[1], sessions[2]);
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "arm64",
entry_point: "0x8000001",
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), /must be 4-byte aligned/);
const unchanged = await call("binary_session");
assert.equal(unchanged.analysis_run.run_id, sessions[2]);
assert.equal(unchanged.entry_point, "0x9000000");
await call("close_binary");
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
"--target-format",
"raw-binary",
"--architecture",
"arm",
"--entry-point",
"0x08000000",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cli = parseEvidence(JSON.parse(stdout));
assert.equal(cli.subject.digest.sha256, sha256);
assert.equal(cli.normalized_result.status, "verified");
assert.equal(cli.normalized_result.base_address, "0x8000000");
assert.equal(cli.analysis_profile.digest, profiles[0]);
assert.deepEqual(await readFile(path), bytes);
const x86Targets = [];
for (const architecture of ["x86", "x86_64"]) {
const x86Bytes = Buffer.from("b82a000000c3", "hex"); // mov eax, 42; ret.
const x86Sha256 = createHash("sha256").update(x86Bytes).digest("hex");
const x86Path = join(workspace, `${architecture}.bin`);
await writeFile(x86Path, x86Bytes);
await call("open_binary", {
path: x86Path,
format: "raw-binary",
architecture,
entry_point: "0x1000",
});
const image = await call("inspect_native_load_image", {}, x86Sha256);
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image),
);
assert.equal(image.normalized_result.base_address, "0x1000");
assert.equal(image.normalized_result.observations.compiler_spec_id, "gcc");
assert.equal(image.analysis_profile.parameters.compiler_spec_id, "gcc");
const memory = await call(
"read_bytes",
{ address: "0x1000", length: x86Bytes.length },
x86Sha256,
);
assert.equal(memory.normalized_result.bytes_hex, x86Bytes.toString("hex"));
const pseudo = await call(
"procedure_pseudo_code",
{ procedure: "0x1000" },
x86Sha256,
);
assert.match(pseudo.normalized_result, /\b(?:42|0x2a)\b/iu);
await call("close_binary");
assert.deepEqual(await readFile(x86Path), x86Bytes);
x86Targets.push({
architecture,
sha256: x86Sha256,
compiler_spec_id: "gcc",
return_42: true,
});
}
const repeatedPath = join(workspace, "signatures.bin");
const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex");
const repeatedSha256 = createHash("sha256")
.update(repeatedBytes)
.digest("hex");
await writeFile(repeatedPath, repeatedBytes);
await call("open_binary", {
path: repeatedPath,
format: "raw-binary",
architecture: "x86_64",
entry_point: "0x1000",
});
// Import/analysis is complete before timing the first uncached search.
await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256);
const started = performance.now();
const page = await call(
"search_bytes",
{ pattern: "b8 ?? 00 00 00 c3", limit: 1000 },
repeatedSha256,
);
const searchElapsedMs = performance.now() - started;
assert.equal(page.normalized_result.matches.length, 1000);
assert.equal(page.normalized_result.complete, false);
const rest = await call(
"search_bytes",
{
pattern: "b8 ?? 00 00 00 c3",
limit: 1000,
cursor: page.normalized_result.next_cursor,
},
repeatedSha256,
);
assert.equal(rest.normalized_result.matches.length, 5);
assert.equal(
rest.normalized_result.matches[0].address,
`0x${(0x1000 + 6000).toString(16)}`,
);
assert.equal(rest.normalized_result.complete, true);
const masked = await call(
"search_bytes",
{ pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" },
repeatedSha256,
);
assert.deepEqual(
masked.normalized_result.matches.map((match) => match.address),
["0x1000"],
);
const overlapping = await call(
"search_bytes",
{ pattern: "00 00", start: "0x1000", end: "0x1005" },
repeatedSha256,
);
assert.deepEqual(
overlapping.normalized_result.matches.map((match) => match.address),
["0x1002", "0x1003"],
);
const wrongCursor = await client.callTool({
name: "search_bytes",
arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor },
});
assert.equal(wrongCursor.isError, true);
await call("close_binary");
assert.deepEqual(await readFile(repeatedPath), repeatedBytes);
report = {
ok: true,
mocked: false,
lane: "ghidra-raw",
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: {
format: "raw-binary",
architecture: "arm",
sha256,
bytes: bytes.length,
},
transport: { cli: true, stdio_mcp: true },
canonical_entry_reuses_session: true,
changed_entry_replaces_session: true,
lifecycle_entry_preserved: true,
invalid_entry_preserves_active_session: true,
load_images_verified: true,
original_unchanged: true,
x86_targets: x86Targets,
byte_search: {
matches: 1005,
page_size: 1000,
first_search_elapsed_ms: searchElapsedMs,
timing_scope: "MCP request after import; first uncached search",
token_usage: "unmeasured",
process_family_cpu_time: "unmeasured",
peak_process_family_memory: "unmeasured",
},
};
} catch (error) {
if (stderr.trim()) process.stderr.write(stderr);
throw error;
} finally {
await client.close();
await transport.close();
await rm(workspace, { recursive: true, force: true });
}
const verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
process.stdout.write(
JSON.stringify({
verifier_run: verifierRun,
...report,
cleanup: "complete",
}) + "\n",
);
async function call(name, args = {}, targetSha256 = sha256) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
const projected = requireMcpOperationResult(result, name);
if (
name === "open_binary" ||
name === "close_binary" ||
name === "binary_session"
)
return projected;
const evidence = parseEvidence(result.structuredContent);
assert.equal(evidence.subject.digest.sha256, targetSha256);
assert.equal(evidence.subject.format, "raw-binary");
return evidence;
}