refactor(targets): preserve ambiguous MZ header evidence

This commit is contained in:
morluto committed 2026-10-23 16:57:43 +08:00
1 parent 06828b2e65
commit 39d1b2d395
12 files changed
+308 -59

No files matched your search

+9 -5
View File
@@ -25,11 +25,15 @@ same admitted target and provider semantics.
The classifier validates MZ page counts, initialized module length, header and
relocation-table bounds, relocation destinations, and the initialized entry
point. It distinguishes DOS load-module and relocation bytes from the Windows
new-header field. PE remains PE; NE, LE, and LX declarations are explicitly
unsupported. A damaged declared Windows header does not silently become DOS.
Inventory classification returns `unknown` when its bounded prefix cannot
establish the format; this is distinct from the complete target admission check.
point. DOS structural facts are parsed independently from Windows-header
evidence. A recognized PE signature remains PE; recognized NE, LE, and LX
headers are explicitly unsupported. A nonzero Windows-header pointer without a
recognized signature remains an unresolved candidate, even when the DOS
structure validates: automatic target selection fails closed and reports the
DOS facts alongside the candidate. REA does not infer DOS intent from padding
or infer a damaged Windows header from the pointer alone. Inventory
classification returns `unknown` for that candidate and when its bounded prefix
cannot establish the format; this is distinct from complete DOS validation.
Ghidra imports a private target snapshot with `MzLoader`, language
`x86:LE:16:Real Mode`, and compiler specification `default`. These settings,
+70 -7
View File
@@ -32,7 +32,12 @@ import {
rawBinarySelectionError,
} from "../domain/rawBinary.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import { observeMzWindowsHeader } from "../domain/dosMz.js";
import {
observeMzWindowsHeader,
parseMzHeaderEvidence,
type DosMzHeader,
type MzWindowsHeaderObservation,
} from "../domain/dosMz.js";
import {
createXmlPlistSignatureProbe,
hasBinaryPlistSignature,
@@ -63,6 +68,7 @@ import {
} from "../domain/apple/machoContainer.js";
import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import type { JsonValue } from "../domain/jsonValue.js";
const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024;
@@ -242,9 +248,18 @@ export const parseBinaryTarget = async (
kind: "archive",
format: zipRootPackageFormat(path),
});
const reason =
typeof detected.error === "string"
? detected.error
: detected.error.reason;
const diagnostics =
typeof detected.error === "string"
? undefined
: detected.error.diagnostics;
return err(
new BinaryTargetError(path, detected.error, {
new BinaryTargetError(path, reason, {
constraint: "file_format_rejected",
...(diagnostics === undefined ? {} : { diagnostics }),
}),
);
}
@@ -547,7 +562,7 @@ const readExecutableMetadata = async (
handle: FileHandle,
hostArchitecture: NodeJS.Architecture,
signal?: AbortSignal,
): Promise<Result<ExecutableMetadata, string>> => {
): Promise<Result<ExecutableMetadata, ExecutableMetadataFailure>> => {
throwIfTargetResolutionCancelled(signal);
const prefix = Buffer.alloc(4096);
const prefixRead = await handle.read(prefix, 0, prefix.length, 0);
@@ -558,10 +573,10 @@ const readExecutableMetadata = async (
const fileSize = (await handle.stat()).size;
throwIfTargetResolutionCancelled(signal);
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) {
let windowsHeader = observeMzWindowsHeader(bytes, fileSize);
let evidence = parseMzHeaderEvidence(bytes, fileSize);
let signatureBytes: Buffer | undefined;
if (windowsHeader.state === "signature-needed") {
const { offset } = windowsHeader;
if (evidence.windowsHeader.state === "signature-needed") {
const { offset } = evidence.windowsHeader;
const signature = Buffer.alloc(Math.min(4, fileSize - offset));
const observedSignature = await handle.read(
signature,
@@ -571,8 +586,9 @@ const readExecutableMetadata = async (
);
throwIfTargetResolutionCancelled(signal);
signatureBytes = signature.subarray(0, observedSignature.bytesRead);
windowsHeader = observeMzWindowsHeader(bytes, fileSize, signatureBytes);
evidence = parseMzHeaderEvidence(bytes, fileSize, signatureBytes);
}
const { windowsHeader } = evidence;
if (windowsHeader.state === "declared") {
if (windowsHeader.offset < 64)
return err("invalid Windows new-header offset in MZ image");
@@ -599,12 +615,23 @@ const readExecutableMetadata = async (
const table = Buffer.alloc(tableEnd);
const observed = await handle.read(table, 0, table.length, 0);
throwIfTargetResolutionCancelled(signal);
evidence = parseMzHeaderEvidence(
table.subarray(0, observed.bytesRead),
fileSize,
signatureBytes,
);
if (evidence.windowsHeader.state === "candidate")
return err(
unresolvedMzHeaderFailure(evidence.windowsHeader, evidence.dos),
);
return parseExecutableHeader(
table.subarray(0, observed.bytesRead),
hostArchitecture,
fileSize,
);
}
if (windowsHeader.state === "candidate")
return err(unresolvedMzHeaderFailure(windowsHeader, evidence.dos));
return parseExecutableHeader(
bytes,
hostArchitecture,
@@ -653,6 +680,42 @@ const readExecutableMetadata = async (
});
};
type ExecutableMetadataFailure =
| string
| {
readonly reason: string;
readonly diagnostics: Readonly<Record<string, JsonValue>>;
};
const unresolvedMzHeaderFailure = (
windowsHeader: Extract<MzWindowsHeaderObservation, { state: "candidate" }>,
dos: Result<DosMzHeader, string>,
): Exclude<ExecutableMetadataFailure, string> => {
return {
reason: "MZ Windows new-header candidate is unresolved",
diagnostics: {
mz_windows_header: {
state: windowsHeader.state,
offset: windowsHeader.offset,
signature: windowsHeader.signature,
},
mz_dos: dos.ok
? {
state: "valid",
header_bytes: dos.value.headerBytes,
image_bytes: dos.value.imageBytes,
module_bytes: dos.value.moduleBytes,
overlay_bytes: dos.value.overlayBytes,
relocation_count: dos.value.relocationCount,
relocation_table_offset: dos.value.relocationTableOffset,
entry_segment: dos.value.entrySegment,
entry_offset: dos.value.entryOffset,
}
: { state: "invalid", reason: dos.error },
},
};
};
/** Read the validated FAT table and bounded thin-header identity prefixes. */
const readFatSliceHeaderEvidence = async (
handle: FileHandle,
@@ -19,6 +19,21 @@ import type { ArtifactEntry } from "../ArtifactReader.js";
import { classifyArtifactBytesAtPath } from "../../../tests/fixtures/artifactClassification.js";
it("keeps an ambiguous DOS MZ inventory candidate unknown", () => {
const bytes = Buffer.alloc(128);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(128, 2);
bytes.writeUInt16LE(1, 4);
bytes.writeUInt16LE(2, 6);
bytes.writeUInt16LE(4, 8);
bytes.writeUInt16LE(28, 24);
bytes.write("ABCD", 60, "ascii");
expect(classifyArtifactBytesAtPath("legacy.exe", bytes)).toEqual({
kind: "unknown",
format: "unknown",
});
});
const entry = (path: string, kind: ArtifactEntry["kind"]): ArtifactEntry => ({
path,
kind,
@@ -27,10 +27,7 @@ import {
zipPackageFormatForPath,
type ZipPackageFormat,
} from "../../domain/zipPackageFormat.js";
import {
observeMzWindowsHeader,
parseDosMzHeader,
} from "../../domain/dosMz.js";
import { parseMzHeaderEvidence } from "../../domain/dosMz.js";
import {
classifyFatContainerIdentity,
readFatContainerHeader,
@@ -585,8 +582,10 @@ export const classifyArtifactBytes = (
prefix.length < Math.min(fileSize, 64)
)
return "unknown";
const windowsHeader = observeMzWindowsHeader(prefix, fileSize);
const evidence = parseMzHeaderEvidence(prefix, fileSize);
const { windowsHeader } = evidence;
if (windowsHeader.state === "signature-needed") return "unknown";
if (windowsHeader.state === "candidate") return "unknown";
if (windowsHeader.state === "declared") {
const windowsOffset = windowsHeader.offset;
if (
@@ -597,7 +596,7 @@ export const classifyArtifactBytes = (
return "pe";
return "unknown";
}
if (parseDosMzHeader(prefix, fileSize).ok) return "dos-mz";
if (evidence.dos.ok) return "dos-mz";
return "unknown";
}
return "file";
+6 -7
View File
@@ -10,7 +10,7 @@ import type {
} from "./binaryTargetTypes.js";
import { err, ok, type Result } from "./result.js";
import { readElfFixedHeader } from "./elfFixedHeader.js";
import { observeMzWindowsHeader, parseDosMzHeader } from "./dosMz.js";
import { parseMzHeaderEvidence } from "./dosMz.js";
import {
decodePeMachine,
peOptionalHeaderMagic,
@@ -438,11 +438,8 @@ const parseMz = (
fileSize: number,
signatureAtOffset?: Buffer,
): Result<ExecutableMetadata, string> => {
const windowsHeader = observeMzWindowsHeader(
bytes,
fileSize,
signatureAtOffset,
);
const evidence = parseMzHeaderEvidence(bytes, fileSize, signatureAtOffset);
const { windowsHeader } = evidence;
if (
windowsHeader.state === "header-needed" ||
windowsHeader.state === "signature-needed"
@@ -450,6 +447,8 @@ const parseMz = (
return err(
"Windows new-header pointer or signature is outside the bounded prefix",
);
if (windowsHeader.state === "candidate")
return err("MZ Windows new-header candidate is unresolved");
if (windowsHeader.state === "declared") {
const { offset } = windowsHeader;
if (offset < 64 || offset > bytes.length - 24)
@@ -463,7 +462,7 @@ const parseMz = (
const codeView = parsePeCodeViewFromBuffer(bytes, offset);
return parsePeRecord(bytes.subarray(offset), codeView);
}
const parsed = parseDosMzHeader(bytes, fileSize, signatureAtOffset);
const parsed = evidence.dos;
return parsed.ok
? ok({
format: "dos-mz",
+42 -9
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { dosMz, pe } from "./binaryTarget.fixture.js";
import { parseExecutableHeader } from "./binaryTarget.js";
import { observeMzWindowsHeader, parseDosMzHeader } from "./dosMz.js";
import {
observeMzWindowsHeader,
parseDosMzHeader,
parseMzHeaderEvidence,
} from "./dosMz.js";
describe("DOS MZ load-module validation", () => {
it("separates initialized module and appended overlay bytes", () => {
@@ -268,16 +272,45 @@ describe("MZ Windows header observations", () => {
).toEqual({ state: "declared", offset, signature: "PE" });
});
it("does not fall back to DOS when a Windows declaration is damaged", () => {
const bytes = Buffer.alloc(256);
dosMz().copy(bytes, 0, 0, 28);
bytes.writeUInt16LE(256, 2);
it("preserves DOS facts while leaving an unsigned Windows pointer unresolved", () => {
const bytes = Buffer.alloc(128);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(128, 2);
bytes.writeUInt16LE(1, 4);
bytes.writeUInt16LE(2, 6);
bytes.writeUInt16LE(4, 8);
bytes.writeUInt16LE(64, 24);
bytes.writeUInt32LE(192, 60);
expect(parseDosMzHeader(bytes).ok).toBe(false);
expect(parseExecutableHeader(bytes, "x64")).toMatchObject({ ok: false });
bytes.writeUInt16LE(28, 24);
bytes.writeUInt32LE(0, 28);
bytes.writeUInt32LE(0, 32);
bytes.write("ABCD", 60, "ascii");
const evidence = parseMzHeaderEvidence(bytes);
expect(evidence.dos).toMatchObject({
ok: true,
value: {
headerBytes: 64,
imageBytes: 128,
moduleBytes: 64,
relocationCount: 2,
relocationTableOffset: 28,
},
});
expect(evidence.windowsHeader).toEqual({
state: "candidate",
offset: 0x44434241,
signature: "unobserved",
});
expect(parseExecutableHeader(bytes, "x64")).toEqual({
ok: false,
error: "MZ Windows new-header candidate is unresolved",
});
bytes.writeUInt32LE(0xfffffff0, 60);
expect(parseMzHeaderEvidence(bytes).windowsHeader).toEqual({
state: "candidate",
offset: 0xfffffff0,
signature: "unobserved",
});
expect(parseExecutableHeader(bytes, "x64")).toMatchObject({ ok: false });
});
});
+41 -17
View File
@@ -27,16 +27,24 @@ export type MzWindowsHeaderObservation =
| { readonly state: "absent" }
| { readonly state: "header-needed" }
| { readonly state: "signature-needed"; readonly offset: number }
| {
/** A nonzero pointer exists, but no Windows signature confirms it. */
readonly state: "candidate";
readonly offset: number;
readonly signature: "other" | "unobserved";
}
| {
readonly state: "declared";
readonly offset: number;
readonly signature: "PE" | "NE" | "LE" | "LX" | "other" | "unobserved";
readonly signature: "PE" | "NE" | "LE" | "LX";
};
/**
* Observe the Windows new-header pointer without mistaking short DOS module
* bytes for e_lfanew. Complete DOS headers declare their pointer; for shorter
* headers, only a PE/NE/LE/LX signature at that pointer confirms the candidate.
* bytes for e_lfanew. A nonzero pointer in a complete DOS header is only a
* candidate until a PE/NE/LE/LX signature is observed. For shorter DOS headers,
* only a recognized signature confirms a candidate because the field may be
* module data.
* A bounded prefix that does not reach the candidate requests one bounded read
* from its owner instead of treating unexamined bytes as absent.
*/
@@ -65,10 +73,22 @@ export const observeMzWindowsHeader = (
: undefined;
const available = signatureAtOffset ?? inlineSignature;
const signature = windowsHeaderSignature(available);
if (headerBytes === 0 || headerBytes >= 64)
return { state: "declared", offset, signature };
if (signature !== "other" && signature !== "unobserved")
return { state: "declared", offset, signature };
if (headerBytes === 0 || headerBytes >= 64) {
const remaining = fileSize - offset;
const needsMore =
(available === undefined && remaining >= 2) ||
(available !== undefined && available.length < 2 && remaining >= 2) ||
(available !== undefined &&
available.length >= 2 &&
available.toString("latin1", 0, 2) === "PE" &&
available.length < 4 &&
remaining >= 4);
if (needsMore && signatureAtOffset === undefined)
return { state: "signature-needed", offset };
return { state: "candidate", offset, signature };
}
if (signature === "other") return { state: "absent" };
const observedPePrefix =
available !== undefined &&
@@ -101,21 +121,12 @@ const windowsHeaderSignature = (
export const parseDosMzHeader = (
bytes: Buffer,
fileSize = bytes.length,
signatureAtOffset?: Buffer,
): Result<DosMzHeader, string> => {
if (bytes.length < 28 || bytes.toString("latin1", 0, 2) !== "MZ")
return err("invalid or truncated DOS MZ header");
if (!Number.isSafeInteger(fileSize) || fileSize < bytes.length)
return err("invalid DOS MZ file length");
const windowsHeader = observeMzWindowsHeader(
bytes,
fileSize,
signatureAtOffset,
);
if (windowsHeader.state === "declared")
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");
// DOS structure is parsed independently from the meaning of bytes at 0x3c.
const extent = dosMzImageByteLength(bytes);
if (!extent.ok) return extent;
const imageBytes = extent.value;
@@ -133,8 +144,6 @@ export const parseDosMzHeader = (
return err("DOS MZ relocation table lies outside its header");
if (relocationCount > 0 && tableEnd > bytes.length)
return err("truncated DOS MZ relocation table");
if (windowsHeader.state === "header-needed")
return err("MZ Windows new-header pointer is outside the bounded prefix");
const moduleBytes = imageBytes - headerBytes;
const relocations = validateRelocations(
bytes,
@@ -159,6 +168,21 @@ export const parseDosMzHeader = (
});
};
/** Preserve DOS structural facts and Windows-pointer evidence independently. */
export interface MzHeaderEvidence {
readonly dos: Result<DosMzHeader, string>;
readonly windowsHeader: MzWindowsHeaderObservation;
}
export const parseMzHeaderEvidence = (
bytes: Buffer,
fileSize = bytes.length,
signatureAtOffset?: Buffer,
): MzHeaderEvidence => ({
dos: parseDosMzHeader(bytes, fileSize),
windowsHeader: observeMzWindowsHeader(bytes, fileSize, signatureAtOffset),
});
const validateRelocations = (
bytes: Buffer,
relocationTableOffset: number,
+16
View File
@@ -6,6 +6,22 @@ import {
} from "./GhidraLoadImage.fixture.js";
import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js";
import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js";
import { prepareGhidraDosLoadImage } from "./GhidraLoadImageValues.js";
it("does not interpret an ambiguous Windows-header candidate as a DOS image", () => {
const bytes = Buffer.alloc(128);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(128, 2);
bytes.writeUInt16LE(1, 4);
bytes.writeUInt16LE(2, 6);
bytes.writeUInt16LE(4, 8);
bytes.writeUInt16LE(28, 24);
bytes.write("ABCD", 60, "ascii");
expect(prepareGhidraDosLoadImage(bytes, bytes.length)).toEqual({
ok: false,
error: "MZ Windows new-header candidate is unresolved",
});
});
const damage: ReadonlyArray<
readonly [string, (observation: NativeLoadImageObservation) => void]
+11 -2
View File
@@ -5,7 +5,7 @@ import {
type NativeLoadImageCheck,
type NativeLoadImageObservation,
} from "../domain/native/nativeLoadImage.js";
import { parseDosMzHeader, type DosMzHeader } from "../domain/dosMz.js";
import { parseMzHeaderEvidence, type DosMzHeader } from "../domain/dosMz.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { validateDosComLength } from "../domain/dosCom.js";
@@ -57,7 +57,16 @@ export const prepareGhidraDosLoadImage = (
fileBytes: number,
signatureAtOffset?: Buffer,
): Result<GhidraDosImage, string> => {
const parsed = parseDosMzHeader(bytes, fileBytes, signatureAtOffset);
const evidence = parseMzHeaderEvidence(bytes, fileBytes, signatureAtOffset);
if (evidence.windowsHeader.state === "candidate")
return err("MZ Windows new-header candidate is unresolved");
if (evidence.windowsHeader.state === "header-needed")
return err("MZ Windows new-header pointer is outside the bounded prefix");
if (evidence.windowsHeader.state === "signature-needed")
return err("MZ Windows new-header signature is outside the bounded prefix");
if (evidence.windowsHeader.state === "declared")
return err("MZ declares a Windows new header; it is not a DOS-only image");
const parsed = evidence.dos;
if (!parsed.ok) return parsed;
const header = parsed.value;
if (header.imageBytes > bytes.length)
+14
View File
@@ -6,6 +6,20 @@ import {
setGoPeVirtualSize,
} from "../../../tests/fixtures/go/image.js";
it("keeps a DOS-valid but unsigned Windows-header candidate unresolved", () => {
const bytes = Buffer.alloc(128);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(128, 2);
bytes.writeUInt16LE(1, 4);
bytes.writeUInt16LE(2, 6);
bytes.writeUInt16LE(4, 8);
bytes.writeUInt16LE(28, 24);
bytes.write("ABCD", 60, "ascii");
expect(() => readPeImage(bytes)).toThrow(
"MZ Windows new-header candidate is unresolved",
);
});
it.each([32, 64] as const)(
"uses raw-size fallback for PE%d Go build-info and keeps RVA mappings intact",
(bits) => {
+9 -6
View File
@@ -1,8 +1,5 @@
import { readPeSectionHeader } from "./NativeSectionDirectory.js";
import {
observeMzWindowsHeader,
parseDosMzHeader,
} from "../../domain/dosMz.js";
import { parseMzHeaderEvidence } from "../../domain/dosMz.js";
import {
declaredFileRange,
BinaryFormatFailure,
@@ -14,9 +11,15 @@ import {
} from "./BinaryContainer.js";
const peHeaderOffset = (reader: BinaryReader): number => {
const windowsHeader = observeMzWindowsHeader(reader.bytes);
const evidence = parseMzHeaderEvidence(reader.bytes);
const { windowsHeader } = evidence;
if (windowsHeader.state === "candidate")
throw new BinaryFormatFailure(
"unsupported",
"MZ Windows new-header candidate is unresolved",
);
if (windowsHeader.state !== "declared") {
const dos = parseDosMzHeader(reader.bytes);
const dos = evidence.dos;
if (!dos.ok) throw new BinaryFormatFailure("malformed", dos.error);
throw new BinaryFormatFailure(
"unsupported",
@@ -1118,6 +1118,76 @@ describe("binary Info.plist app bundle targets", () => {
});
describe("DOS binary target I/O", () => {
it("retains valid DOS facts while refusing automatic selection for an unresolved pointer", async () => {
const directory = await createTestTempDirectory("rea-ambiguous-mz-target-");
const path = join(directory, "legacy.exe");
const bytes = Buffer.alloc(128);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(128, 2);
bytes.writeUInt16LE(1, 4);
bytes.writeUInt16LE(2, 6);
bytes.writeUInt16LE(4, 8);
bytes.writeUInt16LE(28, 24);
bytes.write("ABCD", 60, "ascii");
await writeFile(path, bytes);
const result = await parseBinaryTarget(path);
expect(result).toMatchObject({
ok: false,
error: {
reason: "MZ Windows new-header candidate is unresolved",
diagnostics: {
mz_windows_header: {
state: "candidate",
offset: 0x44434241,
signature: "unobserved",
},
mz_dos: {
state: "valid",
header_bytes: 64,
image_bytes: 128,
module_bytes: 64,
relocation_count: 2,
relocation_table_offset: 28,
},
},
},
});
});
it("validates ambiguous DOS facts after extending the bounded prefix for relocations", async () => {
const directory = await createTestTempDirectory(
"rea-ambiguous-mz-relocations-",
);
const path = join(directory, "legacy.exe");
const bytes = Buffer.alloc(8192);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(16, 4);
bytes.writeUInt16LE(1010, 6);
bytes.writeUInt16LE(257, 8);
bytes.writeUInt16LE(64, 24);
bytes.write("ABCD", 60, "ascii");
await writeFile(path, bytes);
const result = await parseBinaryTarget(path);
expect(result).toMatchObject({
ok: false,
error: {
reason: "MZ Windows new-header candidate is unresolved",
diagnostics: {
mz_dos: {
state: "valid",
header_bytes: 4112,
image_bytes: 8192,
module_bytes: 4080,
relocation_count: 1010,
relocation_table_offset: 64,
},
},
},
});
});
it("resolves a short-header PE signature beyond the bounded prefix", async () => {
const directory = await createTestTempDirectory(
"rea-short-stub-pe-target-",