fix(ghidra): compare final DOS relocation windows

Adapt the correction and authored overlap control proposed by @berelels in #4156 to the bounded prepared-image owner. Retain each fixup's application facts, then read every memory window after all writes. Carry the producer's load segment into comparison and advance only the MZ evidence revision.

Validation: 129 focused parser/comparison/MCP checks, 38 shared MCP harness checks and 41 selected provider checks passed, with 15 checks overlapping those runs. Fourteen lifecycle checks also passed, plus static/build/docs checks. Two baseline controls failed before the correction. Twelve completed normal source comparisons retained byte-identical replies; no real Ghidra or maximal-relocation performance qualification is claimed.

Fixes #3981
This commit is contained in:
morluto committed 2026-10-17 19:30:11 +08:00
1 parent 34239d445b
commit 969aafcf28
8 files changed
+217 -12

No files matched your search

+4
View File
@@ -124,6 +124,10 @@ FileBytes digests, every source mapping and its memory digest, relocation record
module partitions, relocation fixups and linear coordinates rather than relying
on the imported executable SHA alone.
Expected DOS relocation records retain each fixup's pre-application bytes and
applied value. Their memory-byte windows come from the final relocated image,
including changes made by later overlapping fixups.
The result is `verified`, `mismatch`, or `unsupported`. Every check retains its
expected and observed values. A memory-digest mismatch identifies the source
range's file offset and analysis address; it does not locate the first differing
+1 -1
View File
@@ -267,7 +267,7 @@ export const resolveGhidraAnalysisProfile = async (
? {
load_image_evidence: dosCom
? "independent-com-mapping-context-v1"
: "independent-mz-mapping-relocations-v1",
: "independent-mz-mapping-relocations-v2",
}
: {}),
jump_table_evidence: "typed-case-default-blocks-v1",
+41 -1
View File
@@ -3,12 +3,52 @@ import { open, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
import {
fixtureDosLoadImage,
fixtureDosOverlapLoadImage,
} from "./GhidraLoadImage.fixture.js";
import { attestGhidraDosSnapshot } from "./GhidraDosSnapshot.js";
const hash = (bytes: Buffer) =>
createHash("sha256").update(bytes).digest("hex");
it.each([false, true])(
"compares final overlapping relocation windows (intermediate=%s)",
async (intermediate) => {
const fixture = fixtureDosOverlapLoadImage();
expect(fixture.bytes.subarray(56, 59).toString("hex")).toBe("000400");
const first = fixture.observation.relocations[0];
if (first === undefined) throw new Error("Missing authored overlap record");
if (intermediate) first.memory_bytes_hex = "0410";
const directory = await createTestTempDirectory("rea-mz-overlap-");
const path = join(directory, "inert.exe");
await writeFile(path, fixture.bytes);
const handle = await open(path, "r");
try {
const result = await attestGhidraDosSnapshot(
handle,
fixture.bytes.length,
path,
fixture.sha256,
fixture.observation,
);
if (!result.ok || result.value.status === "unsupported")
throw new Error("Expected completed MZ comparison");
expect(result.value.status).toBe(intermediate ? "mismatch" : "verified");
expect(
result.value.checks
.filter((check) => !check.matched)
.map((check) => check.name),
).toEqual(intermediate ? ["relocation_records"] : []);
expect(result.value.observations.relocations).toEqual(
fixture.observation.relocations,
);
} finally {
await handle.close();
}
},
);
it.each([23, 65539])(
"streams complete MZ identity with a %s-byte overlay",
async (overlay) => {
+65
View File
@@ -143,6 +143,71 @@ export const fixtureDosLoadImage = (
};
};
/** Authored overlapping-word control adapted from #4156; no engine is run here. */
export const fixtureDosOverlapLoadImage = () => {
const fixture = fixtureDosLoadImage();
const bytes = Buffer.concat([
fixture.bytes.subarray(0, 32),
Buffer.alloc(16),
fixture.bytes.subarray(32),
]);
bytes.writeUInt16LE(176, 2);
bytes.writeUInt16LE(2, 6);
bytes.writeUInt16LE(3, 8);
bytes.writeUInt32LE(9, 28);
bytes.writeUInt32LE(8, 32);
const modified = Buffer.from(bytes);
// Independent final-byte oracle: source 00 04 00 becomes 00 14 10.
modified.write("001410", 56, "hex");
const observation = fixture.observation;
const source = observation.source_files[0];
const headerMapping = observation.mappings[0];
const moduleMapping = observation.mappings[1];
if (
source === undefined ||
headerMapping === undefined ||
moduleMapping === undefined
)
throw new Error("Missing base MZ fixture source mappings");
Object.assign(source, {
size: bytes.length,
original_sha256: hash(bytes),
modified_sha256: hash(modified),
});
Object.assign(headerMapping, {
end: "HEADER:0x2f",
length: 48,
sha256: hash(bytes.subarray(0, 48)),
});
Object.assign(moduleMapping, {
file_offset: 48,
sha256: hash(modified.subarray(48, 176)),
});
observation.relocations = [
{
address: "0x10009",
segment: 0x1000,
offset: 9,
status: "APPLIED",
type: 0,
values: [0, 9, 0x1004],
original_bytes_hex: "0400",
memory_bytes_hex: "1410",
},
{
address: "0x10008",
segment: 0x1000,
offset: 8,
status: "APPLIED",
type: 0,
values: [0, 8, 0x1400],
original_bytes_hex: "0004",
memory_bytes_hex: "0014",
},
];
return { bytes, sha256: hash(bytes), observation };
};
/** Source-owned headerless COM image and measured real-mode import facts. */
export const fixtureDosComLoadImage = (): {
bytes: Buffer;
+12 -7
View File
@@ -24,6 +24,7 @@ const limitations = [
export type GhidraDosImage = {
readonly header: DosMzHeader;
readonly loadSegment: number;
readonly modified: Buffer;
readonly expectedRelocations: NativeLoadImageObservation["relocations"];
};
@@ -41,7 +42,7 @@ export const prepareGhidraDosLoadImage = (
return err("DOS MZ initialized image was not completely captured");
const loadSegment = 0x1000;
const modified = Buffer.from(bytes.subarray(0, header.imageBytes));
const expectedRelocations = [];
const applied = [];
for (let index = 0; index < header.relocationCount; index += 1) {
const table = header.relocationTableOffset + index * 4;
const offset = bytes.readUInt16LE(table);
@@ -53,7 +54,12 @@ export const prepareGhidraDosLoadImage = (
const relocated =
(modified.readUInt16LE(fileOffset) + loadSegment) & 0xffff;
modified.writeUInt16LE(relocated, fileOffset);
expectedRelocations.push({
applied.push({ segment, offset, fileOffset, original, relocated });
}
// Application records describe their own fixup; the bridge reads memory
// once all fixups have completed, including later overlapping writes.
const expectedRelocations = applied.map(
({ segment, offset, fileOffset, original, relocated }) => ({
address: address(loadSegment * 16 + segment * 16 + offset),
segment: (loadSegment + segment) & 0xffff,
offset,
@@ -64,9 +70,9 @@ export const prepareGhidraDosLoadImage = (
memory_bytes_hex: modified
.subarray(fileOffset, fileOffset + 2)
.toString("hex"),
});
}
return ok({ header, modified, expectedRelocations });
}),
);
return ok({ header, loadSegment, modified, expectedRelocations });
};
export type GhidraDosLoadImageSource = {
@@ -86,8 +92,7 @@ export const attestGhidraDosLoadImage = (
return err(
"Ghidra load-image snapshot digest does not match the admitted target",
);
const { header, modified, expectedRelocations } = source.image;
const loadSegment = 0x1000;
const { header, loadSegment, modified, expectedRelocations } = source.image;
const checks: NativeLoadImageCheck[] = [];
const check = (
name: string,
@@ -271,6 +271,7 @@ describe("Ghidra provider", () => {
target_format: "dos-mz",
architecture: "x86",
loader: "MzLoader",
load_image_evidence: "independent-mz-mapping-relocations-v2",
language_id: "x86:LE:16:Real Mode",
compiler_spec_id: "default",
load_segment: "0x1000",
+11 -3
View File
@@ -29,9 +29,11 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
export const connectGhidraMcp = async (
name: string,
execute?: ReturnType<GhidraProviderClientFactory>["callTool"],
observations: {
fixture: {
readonly targetPath?: string;
readonly pdb?: GhidraSessionInfo["pdb"];
readonly sessionTarget?: GhidraSessionInfo["target"];
readonly attestDosTargetSnapshot?: ReturnType<GhidraProviderClientFactory>["attestDosTargetSnapshot"];
} = {},
) => {
const calls: GhidraOperation[] = [];
@@ -40,7 +42,10 @@ export const connectGhidraMcp = async (
Promise.resolve(
ok({
...sessionInfo(options.profileDigest, options.targetSha256),
...(observations.pdb === undefined ? {} : { pdb: observations.pdb }),
...(fixture.sessionTarget === undefined
? {}
: { target: fixture.sessionTarget }),
...(fixture.pdb === undefined ? {} : { pdb: fixture.pdb }),
}),
),
callTool: (operation, input, options) => {
@@ -49,6 +54,9 @@ export const connectGhidraMcp = async (
return Promise.resolve(ok(resultFor(operation, input)));
},
close: () => Promise.resolve(ok(null)),
...(fixture.attestDosTargetSnapshot === undefined
? {}
: { attestDosTargetSnapshot: fixture.attestDosTargetSnapshot }),
});
const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL });
if (!config.ok) throw config.error;
@@ -78,7 +86,7 @@ export const connectGhidraMcp = async (
await mcp.connect(clientTransport);
const opened = await mcp.callTool({
name: "open_binary",
arguments: { path: observations.targetPath ?? process.execPath },
arguments: { path: fixture.targetPath ?? process.execPath },
});
if (opened.isError === true)
throw new Error(
@@ -1,5 +1,87 @@
import { describe, expect, it } from "vitest";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
import {
fixtureDosOverlapLoadImage,
compareDosClientFixture,
} from "../../../src/ghidra/GhidraLoadImage.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
import { nativeLoadImageSchema } from "../../../src/domain/native/nativeLoadImage.js";
import { ok } from "../../../src/domain/result.js";
it.each([
{ state: "final", failed: [] },
{ state: "intermediate", failed: ["relocation_records"] },
{ state: "missing-record", failed: ["relocation_records"] },
{ state: "duplicate-record", failed: ["relocation_records"] },
{ state: "reversed-records", failed: [] },
{ state: "bad-source", failed: ["modified_file_sha256"] },
{ state: "bad-mapping", failed: ["mapping[1].memory_sha256"] },
])(
"retains final overlapping MZ relocation Evidence through MCP ($state)",
async ({ state, failed }) => {
const fixture = fixtureDosOverlapLoadImage();
const first = fixture.observation.relocations[0];
const source = fixture.observation.source_files[0];
const mapping = fixture.observation.mappings[1];
if (first === undefined || source === undefined || mapping === undefined)
throw new Error("Missing authored overlap facts");
if (state === "intermediate") first.memory_bytes_hex = "0410";
if (state === "missing-record") fixture.observation.relocations.pop();
if (state === "duplicate-record")
fixture.observation.relocations.push(structuredClone(first));
if (state === "reversed-records") fixture.observation.relocations.reverse();
if (state === "bad-source") source.modified_sha256 = "f".repeat(64);
if (state === "bad-mapping") mapping.sha256 = "f".repeat(64);
const directory = await createTestTempDirectory("rea-mcp-mz-overlap-");
const targetPath = join(directory, "inert.exe");
await writeFile(targetPath, fixture.bytes);
const harness = await connectGhidraMcp(
"ghidra-mz-relocation-windows",
async () => ok(jsonValueSchema.parse(fixture.observation)),
{
targetPath,
sessionTarget: {
name: "inert.exe",
language_id: fixture.observation.language_id,
compiler_spec_id: fixture.observation.compiler_spec_id,
image_base: fixture.observation.image_base,
default_address_space: fixture.observation.default_address_space,
sha256: fixture.sha256,
},
attestDosTargetSnapshot: async (observations) =>
compareDosClientFixture(fixture.bytes, fixture.sha256, observations),
},
);
try {
const reply = await harness.mcp.callTool({
name: "inspect_native_load_image",
arguments: {},
});
expect(reply.isError).not.toBe(true);
const evidence = sessionEvidence(
harness.session,
reply.structuredContent,
);
const result = nativeLoadImageSchema.parse(evidence.normalized_result);
if (result.status === "unsupported")
throw new Error("Expected MZ comparison");
expect(result.status).toBe(failed.length > 0 ? "mismatch" : "verified");
expect(
result.checks
.filter((check) => !check.matched)
.map((check) => check.name),
).toEqual(failed);
expect(result.observations.relocations).toEqual(
fixture.observation.relocations,
);
} finally {
await harness.close();
}
},
);
describe("Ghidra MCP native API evidence", () => {
it("returns complete native API boundaries without output-cap unknowns", async () => {