mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
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:
1 parent
34239d445b
commit
969aafcf28
8 files changed
+217
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user