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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-09 03:51:37 +00:00
1 parent e1afbc1914
commit 0b04d384cc
6 files changed
+137 -20

No files matched your search

+51 -12
View File
@@ -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<ExecutableMetadata, string> => {
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<ExecutableMetadata, string> => {
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<BinaryArchitecture, bigint>();
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<ExecutableMetadata, string> => {
if (bytes.length < 20) return err("truncated ELF header");
const parseElf = (
bytes: Buffer,
fileSize: number,
): Result<ExecutableMetadata, string> => {
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");