From 969aafcf28b20599e34e47a11c06863aee06f15b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:28:32 +0800 Subject: [PATCH] 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 --- docs/ghidra-dos.md | 4 + src/ghidra/GhidraAnalysisProfile.ts | 2 +- src/ghidra/GhidraDosSnapshot.test.ts | 42 +++++++++- src/ghidra/GhidraLoadImage.fixture.ts | 65 +++++++++++++++ src/ghidra/GhidraLoadImageValues.ts | 19 +++-- src/ghidra/GhidraProvider.behavior.test.ts | 1 + tests/boundary/mcp/ghidraMcpHarness.ts | 14 +++- tests/boundary/mcp/ghidraNativeApiMcp.test.ts | 82 +++++++++++++++++++ 8 files changed, 217 insertions(+), 12 deletions(-) diff --git a/docs/ghidra-dos.md b/docs/ghidra-dos.md index a9be485003..a788687298 100644 --- a/docs/ghidra-dos.md +++ b/docs/ghidra-dos.md @@ -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 diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 43d9386c96..a4506dd97e 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -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", diff --git a/src/ghidra/GhidraDosSnapshot.test.ts b/src/ghidra/GhidraDosSnapshot.test.ts index 953af0754b..137b5000ff 100644 --- a/src/ghidra/GhidraDosSnapshot.test.ts +++ b/src/ghidra/GhidraDosSnapshot.test.ts @@ -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) => { diff --git a/src/ghidra/GhidraLoadImage.fixture.ts b/src/ghidra/GhidraLoadImage.fixture.ts index 46b08f7ffd..03ce258439 100644 --- a/src/ghidra/GhidraLoadImage.fixture.ts +++ b/src/ghidra/GhidraLoadImage.fixture.ts @@ -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; diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index 4655881dce..7a3e865ab4 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -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, diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 0b5e1bc545..9ebde35230 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -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", diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index bdf9df2266..ca4e87ed02 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -29,9 +29,11 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; export const connectGhidraMcp = async ( name: string, execute?: ReturnType["callTool"], - observations: { + fixture: { readonly targetPath?: string; readonly pdb?: GhidraSessionInfo["pdb"]; + readonly sessionTarget?: GhidraSessionInfo["target"]; + readonly attestDosTargetSnapshot?: ReturnType["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( diff --git a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts index b0fe73363f..2988d1a179 100644 --- a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts +++ b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts @@ -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 () => {