diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 48399a5b75..45cd7aa631 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -57,9 +57,10 @@ import { admitGamecubeDol } from "../domain/gamecubeDolAdmission.js"; import { DOL_HEADER_SIZE } from "../domain/gamecubeDol.js"; import { mzWindowsHeaderLayoutLimitation, - observeMzWindowsHeader, parseMzHeaderEvidence, + withMzWindowsHeaderSignature, type DosMzHeader, + type MzHeaderEvidence, type MzWindowsHeaderObservation, } from "../domain/dosMz.js"; import { @@ -80,6 +81,7 @@ import { parseCodeViewData, claimsExecutableHeader, parseExecutableHeader, + parseMzExecutableMetadata, parsePeRecordFacts, peDebugDirectoryEntry, peCodeViewDataRanges, @@ -877,7 +879,12 @@ const readExecutableMetadata = async ( offset, bytes: signature.subarray(0, observedSignature.bytesRead), }; - evidence = parseMzHeaderEvidence(bytes, fileSize, signatureEvidence); + evidence = withMzWindowsHeaderSignature( + bytes, + fileSize, + evidence, + signatureEvidence, + ); } const { windowsHeader } = evidence; if (windowsHeader.state === "declared") { @@ -890,31 +897,28 @@ const readExecutableMetadata = async ( mz_windows_header: { ...windowsHeader }, }, }); - return readPeMetadata( - handle, - windowsHeader.offset, - bytes, + return readPeMetadata(handle, windowsHeader.offset, { + mzPrefix: bytes, fileSize, + mzEvidence: evidence, signal, - ); + }); } // MZ relocation records may extend past the initial probe; their format // bounds are at most 65535 records inside a 65535-paragraph header. - const tableEnd = - bytes.length >= 28 - ? bytes.readUInt16LE(24) + bytes.readUInt16LE(6) * 4 - : 0; - const headerBytes = bytes.length >= 28 ? bytes.readUInt16LE(8) * 16 : 0; - if ( - tableEnd > bytes.length && - tableEnd <= headerBytes && - tableEnd <= fileSize - ) { - const table = Buffer.alloc(tableEnd); - const observed = await handle.read(table, 0, table.length, 0); + const { relocationTableRead } = evidence; + if (relocationTableRead !== undefined) { + const table = Buffer.alloc(relocationTableRead.byteLength); + const observed = await handle.read( + table, + 0, + table.length, + relocationTableRead.offset, + ); throwIfTargetResolutionCancelled(signal); + const observedBytes = table.subarray(0, observed.bytesRead); evidence = parseMzHeaderEvidence( - table.subarray(0, observed.bytesRead), + observedBytes, fileSize, signatureEvidence, ); @@ -922,21 +926,11 @@ const readExecutableMetadata = async ( return err( unresolvedMzHeaderFailure(evidence.windowsHeader, evidence.dos), ); - return parseExecutableHeader( - table.subarray(0, observed.bytesRead), - hostArchitecture, - fileSize, - signatureEvidence, - ); + return parseMzExecutableMetadata(observedBytes, evidence); } if (windowsHeader.state === "candidate") return err(unresolvedMzHeaderFailure(windowsHeader, evidence.dos)); - return parseExecutableHeader( - bytes, - hostArchitecture, - fileSize, - signatureEvidence, - ); + return parseMzExecutableMetadata(bytes, evidence); } if (bytes.length >= 4 && bytes.toString("hex", 0, 4) === "7f454c46") return readElfMetadata(handle, bytes, { @@ -1264,9 +1258,17 @@ const readFatSliceHeaderEvidence = async ( const readPeMetadata = async ( handle: FileHandle, offset: number, - mzPrefix: Buffer, - fileSize: number, - signal?: AbortSignal, + { + mzPrefix, + fileSize, + mzEvidence, + signal, + }: { + readonly mzPrefix: Buffer; + readonly fileSize: number; + readonly mzEvidence: MzHeaderEvidence; + readonly signal: AbortSignal | undefined; + }, ): Promise> => { throwIfTargetResolutionCancelled(signal); const fileHeader = Buffer.alloc(24); @@ -1279,10 +1281,12 @@ const readPeMetadata = async ( throwIfTargetResolutionCancelled(signal); if (fileHeaderRead.bytesRead !== fileHeader.length) return err("invalid or truncated PE header"); - const windowsHeader = observeMzWindowsHeader(mzPrefix, fileSize, { - offset, - bytes: fileHeader.subarray(0, 4), - }); + const { windowsHeader } = withMzWindowsHeaderSignature( + mzPrefix, + fileSize, + mzEvidence, + { offset, bytes: fileHeader.subarray(0, 4) }, + ); if (windowsHeader.state !== "declared") return err("invalid Windows new-header signature in MZ image"); if (["NE", "LE", "LX"].includes(windowsHeader.signature)) diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 99dd5a0ac8..c5d0aaeb5a 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -20,6 +20,7 @@ import { readElfFixedHeader } from "./elfFixedHeader.js"; import { mzWindowsHeaderLayoutLimitation, parseMzHeaderEvidence, + type MzHeaderEvidence, type MzWindowsHeaderSignatureEvidence, } from "./dosMz.js"; import { decodePeMachine, readPeOptionalHeader } from "./peMachine.js"; @@ -126,7 +127,10 @@ export const parseExecutableHeader = ( ) return parseElf(bytes, fileSize); if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) - return parseMz(bytes, fileSize, mzWindowsHeaderSignature); + return parseMzExecutableMetadata( + bytes, + parseMzHeaderEvidence(bytes, fileSize, mzWindowsHeaderSignature), + ); if (bytes.length < 8) return err("truncated or unsupported binary header"); const magic = bytes.readUInt32BE(0); if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) @@ -434,12 +438,11 @@ const parseElf = ( }); }; -const parseMz = ( +/** Select executable metadata from the reader's completed MZ observations. */ +export const parseMzExecutableMetadata = ( bytes: Buffer, - fileSize: number, - signatureAtOffset?: MzWindowsHeaderSignatureEvidence, + evidence: MzHeaderEvidence, ): Result => { - const evidence = parseMzHeaderEvidence(bytes, fileSize, signatureAtOffset); const { windowsHeader } = evidence; if ( windowsHeader.state === "header-needed" || diff --git a/src/domain/dosMz.test.ts b/src/domain/dosMz.test.ts index 1f152cf8a6..77fc62099a 100644 --- a/src/domain/dosMz.test.ts +++ b/src/domain/dosMz.test.ts @@ -4,6 +4,10 @@ import { dosMz, pe } from "./binaryTarget.fixture.js"; import { parseExecutableHeader } from "./binaryTarget.js"; import { observeMzWindowsHeader, + readDosMzDeclarations, + dosMzDeclaredImageByteLength, + parseDeclaredMzHeaderEvidence, + withMzWindowsHeaderSignature, parseDosMzHeader, parseMzHeaderEvidence, } from "./dosMz.js"; @@ -419,3 +423,166 @@ it("retains ordered DOS relocation aliases and their source/module target coordi error: "DOS MZ relocation 1 points outside its load module", }); }); + +const extendedRelocationImage = (): Buffer => { + const bytes = dosMz(8192); + bytes.writeUInt16LE(257, 8); // 4112-byte header. + bytes.writeUInt16LE(2, 6); + bytes.writeUInt16LE(4096, 24); + return bytes; +}; + +it("retains fixed declarations while requesting only a source-bounded relocation prefix", () => { + const bytes = extendedRelocationImage(); + const prefix = bytes.subarray(0, 64); + const evidence = parseMzHeaderEvidence(prefix, bytes.length); + expect(evidence.declarations).toEqual(readDosMzDeclarations(prefix)); + expect(evidence.declarations).toMatchObject({ + ok: true, + value: { + headerBytes: 4112, + relocationCount: 2, + relocationTableOffset: 4096, + relocationTableEnd: 4104, + entryOffset: 0, + entrySegment: 0, + }, + }); + expect(evidence.relocationTableRead).toEqual({ offset: 0, byteLength: 4104 }); + expect(evidence.dos).toEqual({ + ok: false, + error: "truncated DOS MZ relocation table", + }); + const observed = parseMzHeaderEvidence(bytes.subarray(0, 4104), bytes.length); + expect(observed.relocationTableRead).toBeUndefined(); + expect(observed.dos).toMatchObject({ + ok: true, + value: { relocationCount: 2, headerBytes: 4112 }, + }); +}); + +it("does not request absent or out-of-bounds relocation records", () => { + const zero = extendedRelocationImage(); + zero.writeUInt16LE(512, 8); + zero.writeUInt16LE(0, 6); + zero.writeUInt16LE(6000, 24); + expect( + parseMzHeaderEvidence(zero.subarray(0, 64), zero.length) + .relocationTableRead, + ).toBeUndefined(); + expect(parseDosMzHeader(zero).ok).toBe(true); + const outsideHeader = extendedRelocationImage(); + outsideHeader.writeUInt16LE(5000, 24); + const headerEvidence = parseMzHeaderEvidence( + outsideHeader.subarray(0, 64), + outsideHeader.length, + ); + expect(headerEvidence.relocationTableRead).toBeUndefined(); + expect(headerEvidence.dos).toEqual({ + ok: false, + error: "DOS MZ relocation table lies outside its header", + }); + const outsideFile = extendedRelocationImage(); + outsideFile.writeUInt16LE(1024, 8); + outsideFile.writeUInt16LE(9000, 24); + expect( + parseMzHeaderEvidence(outsideFile.subarray(0, 64), outsideFile.length) + .relocationTableRead, + ).toBeUndefined(); +}); + +it("keeps independent malformed declarations and the established DOS error precedence", () => { + const bytes = extendedRelocationImage(); + bytes.writeUInt16LE(0, 4); + bytes.writeUInt16LE(65535, 20); + const evidence = parseMzHeaderEvidence(bytes.subarray(0, 64), bytes.length); + expect(evidence.declarations).toMatchObject({ + ok: true, + value: { pages: 0, entryOffset: 65535 }, + }); + expect(evidence.relocationTableRead?.byteLength).toBe(4104); + expect(evidence.dos).toEqual({ + ok: false, + error: "invalid DOS MZ page count or final-page size", + }); + expect(parseDosMzHeader(bytes, bytes.length - 1)).toEqual({ + ok: false, + error: "invalid DOS MZ file length", + }); + bytes.writeUInt16LE(17, 4); + expect(parseDosMzHeader(bytes.subarray(0, 64), bytes.length)).toEqual({ + ok: false, + error: "truncated DOS MZ relocation table", + }); + expect(parseDosMzHeader(bytes)).toEqual({ + ok: false, + error: "DOS MZ entry point lies outside its initialized load module", + }); +}); + +it("keeps declared Windows signatures independent of incomplete DOS relocations", () => { + const bytes = extendedRelocationImage(); + bytes.writeUInt32LE(128, 60); + const evidence = parseMzHeaderEvidence(bytes.subarray(0, 64), bytes.length, { + offset: 128, + bytes: Buffer.from("PE\0\0"), + }); + expect(evidence.windowsHeader).toEqual({ + state: "declared", + signature: "PE", + offset: 128, + }); + expect(evidence.dos).toEqual({ + ok: false, + error: "truncated DOS MZ relocation table", + }); + expect(evidence.relocationTableRead?.byteLength).toBe(4104); +}); + +it("completes captured evidence from the original admitted fixed declarations", () => { + const bytes = extendedRelocationImage(); + const declarations = readDosMzDeclarations(bytes.subarray(0, 64)); + if (!declarations.ok) throw new Error(declarations.error); + expect(dosMzDeclaredImageByteLength(declarations.value)).toEqual({ + ok: true, + value: bytes.length, + }); + const evidence = parseDeclaredMzHeaderEvidence( + bytes.subarray(0, 4104), + bytes.length, + declarations, + ); + expect(evidence.declarations).toBe(declarations); + expect(evidence).toEqual( + parseMzHeaderEvidence(bytes.subarray(0, 4104), bytes.length), + ); + expect(evidence.dos.ok).toBe(true); +}); + +it("adds distant Windows signatures while retaining prior DOS observations", () => { + const bytes = extendedRelocationImage(); + bytes.writeUInt32LE(6000, 60); + const prefix = bytes.subarray(0, 4104); + const evidence = parseMzHeaderEvidence(prefix, bytes.length); + expect(evidence.windowsHeader).toEqual({ + state: "signature-needed", + offset: 6000, + }); + const signature = { offset: 6000, bytes: Buffer.from("PE\0\0") }; + const completed = withMzWindowsHeaderSignature( + prefix, + bytes.length, + evidence, + signature, + ); + expect(completed.dos).toBe(evidence.dos); + expect(completed.declarations).toBe(evidence.declarations); + expect(completed.windowsHeader).toEqual({ + state: "declared", + signature: "PE", + offset: 6000, + }); + expect(completed).toEqual( + parseMzHeaderEvidence(prefix, bytes.length, signature), + ); +}); diff --git a/src/domain/dosMz.ts b/src/domain/dosMz.ts index 2171505c12..c0c96702bb 100644 --- a/src/domain/dosMz.ts +++ b/src/domain/dosMz.ts @@ -1,11 +1,45 @@ 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 => { - 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); +/** Source declarations in the fixed DOS header, independent of load-module validity. */ +export interface DosMzDeclarations { + readonly lastPageBytes: number; + readonly pages: number; + readonly headerBytes: number; + readonly relocationCount: number; + readonly relocationTableOffset: number; + readonly relocationTableEnd: number; + readonly entrySegment: number; + readonly entryOffset: number; +} + +const fixedDosMzDeclarations = (bytes: Buffer): DosMzDeclarations => { + const relocationCount = bytes.readUInt16LE(6); + const relocationTableOffset = bytes.readUInt16LE(24); + return { + lastPageBytes: bytes.readUInt16LE(2), + pages: bytes.readUInt16LE(4), + headerBytes: bytes.readUInt16LE(8) * 16, + relocationCount, + relocationTableOffset, + relocationTableEnd: relocationTableOffset + relocationCount * 4, + entrySegment: bytes.readUInt16LE(22), + entryOffset: bytes.readUInt16LE(20), + }; +}; + +/** Admit only the fixed source header; malformed page and load declarations remain observable. */ +export const readDosMzDeclarations = ( + bytes: Buffer, +): Result => + bytes.length < 28 || bytes.toString("latin1", 0, 2) !== "MZ" + ? err("invalid or truncated DOS MZ header") + : ok(fixedDosMzDeclarations(bytes)); + +/** Derive the initialized source extent from already admitted fixed declarations. */ +export const dosMzDeclaredImageByteLength = ({ + pages, + lastPageBytes, +}: DosMzDeclarations): Result => { 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)); @@ -75,13 +109,23 @@ export const observeMzWindowsHeader = ( bytes: Buffer, fileSize = bytes.length, signatureEvidence?: MzWindowsHeaderSignatureEvidence, +): MzWindowsHeaderObservation => + observeWindowsHeader(bytes, fileSize, signatureEvidence); + +const observeWindowsHeader = ( + bytes: Buffer, + fileSize: number, + signatureEvidence?: MzWindowsHeaderSignatureEvidence, + declarations?: DosMzDeclarations, ): MzWindowsHeaderObservation => { if (bytes.length < 64) return fileSize >= 64 ? { state: "header-needed" } : { state: "absent" }; - const headerBytes = bytes.readUInt16LE(8) * 16; - const relocationCount = bytes.readUInt16LE(6); - const tableStart = bytes.readUInt16LE(24); - const tableEnd = tableStart + relocationCount * 4; + const { + headerBytes, + relocationCount, + relocationTableOffset: tableStart, + relocationTableEnd: tableEnd, + } = declarations ?? fixedDosMzDeclarations(bytes); if ( headerBytes !== 0 && relocationCount > 0 && @@ -159,22 +203,32 @@ const windowsHeaderSignature = ( export const parseDosMzHeader = ( bytes: Buffer, fileSize = bytes.length, +): Result => + parseDeclaredDosMzHeader(bytes, fileSize, readDosMzDeclarations(bytes)); + +const parseDeclaredDosMzHeader = ( + bytes: Buffer, + fileSize: number, + declarations: Result, ): Result => { - if (bytes.length < 28 || bytes.toString("latin1", 0, 2) !== "MZ") - return err("invalid or truncated DOS MZ header"); + if (!declarations.ok) return declarations; if (!Number.isSafeInteger(fileSize) || fileSize < bytes.length) return err("invalid DOS MZ file length"); // DOS structure is parsed independently from the meaning of bytes at 0x3c. - const extent = dosMzImageByteLength(bytes); + const extent = dosMzDeclaredImageByteLength(declarations.value); if (!extent.ok) return extent; const imageBytes = extent.value; - const headerBytes = bytes.readUInt16LE(8) * 16; + const { + headerBytes, + relocationCount, + relocationTableOffset, + relocationTableEnd: tableEnd, + entryOffset, + entrySegment, + } = declarations.value; if (headerBytes < 28 || headerBytes >= imageBytes) return err("invalid DOS MZ header size or empty load module"); if (imageBytes > fileSize) return err("truncated DOS MZ load module"); - const relocationCount = bytes.readUInt16LE(6); - const relocationTableOffset = bytes.readUInt16LE(24); - const tableEnd = relocationTableOffset + relocationCount * 4; if ( relocationCount > 0 && (relocationTableOffset < 28 || tableEnd > headerBytes) @@ -191,8 +245,6 @@ export const parseDosMzHeader = ( moduleBytes, ); if (!relocations.ok) return relocations; - const entryOffset = bytes.readUInt16LE(20); - const entrySegment = bytes.readUInt16LE(22); if (entrySegment * 16 + entryOffset >= moduleBytes) return err("DOS MZ entry point lies outside its initialized load module"); return ok({ @@ -210,6 +262,12 @@ export const parseDosMzHeader = ( /** Preserve DOS structural facts and Windows-pointer evidence independently. */ export interface MzHeaderEvidence { + readonly declarations: Result; + /** A source-bounded prefix read, independent of later DOS module validity. */ + readonly relocationTableRead?: { + readonly offset: 0; + readonly byteLength: number; + }; readonly dos: Result; readonly windowsHeader: MzWindowsHeaderObservation; } @@ -218,9 +276,64 @@ export const parseMzHeaderEvidence = ( bytes: Buffer, fileSize = bytes.length, signatureEvidence?: MzWindowsHeaderSignatureEvidence, +): MzHeaderEvidence => + parseDeclaredMzHeaderEvidence( + bytes, + fileSize, + readDosMzDeclarations(bytes), + signatureEvidence, + ); + +/** Complete evidence using fixed declarations from the same captured source. */ +export const parseDeclaredMzHeaderEvidence = ( + bytes: Buffer, + fileSize: number, + declarations: Result, + signatureEvidence?: MzWindowsHeaderSignatureEvidence, +): MzHeaderEvidence => { + const fields = declarations.ok ? declarations.value : undefined; + const needed = + fields !== undefined && + Number.isSafeInteger(fileSize) && + fileSize >= bytes.length && + fields.relocationCount > 0 && + fields.relocationTableEnd > bytes.length && + fields.relocationTableEnd <= fields.headerBytes && + fields.relocationTableEnd <= fileSize; + return { + declarations, + ...(needed + ? { + relocationTableRead: { + offset: 0 as const, + byteLength: fields.relocationTableEnd, + }, + } + : {}), + dos: parseDeclaredDosMzHeader(bytes, fileSize, declarations), + windowsHeader: observeWindowsHeader( + bytes, + fileSize, + signatureEvidence, + fields, + ), + }; +}; + +/** Add a signature observation without reinterpreting the captured DOS structure. */ +export const withMzWindowsHeaderSignature = ( + bytes: Buffer, + fileSize: number, + evidence: MzHeaderEvidence, + signature: MzWindowsHeaderSignatureEvidence, ): MzHeaderEvidence => ({ - dos: parseDosMzHeader(bytes, fileSize), - windowsHeader: observeMzWindowsHeader(bytes, fileSize, signatureEvidence), + ...evidence, + windowsHeader: observeWindowsHeader( + bytes, + fileSize, + signature, + evidence.declarations.ok ? evidence.declarations.value : undefined, + ), }); const readRelocations = ( diff --git a/src/ghidra/GhidraDosSnapshot.test.ts b/src/ghidra/GhidraDosSnapshot.test.ts index 137b5000ff..a944468275 100644 --- a/src/ghidra/GhidraDosSnapshot.test.ts +++ b/src/ghidra/GhidraDosSnapshot.test.ts @@ -189,3 +189,33 @@ it("preserves exact cancellation after an overlay descriptor read", async () => await handle.close(); } }); + +it.each([ + [0, "invalid DOS MZ page count or final-page size"], + [4, "truncated DOS MZ load module"], +] as const)( + "preserves early extent failure before Windows inspection (pages=%s)", + async (pages, error) => { + const fixture = fixtureDosLoadImage(Buffer.alloc(1024)); + fixture.bytes.writeUInt16LE(pages, 4); + fixture.bytes.writeUInt32LE(512, 60); + fixture.bytes.write("NE", 512, "ascii"); + const directory = await createTestTempDirectory("rea-mz-extent-"); + const path = join(directory, "inert.exe"); + await writeFile(path, fixture.bytes); + const handle = await open(path, "r"); + try { + await expect( + attestGhidraDosSnapshot( + handle, + fixture.bytes.length, + path, + fixture.sha256, + fixture.observation, + ), + ).resolves.toEqual({ ok: false, error }); + } finally { + await handle.close(); + } + }, +); diff --git a/src/ghidra/GhidraDosSnapshot.ts b/src/ghidra/GhidraDosSnapshot.ts index 2c4af0211d..3188de5864 100644 --- a/src/ghidra/GhidraDosSnapshot.ts +++ b/src/ghidra/GhidraDosSnapshot.ts @@ -3,8 +3,10 @@ import type { FileHandle } from "node:fs/promises"; import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { - dosMzImageByteLength, - observeMzWindowsHeader, + dosMzDeclaredImageByteLength, + readDosMzDeclarations, + parseDeclaredMzHeaderEvidence, + withMzWindowsHeaderSignature, } from "../domain/dosMz.js"; import { err } from "../domain/result.js"; import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js"; @@ -37,7 +39,9 @@ export const attestGhidraDosSnapshot = async ( }); if (header === undefined || header.length !== headerBytes) throw new RegularFileChangedError(path); - const extent = dosMzImageByteLength(header); + const declarations = readDosMzDeclarations(header); + if (!declarations.ok) return declarations; + const extent = dosMzDeclaredImageByteLength(declarations.value); 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. @@ -57,7 +61,8 @@ export const attestGhidraDosSnapshot = async ( } } if (captured !== captureBytes) throw new RegularFileChangedError(path); - const windowsHeader = observeMzWindowsHeader(prefix, fileBytes); + let evidence = parseDeclaredMzHeaderEvidence(prefix, fileBytes, declarations); + const { windowsHeader } = evidence; let signature: | { readonly offset: number; readonly bytes: Buffer } | undefined; @@ -81,7 +86,14 @@ export const attestGhidraDosSnapshot = async ( bytes: signatureBytes, }; } - const prepared = prepareGhidraDosLoadImage(prefix, fileBytes, signature); + if (signature !== undefined) + evidence = withMzWindowsHeaderSignature( + prefix, + fileBytes, + evidence, + signature, + ); + const prepared = prepareGhidraDosLoadImage(prefix, evidence); if (!prepared.ok) return prepared; const originalHash = createHash("sha256").update(prefix); const modifiedHash = createHash("sha256") diff --git a/src/ghidra/GhidraLoadImage.fixture.ts b/src/ghidra/GhidraLoadImage.fixture.ts index 5610a340c5..1d665473b4 100644 --- a/src/ghidra/GhidraLoadImage.fixture.ts +++ b/src/ghidra/GhidraLoadImage.fixture.ts @@ -1,3 +1,4 @@ +import { parseMzHeaderEvidence } from "../domain/dosMz.js"; import { createHash } from "node:crypto"; import { dosMz } from "../domain/binaryTarget.fixture.js"; import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js"; @@ -21,7 +22,7 @@ export const compareDosFixture = ( return err( "Ghidra load-image snapshot digest does not match the admitted target", ); - const image = prepareGhidraDosLoadImage(bytes, bytes.length); + const image = prepareGhidraDosLoadImage(bytes, parseMzHeaderEvidence(bytes)); if (!image.ok) return image; return attestGhidraDosLoadImage( { diff --git a/src/ghidra/GhidraLoadImageValues.test.ts b/src/ghidra/GhidraLoadImageValues.test.ts index 86f65cf5b7..f34cfca3c9 100644 --- a/src/ghidra/GhidraLoadImageValues.test.ts +++ b/src/ghidra/GhidraLoadImageValues.test.ts @@ -1,3 +1,4 @@ +import { parseMzHeaderEvidence } from "../domain/dosMz.js"; import { describe, expect, it } from "vitest"; import { createHash } from "node:crypto"; import { @@ -22,7 +23,9 @@ it("accepts DOS header padding after a complete relocation table", () => { bytes.writeUInt16LE(4, 8); bytes.writeUInt16LE(28, 24); bytes.write("ABCD", 60, "ascii"); - expect(prepareGhidraDosLoadImage(bytes, bytes.length)).toMatchObject({ + expect( + prepareGhidraDosLoadImage(bytes, parseMzHeaderEvidence(bytes)), + ).toMatchObject({ ok: true, value: { header: { @@ -38,20 +41,25 @@ it("accepts DOS header padding after a complete relocation table", () => { }); }); -it("does not interpret an unresolved 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(0, 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", - }); -}); +it.each([0, 1])( + "prioritizes an unresolved Windows-header candidate over DOS page validity (pages=%s)", + (pages) => { + const bytes = Buffer.alloc(128); + bytes.write("MZ", 0, "ascii"); + bytes.writeUInt16LE(128, 2); + bytes.writeUInt16LE(pages, 4); + bytes.writeUInt16LE(0, 6); + bytes.writeUInt16LE(4, 8); + bytes.writeUInt16LE(28, 24); + bytes.write("ABCD", 60, "ascii"); + expect( + prepareGhidraDosLoadImage(bytes, parseMzHeaderEvidence(bytes)), + ).toEqual({ + ok: false, + error: "MZ Windows new-header candidate is unresolved", + }); + }, +); const damage: ReadonlyArray< readonly [string, (observation: NativeLoadImageObservation) => void] @@ -505,7 +513,10 @@ it("applies duplicate aliases and overlapping DOS fixups in declared order with bytes.writeUInt16LE(0, 42); bytes.set([1, 0, 2], 80); const before = Buffer.from(bytes); - const prepared = prepareGhidraDosLoadImage(bytes, bytes.length); + const prepared = prepareGhidraDosLoadImage( + bytes, + parseMzHeaderEvidence(bytes), + ); if (!prepared.ok) throw new Error(prepared.error); expect(bytes.equals(before)).toBe(true); expect( diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index bd345a24ba..b110770a1a 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -5,11 +5,7 @@ import { type NativeLoadImageCheck, type NativeLoadImageObservation, } from "../domain/native/nativeLoadImage.js"; -import { - parseMzHeaderEvidence, - type DosMzHeader, - type MzWindowsHeaderSignatureEvidence, -} from "../domain/dosMz.js"; +import type { DosMzHeader, MzHeaderEvidence } from "../domain/dosMz.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { validateDosComLength } from "../domain/dosCom.js"; @@ -58,10 +54,8 @@ export type GhidraDosImage = { /** Prepare initialized bytes and relocation facts without copying overlays. */ export const prepareGhidraDosLoadImage = ( bytes: Buffer, - fileBytes: number, - signatureAtOffset?: MzWindowsHeaderSignatureEvidence, + evidence: MzHeaderEvidence, ): Result => { - 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") diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index efafa4adc9..ae49790bca 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { open, + type FileHandle, chmod, mkdir, readFile, @@ -12,7 +13,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { buildBinary } from "plist"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { @@ -1418,3 +1419,62 @@ describe("binary target I/O: PE CodeView header ranges", () => { }, ); }); + +it("does not read a relocation table offset when DOS declares zero records", async () => { + const directory = await createTestTempDirectory("rea-empty-dos-relocations-"); + const path = join(directory, "legacy.exe"); + const bytes = dosMz(8192); + bytes.writeUInt16LE(257, 8); + bytes.writeUInt16LE(4104, 24); + bytes.writeUInt32LE(0, 60); + await writeFile(path, bytes); + const probe = await open(path, "r"); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + const reads = vi.spyOn(prototype, "read"); + try { + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: true, + value: { format: "dos-mz", architecture: "x86" }, + }); + const lengths = reads.mock.calls.map( + (args) => (args as readonly unknown[])[2], + ); + expect(lengths).toContain(4096); + expect(lengths).not.toContain(4104); + } finally { + reads.mockRestore(); + } +}); + +it("recognizes Windows metadata independently of invalid DOS load declarations", async () => { + const directory = await createTestTempDirectory("rea-invalid-dos-valid-pe-"); + const path = join(directory, "windows.exe"); + const bytes = pe(0x8664, 8192); + bytes.writeUInt16LE(0, 4); + bytes.writeUInt16LE(1010, 6); + bytes.writeUInt16LE(257, 8); + bytes.writeUInt16LE(64, 24); + await writeFile(path, bytes); + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: true, + value: { format: "pe", architecture: "x86_64" }, + }); +}); + +it("refuses a declared DOS relocation header that is not present in the file", async () => { + const directory = await createTestTempDirectory( + "rea-truncated-dos-relocations-", + ); + const path = join(directory, "legacy.exe"); + const bytes = dosMz(8192); + bytes.writeUInt16LE(1010, 6); + bytes.writeUInt16LE(257, 8); + bytes.writeUInt16LE(64, 24); + bytes.writeUInt32LE(0, 60); + await writeFile(path, bytes.subarray(0, 4096)); + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: false, + error: { reason: "truncated DOS MZ load module" }, + }); +});