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:
parent
e1afbc1914
commit
0b04d384cc
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
|
||||
);
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue