fix(ghidra): stream DOS MZ overlay attestation

Keep initialized image facts and full source identity separate at the snapshot owner. Stream overlay bytes into original and modified digests, retain format-bounded image bytes, and consume prepared relocation/mapping facts. Preserve completed comparisons, exact cancellation and cleanup retry through descriptor finalization. Keep explicit source refusals as typed primary failures.

Validation: 119 focused parser/comparison/MCP checks, 40 selected provider checks and 14 lifecycle checks passed; static checks, runtime build, discovery and docs checks passed. Twelve small completed source comparisons retained byte-identical full replies. Real Ghidra, large input and full public CLI/MCP performance remain unverified. The separate overlap timing issue #3981 remains open.

Fixes #4770
This commit is contained in:
morluto
2026-10-17 19:18:34 +08:00
parent 3fc007534b
commit 2f9b9c48b8
14 changed files with 603 additions and 89 deletions
+4
View File
@@ -134,6 +134,10 @@ their contents and DOS runtime semantics are not verified. Independent verificat
also covers explicit [raw-binary imports](raw-binary.md); other formats return
`unsupported` with measured observations.
MZ verification retains the initialized image described by the header's page
count and streams appended overlay bytes into the complete original and modified
source digests. Overlay size does not increase the retained image allocation.
`read_bytes` returns initialized provider memory, including loader fixups; an
unmapped or uninitialized byte ends the read with an explicit returned length and
`complete: false`. `address_to_file_offset` uses the provider's source mapping;
+14 -6
View File
@@ -1,5 +1,16 @@
import { err, ok, type Result } from "./result.js";
/** The unsigned 16-bit page count bounds the initialized image, not overlays. */
export const dosMzImageByteLength = (bytes: Buffer): Result<number, string> => {
if (bytes.length < 28 || bytes.toString("latin1", 0, 2) !== "MZ")
return err("invalid or truncated DOS MZ header");
const lastPageBytes = bytes.readUInt16LE(2);
const pages = bytes.readUInt16LE(4);
if (pages === 0 || lastPageBytes > 511)
return err("invalid DOS MZ page count or final-page size");
return ok((pages - 1) * 512 + (lastPageBytes === 0 ? 512 : lastPageBytes));
};
/** DOS load-module coordinates before applying the loader's segment relocation. */
export interface DosMzHeader {
readonly headerBytes: number;
@@ -105,12 +116,9 @@ export const parseDosMzHeader = (
return err("MZ declares a Windows new header; it is not a DOS-only image");
if (windowsHeader.state === "signature-needed")
return err("MZ Windows new-header signature is outside the bounded prefix");
const lastPageBytes = bytes.readUInt16LE(2);
const pages = bytes.readUInt16LE(4);
if (pages === 0 || lastPageBytes > 511)
return err("invalid DOS MZ page count or final-page size");
const imageBytes =
(pages - 1) * 512 + (lastPageBytes === 0 ? 512 : lastPageBytes);
const extent = dosMzImageByteLength(bytes);
if (!extent.ok) return extent;
const imageBytes = extent.value;
const headerBytes = bytes.readUInt16LE(8) * 16;
if (headerBytes < 28 || headerBytes >= imageBytes)
return err("invalid DOS MZ header size or empty load module");
+54 -8
View File
@@ -62,10 +62,18 @@ import {
type GhidraSessionInfo,
} from "./GhidraSessionValues.js";
import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js";
import { attestGhidraDosSnapshot } from "./GhidraDosSnapshot.js";
import {
nativeLoadImagePartialObservationSchema,
type NativeLoadImage,
type NativeLoadImageObservation,
type NativeLoadImagePartialObservation,
} from "../domain/native/nativeLoadImage.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
artifactFileCancellationCause,
artifactFilePrimaryFailure,
bindArtifactFileCleanup,
withArtifactRegularFile,
} from "../artifacts/ArtifactFileOperation.js";
@@ -467,12 +475,46 @@ export class GhidraClient {
);
}
/** Compare MZ source identity without retaining unbounded appended overlays. */
attestDosTargetSnapshot(
observations: NativeLoadImageObservation,
signal?: AbortSignal,
): Promise<Result<NativeLoadImage, GhidraSessionError>> {
return this.#withTargetSnapshot(
async (handle, initial, path) => {
const result = await attestGhidraDosSnapshot(
handle,
initial.size,
path,
this.#options.targetSha256,
observations,
signal,
);
if (!result.ok) throw this.#failure("protocol", result.error);
return result.value;
},
(result) =>
nativeLoadImagePartialObservationSchema.parse({
kind: "native-load-image",
stage: "completed",
result,
}),
signal,
);
}
async #withTargetSnapshot<T>(
read: (handle: FileHandle, initial: Stats, path: string) => Promise<T>,
observe: (value: T, path: string) => FileSnapshotPartialObservation,
observe: (
value: T,
path: string,
) => FileSnapshotPartialObservation | NativeLoadImagePartialObservation,
signal?: AbortSignal,
): Promise<Result<T, GhidraSessionError>> {
let completedObservation: FileSnapshotPartialObservation | undefined;
let completedObservation:
| FileSnapshotPartialObservation
| NativeLoadImagePartialObservation
| undefined;
try {
return await this.#fileResources.run(async () => {
const path = this.#snapshotPath;
@@ -514,12 +556,16 @@ export class GhidraClient {
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled"
? { reason: artifactFileCancellationCause(cause) }
: undefined;
let failure = this.#failure(
cancellation === undefined ? "protocol" : "cancelled",
"Ghidra target snapshot read or finalization did not complete",
cause,
cancellation === undefined ? {} : { cancellation },
);
const primary = artifactFilePrimaryFailure(cause);
let failure =
primary instanceof GhidraSessionError
? primary
: this.#failure(
cancellation === undefined ? "protocol" : "cancelled",
"Ghidra target snapshot read or finalization did not complete",
cause,
cancellation === undefined ? {} : { cancellation },
);
if (completedObservation !== undefined)
failure = failure.withPartialObservation(completedObservation);
if (
+151
View File
@@ -0,0 +1,151 @@
import { createHash } from "node:crypto";
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 { attestGhidraDosSnapshot } from "./GhidraDosSnapshot.js";
const hash = (bytes: Buffer) =>
createHash("sha256").update(bytes).digest("hex");
it.each([23, 65539])(
"streams complete MZ identity with a %s-byte overlay",
async (overlay) => {
const fixture = fixtureDosLoadImage(Buffer.alloc(overlay, 0xa5));
const directory = await createTestTempDirectory("rea-mz-source-");
const path = join(directory, "inert.exe");
await writeFile(path, fixture.bytes);
const handle = await open(path, "r");
const allocation = vi.spyOn(Buffer, "allocUnsafe");
const copies = vi.spyOn(Buffer, "from");
try {
const result = await attestGhidraDosSnapshot(
handle,
fixture.bytes.length,
path,
fixture.sha256,
fixture.observation,
);
expect(result).toMatchObject({
ok: true,
value: {
status: "verified",
overlay_bytes: overlay,
observations: fixture.observation,
},
});
expect(
Math.max(...allocation.mock.calls.map(([size]) => size)),
).toBeLessThanOrEqual(65536);
expect(
copies.mock.calls
.filter(([source]) => Buffer.isBuffer(source))
.every(([source]) => (source as Buffer).length <= 160),
).toBe(true);
expect(handle.fd).toBeGreaterThanOrEqual(0);
} finally {
allocation.mockRestore();
copies.mockRestore();
await handle.close();
}
},
);
it.each([41, 170])(
"rejects changed source bytes at file offset %s",
async (offset) => {
const fixture = fixtureDosLoadImage();
fixture.bytes[offset] = (fixture.bytes[offset] ?? 0) ^ 1;
const directory = await createTestTempDirectory("rea-mz-identity-");
const path = join(directory, "inert.exe");
await writeFile(path, fixture.bytes);
const handle = await open(path, "r");
try {
expect(
await attestGhidraDosSnapshot(
handle,
fixture.bytes.length,
path,
fixture.sha256,
fixture.observation,
),
).toMatchObject({ ok: false, error: expect.stringContaining("digest") });
} finally {
await handle.close();
}
},
);
it.each(["ZZZZ", "NE00"])(
"observes distant Windows signatures (%s) without capturing the overlay",
async (signature) => {
const fixture = fixtureDosLoadImage(Buffer.alloc(1024));
fixture.bytes.writeUInt32LE(512, 60);
fixture.bytes.write(signature, 512, "ascii");
fixture.sha256 = hash(fixture.bytes);
const modified = Buffer.from(fixture.bytes);
modified.writeUInt16LE(0x1004, 41);
Object.assign(fixture.observation.source_files[0] ?? {}, {
original_sha256: fixture.sha256,
modified_sha256: hash(modified),
});
Object.assign(fixture.observation.mappings[1] ?? {}, {
sha256: hash(modified.subarray(32, 160)),
});
const directory = await createTestTempDirectory("rea-mz-signature-");
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,
);
expect(result).toMatchObject(
signature === "ZZZZ"
? { ok: true, value: { status: "verified" } }
: { ok: false, error: expect.stringContaining("Windows new header") },
);
} finally {
await handle.close();
}
},
);
it("preserves exact cancellation after an overlay descriptor read", async () => {
const fixture = fixtureDosLoadImage(Buffer.alloc(65539));
const directory = await createTestTempDirectory("rea-mz-cancel-");
const path = join(directory, "inert.exe");
await writeFile(path, fixture.bytes);
const handle = await open(path, "r");
const controller = new AbortController();
const reason = { stage: "overlay read" };
const original = handle.read.bind(handle);
const read = vi
.spyOn(handle, "read")
.mockImplementation(async (...args: Parameters<typeof handle.read>) => {
const result = await original(...args);
if (Reflect.get(args, 3) === 160n) controller.abort(reason);
return result;
});
try {
await expect(
attestGhidraDosSnapshot(
handle,
fixture.bytes.length,
path,
fixture.sha256,
fixture.observation,
controller.signal,
),
).rejects.toMatchObject({ reason: "cancelled", cause: reason });
expect(handle.fd).toBeGreaterThanOrEqual(0);
} finally {
read.mockRestore();
await handle.close();
}
});
+109
View File
@@ -0,0 +1,109 @@
import { createHash } from "node:crypto";
import type { FileHandle } from "node:fs/promises";
import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
dosMzImageByteLength,
observeMzWindowsHeader,
} from "../domain/dosMz.js";
import { err } from "../domain/result.js";
import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import {
attestGhidraDosLoadImage,
prepareGhidraDosLoadImage,
} from "./GhidraLoadImageValues.js";
const cancelled = (reason: unknown): Error =>
new ArtifactReaderFailure("cancelled", "DOS snapshot read cancelled", {
cause: reason,
});
/** The caller owns admission, state validation and descriptor finalization. */
export const attestGhidraDosSnapshot = async (
handle: FileHandle,
fileBytes: number,
path: string,
targetSha256: string,
observations: NativeLoadImageObservation,
signal?: AbortSignal,
) => {
const headerBytes = Math.min(fileBytes, 64);
const header = await readBoundedFileBytes(
handle,
headerBytes,
signal,
headerBytes,
cancelled,
);
if (header === undefined || header.length !== headerBytes)
throw new RegularFileChangedError(path);
const extent = dosMzImageByteLength(header);
if (!extent.ok) return extent;
if (extent.value > fileBytes) return err("truncated DOS MZ load module");
// Retain the complete initialized image and the Windows-pointer header only.
// The unsigned 16-bit page count bounds this allocation independently of overlays.
const captureBytes = Math.max(headerBytes, extent.value);
const prefix = Buffer.allocUnsafe(captureBytes);
header.copy(prefix);
let captured = header.length;
if (captured < captureBytes) {
for await (const chunk of readArtifactFileChunks(handle, {
start: captured,
end: captureBytes - 1,
signal,
})) {
chunk.copy(prefix, captured);
captured += chunk.length;
}
}
if (captured !== captureBytes) throw new RegularFileChangedError(path);
const windowsHeader = observeMzWindowsHeader(prefix, fileBytes);
let signature: Buffer | undefined;
if (windowsHeader.state === "signature-needed") {
const size = Math.min(4, fileBytes - windowsHeader.offset);
signature = Buffer.allocUnsafe(size);
let read = 0;
while (read < size) {
if (signal?.aborted) throw cancelled(signal.reason);
const { bytesRead } = await handle.read(
signature,
read,
size - read,
windowsHeader.offset + read,
);
if (bytesRead === 0) throw new RegularFileChangedError(path);
read += bytesRead;
}
}
const prepared = prepareGhidraDosLoadImage(prefix, fileBytes, signature);
if (!prepared.ok) return prepared;
const originalHash = createHash("sha256").update(prefix);
const modifiedHash = createHash("sha256")
.update(prepared.value.modified)
.update(prefix.subarray(prepared.value.header.imageBytes));
let read = prefix.length;
if (read < fileBytes) {
for await (const chunk of readArtifactFileChunks(handle, {
start: read,
end: fileBytes - 1,
signal,
})) {
originalHash.update(chunk);
modifiedHash.update(chunk);
read += chunk.length;
}
}
if (read !== fileBytes) throw new RegularFileChangedError(path);
return attestGhidraDosLoadImage(
{
sha256: originalHash.digest("hex"),
fileBytes,
modifiedSha256: modifiedHash.digest("hex"),
image: prepared.value,
},
targetSha256,
observations,
);
};
+50 -5
View File
@@ -1,12 +1,60 @@
import { createHash } from "node:crypto";
import { dosMz } from "../domain/binaryTarget.fixture.js";
import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js";
import {
attestGhidraDosLoadImage,
prepareGhidraDosLoadImage,
} from "./GhidraLoadImageValues.js";
import { err } from "../domain/result.js";
import { GhidraSessionError } from "./GhidraSessionError.js";
const hash = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
/** Buffered tiny-fixture input for comparison tests; production streams the descriptor. */
export const compareDosFixture = (
bytes: Buffer,
sha256: string,
observations: NativeLoadImageObservation,
) => {
if (hash(bytes) !== sha256)
return err(
"Ghidra load-image snapshot digest does not match the admitted target",
);
const image = prepareGhidraDosLoadImage(bytes, bytes.length);
if (!image.ok) return image;
return attestGhidraDosLoadImage(
{
sha256,
fileBytes: bytes.length,
modifiedSha256: createHash("sha256")
.update(image.value.modified)
.update(bytes.subarray(image.value.header.imageBytes))
.digest("hex"),
image: image.value,
},
sha256,
observations,
);
};
export const compareDosClientFixture = (
...input: Parameters<typeof compareDosFixture>
) => {
const compared = compareDosFixture(...input);
return compared.ok
? compared
: err(
new GhidraSessionError("protocol", compared.error, {
reason: compared.error,
}),
);
};
/** Source-owned MZ with one relocated word, overlay and uninitialized allocation. */
export const fixtureDosLoadImage = (): {
export const fixtureDosLoadImage = (
overlay = Buffer.from("private-overlay-fixture"),
): {
bytes: Buffer;
sha256: string;
observation: NativeLoadImageObservation;
@@ -16,10 +64,7 @@ export const fixtureDosLoadImage = (): {
original.writeUInt16LE(9, 28);
original.writeUInt16LE(0, 30);
original.writeUInt16LE(4, 41);
const bytes = Buffer.concat([
original,
Buffer.from("private-overlay-fixture"),
]);
const bytes = Buffer.concat([original, overlay]);
const modified = Buffer.from(bytes);
modified.writeUInt16LE(0x1004, 41);
return {
+25 -12
View File
@@ -16,6 +16,7 @@ import {
} from "../domain/native/nativeLoadImage.js";
import {
fixtureDosLoadImage,
compareDosClientFixture,
fixtureDosComLoadImage,
} from "./GhidraLoadImage.fixture.js";
import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js";
@@ -50,19 +51,31 @@ it.each(["dos-mz", "dos-com", "raw-binary"] as const)(
target: fixture.target,
measured: fixture.observation,
client:
"bytes" in fixture
format === "dos-mz" && "bytes" in fixture
? {
readTargetSnapshot: async () => {
attestDosTargetSnapshot: async (observations) => {
const compared = compareDosClientFixture(
fixture.bytes,
fixture.sha256,
observations,
);
caller.abort(reason);
return ok({ kind: "captured", bytes: fixture.bytes });
return compared;
},
}
: {
hashTargetSnapshot: async () => {
caller.abort(reason);
return ok(fixture.snapshot);
: "bytes" in fixture
? {
readTargetSnapshot: async () => {
caller.abort(reason);
return ok({ kind: "captured", bytes: fixture.bytes });
},
}
: {
hashTargetSnapshot: async () => {
caller.abort(reason);
return ok(fixture.snapshot);
},
},
},
mapSessionError: (cause) =>
new ProviderAdapterError("ghidra", "inspect_native_load_image", {
cause,
@@ -99,7 +112,7 @@ it.each([null, "caller stopped", new Error("caller stopped")])(
operation: "inspect_native_load_image",
target: fixture.target,
measured: fixture.observation,
client: { readTargetSnapshot: read },
client: { attestDosTargetSnapshot: read },
mapSessionError: () => {
throw new Error("No source failure expected");
},
@@ -134,7 +147,7 @@ it("preserves a snapshot failure before late cancellation", async () => {
target: fixture.target,
measured: fixture.observation,
client: {
readTargetSnapshot: async () => {
attestDosTargetSnapshot: async () => {
caller.abort("late caller");
return err(source);
},
@@ -152,8 +165,8 @@ it("keeps ordinary DOS verification valid", async () => {
target: fixture.target,
measured: fixture.observation,
client: {
readTargetSnapshot: async () =>
ok({ kind: "captured", bytes: fixture.bytes }),
attestDosTargetSnapshot: async (observations) =>
compareDosClientFixture(fixture.bytes, fixture.sha256, observations),
},
mapSessionError: () => {
throw new Error("No source failure expected");
+20 -7
View File
@@ -12,7 +12,6 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import {
attestGhidraDosComLoadImage,
attestGhidraDosLoadImage,
attestGhidraRawBinaryLoadImage,
} from "./GhidraLoadImageValues.js";
import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js";
@@ -24,7 +23,10 @@ type NativeLoadImageAttestationInput = {
readonly operation: AnalysisOperation;
readonly measured: NativeLoadImageObservation;
readonly client: Partial<
Pick<GhidraClient, "hashTargetSnapshot" | "readTargetSnapshot">
Pick<
GhidraClient,
"hashTargetSnapshot" | "readTargetSnapshot" | "attestDosTargetSnapshot"
>
>;
readonly mapSessionError: (failure: GhidraSessionError) => AnalysisError;
readonly nativeAotResult?: NativeAotPeResult | undefined;
@@ -157,6 +159,17 @@ const compareLoadImage = async (
? attested
: err(comparisonFailure(input, attested.error));
}
if (target.format === "dos-mz") {
if (client.attestDosTargetSnapshot === undefined)
return err(
comparisonFailure(
input,
"The Ghidra client does not expose its bounded DOS target snapshot comparison.",
),
);
const compared = await client.attestDosTargetSnapshot(observations, signal);
return compared.ok ? compared : err(sourceFailure(compared.error));
}
if (client.readTargetSnapshot === undefined)
return err(
comparisonFailure(
@@ -173,10 +186,10 @@ const compareLoadImage = async (
"The uncapped load-image snapshot was unexpectedly not captured.",
),
);
const attested = (
target.format === "dos-com"
? attestGhidraDosComLoadImage
: attestGhidraDosLoadImage
)(snapshot.value.bytes, target.sha256, observations);
const attested = attestGhidraDosComLoadImage(
snapshot.value.bytes,
target.sha256,
observations,
);
return attested.ok ? attested : err(comparisonFailure(input, attested.error));
};
+11 -21
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { createHash } from "node:crypto";
import { attestGhidraDosLoadImage } from "./GhidraLoadImageValues.js";
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
import {
compareDosFixture,
fixtureDosLoadImage,
} from "./GhidraLoadImage.fixture.js";
import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js";
import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js";
@@ -214,7 +216,7 @@ const damage: ReadonlyArray<
describe("independent DOS MZ import verification", () => {
it("verifies every initialized source byte while retaining overlay and BSS scope", () => {
const fixture = fixtureDosLoadImage();
const result = attestGhidraDosLoadImage(
const result = compareDosFixture(
fixture.bytes,
fixture.sha256,
fixture.observation,
@@ -243,7 +245,7 @@ describe("independent DOS MZ import verification", () => {
(_name, mutate) => {
const fixture = fixtureDosLoadImage();
mutate(fixture.observation);
const result = attestGhidraDosLoadImage(
const result = compareDosFixture(
fixture.bytes,
fixture.sha256,
fixture.observation,
@@ -263,7 +265,7 @@ describe("independent DOS MZ import verification", () => {
const mapping = fixture.observation.mappings[1];
if (mapping === undefined) throw new Error("Missing module mapping");
mapping.sha256 = "f".repeat(64);
const result = attestGhidraDosLoadImage(
const result = compareDosFixture(
fixture.bytes,
fixture.sha256,
fixture.observation,
@@ -284,11 +286,7 @@ describe("independent DOS MZ import verification", () => {
const fixture = fixtureDosLoadImage();
fixture.observation.mappings.reverse();
expect(
attestGhidraDosLoadImage(
fixture.bytes,
fixture.sha256,
fixture.observation,
),
compareDosFixture(fixture.bytes, fixture.sha256, fixture.observation),
).toMatchObject({ ok: true, value: { status: "verified" } });
});
@@ -300,11 +298,7 @@ describe("independent DOS MZ import verification", () => {
relocation.segment = 0x0fff;
relocation.offset = 25;
expect(
attestGhidraDosLoadImage(
fixture.bytes,
fixture.sha256,
fixture.observation,
),
compareDosFixture(fixture.bytes, fixture.sha256, fixture.observation),
).toMatchObject({
ok: true,
value: {
@@ -322,15 +316,11 @@ describe("independent DOS MZ import verification", () => {
const fixture = fixtureDosLoadImage();
fixture.bytes[0] = 0;
expect(
attestGhidraDosLoadImage(
fixture.bytes,
fixture.sha256,
fixture.observation,
),
compareDosFixture(fixture.bytes, fixture.sha256, fixture.observation),
).toMatchObject({ ok: false, error: expect.stringContaining("digest") });
const digest = createHash("sha256").update(fixture.bytes).digest("hex");
expect(
attestGhidraDosLoadImage(fixture.bytes, digest, fixture.observation),
compareDosFixture(fixture.bytes, digest, fixture.observation),
).toMatchObject({ ok: false, error: expect.stringContaining("MZ header") });
});
+48 -20
View File
@@ -22,21 +22,25 @@ const limitations = [
"A mismatching memory digest identifies the reported source range; it does not locate the first differing byte inside that range.",
];
/** Verify measured MZ import state against the session's immutable snapshot. */
export const attestGhidraDosLoadImage = (
export type GhidraDosImage = {
readonly header: DosMzHeader;
readonly modified: Buffer;
readonly expectedRelocations: NativeLoadImageObservation["relocations"];
};
/** Prepare initialized bytes and relocation facts without copying overlays. */
export const prepareGhidraDosLoadImage = (
bytes: Buffer,
targetSha256: string,
observations: NativeLoadImageObservation,
): Result<NativeLoadImage, string> => {
if (sha256(bytes) !== targetSha256)
return err(
"Ghidra load-image snapshot digest does not match the admitted target",
);
const parsed = parseDosMzHeader(bytes);
fileBytes: number,
signatureAtOffset?: Buffer,
): Result<GhidraDosImage, string> => {
const parsed = parseDosMzHeader(bytes, fileBytes, signatureAtOffset);
if (!parsed.ok) return parsed;
const header = parsed.value;
if (header.imageBytes > bytes.length)
return err("DOS MZ initialized image was not completely captured");
const loadSegment = 0x1000;
const modified = Buffer.from(bytes);
const modified = Buffer.from(bytes.subarray(0, header.imageBytes));
const expectedRelocations = [];
for (let index = 0; index < header.relocationCount; index += 1) {
const table = header.relocationTableOffset + index * 4;
@@ -62,6 +66,28 @@ export const attestGhidraDosLoadImage = (
.toString("hex"),
});
}
return ok({ header, modified, expectedRelocations });
};
export type GhidraDosLoadImageSource = {
readonly sha256: string;
readonly fileBytes: number;
readonly modifiedSha256: string;
readonly image: GhidraDosImage;
};
/** Verify measured MZ import state against complete identity and prepared image facts. */
export const attestGhidraDosLoadImage = (
source: GhidraDosLoadImageSource,
targetSha256: string,
observations: NativeLoadImageObservation,
): Result<NativeLoadImage, string> => {
if (source.sha256 !== targetSha256)
return err(
"Ghidra load-image snapshot digest does not match the admitted target",
);
const { header, modified, expectedRelocations } = source.image;
const loadSegment = 0x1000;
const checks: NativeLoadImageCheck[] = [];
const check = (
name: string,
@@ -89,13 +115,17 @@ export const attestGhidraDosLoadImage = (
check("image_base", "0x0", observations.image_base);
check("default_address_space", "ram", observations.default_address_space);
check("source_file_count", 1, observations.source_files.length);
const source = observations.source_files[0];
check("source_file_size", bytes.length, source?.size ?? null);
check("original_file_sha256", targetSha256, source?.original_sha256 ?? null);
const observedSource = observations.source_files[0];
check("source_file_size", source.fileBytes, observedSource?.size ?? null);
check(
"original_file_sha256",
targetSha256,
observedSource?.original_sha256 ?? null,
);
check(
"modified_file_sha256",
sha256(modified),
source?.modified_sha256 ?? null,
source.modifiedSha256,
observedSource?.modified_sha256 ?? null,
);
const entryAddress = address(
((loadSegment + header.entrySegment) & 0xffff) * 16 + header.entryOffset,
@@ -124,7 +154,7 @@ export const attestGhidraDosLoadImage = (
rows(observations.relocations),
);
checkMappings(header, loadSegment * 16, bytes, modified, observations, check);
checkMappings(header, loadSegment * 16, modified, observations, check);
return ok(
nativeLoadImageSchema.parse({
status: checks.every((item) => item.matched) ? "verified" : "mismatch",
@@ -149,7 +179,6 @@ export const attestGhidraDosLoadImage = (
const checkMappings = (
header: Pick<DosMzHeader, "headerBytes" | "imageBytes">,
loadBase: number,
bytes: Buffer,
modified: Buffer,
observations: NativeLoadImageObservation,
check: (
@@ -227,7 +256,7 @@ const checkMappings = (
check(`${at}.end`, expectedEnd, mapping.end, start, mapping.end);
check(
`${at}.memory_sha256`,
start !== null && end !== null && end <= bytes.length
start !== null && end !== null && end <= modified.length
? sha256(modified.subarray(start, end))
: null,
mapping.sha256,
@@ -302,7 +331,6 @@ export const attestGhidraDosComLoadImage = (
{ headerBytes: 0, imageBytes: bytes.length },
0x10100,
bytes,
bytes,
observations,
check,
);
+10 -3
View File
@@ -18,7 +18,10 @@ import { promisify } from "node:util";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import * as nativeLoader from "../windows/WindowsNativeLoader.js";
import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js";
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
import {
fixtureDosLoadImage,
compareDosClientFixture,
} from "./GhidraLoadImage.fixture.js";
import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js";
import {
nativeAotPeDigest,
@@ -1500,8 +1503,12 @@ describe("Ghidra measured DOS load-image projection", () => {
...(state === "missing-snapshot"
? {}
: {
readTargetSnapshot: () =>
Promise.resolve(ok({ kind: "captured", bytes: fixture.bytes })),
attestDosTargetSnapshot: async (observations) =>
compareDosClientFixture(
fixture.bytes,
fixture.sha256,
observations,
),
}),
});
const ghidra = provider(installationHost(), factory);
+1
View File
@@ -113,6 +113,7 @@ export type GhidraProviderClientFactory = (
| "runtimeLineage"
| "readTargetSnapshot"
| "hashTargetSnapshot"
| "attestDosTargetSnapshot"
>
>;
@@ -1,6 +1,8 @@
import { nativeLoadImagePartialObservationSchema } from "../../../src/domain/native/nativeLoadImage.js";
import { fixtureDosLoadImage } from "../../../src/ghidra/GhidraLoadImage.fixture.js";
import { attestGhidraDosLoadImage } from "../../../src/ghidra/GhidraLoadImageValues.js";
import {
fixtureDosLoadImage,
compareDosFixture,
} from "../../../src/ghidra/GhidraLoadImage.fixture.js";
import { nativeAotSnapshotObservationSchema } from "../../../src/domain/native/nativeAotSnapshotObservation.js";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -132,7 +134,7 @@ it.each(
capture: { status: "captured", bytes: bytes.length },
})
: (() => {
const compared = attestGhidraDosLoadImage(
const compared = compareDosFixture(
dos.bytes,
dos.sha256,
dos.observation,
@@ -286,8 +286,11 @@ it.each(["descriptor-close", "result-consumption"] as const)(
() => ({
start: () => live.start(),
callTool: async () => ok(jsonValueSchema.parse(fixture.observation)),
readTargetSnapshot: async (maximum, signal) => {
const result = await live.readTargetSnapshot(maximum, signal);
attestDosTargetSnapshot: async (observations, signal) => {
const result = await live.attestDosTargetSnapshot(
observations,
signal,
);
if (phase === "result-consumption" && result.ok) caller.abort(reason);
return result;
},
@@ -333,8 +336,9 @@ it.each(["descriptor-close", "result-consumption"] as const)(
if (phase === "descriptor-close") {
expect(closedOwner?.handle.fd).toBe(-1);
expect(result.error.partialObservation).toMatchObject({
kind: "file-snapshot-read",
capture: { status: "captured", bytes: fixture.bytes.length },
kind: "native-load-image",
stage: "completed",
result: { status: "verified", observations: fixture.observation },
});
expect(result.error.diagnostics).toMatchObject({
load_image_observations: fixture.observation,
@@ -351,6 +355,99 @@ it.each(["descriptor-close", "result-consumption"] as const)(
},
);
it.each([false, true])(
"retains a completed bounded MZ comparison through failed close (cancel=%s)",
async (cancel) => {
const fixture = fixtureDosLoadImage();
const directory = await createTestTempDirectory("rea-mz-finalization-");
const targetPath = join(directory, "inert.exe");
await writeFile(targetPath, fixture.bytes);
const client = clientFor(new FixtureLauncher(), {
targetPath,
targetSha256: fixture.sha256,
});
const started = await client.start();
if (!started.ok) throw started.error;
const caller = new AbortController();
const reason = { stage: "MZ descriptor close" };
let allowClose = false;
const original = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(function (this: OwnedFileHandle) {
if (allowClose) return original.call(this);
if (cancel) caller.abort(reason);
return Promise.reject(
new Error("controlled MZ descriptor close failure"),
);
});
onTestFinished(async () => {
allowClose = true;
await client.close();
close.mockRestore();
});
const result = await client.attestDosTargetSnapshot(
fixture.observation,
caller.signal,
);
if (result.ok) throw new Error("Expected finalization failure");
expect(result.error.kind).toBe(cancel ? "cancelled" : "protocol");
if (cancel) expect(result.error.cancellation?.reason).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
kind: "native-load-image",
stage: "completed",
result: { status: "verified", observations: fixture.observation },
});
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
const blocked = await client.attestDosTargetSnapshot(fixture.observation);
expect(blocked.ok).toBe(false);
expect(new Set(close.mock.contexts).size).toBe(1);
allowClose = true;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
expect(result.error.cleanupFailure?.cleanupResources).toEqual([]);
},
);
it.each([false, true])(
"preserves an explicit MZ refusal through finalization (failed close=%s)",
async (failClose) => {
const fixture = fixtureDosLoadImage();
fixture.bytes[0] = 0;
const directory = await createTestTempDirectory("rea-mz-refusal-");
const targetPath = join(directory, "inert.exe");
await writeFile(targetPath, fixture.bytes);
const client = clientFor(new FixtureLauncher(), {
targetPath,
targetSha256: createHash("sha256").update(fixture.bytes).digest("hex"),
});
const started = await client.start();
if (!started.ok) throw started.error;
const close = failClose
? vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockRejectedValueOnce(new Error("controlled refusal close failure"))
: undefined;
onTestFinished(async () => {
close?.mockRestore();
await client.close();
});
const result = await client.attestDosTargetSnapshot(fixture.observation);
expect(result).toMatchObject({
ok: false,
error: {
kind: "protocol",
message: "invalid or truncated DOS MZ header",
diagnostics: { failure_message: "invalid or truncated DOS MZ header" },
},
});
if (result.ok) throw new Error("Expected MZ refusal");
expect(result.error.cleanupFailure?.cleanupIncomplete ?? false).toBe(
failClose,
);
},
);
it("reports snapshot descriptor cleanup immediately and resolves its exact error on retry", async () => {
const client = clientFor(new FixtureLauncher());
const started = await client.start();