diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 6c9d99475c..b1573eb9be 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -128,6 +128,7 @@ export const SCOPES = { "tests/boundary/process/releaseArtifactCanary.test.ts", "src/windows/**", "src/domain/windowsNativeArtifact*.ts", + "src/domain/dosMz*.ts", "src/domain/peCoffHeader*.ts", "src/domain/result.ts", "scripts/lib/verifier-source-loader.mjs", diff --git a/src/application/MipsElfMetadata.ts b/src/application/MipsElfMetadata.ts index a4c2b2a0d9..6fde331d35 100644 --- a/src/application/MipsElfMetadata.ts +++ b/src/application/MipsElfMetadata.ts @@ -1,3 +1,4 @@ +import { readElfSectionHeader } from "../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; import { readElfRawTableDeclarations } from "../domain/elfRawTableDeclarations.js"; import type { FileHandle } from "node:fs/promises"; @@ -158,27 +159,34 @@ export const readMipsElfMetadata = async ( for (let i = 0; i < count; i += 1) { checkCancelled(); const position = i * table.entrySize; - const parsedProgram = !table.optional - ? readElfProgramHeader( + const parsedRecord = table.optional + ? readElfSectionHeader( contents.value.subarray(position, position + table.entrySize), 32, little, ) - : undefined; - if (parsedProgram !== undefined && !parsedProgram.ok) - return err(parsedProgram.error.message); - const program = parsedProgram?.value; - const type = program?.type ?? u32(contents.value, position + 4); + : readElfProgramHeader( + contents.value.subarray(position, position + table.entrySize), + 32, + little, + ); + if (!parsedRecord.ok) return err(parsedRecord.error.message); + const declaration = parsedRecord.value; + const { type } = declaration; + const declaredOffset = Number(declaration.offset); + const declaredSize = Number( + "fileSize" in declaration ? declaration.fileSize : declaration.size, + ); if (table.optional && type === SHT_GNU_ATTRIBUTES) { if (gnuAttributes !== null) return err("duplicate MIPS GNU attribute sections"); - const size = u32(contents.value, position + 20); + const size = declaredSize; if (size === 0 || size > MIPS_GNU_ATTRIBUTES_MAX_BYTES) return err( `MIPS GNU attribute section size ${size} is outside REA's inspected bound of ${MIPS_GNU_ATTRIBUTES_MAX_BYTES} bytes`, ); const section = await read( - u32(contents.value, position + 16), + declaredOffset, size, "GNU attribute section", ); @@ -190,26 +198,20 @@ export const readMipsElfMetadata = async ( } if ( metadata.type === 3 && - table.optional && + "entrySize" in declaration && (type === 4 || type === 9 || type === 19) ) relocationSections.push({ type, - offset: u32(contents.value, position + 16), - size: u32(contents.value, position + 20), - entrySize: u32(contents.value, position + 36), + offset: declaredOffset, + size: declaredSize, + entrySize: Number(declaration.entrySize), }); if (type !== table.recordType) continue; if (found) return err(`duplicate MIPS ABI records in ${table.label}`); found = true; - const offset = - program !== undefined - ? Number(program.offset) - : u32(contents.value, position + 16); - const size = - program !== undefined - ? Number(program.fileSize) - : u32(contents.value, position + 20); + const offset = declaredOffset; + const size = declaredSize; if (size !== 24) return err("unsupported MIPS ABI record size; expected 24 bytes"); const candidate = await read(offset, 24, "ABI record"); diff --git a/src/artifacts/DesktopFileInspection.ts b/src/artifacts/DesktopFileInspection.ts index 7dbddd15b9..99ecce16db 100644 --- a/src/artifacts/DesktopFileInspection.ts +++ b/src/artifacts/DesktopFileInspection.ts @@ -1,7 +1,9 @@ +import { observeMzWindowsHeader } from "../domain/dosMz.js"; import { readPeCoffHeader } from "../domain/peCoffHeader.js"; import { hasGodotPckHeader, readGodotPckHeaderFacts, + readGodotPckTrailerFacts, } from "../domain/godotPckFormat.js"; import type { FileHandle } from "node:fs/promises"; import { createHash } from "node:crypto"; @@ -189,7 +191,15 @@ export const inspectDesktopFile = async ( }; if (prefix.length === 64 && prefix.readUInt16LE(0) === 0x5a4d) await observe(() => - inspectPeFile(prefix, row, read, fact, unityManagedDirectory, signal), + inspectPeFile( + prefix, + size, + row, + read, + fact, + unityManagedDirectory, + signal, + ), ); if ( (native || (prefix.length >= 2 && prefix.readUInt16LE(0) === 0x5a4d)) && @@ -203,15 +213,29 @@ type RecordDesktopFact = (observation: DesktopFact) => void; const inspectPeFile = async ( prefix: Buffer, + size: number, row: DesktopFile, read: DesktopByteRead, fact: RecordDesktopFact, unityManagedDirectory: string | null, signal?: AbortSignal, ): Promise => { - const peOffset = prefix.readUInt32LE(0x3c); + const observed = observeMzWindowsHeader(prefix, size); + if (observed.state === "candidate") { + row.issues.push("MZ Windows new-header candidate is unresolved"); + return; + } + if (observed.state !== "signature-needed" && observed.state !== "declared") + return; + const peOffset = observed.offset; const coff = await read(peOffset, 24); - if (coff.toString("latin1", 0, 4) !== "PE\0\0") return; + const confirmed = observeMzWindowsHeader(prefix, size, { + offset: peOffset, + bytes: coff, + }); + if (confirmed.state === "candidate") + row.issues.push("MZ Windows new-header candidate is unresolved"); + if (confirmed.state !== "declared" || confirmed.signature !== "PE") return; const header = readPeCoffHeader(coff.subarray(4)); if (!header.ok) { row.issues.push(header.error.message); @@ -327,14 +351,14 @@ const inspectPckTrailer = async ( fact: RecordDesktopFact, ): Promise => { const trailer = await read(size - 12, 12); - if (trailer.toString("latin1", 8, 12) === "GDPC") { - const packSize = trailer.readBigUInt64LE(0); - if (packSize < 20n || packSize > BigInt(size - 12)) + const declaration = readGodotPckTrailerFacts(trailer, size); + if (declaration.state === "declared") { + const { start: offset, packBytes } = declaration; + if (offset === null || packBytes < 20n) throw new ArtifactReaderFailure( "format", "Embedded PCK trailer declares an invalid pack extent", ); - const offset = size - 12 - Number(packSize); const bytes = await read(offset, 20); if (!hasGodotPckHeader(bytes)) throw new ArtifactReaderFailure( diff --git a/src/artifacts/GodotPckLocation.ts b/src/artifacts/GodotPckLocation.ts index cc2191b6ae..468aa44e69 100644 --- a/src/artifacts/GodotPckLocation.ts +++ b/src/artifacts/GodotPckLocation.ts @@ -1,5 +1,8 @@ import type { FileHandle } from "node:fs/promises"; -import { GODOT_PCK_MAGIC } from "../domain/godotPckFormat.js"; +import { + hasGodotPckHeader, + readGodotPckTrailerFacts, +} from "../domain/godotPckFormat.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; @@ -62,20 +65,15 @@ export const locateGodotPck = async ( prefix !== undefined && prefix.length >= 4 ? Buffer.from(prefix.buffer, prefix.byteOffset, 4) : await readPckBytes(handle, 0, 4, size, signal); - if (magic.readUInt32LE(0) === GODOT_PCK_MAGIC) - return { start: 0, end: size, embedded: false }; + if (hasGodotPckHeader(magic)) return { start: 0, end: size, embedded: false }; if (size < 12) return undefined; const trailer = await readPckBytes(handle, size - 12, 12, size, signal); - if (trailer.readUInt32LE(8) !== GODOT_PCK_MAGIC) return undefined; - const end = size - 12; - const bytes = trailer.readBigUInt64LE(0); + const declaration = readGodotPckTrailerFacts(trailer, size); // The terminal marker is only a candidate until its range resolves to a PCK header. - if (bytes < 4n || bytes > BigInt(end)) return undefined; - const start = end - Number(bytes); - if ( - (await readPckBytes(handle, start, 4, end, signal)).readUInt32LE(0) !== - GODOT_PCK_MAGIC - ) + if (declaration.state === "absent") return undefined; + const { start, end, packBytes } = declaration; + if (start === null || packBytes < 4n) return undefined; + if (!hasGodotPckHeader(await readPckBytes(handle, start, 4, end, signal))) return undefined; return { start, end, embedded: true }; }; diff --git a/src/artifacts/nodeSea/NodeSeaLocation.ts b/src/artifacts/nodeSea/NodeSeaLocation.ts index 8adc94ed36..4be4ce3638 100644 --- a/src/artifacts/nodeSea/NodeSeaLocation.ts +++ b/src/artifacts/nodeSea/NodeSeaLocation.ts @@ -1,3 +1,4 @@ +import { readElfSectionHeader } from "../../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js"; import { createHash } from "node:crypto"; @@ -402,18 +403,28 @@ const locateElfNotes = async ( if (sections === undefined) opaque = opaque || shnum > 0; else for (const entry of sections) { - if (readU32(entry, 4, little) !== SHT_NOTE) continue; - const note = elf64 - ? { - offset: readU64(entry, 24, little), - length: readU64(entry, 32, little), - align: readU64(entry, 48, little), - } - : { - offset: readU32(entry, 16, little), - length: readU32(entry, 20, little), - align: readU32(entry, 32, little), - }; + const parsedSection = readElfSectionHeader( + entry, + header.value.elfClass, + little, + ); + if (!parsedSection.ok) continue; + const section = parsedSection.value; + if (section.type !== SHT_NOTE) continue; + const note = { + offset: + section.offset <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(section.offset) + : undefined, + length: + section.size <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(section.size) + : undefined, + align: + section.alignment <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(section.alignment) + : undefined, + }; const scanned = await scanElfNoteTable( carrier, note, diff --git a/src/domain/elfDeclaredRanges.ts b/src/domain/elfDeclaredRanges.ts index 9c2a16666d..d1812419bd 100644 --- a/src/domain/elfDeclaredRanges.ts +++ b/src/domain/elfDeclaredRanges.ts @@ -1,3 +1,4 @@ +import { readElfSectionHeader } from "./elfSectionHeader.js"; import { readElfProgramHeader } from "./elfProgramHeader.js"; import { readElfRawTableDeclarations } from "./elfRawTableDeclarations.js"; import { readElfFixedHeader, type ElfFixedHeader } from "./elfFixedHeader.js"; @@ -21,19 +22,6 @@ export interface ElfTableDeclarations { readonly sections: ElfDeclaredTable & { readonly namesIndex: number }; } -const reader = (bytes: Buffer, header: ElfFixedHeader) => { - const little = header.byteOrder === "little"; - const u32 = (at: number) => - little ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at); - const word = (at: number) => - header.elfClass === 32 - ? BigInt(u32(at)) - : little - ? bytes.readBigUInt64LE(at) - : bytes.readBigUInt64BE(at); - return { u32, word }; -}; - /** Zero-sized/inactive declarations own no file bytes. Arithmetic stays exact. */ const rangeFailure = ( label: string, @@ -86,24 +74,30 @@ const extendedCounts = ( const wide = header.elfClass === 64; if (first.length !== (wide ? 64 : 40)) return err("truncated ELF extended section-zero header"); - const zero = reader(first, header); - if (zero.u32(4) !== 0) + const parsed = readElfSectionHeader( + first, + header.elfClass, + header.byteOrder === "little", + ); + if (!parsed.ok) return err(parsed.error.message); + const zero = parsed.value; + if (zero.type !== 0) return err( "ELF extended numbering requires an inactive SHT_NULL section zero", ); const counts = { ...initial }; if (counts.sectionCount === 0n) { - counts.sectionCount = zero.word(wide ? 32 : 20); + counts.sectionCount = zero.size; if (counts.sectionCount < 0xff00n) return err("invalid ELF extended section count"); } if (counts.namesIndex === 0xffff) { - counts.namesIndex = zero.u32(wide ? 40 : 24); + counts.namesIndex = zero.link; if (counts.namesIndex < 0xff00) return err("invalid ELF extended section-name index"); } if (counts.programCount === 0xffffn) { - counts.programCount = BigInt(zero.u32(wide ? 44 : 28)); + counts.programCount = BigInt(zero.info); if (counts.programCount < 0xffffn) return err("invalid ELF extended program count"); } @@ -196,7 +190,6 @@ function* inspectTableRanges( header: ElfFixedHeader, fileSize: number, ): Generator, Buffer> { - const wide = header.elfClass === 64; const perRead = Math.max(1, Math.floor((64 * 1024) / table.width)); for (let index = 0; index < table.count; index += perRead) { const count = Math.min(perRead, table.count - index); @@ -206,24 +199,26 @@ function* inspectTableRanges( size, }; if (bytes.length !== size) return err(`truncated ELF ${kind} header table`); - const r = reader(bytes, header); for (let item = 0; item < count; item++) { const at = item * table.width; - const program = + const parsed = kind === "program" ? readElfProgramHeader( bytes.subarray(at, at + table.width), header.elfClass, header.byteOrder === "little", ) - : undefined; - if (program !== undefined && !program.ok) - return err(program.error.message); - const facts = program?.value; - const type = facts?.type ?? r.u32(at + 4); - if (type === 0 || (kind === "section" && type === 8)) continue; - const offset = facts?.offset ?? r.word(at + (wide ? 24 : 16)); - const length = facts?.fileSize ?? r.word(at + (wide ? 32 : 20)); + : readElfSectionHeader( + bytes.subarray(at, at + table.width), + header.elfClass, + header.byteOrder === "little", + ); + if (!parsed.ok) return err(parsed.error.message); + const facts = parsed.value; + if (facts.type === 0 || (kind === "section" && facts.type === 8)) + continue; + const offset = facts.offset; + const length = "fileSize" in facts ? facts.fileSize : facts.size; const failure = rangeFailure( `${kind} ${String(index + item)}`, offset, diff --git a/src/domain/elfSectionHeader.test.ts b/src/domain/elfSectionHeader.test.ts new file mode 100644 index 0000000000..9f65da31d6 --- /dev/null +++ b/src/domain/elfSectionHeader.test.ts @@ -0,0 +1,91 @@ +import { expect, it } from "vitest"; +import { readElfSectionHeader } from "./elfSectionHeader.js"; + +const formats = [ + { bits: 32, littleEndian: false }, + { bits: 32, littleEndian: true }, + { bits: 64, littleEndian: false }, + { bits: 64, littleEndian: true }, +] as const; + +it.each(formats)( + "preserves exact ELF$bits section and section-zero fields (littleEndian=$littleEndian)", + ({ bits, littleEndian }) => { + const wide = bits === 64; + const length = wide ? 64 : 40; + const backing = Buffer.alloc(length + 19, 0xa5); + const bytes = backing.subarray(7, 7 + length); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const values = wide + ? [ + 0xffffffffffffffffn, + 0xfedcba9876543210n, + 0x8000000000000001n, + 0x123456789abcdef0n, + 0x8888888888888888n, + 0x7777777777777777n, + ] + : [ + 0xffffffffn, + 0xfedcba98n, + 0x80000001n, + 0x12345678n, + 0x88888888n, + 0x77777777n, + ]; + view.setUint32(0, 0xfedcba98, littleEndian); + view.setUint32(4, 0x89abcdef, littleEndian); + view.setUint32(wide ? 40 : 24, 0xffffffff, littleEndian); + view.setUint32(wide ? 44 : 28, 0x80000001, littleEndian); + const offsets = wide ? [8, 16, 24, 32, 48, 56] : [8, 12, 16, 20, 32, 36]; + values.forEach((value, index) => { + if (wide) view.setBigUint64(offsets[index]!, value, littleEndian); + else view.setUint32(offsets[index]!, Number(value), littleEndian); + }); + const before = Buffer.from(backing); + const parsed = readElfSectionHeader( + backing.subarray(7), + bits, + littleEndian, + ); + expect(backing.equals(before)).toBe(true); + backing.fill(0); + expect(parsed).toEqual({ + ok: true, + value: { + nameOffset: 0xfedcba98, + type: 0x89abcdef, + flags: values[0], + address: values[1], + offset: values[2], + size: values[3], + link: 0xffffffff, + info: 0x80000001, + alignment: values[4], + entrySize: values[5], + }, + }); + }, +); + +it.each(formats)( + "refuses each incomplete ELF$bits section (littleEndian=$littleEndian)", + ({ bits, littleEndian }) => { + const length = bits === 64 ? 64 : 40; + const backing = Buffer.alloc(length + 7); + for (let observed = 0; observed < length; observed++) + expect( + readElfSectionHeader( + backing.subarray(7, 7 + observed), + bits, + littleEndian, + ), + ).toEqual({ + ok: false, + error: { + kind: "malformed", + message: "ELF section header is truncated", + }, + }); + }, +); diff --git a/src/domain/elfSectionHeader.ts b/src/domain/elfSectionHeader.ts new file mode 100644 index 0000000000..a1c09af650 --- /dev/null +++ b/src/domain/elfSectionHeader.ts @@ -0,0 +1,32 @@ +import { err, ok } from "./result.js"; + +/** Fixed ELF32/ELF64 section declarations; consumers own names, numbering and mapping admission. */ +export const readElfSectionHeader = ( + bytes: Uint8Array, + bits: 32 | 64, + littleEndian: boolean, +) => { + const wide = bits === 64; + if (bytes.byteLength < (wide ? 64 : 40)) + return err({ + kind: "malformed" as const, + message: "ELF section header is truncated", + }); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const word = (at: number): bigint => + wide + ? view.getBigUint64(at, littleEndian) + : BigInt(view.getUint32(at, littleEndian)); + return ok({ + nameOffset: view.getUint32(0, littleEndian), + type: view.getUint32(4, littleEndian), + flags: word(8), + address: word(wide ? 16 : 12), + offset: word(wide ? 24 : 16), + size: word(wide ? 32 : 20), + link: view.getUint32(wide ? 40 : 24, littleEndian), + info: view.getUint32(wide ? 44 : 28, littleEndian), + alignment: word(wide ? 48 : 32), + entrySize: word(wide ? 56 : 36), + }); +}; diff --git a/src/domain/godotPckFormat.ts b/src/domain/godotPckFormat.ts index 23224271a0..09dccebff0 100644 --- a/src/domain/godotPckFormat.ts +++ b/src/domain/godotPckFormat.ts @@ -29,3 +29,34 @@ export const readGodotPckHeaderFacts = ( enginePatch: view.getUint32(16, true), }; }; + +/** A terminal marker declares an extent; the located header still needs confirmation. */ +export type GodotPckTrailerFacts = + | { readonly state: "absent" } + | { + readonly state: "declared"; + readonly packBytes: bigint; + readonly start: number | null; + readonly end: number; + }; + +/** Decode an admitted complete 12-byte trailer and its host-sized source extent. */ +export const readGodotPckTrailerFacts = ( + trailer: Uint8Array, + fileSize: number, +): GodotPckTrailerFacts => { + if (!hasGodotPckHeader(trailer.subarray(8, 12))) return { state: "absent" }; + const view = new DataView( + trailer.buffer, + trailer.byteOffset, + trailer.byteLength, + ); + const packBytes = view.getBigUint64(0, true); + const end = fileSize - 12; + return { + state: "declared", + packBytes, + start: packBytes > BigInt(end) ? null : end - Number(packBytes), + end, + }; +}; diff --git a/src/domain/native/nativeAotPe.test.ts b/src/domain/native/nativeAotPe.test.ts index ebcdccf20c..619fab011b 100644 --- a/src/domain/native/nativeAotPe.test.ts +++ b/src/domain/native/nativeAotPe.test.ts @@ -241,7 +241,7 @@ describe("read-only NativeAOT PE parser", () => { describe("NativeAOT PE signature admission", () => { it.each([ ["MZ", "Not a PE image"], - ["PE", "Invalid PE signature"], + ["PE", "MZ header does not declare a PE image"], ])( "rejects a high-bit lookalike of the %s signature", (signature, reason) => { diff --git a/src/domain/native/nativeAotPe.ts b/src/domain/native/nativeAotPe.ts index cfc6317f21..d3073e7357 100644 --- a/src/domain/native/nativeAotPe.ts +++ b/src/domain/native/nativeAotPe.ts @@ -1,3 +1,4 @@ +import { observeMzWindowsHeader } from "../dosMz.js"; import { createHash } from "node:crypto"; import { readPeOptionalHeaderFixedFields } from "../peOptionalHeader.js"; import { readPeCoffHeader } from "../peCoffHeader.js"; @@ -16,7 +17,7 @@ export const NATIVE_AOT_MAX_WORK_UNITS = 64 * 1024 * 1024; export const NATIVE_AOT_MAX_WORKING_MEMORY_BYTES = 64 * 1024 * 1024; /** Version of the built-in read-only PE metadata interpretation in profiles. */ export const NATIVE_AOT_PROFILE_CONTRACT_REVISION = - "rtr-9.1-x64-pe-read-only-v7"; + "rtr-9.1-x64-pe-read-only-v8"; export type NativeAotParserLimits = { readonly sourceBytes: number; @@ -490,11 +491,12 @@ class PeImage { this.#bytes = bytes; if (bytes.length < 0x40 || bytes.toString("latin1", 0, 2) !== "MZ") throw new Error("Not a PE image"); - const peOffset = this.#u32(0x3c); + const observed = observeMzWindowsHeader(bytes); + if (observed.state !== "declared" || observed.signature !== "PE") + throw new Error("MZ header does not declare a PE image"); + const peOffset = observed.offset; if (peOffset < 0x40) throw new Error("PE header overlaps the DOS header"); this.#range(peOffset, 24); - if (bytes.toString("latin1", peOffset, peOffset + 4) !== "PE\0\0") - throw new Error("Invalid PE signature"); const coff = readPeCoffHeader(bytes.subarray(peOffset + 4, peOffset + 24)); if (!coff.ok) throw new Error(coff.error.message); this.#machine = coff.value.machine; @@ -661,10 +663,6 @@ class PeImage { ) throw new Error("PE field leaves the captured file"); } - #u32(offset: number): number { - this.#range(offset, 4); - return this.#bytes.readUInt32LE(offset); - } } const hex = (value: bigint): string => `0x${value.toString(16)}`; diff --git a/src/domain/windowsNativeArtifact.ts b/src/domain/windowsNativeArtifact.ts index 96b1edbf1b..0412340549 100644 --- a/src/domain/windowsNativeArtifact.ts +++ b/src/domain/windowsNativeArtifact.ts @@ -1,3 +1,4 @@ +import { observeMzWindowsHeader } from "./dosMz.js"; import { createHash } from "node:crypto"; import { readPeCoffHeader } from "./peCoffHeader.js"; import { err, ok, type Result } from "./result.js"; @@ -90,12 +91,15 @@ const validateWindowsX64Artifact = ( "Windows native artifact is not a PE image", ), ); - const offset = bytes.readUInt32LE(0x3c); - if ( - offset > bytes.length - 26 || - bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" || - bytes.readUInt16LE(offset + 24) !== 0x20b - ) + const observed = observeMzWindowsHeader(bytes); + if (observed.state !== "declared" || observed.signature !== "PE") + return err( + new WindowsNativeArtifactError( + "Windows native artifact is not a Windows x64 PE32+ image", + ), + ); + const offset = observed.offset; + if (offset > bytes.length - 26 || bytes.readUInt16LE(offset + 24) !== 0x20b) return err( new WindowsNativeArtifactError( "Windows native artifact is not a Windows x64 PE32+ image", diff --git a/src/dotnet/ManagedPeReader.ts b/src/dotnet/ManagedPeReader.ts index 821845cf77..57f1d2c541 100644 --- a/src/dotnet/ManagedPeReader.ts +++ b/src/dotnet/ManagedPeReader.ts @@ -1,3 +1,4 @@ +import { observeMzWindowsHeader } from "../domain/dosMz.js"; import { readPeOptionalHeaderFixedFields, readPeOptionalHeaderDirectory, @@ -295,15 +296,18 @@ const readCliHeader = ( }; const readCoffHeader = (bytes: Buffer): CoffHeader => { - const peOffset = bytes.readUInt32LE(0x3c); - requireRange(bytes, peOffset, 24, "pe.coff-header"); - if (bytes.readUInt32LE(peOffset) !== 0x0000_4550) + const observed = observeMzWindowsHeader(bytes); + if (observed.state === "candidate" || observed.state === "signature-needed") + requireRange(bytes, observed.offset, 24, "pe.coff-header"); + if (observed.state !== "declared" || observed.signature !== "PE") throw managedFailure( "invalid-directory", "pe.signature", - "PE signature is invalid", - peOffset, + "MZ header does not declare a PE image", + 0x3c, ); + const peOffset = observed.offset; + requireRange(bytes, peOffset, 24, "pe.coff-header"); const coff = peOffset + 4; const parsed = readPeCoffHeader(bytes.subarray(coff, coff + 20)); if (!parsed.ok) diff --git a/src/electron/ElectronFuseReader.ts b/src/electron/ElectronFuseReader.ts index aef40d00d8..b060b0ac2a 100644 --- a/src/electron/ElectronFuseReader.ts +++ b/src/electron/ElectronFuseReader.ts @@ -1,3 +1,4 @@ +import { observeMzWindowsHeader } from "../domain/dosMz.js"; import { readPeCoffHeader } from "../domain/peCoffHeader.js"; import { readElfFixedHeader } from "../domain/elfFixedHeader.js"; import { decodePeMachine } from "../domain/peMachine.js"; @@ -484,20 +485,24 @@ const identifyPe = async ( const header = await readAt(0, 64); if (header.length < 64 || header[0] !== 0x4d || header[1] !== 0x5a) return undefined; - const offset = Buffer.from( + const prefix = Buffer.from( header.buffer, header.byteOffset, header.byteLength, - ).readUInt32LE(0x3c); + ); + const observed = observeMzWindowsHeader(prefix, size); + if (observed.state !== "signature-needed" && observed.state !== "declared") + return undefined; + const offset = observed.offset; if ( - !Number.isSafeInteger(offset) || offset < 64 || offset > ELECTRON_FUSE_LIMITS.maximumPeHeaderOffset || offset > size - 4 ) return undefined; const bytes = Buffer.from(await readAt(offset, 24)); - if (bytes.length < 4 || bytes.readUInt32LE(0) !== 0x00004550) + const confirmed = observeMzWindowsHeader(prefix, size, { offset, bytes }); + if (confirmed.state !== "declared" || confirmed.signature !== "PE") return undefined; // A signature establishes the existing scan boundary; only a complete COFF // header establishes its machine. Managed OS overrides need separate proof. diff --git a/src/flutter/DartElf.test.ts b/src/flutter/DartElf.test.ts index 28e6e67f3d..23940d0889 100644 --- a/src/flutter/DartElf.test.ts +++ b/src/flutter/DartElf.test.ts @@ -566,3 +566,22 @@ it("keeps precision admission limited to consumed ELF program fields and types", failures: [{ reason: "ELF segment 0 is not file-backed" }], }); }); + +it("keeps section precision checks on consumed declarations and mapping fields", async () => { + const bytes = buildElf64Image([{ name: "selected", value: 64, size: 4 }]); + const expected = await readElfSymbols(bytes, ["selected"]); + const table = Number(bytes.readBigUInt64LE(40)); + // An unrelated null record may carry uninterpreted declaration words. + for (const relative of [8, 16, 24, 32, 48, 56]) + bytes.writeBigUInt64LE(0xffffffffffffffffn, table + relative); + // Alignment is not consumed for this symbol-table mapping. + bytes.writeBigUInt64LE(0xffffffffffffffffn, table + 64 + 48); + expect(await readElfSymbols(bytes, ["selected"])).toEqual(expected); + bytes.writeBigUInt64LE(0xffffffffffffffffn, table + 64 + 8); + expect(await readElfSymbols(bytes, ["selected"])).toEqual({ + symbols: [], + failures: [ + { reason: "Dynamic symbol table has an unrepresentable section header" }, + ], + }); +}); diff --git a/src/flutter/DartElf.ts b/src/flutter/DartElf.ts index 6665e5de36..96113e2996 100644 --- a/src/flutter/DartElf.ts +++ b/src/flutter/DartElf.ts @@ -1,3 +1,4 @@ +import { readElfSectionHeader } from "../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; import { readElfSymbol } from "../domain/elfSymbol.js"; import { readElfRawTableDeclarations } from "../domain/elfRawTableDeclarations.js"; @@ -244,15 +245,13 @@ export const readElfSymbols = async ( * offset must be the single file interpretation of its address range. */ const sectionMappingFailure = ( - header: number, + section: { readonly flags: bigint; readonly address: bigint }, offset: number, size: number, ): string | undefined => { - const flags = is64 ? reader.u64(header + 8) : reader.u32(header + 8); - const address = is64 - ? reader.u64(header + 0x10) - : reader.u32(header + 0x0c); - if (flags === null || address === null) + const flags = Number(section.flags); + const address = Number(section.address); + if (!Number.isSafeInteger(flags) || !Number.isSafeInteger(address)) return "has an unrepresentable section header"; if ((flags & SHF_ALLOC) === 0) return undefined; const mapped = toOffset(address, size); @@ -268,48 +267,53 @@ export const readElfSymbols = async ( signal?.throwIfAborted(); } const header = e_shoff + index * e_shentsize; - if (reader.u32(header + 4) !== SHT_DYNSYM) continue; - const offset = is64 - ? reader.u64(header + 0x18) - : reader.u32(header + 0x10); - const size = is64 ? reader.u64(header + 0x20) : reader.u32(header + 0x14); - const width = is64 - ? reader.u64(header + 0x38) - : reader.u32(header + 0x24); - const link = reader.u32(header + (is64 ? 0x28 : 0x18)); + const parsedSection = readElfSectionHeader( + bytes.subarray(header, header + e_shentsize), + is64 ? 64 : 32, + true, + ); + if (!parsedSection.ok) return failure(parsedSection.error.message); + const section = parsedSection.value; + if (section.type !== SHT_DYNSYM) continue; + const offset = Number(section.offset); + const size = Number(section.size); + const link = section.link; if ( - offset === null || - size === null || - width !== entrySize || + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(size) || + section.entrySize !== BigInt(entrySize) || offset > bytes.length || size > bytes.length - offset || size % entrySize !== 0 ) return failure("Dynamic symbol table is malformed"); - if (link === null || link >= e_shnum) + if (link >= e_shnum) return failure("Dynamic symbol string section link is invalid"); const stringHeader = e_shoff + link * e_shentsize; - const stringOffset = is64 - ? reader.u64(stringHeader + 0x18) - : reader.u32(stringHeader + 0x10); - const stringSize = is64 - ? reader.u64(stringHeader + 0x20) - : reader.u32(stringHeader + 0x14); + const parsedStrings = readElfSectionHeader( + bytes.subarray(stringHeader, stringHeader + e_shentsize), + is64 ? 64 : 32, + true, + ); + if (!parsedStrings.ok) return failure(parsedStrings.error.message); + const strings = parsedStrings.value; + const stringOffset = Number(strings.offset); + const stringSize = Number(strings.size); if ( - reader.u32(stringHeader + 4) !== 3 || - stringOffset === null || - stringSize === null || + strings.type !== 3 || + !Number.isSafeInteger(stringOffset) || + !Number.isSafeInteger(stringSize) || stringOffset > bytes.length || stringSize > bytes.length - stringOffset ) return failure( "Dynamic symbol strings are out of bounds or not a string table", ); - const symbolMapping = sectionMappingFailure(header, offset, size); + const symbolMapping = sectionMappingFailure(section, offset, size); if (symbolMapping !== undefined) return failure(`Dynamic symbol table ${symbolMapping}`); const stringMapping = sectionMappingFailure( - stringHeader, + strings, stringOffset, stringSize, ); diff --git a/src/flutter/ElfBuildId.ts b/src/flutter/ElfBuildId.ts index 1b882ed72e..aaef19e7aa 100644 --- a/src/flutter/ElfBuildId.ts +++ b/src/flutter/ElfBuildId.ts @@ -1,3 +1,4 @@ +import { readElfSectionHeader } from "../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; import { MAX_FLUTTER_BUILD_ID_BYTES } from "../domain/flutter/flutterBuildAnalysis.js"; import type { FileHandle } from "node:fs/promises"; @@ -98,44 +99,30 @@ export const readElfBuildIds = async ( signal?.throwIfAborted(); } const base = index * table.width; - const parsedProgram = table.program + const parsedRecord = table.program ? readElfProgramHeader( bytes.subarray(base, base + table.width), h.is64 ? 64 : 32, true, ) - : undefined; - if (parsedProgram !== undefined && !parsedProgram.ok) - return failure(parsedProgram.error.message); - const program = parsedProgram?.value; - if ( - (program?.type ?? bytes.readUInt32LE(base + 4)) !== - (table.program ? 4 : 7) - ) - continue; - const offset = - program !== undefined - ? Number(program.offset) - : h.is64 - ? Number(bytes.readBigUInt64LE(base + 0x18)) - : bytes.readUInt32LE(base + 0x10); - const size = - program !== undefined - ? Number(program.fileSize) - : h.is64 - ? Number(bytes.readBigUInt64LE(base + 0x20)) - : bytes.readUInt32LE(base + 0x14); + : readElfSectionHeader( + bytes.subarray(base, base + table.width), + h.is64 ? 64 : 32, + true, + ); + if (!parsedRecord.ok) return failure(parsedRecord.error.message); + const record = parsedRecord.value; + if (record.type !== (table.program ? 4 : 7)) continue; + const offset = Number(record.offset); + const size = Number("fileSize" in record ? record.fileSize : record.size); if ( !Number.isSafeInteger(offset) || !Number.isSafeInteger(size) || size > totalBytes - offset ) return failure("ELF note range is not file-backed"); - if (!table.program) { - const flags = h.is64 - ? bytes.readBigUInt64LE(base + 8) - : BigInt(bytes.readUInt32LE(base + 8)); - if ((flags & 0x800n) !== 0n) { + if ("size" in record) { + if ((record.flags & 0x800n) !== 0n) { if (!compressedNotes) { failures.push({ reason: "Compressed ELF note sections are unsupported", @@ -146,12 +133,7 @@ export const readElfBuildIds = async ( } } if (size === 0) continue; - const alignment = elfNoteAlignment( - program?.alignment ?? - (h.is64 - ? bytes.readBigUInt64LE(base + 48) - : bytes.readUInt32LE(base + 32)), - ); + const alignment = elfNoteAlignment(record.alignment); const key = `${offset}:${size}:${alignment}`; if (ranges.has(key)) continue; if (ranges.size === MAX_NOTE_RANGES) { diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index b5fae213c1..af738c96db 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -665,7 +665,7 @@ describe("Ghidra platform support", () => { executable_role: target.executableRole, managed: target.managed, native_aot_metadata: { - contract_revision: "rtr-9.1-x64-pe-read-only-v7", + contract_revision: "rtr-9.1-x64-pe-read-only-v8", source_bytes: 128 * 1024 * 1024, work_units: 64 * 1024 * 1024, working_memory_bytes: 64 * 1024 * 1024, diff --git a/src/native/elf/ElfNoteParser.test.ts b/src/native/elf/ElfNoteParser.test.ts index c7f16deb77..0db69cc8d7 100644 --- a/src/native/elf/ElfNoteParser.test.ts +++ b/src/native/elf/ElfNoteParser.test.ts @@ -637,3 +637,12 @@ it("does not apply note precision admission to unrelated program records", () => "ELF offset or size exceeds a safe integer.", ); }); + +it("keeps compressed-note refusal ahead of alignment precision admission", () => { + const bytes = compressedElfNoteSectionFixture(); + const sectionTable = Number(bytes.readBigUInt64LE(40)); + bytes.writeBigUInt64LE(0xffffffffffffffffn, sectionTable + 64 + 48); + expect(() => parse(bytes)).toThrow( + "Compressed ELF note sections are unsupported", + ); +}); diff --git a/src/native/elf/ElfNoteParser.ts b/src/native/elf/ElfNoteParser.ts index 3dd994fd3a..1834dac569 100644 --- a/src/native/elf/ElfNoteParser.ts +++ b/src/native/elf/ElfNoteParser.ts @@ -1,3 +1,4 @@ +import { readElfSectionHeader } from "../../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js"; import { createHash } from "node:crypto"; @@ -97,7 +98,6 @@ class ElfReader { constructor( readonly bytes: Buffer, readonly little: boolean, - readonly bits: 32 | 64, ) {} u32(offset: number): number { @@ -107,22 +107,6 @@ class ElfReader { : this.bytes.readUInt32BE(offset); } - wordBits(offset: number): bigint { - const width = this.bits === 64 ? 8 : 4; - this.require(offset, width); - if (this.bits === 32) return BigInt(this.u32(offset)); - return this.little - ? this.bytes.readBigUInt64LE(offset) - : this.bytes.readBigUInt64BE(offset); - } - - word(offset: number): number { - const value = this.wordBits(offset); - if (value > BigInt(Number.MAX_SAFE_INTEGER)) - fail("ELF offset or size exceeds a safe integer."); - return Number(value); - } - require(offset: number, length: number): void { if ( !Number.isSafeInteger(offset) || @@ -316,11 +300,7 @@ export const parseElfNotes = ( const fixed = readElfFixedHeader(bytes); if (!fixed.ok) return fail(fixed.error); const header = fixed.value; - const reader = new ElfReader( - bytes, - header.byteOrder === "little", - header.elfClass, - ); + const reader = new ElfReader(bytes, header.byteOrder === "little"); const wide = header.elfClass === 64; const type = header.type; const raw = readElfRawTableDeclarations(bytes, header); @@ -372,12 +352,25 @@ export const parseElfNotes = ( if (shstrndx >= shnum) return fail("ELF section name index is out of range."); const namesHeader = shoff + shstrndx * shentsize; - if (reader.u32(namesHeader + 4) !== SHT_STRTAB) + const parsedNames = readElfSectionHeader( + bytes.subarray(namesHeader, namesHeader + shentsize), + header.elfClass, + reader.little, + ); + if (!parsedNames.ok) return fail(parsedNames.error.message); + const namesSection = parsedNames.value; + if (namesSection.type !== SHT_STRTAB) return fail("ELF section name table is not a string table."); - const strOff = reader.word(namesHeader + (wide ? 24 : 16)); - const strSize = reader.word(namesHeader + (wide ? 32 : 20)); - const rawNames = reader.slice(strOff, strSize); - const flags = reader.wordBits(namesHeader + 8); + if ( + namesSection.offset > BigInt(Number.MAX_SAFE_INTEGER) || + namesSection.size > BigInt(Number.MAX_SAFE_INTEGER) + ) + return fail("ELF offset or size exceeds a safe integer."); + const rawNames = reader.slice( + Number(namesSection.offset), + Number(namesSection.size), + ); + const flags = namesSection.flags; const decoded = decodeElfSectionNameTable( rawNames, flags, @@ -454,17 +447,31 @@ export const parseElfNotes = ( return fail("ELF section header table overlaps the native header."); for (let index = 0; index < shnum; index += 1) { const at = shoff + index * shentsize; - if (reader.u32(at + 4) !== SHT_NOTE) continue; - const nameOffset = reader.u32(at); - const fileOffset = reader.word(at + (wide ? 24 : 16)); - const fileSize = reader.word(at + (wide ? 32 : 20)); + const parsedSection = readElfSectionHeader( + bytes.subarray(at, at + shentsize), + header.elfClass, + reader.little, + ); + if (!parsedSection.ok) return fail(parsedSection.error.message); + const section = parsedSection.value; + if (section.type !== SHT_NOTE) continue; + const nameOffset = section.nameOffset; + if ( + section.offset > BigInt(Number.MAX_SAFE_INTEGER) || + section.size > BigInt(Number.MAX_SAFE_INTEGER) + ) + return fail("ELF offset or size exceeds a safe integer."); + const fileOffset = Number(section.offset); + const fileSize = Number(section.size); reader.require(fileOffset, fileSize); - if ((reader.wordBits(at + 8) & SHF_COMPRESSED) !== 0n) + if ((section.flags & SHF_COMPRESSED) !== 0n) throw new BinaryFormatFailure( "unsupported", "Compressed ELF note sections are unsupported; descriptor file offsets require uncompressed note storage.", ); - const alignment = noteAlignment(reader.word(at + (wide ? 48 : 32))); + if (section.alignment > BigInt(Number.MAX_SAFE_INTEGER)) + return fail("ELF offset or size exceeds a safe integer."); + const alignment = noteAlignment(Number(section.alignment)); const sectionName = shstrtab === null ? { name: null, limitation: null } diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts index 4aac10b5a3..5bb75c67d6 100644 --- a/src/native/metadata/NativeSectionDirectory.ts +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -1,3 +1,4 @@ +import { readElfSectionHeader } from "../../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readPeCoffHeader } from "../../domain/peCoffHeader.js"; import { readPeSectionHeader } from "../../domain/peSectionHeader.js"; @@ -102,7 +103,6 @@ export function* readElfSectionDirectory( const { header, programs, sections: sectionTable } = parsed.value; const { elfClass: bits, byteOrder, machine } = header; const little = byteOrder === "little"; - const wide = bits === 64; const { offset: program_offset, width: program_width, @@ -153,27 +153,31 @@ export function* readElfSectionDirectory( const sections: NativeSection[] = []; let section_names_limitation: string | undefined; if (count !== 0) { - const table = new BinaryReader( - yield request( - fileSize, - sectionOffset, - BigInt(count) * BigInt(sectionWidth), - ), - little, + const table = yield request( + fileSize, + sectionOffset, + BigInt(count) * BigInt(sectionWidth), ); let names: Buffer | null = null; if (namesIndex !== 0) { const h = namesIndex * sectionWidth; - if (table.u32(h + 4) !== 3) + const parsedNames = readElfSectionHeader( + table.subarray(h, h + sectionWidth), + bits, + little, + ); + if (!parsedNames.ok) return reject(parsedNames.error.message); + const namesSection = parsedNames.value; + if (namesSection.type !== 3) reject("ELF section name table is not a string table"); const rawNames = yield request( fileSize, - table.word(h + (wide ? 24 : 16), bits), - table.word(h + (wide ? 32 : 20), bits), + namesSection.offset, + namesSection.size, ); const decoded = decodeElfSectionNameTable( rawNames, - table.word(h + 8, bits), + namesSection.flags, bits, little, Math.max(0, STRUCTURE_BYTES - rawNames.length), @@ -199,7 +203,14 @@ export function* readElfSectionDirectory( let nameBytes = 0; for (let index = 0; index < count; index++) { const h = index * sectionWidth; - const nameOffset = table.u32(h); + const parsedSection = readElfSectionHeader( + table.subarray(h, h + sectionWidth), + bits, + little, + ); + if (!parsedSection.ok) return reject(parsedSection.error.message); + const section = parsedSection.value; + const nameOffset = section.nameOffset; let name: string | null = null; let name_state: NativeSection["name_state"] = "unavailable"; if (names !== null) { @@ -234,9 +245,7 @@ export function* readElfSectionDirectory( name = decoded.name; } } - const type = table.u32(h + 4); - const offset = table.word(h + (wide ? 24 : 16), bits); - const size = table.word(h + (wide ? 32 : 20), bits); + const { type, offset, size } = section; sections.push({ index, name, @@ -244,12 +253,12 @@ export function* readElfSectionDirectory( header_offset: exact(sectionOffset) + h, offset, size, - address: table.word(h + (wide ? 16 : 12), bits), + address: section.address, type, - flags: table.word(h + 8, bits), - link: table.u32(h + (wide ? 40 : 24)), - info: table.u32(h + (wide ? 44 : 28)), - entry_size: table.word(h + (wide ? 56 : 36), bits), + flags: section.flags, + link: section.link, + info: section.info, + entry_size: section.entrySize, file_backed: type !== 8 && declaredFileRange(fileSize, offset, size).file_backed, }); diff --git a/src/native/pe/PeResourceLayout.ts b/src/native/pe/PeResourceLayout.ts index a04b679ad3..269a126916 100644 --- a/src/native/pe/PeResourceLayout.ts +++ b/src/native/pe/PeResourceLayout.ts @@ -1,3 +1,4 @@ +import { observeMzWindowsHeader } from "../../domain/dosMz.js"; import { readPeCoffHeader } from "../../domain/peCoffHeader.js"; import { readPeSectionHeader } from "../../domain/peSectionHeader.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; @@ -82,9 +83,15 @@ const intersectsResource = ( export const readPeHeaders = (bytes: Buffer, fileSize = bytes.length) => { requirePeRange(bytes, 0, 64); if (bytes.readUInt16LE(0) !== 0x5a4d) peFailure("Expected an MZ PE image."); - const pe = bytes.readUInt32LE(60); + const observed = observeMzWindowsHeader(bytes, fileSize); + if (observed.state === "candidate" || observed.state === "signature-needed") + requirePeRange(bytes, observed.offset, 24); + if (observed.state !== "declared" || observed.signature !== "PE") + return peFailure( + "MZ header does not declare a PE image in the inspected bytes.", + ); + const pe = observed.offset; requirePeRange(bytes, pe, 24); - if (bytes.readUInt32LE(pe) !== 0x4550) peFailure("Expected a PE signature."); const coff = readPeCoffHeader(bytes.subarray(pe + 4, pe + 24)); if (!coff.ok) return peFailure(coff.error.message); const { diff --git a/src/native/pwntools/RecordedCrashStructureBindings.test.ts b/src/native/pwntools/RecordedCrashStructureBindings.test.ts index dd7665278a..9f576a20c8 100644 --- a/src/native/pwntools/RecordedCrashStructureBindings.test.ts +++ b/src/native/pwntools/RecordedCrashStructureBindings.test.ts @@ -61,3 +61,22 @@ it("keeps snapshot rebinding independent of unused ELF section and header-size a validateRecordedCrashStructure(report, bytes, report.decoder_diagnostics), ).not.toThrow(); }); + +it("binds PN_XNUM through raw section-zero info without full ELF numbering admission", () => { + const report = recordedCrashSchema.parse(recordedCrashFixture()); + const original = recordedCrashFixtureBytes(report); + const bytes = Buffer.concat([original, Buffer.alloc(64)]); + bytes.writeUInt16LE(0xffff, 56); + bytes.writeBigUInt64LE(BigInt(original.length), 40); + bytes.writeUInt16LE(64, 58); + // Rebinding only needs the reported count; full ELF admission separately requires SHT_NULL and reserved thresholds. + bytes.writeUInt32LE(1, original.length + 4); + bytes.writeUInt32LE(report.segments.length, original.length + 44); + expect(() => + validateRecordedCrashStructure(report, bytes, report.decoder_diagnostics), + ).not.toThrow(); + bytes.writeUInt32LE(report.segments.length + 1, original.length + 44); + expect(() => + validateRecordedCrashStructure(report, bytes, report.decoder_diagnostics), + ).toThrow(/original ELF\/note headers/); +}); diff --git a/src/native/pwntools/RecordedCrashStructureBindings.ts b/src/native/pwntools/RecordedCrashStructureBindings.ts index 524fa9c591..1edae6c9c6 100644 --- a/src/native/pwntools/RecordedCrashStructureBindings.ts +++ b/src/native/pwntools/RecordedCrashStructureBindings.ts @@ -1,3 +1,4 @@ +import { readElfSectionHeader } from "../../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; @@ -117,7 +118,9 @@ export function validateRecordedCrashStructure( let count = programs.count; if (count === 0xffff) { if (sections.width < 64) return fail(); - count = bytesAt(sections.offset, 64).readUInt32LE(44); + const zero = readElfSectionHeader(bytesAt(sections.offset, 64), 64, true); + if (!zero.ok) return fail(); + count = zero.value.info; } if (count !== report.segments.length || (count > 0 && entryBytes < 56)) fail(); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 2206324447..4df72101d3 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -743,6 +743,7 @@ it.each([ { code: true, runtime: true, package: true }, ], ["src/domain/peCoffHeader.ts", { code: true, runtime: true, package: true }], + ["src/domain/dosMz.ts", { code: true, runtime: true, package: true }], [ "scripts/lib/verifier-source-loader.mjs", { code: true, runtime: true, package: true }, diff --git a/tests/boundary/filesystem/desktopApplication.test.ts b/tests/boundary/filesystem/desktopApplication.test.ts index 59e9c56708..476ff3e4f1 100644 --- a/tests/boundary/filesystem/desktopApplication.test.ts +++ b/tests/boundary/filesystem/desktopApplication.test.ts @@ -29,6 +29,7 @@ import { fatImage, machoImage, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; +import { locateGodotPck } from "../../../src/artifacts/GodotPckLocation.js"; import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; import { DesktopApplicationReader } from "../../../src/artifacts/DesktopApplicationReader.js"; @@ -216,6 +217,55 @@ it("reads sparse embedded PCK ranges without hashing or retaining a large execut }); }); +it.each([4, 19, 20])( + "keeps PCK location separate from desktop header admission for %i-byte packs", + async (packBytes) => { + const root = await createTestTempDirectory("rea-desktop-pck-minimum-"); + const path = join(root, "game.exe"); + const carrier = Buffer.alloc(64); + carrier.set([0x7f, 0x45, 0x4c, 0x46]); + const pack = Buffer.alloc(packBytes); + pack.write("GDPC"); + const trailer = Buffer.alloc(12); + trailer.writeBigUInt64LE(BigInt(packBytes)); + trailer.write("GDPC", 8); + const bytes = Buffer.concat([carrier, pack, trailer]); + await writeFile(path, bytes); + const handle = await open(path, "r"); + try { + expect(await locateGodotPck(handle, bytes.length)).toEqual({ + start: carrier.length, + end: carrier.length + packBytes, + embedded: true, + }); + } finally { + await handle.close(); + } + const { report } = await inspect(root); + const file = report.files.find((row) => row.path === "game.exe"); + expect(file).toBeDefined(); + if (packBytes < 20) { + expect(file?.issues).toContain( + "Embedded PCK trailer declares an invalid pack extent", + ); + expect(file?.facts.some((fact) => fact.kind === "godot-pck-header")).toBe( + false, + ); + } else { + expect(file?.issues).toEqual([]); + expect(file?.facts).toContainEqual({ + kind: "godot-pck-header", + offset: carrier.length, + value: { + pack_version: 0, + engine_version: [0, 0, 0], + placement: "embedded-trailer", + }, + }); + } + }, +); + it("does not promote filenames, arbitrary pak files or symlink targets into byte facts", async () => { const root = await createTestTempDirectory("rea-desktop-unknown-"); await put(root, "GameAssembly.dll", "x"); diff --git a/tests/boundary/process/windowsNativeArtifactVerifier.test.ts b/tests/boundary/process/windowsNativeArtifactVerifier.test.ts index b1d2744661..c49b04d08c 100644 --- a/tests/boundary/process/windowsNativeArtifactVerifier.test.ts +++ b/tests/boundary/process/windowsNativeArtifactVerifier.test.ts @@ -21,11 +21,13 @@ for (const mode of ["preinstall source", "packed source-free"]) { "scripts/lib/verifier-source-loader.mjs", "src/domain/windowsNativeArtifact.ts", "src/domain/peCoffHeader.ts", + "src/domain/dosMz.ts", "src/domain/result.ts", ] : [ "dist/domain/windowsNativeArtifact.js", "dist/domain/peCoffHeader.js", + "dist/domain/dosMz.js", "dist/domain/result.js", ]; for (const path of [ diff --git a/tests/conformance/formats/mzPeAdmission.test.ts b/tests/conformance/formats/mzPeAdmission.test.ts new file mode 100644 index 0000000000..b7e2758ac4 --- /dev/null +++ b/tests/conformance/formats/mzPeAdmission.test.ts @@ -0,0 +1,218 @@ +import { open, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { observeMzWindowsHeader } from "../../../src/domain/dosMz.js"; +import { createWindowsNativeManifest } from "../../../src/domain/windowsNativeArtifact.js"; +import { + NATIVE_AOT_MAX_REPORT_BYTES, + parseNativeAotPe, +} from "../../../src/domain/native/nativeAotPe.js"; +import { inspectDesktopFile } from "../../../src/artifacts/DesktopFileInspection.js"; +import type { DesktopFile } from "../../../src/domain/desktopApplication.js"; +import { readManagedPeLayout } from "../../../src/dotnet/ManagedPeReader.js"; +import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js"; +import { readPeHeaders } from "../../../src/native/pe/PeResourceLayout.js"; +import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js"; +import { ElectronFuseService } from "../../../src/application/electron/ElectronFuseService.js"; +import { ElectronFuseProvider } from "../../../src/electron/ElectronFuseProvider.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + nativeAotPeDigest, + nativeAotPeFixture, +} from "../../fixtures/nativeaotPe.js"; +import { + OBSIDIAN_FUSE_BYTES, + peHeaderFuseImage, +} from "../../fixtures/electron/fuseImage.js"; + +/** A valid DOS relocation owns dword60, even when those bytes point at planted PE data. */ +const relocationOwnedPointer = (bytes: Buffer): Buffer => { + bytes.writeUInt16LE(bytes.length % 512, 2); + bytes.writeUInt16LE(Math.ceil(bytes.length / 512), 4); + bytes.writeUInt16LE(1, 6); + bytes.writeUInt16LE(4, 8); + bytes.writeUInt16LE(60, 24); + expect(observeMzWindowsHeader(bytes)).toEqual({ state: "absent" }); + return bytes; +}; + +it("keeps relocation-owned DOS bytes out of managed PE layout", () => { + const bytes = relocationOwnedPointer(buildManagedPeFixture()); + expect(() => readManagedPeLayout(bytes)).toThrow(/PE/u); +}); + +it("keeps relocation-owned DOS bytes out of PE resource/certificate header admission", () => { + const bytes = relocationOwnedPointer(peResourceFixture().bytes); + expect(() => readPeHeaders(bytes)).toThrow(/PE/u); +}); + +it("keeps relocation-owned DOS bytes out of NativeAOT metadata recovery", () => { + const bytes = relocationOwnedPointer(nativeAotPeFixture()); + const result = parseNativeAotPe( + bytes, + nativeAotPeDigest(bytes), + NATIVE_AOT_MAX_REPORT_BYTES, + ); + expect(result.summary).toMatchObject({ + status: "not_applicable", + coverage: { basis: "unsupported-layout" }, + }); +}); + +it("refuses relocation-owned DOS bytes as packaged Windows native artifacts", () => { + const bytes = relocationOwnedPointer(nativeAotPeFixture()); + expect(createWindowsNativeManifest("fixture", bytes)).toMatchObject({ + ok: false, + }); +}); + +it("does not scan fuse wires in a planted PE record owned by DOS relocations", async () => { + const root = await createTestTempDirectory("rea-fuse-dos-relocations-"); + const path = join(root, "selected.exe"); + await writeFile( + path, + relocationOwnedPointer( + peHeaderFuseImage(0x8664, OBSIDIAN_FUSE_BYTES).bytes, + ), + ); + const service = new ElectronFuseService(new ElectronFuseProvider()); + try { + expect((await service.inspect({ path })).ok).toBe(false); + } finally { + await service.close(); + } +}); + +it("does not emit desktop PE facts from relocation-owned DOS bytes", async () => { + const root = await createTestTempDirectory("rea-desktop-dos-relocations-"); + const path = join(root, "selected.exe"); + const bytes = relocationOwnedPointer(buildManagedPeFixture()); + await writeFile(path, bytes); + const file = await open(path, "r"); + const row: DesktopFile = { + file_id: `dsf_${"a".repeat(64)}`, + path: "selected.exe", + local_path: path, + kind: "file", + bytes: bytes.length, + identity: null, + read_status: "examined", + ranges: [], + facts: [], + issues: [], + }; + try { + await inspectDesktopFile(file, row, { + bytes: 0, + maximum: 1024 * 1024, + retainedBytes: 0, + maximumRetainedBytes: 1024 * 1024, + }); + } finally { + await file.close(); + } + expect( + row.facts.filter( + (fact) => + fact.kind === "native-image-header" || fact.kind === "pe-cli-directory", + ), + ).toEqual([]); +}); + +const shortDosHeader = (bytes: Buffer): Buffer => { + bytes.writeUInt16LE(bytes.length % 512, 2); + bytes.writeUInt16LE(Math.ceil(bytes.length / 512), 4); + bytes.writeUInt16LE(0, 6); + bytes.writeUInt16LE(2, 8); + bytes.writeUInt16LE(28, 24); + expect(observeMzWindowsHeader(bytes)).toMatchObject({ + state: "declared", + signature: "PE", + }); + return bytes; +}; + +it("preserves recognized PE records after short DOS headers in complete-byte readers", () => { + expect(() => + readManagedPeLayout(shortDosHeader(buildManagedPeFixture())), + ).not.toThrow(); + expect(() => + readPeHeaders(shortDosHeader(peResourceFixture().bytes)), + ).not.toThrow(); + const bytes = shortDosHeader(nativeAotPeFixture()); + expect( + parseNativeAotPe( + bytes, + nativeAotPeDigest(bytes), + NATIVE_AOT_MAX_REPORT_BYTES, + ).summary.status, + ).toBe("complete"); + expect(createWindowsNativeManifest("fixture", bytes)).toMatchObject({ + ok: true, + }); +}); + +it("preserves fuse inspection after a short DOS header with a confirmed PE signature", async () => { + const root = await createTestTempDirectory("rea-fuse-short-dos-"); + const path = join(root, "selected.exe"); + await writeFile( + path, + shortDosHeader(peHeaderFuseImage(0x8664, OBSIDIAN_FUSE_BYTES).bytes), + ); + const service = new ElectronFuseService(new ElectronFuseProvider()); + try { + const inspected = await service.inspect({ path }); + expect(inspected).toMatchObject({ + ok: true, + value: { subject: { architecture: "x86_64" } }, + }); + } finally { + await service.close(); + } +}); + +it("preserves desktop PE observations after a short DOS header", async () => { + const root = await createTestTempDirectory("rea-desktop-short-dos-"); + const path = join(root, "selected.exe"); + const bytes = shortDosHeader(buildManagedPeFixture()); + await writeFile(path, bytes); + const file = await open(path, "r"); + const row: DesktopFile = { + file_id: `dsf_${"a".repeat(64)}`, + path: "selected.exe", + local_path: path, + kind: "file", + bytes: bytes.length, + identity: null, + read_status: "examined", + ranges: [], + facts: [], + issues: [], + }; + try { + await inspectDesktopFile(file, row, { + bytes: 0, + maximum: 1024 * 1024, + retainedBytes: 0, + maximumRetainedBytes: 1024 * 1024, + }); + } finally { + await file.close(); + } + expect(row.facts.map((fact) => fact.kind)).toEqual( + expect.arrayContaining(["native-image-header", "pe-cli-directory"]), + ); +}); + +it("keeps a PE signature outside an inspected prefix unresolved until its required range is read", () => { + const bytes = peResourceFixture().bytes; + const prefix = bytes.subarray(0, 64); + expect(observeMzWindowsHeader(prefix, bytes.length)).toMatchObject({ + state: "signature-needed", + offset: 128, + }); + expect(() => readPeHeaders(bytes)).not.toThrow(); + expect(() => readPeHeaders(prefix, bytes.length)).toThrow( + "PE range 128 + 24 leaves the artifact.", + ); +});