mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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") });
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user