From 0b04d384cca4f22f271d60bcfe67f79cab89c8de Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:51:37 +0000 Subject: [PATCH] fix(native): refuse truncated executable headers at target admission Target admission accepted a thin Mach-O after reading only its magic and CPU type, so a 12-byte prefix opened as a valid arm64 executable and the first provider query failed as a generic execution failure. ELF headers were likewise accepted after 20 bytes, and a FAT file whose selected slice lies beyond its end was admitted. Require the complete fixed header (Mach-O 28 or 32 bytes, ELF 52 or 64), Mach-O load commands that fit in the file, and a selected FAT slice within the file, checking commitments against the file size rather than the probed prefix. Each refusal names the failed constraint. Fixes #1189 Co-Authored-By: Claude Opus 5.5 --- src/application/BinaryTargetResolver.ts | 7 ++- src/domain/binaryTarget.fixture.ts | 6 +- src/domain/binaryTarget.test.ts | 49 +++++++++++++++ src/domain/binaryTarget.ts | 63 +++++++++++++++---- .../boundary/filesystem/binaryTarget.test.ts | 26 ++++++++ .../artifactProvider.directoryErrors.test.ts | 6 +- 6 files changed, 137 insertions(+), 20 deletions(-) diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 938633e9b..24fb5358f 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -237,6 +237,9 @@ const readExecutableMetadata = async ( const prefix = Buffer.alloc(4096); const prefixRead = await handle.read(prefix, 0, prefix.length, 0); const bytes = prefix.subarray(0, prefixRead.bytesRead); + // Header commitments such as Mach-O load commands and FAT slices are + // checked against the whole file, not the probed prefix. + const fileSize = (await handle.stat()).size; if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) { const offset = mzWindowsHeaderOffset(bytes); if (offset !== null) { @@ -251,7 +254,6 @@ const readExecutableMetadata = async ( ? bytes.readUInt16LE(24) + bytes.readUInt16LE(6) * 4 : 0; const headerBytes = bytes.length >= 28 ? bytes.readUInt16LE(8) * 16 : 0; - const fileSize = (await handle.stat()).size; if ( tableEnd > bytes.length && tableEnd <= headerBytes && @@ -279,11 +281,12 @@ const readExecutableMetadata = async ( return parseExecutableHeader( header.subarray(0, headerRead.bytesRead), hostArchitecture, + fileSize, ); } } } - return parseExecutableHeader(bytes, hostArchitecture); + return parseExecutableHeader(bytes, hostArchitecture, fileSize); }; const readPeMetadata = async ( diff --git a/src/domain/binaryTarget.fixture.ts b/src/domain/binaryTarget.fixture.ts index 69587f092..bbc1dc5dd 100644 --- a/src/domain/binaryTarget.fixture.ts +++ b/src/domain/binaryTarget.fixture.ts @@ -4,7 +4,7 @@ export const elf = ( endian: number, machine: number, ): Buffer => { - const bytes = Buffer.alloc(20); + const bytes = Buffer.alloc(class_ === 1 ? 52 : 64); bytes.set([0x7f, 0x45, 0x4c, 0x46, class_, endian]); if (endian === 1) bytes.writeUInt16LE(machine, 18); else bytes.writeUInt16BE(machine, 18); @@ -13,7 +13,9 @@ export const elf = ( /** Build a minimal thin Mach-O header for binary-target parser tests. */ export const thinMach = (magic: number, cpu: number): Buffer => { - const bytes = Buffer.alloc(8); + const bytes = Buffer.alloc( + magic === 0xfeedfacf || magic === 0xcffaedfe ? 32 : 28, + ); bytes.writeUInt32BE(magic, 0); if (magic === 0xcefaedfe || magic === 0xcffaedfe) bytes.writeUInt32LE(cpu, 4); else bytes.writeUInt32BE(cpu, 4); diff --git a/src/domain/binaryTarget.test.ts b/src/domain/binaryTarget.test.ts index d8f35e713..053232ac0 100644 --- a/src/domain/binaryTarget.test.ts +++ b/src/domain/binaryTarget.test.ts @@ -90,3 +90,52 @@ describe("executable header parsing", () => { expect(parseExecutableHeader(truncatedDirectories, "x64").ok).toBe(false); }); }); + +describe("executable header completeness", () => { + const reason = (bytes: Buffer, fileSize = bytes.length) => { + const result = parseExecutableHeader(bytes, "arm64", fileSize); + return result.ok ? null : result.error; + }; + + it.each([ + [ + thinMach(0xcffaedfe, 0x0100000c).subarray(0, 12), + "a 64-bit header needs 32 bytes", + ], + [ + thinMach(0xcefaedfe, 0x01000007).subarray(0, 27), + "a 32-bit header needs 28 bytes", + ], + [elf(1, 1, 62).subarray(0, 51), "an ELF32 header needs 52 bytes"], + [elf(2, 1, 183).subarray(0, 63), "an ELF64 header needs 64 bytes"], + ])( + "rejects a header shorter than its fixed size (%#)", + (bytes, constraint) => { + expect(reason(bytes)).toContain(constraint); + }, + ); + + it("rejects Mach-O load commands that extend past the file", () => { + const header = thinMach(0xcffaedfe, 0x0100000c); + header.writeUInt32LE(100, 20); + + expect(reason(header)).toBe( + "truncated Mach-O load commands: the header declares 100 bytes after its 32-byte header; the file has 32", + ); + // The resolver probes a prefix; commitments are checked against the file. + expect(reason(header, 132)).toBeNull(); + }); + + it("rejects a FAT file whose host slice extends past the file", () => { + const bytes = fat([0x01000007, 0x0100000c]); + bytes.writeUInt32BE(4096, 8 + 20 + 8); + bytes.writeUInt32BE(64, 8 + 20 + 12); + + expect(reason(bytes, 4100)).toBe( + "truncated FAT slice: the arm64 slice ends at byte 4160; the file has 4100", + ); + expect(reason(bytes, 4160)).toBeNull(); + // Only the slice REA would analyze must be present. + expect(parseExecutableHeader(bytes, "x64", 4100).ok).toBe(true); + }); +}); diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index d5056c273..9c9a01a5c 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -35,28 +35,39 @@ export const parseExecutableHeader = ( bytes.length >= 4 && bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) ) - return parseElf(bytes); + return parseElf(bytes, fileSize); if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) return parseMz(bytes, fileSize); if (bytes.length < 8) return err("truncated or unsupported binary header"); const magic = bytes.readUInt32BE(0); if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) - return parseThinMachO(bytes, magic); + return parseThinMachO(bytes, magic, fileSize); if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic)) - return parseFatMachO(bytes, magic, hostArchitecture); + return parseFatMachO(bytes, magic, hostArchitecture, fileSize); return err("unsupported binary format"); }; const parseThinMachO = ( bytes: Buffer, magic: number, + fileSize: number, ): Result => { - if (bytes.length < 8) return err("truncated Mach-O header"); + const is64 = magic === 0xfeedfacf || magic === 0xcffaedfe; + const headerSize = is64 ? 32 : 28; + if (bytes.length < headerSize) + return err( + `truncated Mach-O header: a ${is64 ? 64 : 32}-bit header needs ${headerSize} bytes; the file has ${fileSize}`, + ); const little = magic === 0xcefaedfe || magic === 0xcffaedfe; - const architecture = machArchitecture( - little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4), - ); + const read = (offset: number): number => + little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); + const architecture = machArchitecture(read(4)); if (architecture === undefined) return err("unsupported Mach-O architecture"); + const commandBytes = read(20); + if (headerSize + commandBytes > fileSize) + return err( + `truncated Mach-O load commands: the header declares ${commandBytes} bytes after its ${headerSize}-byte header; the file has ${fileSize}`, + ); return ok({ format: "mach-o", architecture, @@ -68,6 +79,7 @@ const parseFatMachO = ( bytes: Buffer, magic: number, host: NodeJS.Architecture, + fileSize: number, ): Result => { const little = magic === 0xbebafeca || magic === 0xbfbafeca; const is64 = magic === 0xcafebabf || magic === 0xbfbafeca; @@ -79,10 +91,24 @@ const parseFatMachO = ( if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize) return err("truncated or invalid FAT architecture table"); const architectures: BinaryArchitecture[] = []; + const sliceEnds = new Map(); for (let index = 0; index < count; index += 1) { - const architecture = machArchitecture(read(8 + index * entrySize)); - if (architecture !== undefined && !architectures.includes(architecture)) - architectures.push(architecture); + const entry = 8 + index * entrySize; + const architecture = machArchitecture(read(entry)); + if (architecture === undefined || architectures.includes(architecture)) + continue; + architectures.push(architecture); + const offset = is64 + ? little + ? bytes.readBigUInt64LE(entry + 8) + : bytes.readBigUInt64BE(entry + 8) + : BigInt(read(entry + 8)); + const size = is64 + ? little + ? bytes.readBigUInt64LE(entry + 16) + : bytes.readBigUInt64BE(entry + 16) + : BigInt(read(entry + 12)); + sliceEnds.set(architecture, offset + size); } const preferred = host === "arm64" @@ -96,6 +122,11 @@ const parseFatMachO = ( : undefined; if (preferred === undefined || !architectures.includes(preferred)) return err(`FAT binary has no host-compatible ${host} architecture`); + const sliceEnd = sliceEnds.get(preferred) ?? 0n; + if (sliceEnd > BigInt(fileSize)) + return err( + `truncated FAT slice: the ${preferred} slice ends at byte ${sliceEnd}; the file has ${fileSize}`, + ); return ok({ format: "mach-o", architecture: preferred, @@ -103,11 +134,19 @@ const parseFatMachO = ( }); }; -const parseElf = (bytes: Buffer): Result => { - if (bytes.length < 20) return err("truncated ELF header"); +const parseElf = ( + bytes: Buffer, + fileSize: number, +): Result => { + if (bytes.length < 6) return err("truncated ELF header"); if (bytes[4] !== 1 && bytes[4] !== 2) return err("unsupported ELF class"); const little = bytes[5] === 1; if (!little && bytes[5] !== 2) return err("unsupported ELF endianness"); + const headerSize = bytes[4] === 1 ? 52 : 64; + if (bytes.length < headerSize) + return err( + `truncated ELF header: an ELF${bytes[4] === 1 ? 32 : 64} header needs ${headerSize} bytes; the file has ${fileSize}`, + ); const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18); const architecture = elfArchitecture(machine); if (architecture === undefined) return err("unsupported ELF architecture"); diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index e9da6d234..d0b7a395e 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -325,6 +325,32 @@ describe("binary target I/O: explicit kinds and executable headers", () => { architecture: "x86_64", }); }); + + it("refuses a truncated Mach-O header before any provider sees it", async () => { + const directory = await createTestTempDirectory("rea-target-"); + const path = join(directory, "truncated"); + await writeFile(path, thinMach(0xcffaedfe, 0x0100000c).subarray(0, 12)); + const result = await parseBinaryTarget(path, directory, "arm64"); + expect(result.ok).toBe(false); + expect(result.ok ? undefined : result.error).toMatchObject({ + path, + reason: + "truncated Mach-O header: a 64-bit header needs 32 bytes; the file has 12", + }); + }); + + it("checks Mach-O load commands against the whole file beyond the probe", async () => { + const directory = await createTestTempDirectory("rea-target-"); + const path = join(directory, "large-commands"); + const header = thinMach(0xcffaedfe, 0x0100000c); + header.writeUInt32LE(8192, 20); + await writeFile(path, Buffer.concat([header, Buffer.alloc(8192)])); + const result = await parseBinaryTarget(path, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + format: "mach-o", + architecture: "arm64", + }); + }); }); describe("app executable filename fidelity", () => { diff --git a/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts b/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts index 8a05befd9..e50a6b5d3 100644 --- a/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { thinMach } from "../../../../src/domain/binaryTarget.fixture.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; describe("artifact directory diagnostics", () => { @@ -22,10 +23,7 @@ describe("artifact directory diagnostics", () => { join(contentsPath, "Info.plist"), "CFBundleExecutableFixture", ); - const machHeader = Buffer.alloc(8); - machHeader.writeUInt32BE(0xfeedfacf, 0); - machHeader.writeUInt32BE(0x0100000c, 4); - await writeFile(executablePath, machHeader); + await writeFile(executablePath, thinMach(0xfeedfacf, 0x0100000c)); await writeFile(blockedPath, "member contents"); await chmod(blockedPath, 0); try {