fix(native): validate Mach-O command framing before admission

This commit is contained in:
morluto
2026-10-24 04:15:08 +08:00
parent 84c327ee65
commit 5796220f23
9 changed files with 446 additions and 89 deletions
+7
View File
@@ -358,6 +358,13 @@ Mach-O fixtures check each case against source-owned return values.
## Static inspection
Thin Mach-O admission and each admitted FAT slice walk declared load-command headers, checking
width-specific alignment, command sizes and exact `ncmds`/`sizeofcmds` agreement
before opening a provider. Headers beyond the initial probe use another
4096-byte window; admission skips command payloads. Native image readers use
the same framing owner and perform their own payload checks. This validation
does not add a global command-table byte cap.
Native target admission checks Mach-O slice headers before assigning FAT
identity. A shared `CAFEBABE` prefix whose declarations do not identify Mach-O
images receives the generic unsupported-format refusal, including JVM class
+114 -1
View File
@@ -79,7 +79,15 @@ import {
type FatSliceHeaderEvidence,
type MachoHeaderPrefixFacts,
} from "../domain/apple/machoContainer.js";
import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js";
import {
MachoLoadCommandCursor,
readMachoLoadCommandTableHeader,
type MachoLoadCommandTableHeader,
} from "../domain/apple/machoLoadCommands.js";
import type {
PeCodeViewRecord,
UniversalMachOSlice,
} from "../domain/binaryTargetTypes.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import type { JsonValue } from "../domain/jsonValue.js";
@@ -844,6 +852,12 @@ const readExecutableMetadata = async (
signatureEvidence,
);
}
const thin = await readThinMachoMetadata(handle, bytes, {
fileSize,
hostArchitecture,
signal,
});
if (thin !== null) return thin;
const fatEvidence = await readFatSliceHeaderEvidence(
handle,
bytes,
@@ -857,6 +871,16 @@ const readExecutableMetadata = async (
undefined,
fatEvidence,
);
if (
parsed.ok &&
parsed.value.format === "mach-o-universal" &&
fatEvidence !== undefined
) {
for (const slice of fatEvidence.header.slices) {
const framed = await readMachoSliceCommandFrames(handle, slice, signal);
if (!framed.ok) return framed;
}
}
if (
!parsed.ok ||
parsed.value.architecture !== "mips" ||
@@ -921,6 +945,95 @@ const unresolvedMzHeaderFailure = (
};
};
/** Establish a complete thin-command framing proof before provider admission. */
const readThinMachoMetadata = async (
handle: FileHandle,
prefix: Buffer,
input: {
readonly fileSize: number;
readonly hostArchitecture: NodeJS.Architecture;
readonly signal?: AbortSignal | undefined;
},
): Promise<Result<ExecutableMetadata, string> | null> => {
const table = readMachoLoadCommandTableHeader(prefix, input.fileSize);
if (!table.ok) return table;
if (table.value === null) return null;
const parsed = parseExecutableHeader(
prefix.subarray(0, table.value.headerSize),
input.hostArchitecture,
input.fileSize,
);
if (!parsed.ok) return parsed;
const framed = await readMachoCommandFrames(handle, table.value, {
prefix,
fileSize: input.fileSize,
signal: input.signal,
});
return framed.ok ? parsed : framed;
};
/** Walk thin command headers through one probe window, skipping payload bytes. */
const readMachoCommandFrames = async (
handle: FileHandle,
table: MachoLoadCommandTableHeader,
input: {
readonly prefix: Buffer;
readonly fileSize: number;
readonly fileOffset?: number | undefined;
readonly signal?: AbortSignal | undefined;
},
): Promise<Result<null, string>> => {
const commands = new MachoLoadCommandCursor(table);
let window = input.prefix;
let windowOffset = 0;
while (commands.nextOffset !== null) {
const offset = table.headerSize + commands.nextOffset;
if (
offset + 8 <= input.fileSize &&
(offset < windowOffset || offset + 8 > windowOffset + window.length)
) {
window = Buffer.alloc(Math.min(4096, input.fileSize - offset));
windowOffset = offset;
const observed = await handle.read(
window,
0,
window.length,
(input.fileOffset ?? 0) + offset,
);
throwIfTargetResolutionCancelled(input.signal);
window = window.subarray(0, observed.bytesRead);
}
const relative = offset - windowOffset;
const frame = commands.read(
relative >= 0 ? window.subarray(relative, relative + 8) : Buffer.alloc(0),
);
if (!frame.ok) return frame;
}
return commands.finish();
};
/** Validate declared FAT slices after the canonical table/identity admission succeeds. */
const readMachoSliceCommandFrames = async (
handle: FileHandle,
slice: UniversalMachOSlice,
signal?: AbortSignal,
): Promise<Result<null, string>> => {
const prefix = Buffer.alloc(Math.min(4096, slice.size));
const observed = await handle.read(prefix, 0, prefix.length, slice.offset);
throwIfTargetResolutionCancelled(signal);
const bytes = prefix.subarray(0, observed.bytesRead);
const table = readMachoLoadCommandTableHeader(bytes, slice.size);
if (!table.ok) return table;
if (table.value === null)
return err("FAT slice does not identify a thin Mach-O image");
return readMachoCommandFrames(handle, table.value, {
prefix: bytes,
fileSize: slice.size,
fileOffset: slice.offset,
signal,
});
};
/** Read the validated FAT table and bounded thin-header identity prefixes. */
const readFatSliceHeaderEvidence = async (
handle: FileHandle,
+14 -26
View File
@@ -1,3 +1,4 @@
import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js";
import type {
MachoDependency,
MachoImageFacts,
@@ -5,7 +6,6 @@ import type {
} from "../../domain/apple/dylibResolution.js";
import { machoCpuSubtypeBase } from "../../domain/apple/machoCpuIdentity.js";
import {
machoLoadCommandAlignment,
classifyFatContainerIdentity,
readFatContainerHeader,
readFatSliceDeclarations,
@@ -427,7 +427,6 @@ const decodeCommands = (
)[];
readonly maxVmOffset: bigint;
} => {
const view = viewOf(bytes);
const slice: MachoSlice = {
architecture: architectureName(header.cpuType, header.cpuSubtype),
slice_offset: header.sliceOffset,
@@ -455,24 +454,17 @@ const decodeCommands = (
let unreadableSegment: number | undefined;
let lazyInfo = false;
let lazyInfoCount = 0;
let offset = 0;
for (let index = 0; index < header.commandCount; index++) {
if (offset + 8 > bytes.byteLength)
throw new MachoFormatIssue(
"malformed",
`load command ${index} starts beyond sizeofcmds`,
);
const command = view.getUint32(offset, true);
const size = view.getUint32(offset + 4, true);
if (
size < 8 ||
size % machoLoadCommandAlignment(header.wide) !== 0 ||
offset + size > bytes.byteLength
)
throw new MachoFormatIssue(
"malformed",
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
);
const commands = new MachoLoadCommandCursor({
wide: header.wide,
littleEndian: true,
commandCount: header.commandCount,
commandBytes: bytes.byteLength,
});
while (commands.nextOffset !== null) {
const offset = commands.nextOffset;
const frame = commands.read(bytes.subarray(offset, offset + 8));
if (!frame.ok) throw new MachoFormatIssue("malformed", frame.error);
const { index, command, size } = frame.value;
const body = bytes.subarray(offset, offset + size);
if (command === LC_SEGMENT || command === LC_SEGMENT_64) {
const span = readSegmentSpan(body, command === LC_SEGMENT_64);
@@ -490,13 +482,9 @@ const decodeCommands = (
}
const decoded = decodeCommand(command, body, index, slice);
if (decoded !== undefined) dependencies.push(decoded);
offset += size;
}
if (offset !== bytes.byteLength)
throw new MachoFormatIssue(
"malformed",
`load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`,
);
const complete = commands.finish();
if (!complete.ok) throw new MachoFormatIssue("malformed", complete.error);
if (lazyInfo && unreadableSegment !== undefined)
throw new MachoFormatIssue(
"malformed",
+104
View File
@@ -0,0 +1,104 @@
import { err, ok, type Result } from "../result.js";
import { machoLoadCommandAlignment } from "./machoContainer.js";
/** Header commitments needed to walk commands without retaining their payloads. */
export interface MachoLoadCommandTableHeader {
readonly headerSize: 28 | 32;
readonly wide: boolean;
readonly littleEndian: boolean;
readonly commandCount: number;
readonly commandBytes: number;
}
/** Read only the thin header's load-command framing; other formats return null. */
export const readMachoLoadCommandTableHeader = (
bytes: Uint8Array,
fileSize: number,
): Result<MachoLoadCommandTableHeader | null, string> => {
if (bytes.byteLength < 4) return ok(null);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const magic = view.getUint32(0, false);
if (![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
return ok(null);
const wide = magic === 0xfeedfacf || magic === 0xcffaedfe;
const headerSize = wide ? 32 : 28;
if (bytes.byteLength < headerSize)
return err(
`truncated Mach-O header: a ${wide ? 64 : 32}-bit header needs ${headerSize} bytes; the file has ${fileSize}`,
);
const littleEndian = magic === 0xcefaedfe || magic === 0xcffaedfe;
const commandBytes = view.getUint32(20, littleEndian);
if (commandBytes > fileSize - headerSize)
return err(
`truncated Mach-O load commands: the header declares ${commandBytes} bytes after its ${headerSize}-byte header; the file has ${fileSize}`,
);
return ok({
headerSize,
wide,
littleEndian,
commandCount: view.getUint32(16, littleEndian),
commandBytes,
});
};
/** One owner's count, alignment and extent proof for a sequential command walk. */
export class MachoLoadCommandCursor {
#index = 0;
#offset = 0;
constructor(
private readonly table: Pick<
MachoLoadCommandTableHeader,
"wide" | "littleEndian" | "commandCount" | "commandBytes"
>,
) {}
/** Offset relative to the command table; null when every declared row was read. */
get nextOffset(): number | null {
return this.#index < this.table.commandCount ? this.#offset : null;
}
/** Consume exactly one command's eight-byte header, leaving its payload to callers. */
read(bytes: Uint8Array): Result<
{
readonly index: number;
readonly offset: number;
readonly command: number;
readonly size: number;
},
string
> {
const { commandBytes, littleEndian, wide } = this.table;
if (this.#offset + 8 > commandBytes || bytes.byteLength < 8)
return err(`load command ${this.#index} starts beyond sizeofcmds`);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const size = view.getUint32(4, littleEndian);
if (
size < 8 ||
size % machoLoadCommandAlignment(wide) !== 0 ||
size > commandBytes - this.#offset
)
return err(
`load command ${this.#index} at offset ${this.#offset} has invalid cmdsize ${size}`,
);
const frame = {
index: this.#index,
offset: this.#offset,
command: view.getUint32(0, littleEndian),
size,
};
this.#index += 1;
this.#offset += size;
return ok(frame);
}
/** A successful walk must exhaust both ncmds and sizeofcmds. */
finish(): Result<null, string> {
return this.#index === this.table.commandCount &&
this.#offset === this.table.commandBytes
? ok(null)
: err(
`load commands declare ${this.table.commandCount} commands but sizeofcmds holds ${this.table.commandBytes} bytes`,
);
}
}
+25 -22
View File
@@ -27,6 +27,10 @@ import {
validateFatSliceIdentity,
type FatSliceHeaderEvidence,
} from "./apple/machoContainer.js";
import {
MachoLoadCommandCursor,
readMachoLoadCommandTableHeader,
} from "./apple/machoLoadCommands.js";
/** Format and architecture facts recovered from an executable header. */
export type ExecutableMetadata =
@@ -123,7 +127,7 @@ export const parseExecutableHeader = (
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, fileSize);
return parseThinMachO(bytes, fileSize);
const fatHeader =
fatEvidence?.header ?? readFatContainerHeader(bytes, fileSize);
if (fatHeader.state !== "not-fat")
@@ -136,36 +140,35 @@ const CPU_TYPE_ARM64_32 = 0x0200000c;
const parseThinMachO = (
bytes: Buffer,
magic: number,
fileSize: number,
): Result<ExecutableMetadata, string> => {
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 table = readMachoLoadCommandTableHeader(bytes, fileSize);
if (!table.ok) return table;
if (table.value === null) return err("unsupported binary format");
const identity = readMachoHeaderPrefixFacts(bytes.subarray(0, 12)).identity;
if (identity === null) return err("unsupported Mach-O architecture");
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const read = (offset: number): number =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const architecture =
identity.cpuType === CPU_TYPE_ARM64_32
? "arm64_32"
: machArchitecture(identity.cpuType);
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}`,
);
if (architecture === "arm64_32")
return ok({
format: "mach-o",
architecture,
availableArchitectures: [architecture],
});
const commands = new MachoLoadCommandCursor(table.value);
while (commands.nextOffset !== null) {
const offset = table.value.headerSize + commands.nextOffset;
// Header-only callers may retain a prefix. File-backed admission walks
// every remaining command header through its bounded reader.
if (
offset + 8 > bytes.length &&
commands.nextOffset + 8 <= table.value.commandBytes
)
break;
const frame = commands.read(bytes.subarray(offset, offset + 8));
if (!frame.ok) return frame;
}
if (commands.nextOffset === null) {
const complete = commands.finish();
if (!complete.ok) return complete;
}
return ok({
format: "mach-o",
architecture,
+1 -1
View File
@@ -42,7 +42,7 @@ describe("native Mach-O slice selection", () => {
Buffer.from(machoImageWithUnknownCommand(true, 12)),
"arm64",
),
).toThrow(/Invalid load command size/u);
).toThrow(/invalid cmdsize/u);
});
it("accepts an aligned load command", () => {
+15 -13
View File
@@ -1,3 +1,4 @@
import { MachoLoadCommandCursor } from "../domain/apple/machoLoadCommands.js";
import { createHash } from "node:crypto";
import type { FixupCommands } from "./AppleMachoFixups.js";
@@ -7,7 +8,6 @@ import {
} from "./MachoArchitectureSelection.js";
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import {
machoLoadCommandAlignment,
readFatSliceDeclarations,
validateFatSlice,
type FatSliceDeclaration,
@@ -229,24 +229,26 @@ export const parseMachoLayout = (
chained: null,
baseAddress: undefined,
};
let cursor = slice + 32;
for (let index = 0; index < commands; index++) {
if (cursor + 8 > commandEnd) throw new RangeError("Truncated load command");
const framing = new MachoLoadCommandCursor({
wide: true,
littleEndian: true,
commandCount: commands,
commandBytes: commandEnd - slice - 32,
});
while (framing.nextOffset !== null) {
const cursor = slice + 32 + framing.nextOffset;
const frame = framing.read(bytes.subarray(cursor, cursor + 8));
if (!frame.ok) throw new RangeError(frame.error);
const command = {
cursor,
kind: bytes.readUInt32LE(cursor),
size: bytes.readUInt32LE(cursor + 4),
kind: frame.value.command,
size: frame.value.size,
};
if (
command.size < 8 ||
command.size % machoLoadCommandAlignment(true) !== 0 ||
cursor + command.size > commandEnd
)
throw new RangeError("Invalid load command size");
if (command.kind === 0x19) collectSegment(bytes, command, state);
else collectFixupCommand(bytes, command, state);
cursor += command.size;
}
const complete = framing.finish();
if (!complete.ok) throw new RangeError(complete.error);
const { segments, sections, chained, binds, rebases, dylibs } = state;
const offset = (address: bigint, size = 1): number => {
const matches = segments.filter(
+14 -26
View File
@@ -8,7 +8,7 @@ import {
type LoadRegion,
type ZeroFillMapping,
} from "./BinaryContainer.js";
import { machoLoadCommandAlignment } from "../../domain/apple/machoContainer.js";
import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js";
/** Read thin Mach-O images, retaining the slice's exact file-backed mappings. */
export const readMachoImage = (bytes: Buffer): BinaryContainer => {
@@ -26,31 +26,23 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
1,
"Mach-O load commands",
);
if (count > Math.floor(commandBytes / 8))
throw new BinaryFormatFailure(
"malformed",
"Mach-O command count exceeds its load-command range",
);
const mappings: FileMapping[] = [];
const loadRegions: LoadRegion[] = [];
const executableMappings: FileMapping[] = [];
const zeroFills: ZeroFillMapping[] = [];
const sections: ImageSection[] = [];
let chainedFixups = false;
let offset = commandStart;
const end = commandStart + commandBytes;
for (let index = 0; index < count; index++) {
const command = reader.u32(offset);
const size = reader.u32(offset + 4);
if (
size < 8 ||
size % machoLoadCommandAlignment(bits === 64) !== 0 ||
size > end - offset
)
throw new BinaryFormatFailure(
"malformed",
"Mach-O load command size is invalid or truncated",
);
const commands = new MachoLoadCommandCursor({
wide: bits === 64,
littleEndian: little,
commandCount: count,
commandBytes,
});
while (commands.nextOffset !== null) {
const offset = commandStart + commands.nextOffset;
const frame = commands.read(bytes.subarray(offset, offset + 8));
if (!frame.ok) throw new BinaryFormatFailure("malformed", frame.error);
const { command, size } = frame.value;
if (command === 0x80000034) chainedFixups = true;
if (command === 1 || command === 0x19) {
const wide = command === 0x19;
@@ -143,13 +135,9 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
);
}
}
offset += size;
}
if (offset !== end)
throw new BinaryFormatFailure(
"malformed",
"Mach-O load-command byte count does not match its commands",
);
const complete = commands.finish();
if (!complete.ok) throw new BinaryFormatFailure("malformed", complete.error);
const cpu = reader.u32(4);
const names = new Map([
[7, "386"],
@@ -0,0 +1,152 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { BinaryTargetError } from "../../../src/domain/configurationErrors.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
CPU,
fatImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
const commandImage = (
wide: boolean,
littleEndian: boolean,
sizes: readonly number[],
): Buffer => {
const magic = wide
? littleEndian
? 0xcffaedfe
: 0xfeedfacf
: littleEndian
? 0xcefaedfe
: 0xfeedface;
const header = thinMach(magic, wide ? 0x0100000c : 7);
const bytes = Buffer.alloc(
header.length + sizes.reduce((total, size) => total + size, 0),
);
header.copy(bytes);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
view.setUint32(16, sizes.length, littleEndian);
view.setUint32(20, bytes.length - header.length, littleEndian);
let offset = header.length;
for (const size of sizes) {
view.setUint32(offset, 0x7fffffff, littleEndian); // Unknown commands still have framing.
view.setUint32(offset + 4, size, littleEndian);
offset += size;
}
return bytes;
};
it.each([
{ label: "undersized", field: 36, value: 4 },
{ label: "unaligned", field: 36, value: 12 },
{ label: "outside table", field: 36, value: 0xffffffff },
{ label: "too many commands", field: 16, value: 2 },
{ label: "unclaimed table bytes", field: 16, value: 0 },
{ label: "trailing table bytes", field: 36, value: 8 },
])(
"rejects $label thin command framing before provider admission",
async ({ field, value }) => {
const bytes = commandImage(true, true, [16]);
bytes.writeUInt32LE(value, field);
const path = join(
await createTestTempDirectory("rea-macho-command-"),
"image",
);
await writeFile(path, bytes);
expect(parseExecutableHeader(bytes, "arm64").ok).toBe(false);
const resolved = await parseBinaryTarget(path);
expect(resolved.ok).toBe(false);
if (resolved.ok || !(resolved.error instanceof BinaryTargetError))
throw new Error("Expected target format refusal");
expect(projectAnalysisError(resolved.error)).toMatchObject({
code: "target_unavailable",
category: "unsupported_target",
});
expect(resolved.error.reason).toMatch(/load command/iu);
},
);
it.each([
{ wide: false, littleEndian: false, size: 12 },
{ wide: false, littleEndian: true, size: 12 },
{ wide: true, littleEndian: false, size: 16 },
{ wide: true, littleEndian: true, size: 16 },
])(
"retains width-specific alignment and endian controls ($wide/$littleEndian)",
async ({ wide, littleEndian, size }) => {
const bytes = commandImage(wide, littleEndian, [size]);
const path = join(
await createTestTempDirectory("rea-macho-command-control-"),
"image",
);
await writeFile(path, bytes);
expect(await parseBinaryTarget(path)).toMatchObject({
ok: true,
value: { format: "mach-o", architecture: wide ? "arm64" : "x86" },
});
},
);
it.each([false, true])(
"walks command headers beyond the probe window (corrupt=%s)",
async (corrupt) => {
const bytes = commandImage(true, true, [4104, 16]);
if (corrupt) bytes.writeUInt32LE(0xffffffff, 32 + 4104 + 4);
const path = join(
await createTestTempDirectory("rea-macho-command-window-"),
"image",
);
await writeFile(path, bytes);
const resolved = await parseBinaryTarget(path);
expect(resolved.ok).toBe(!corrupt);
if (!resolved.ok) {
if (!(resolved.error instanceof BinaryTargetError)) throw resolved.error;
expect(resolved.error.reason).toContain("load command 1");
}
},
);
it.each([null, 0, 1])(
"checks every admitted FAT slice's framing (corrupt slice %s)",
async (corruptIndex) => {
const slices = [
commandImage(true, true, [16]),
commandImage(true, true, [16]),
];
const x86 = slices[0]!;
x86.writeUInt32LE(CPU.x86_64.type, 4);
x86.writeUInt32LE(CPU.x86_64.subtype, 8);
if (corruptIndex !== null)
slices[corruptIndex]!.writeUInt32LE(0xffffffff, 36);
const bytes = fatImage([
{ cpu: CPU.x86_64, bytes: x86 },
{ cpu: CPU.arm64, bytes: slices[1]! },
]);
const path = join(
await createTestTempDirectory("rea-fat-command-"),
"image",
);
await writeFile(path, bytes);
const resolved = await parseBinaryTarget(path, {
hostArchitecture: "arm64",
});
expect(resolved.ok).toBe(corruptIndex === null);
if (!resolved.ok) {
if (!(resolved.error instanceof BinaryTargetError)) throw resolved.error;
expect(resolved.error.reason).toContain("invalid cmdsize");
expect(projectAnalysisError(resolved.error)).toMatchObject({
code: "target_unavailable",
});
} else
expect(resolved.value).toMatchObject({
format: "mach-o-universal",
architecture: "arm64",
});
},
);