import type { MachoDependency, MachoImageFacts, MachoSlice, } from "../../domain/apple/dylibResolution.js"; import { machoCpuSubtypeBase } from "../../domain/apple/machoCpuIdentity.js"; import { machoLoadCommandAlignment, classifyFatContainerIdentity, readFatContainerHeader, readMachoHeaderPrefixFacts, validateFatSliceIdentity, type FatSliceDeclaration, } from "../../domain/apple/machoContainer.js"; /** Read `length` bytes at `offset`; shorter results mean end of file. */ export type ReadAt = (offset: number, length: number) => Promise; /** Load commands larger than this are refused instead of buffered. */ export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024; const MH_MAGIC = 0xfeedface; const MH_MAGIC_64 = 0xfeedfacf; const MH_CIGAM = 0xcefaedfe; const MH_CIGAM_64 = 0xcffaedfe; const FAT_MAGIC = 0xcafebabe; const FAT_MAGIC_64 = 0xcafebabf; const FAT_CIGAM = 0xbebafeca; const FAT_CIGAM_64 = 0xbfbafeca; const LC_ID_DYLIB = 0xd; const LC_LOAD_DYLIB = 0xc; const LC_LOAD_WEAK_DYLIB = 0x80000018; const LC_REEXPORT_DYLIB = 0x8000001f; const LC_LAZY_LOAD_DYLIB = 0x20; const LC_LOAD_UPWARD_DYLIB = 0x80000023; const LC_RPATH = 0x8000001c; const LC_DYLD_ENVIRONMENT = 0x27; const LC_CODE_SIGNATURE = 0x1d; const LC_VERSION_MIN_MACOSX = 0x24; const LC_VERSION_MIN_IPHONEOS = 0x25; const LC_VERSION_MIN_TVOS = 0x2f; const LC_VERSION_MIN_WATCHOS = 0x30; const LC_BUILD_VERSION = 0x32; // `mach-o/loader.h` `PLATFORM_*` numbers observed via Apple linker output. const PLATFORM_MACOS = 1; const PLATFORM_IOS = 2; const PLATFORM_TVOS = 3; const PLATFORM_WATCHOS = 4; const PLATFORM_IOSSIMULATOR = 7; const PLATFORM_TVOSSIMULATOR = 8; const PLATFORM_WATCHOSSIMULATOR = 9; const CPU_TYPE_I386 = 7; const CPU_TYPE_X86_64 = 0x01000007; /** * Legacy `LC_VERSION_MIN_*` commands predate explicit simulator platform * IDs. Per Apple's dyld rules the image CPU type distinguishes simulator * from device: Intel images are simulator, ARM images are device * (tvOS simulator applies to x86_64 only). `LC_BUILD_VERSION` platform * numbers are preserved verbatim; unknown future numbers stay unknown * downstream, never mapped to nearest. */ const legacyVersionMinPlatform = ( command: number, cpuType: number, ): number | undefined => { const isIntel = cpuType === CPU_TYPE_I386 || cpuType === CPU_TYPE_X86_64; switch (command) { case LC_VERSION_MIN_MACOSX: return PLATFORM_MACOS; case LC_VERSION_MIN_IPHONEOS: return isIntel ? PLATFORM_IOSSIMULATOR : PLATFORM_IOS; case LC_VERSION_MIN_TVOS: return cpuType === CPU_TYPE_X86_64 ? PLATFORM_TVOSSIMULATOR : PLATFORM_TVOS; case LC_VERSION_MIN_WATCHOS: return isIntel ? PLATFORM_WATCHOSSIMULATOR : PLATFORM_WATCHOS; default: return undefined; } }; /** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */ const DYLIB_USE_MARKER = 0x1a741800; const DYLIB_USE_WEAK_LINK = 0x1; const DYLIB_USE_REEXPORT = 0x2; const DYLIB_USE_UPWARD = 0x4; const DYLIB_USE_DELAYED_INIT = 0x8; const DEPENDENCY_COMMANDS: ReadonlyMap = new Map([ [LC_LOAD_DYLIB, "LC_LOAD_DYLIB"], [LC_LOAD_WEAK_DYLIB, "LC_LOAD_WEAK_DYLIB"], [LC_REEXPORT_DYLIB, "LC_REEXPORT_DYLIB"], [LC_LAZY_LOAD_DYLIB, "LC_LAZY_LOAD_DYLIB"], [LC_LOAD_UPWARD_DYLIB, "LC_LOAD_UPWARD_DYLIB"], ]); /** Malformed or unsupported structure found while decoding one image. */ class MachoFormatIssue extends Error { constructor( readonly status: "malformed" | "unsupported", message: string, ) { super(message); } } /** Report whether the first bytes carry a Mach-O or FAT magic number. */ export const hasMachoMagic = (head: Uint8Array): boolean => { if (head.byteLength < 4) return false; const magic = new DataView( head.buffer, head.byteOffset, head.byteLength, ).getUint32(0, false); return [ MH_MAGIC, MH_MAGIC_64, MH_CIGAM, MH_CIGAM_64, FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64, ].includes(magic); }; /** Decode every slice's dylib-loading load commands without reading code. */ export const readMachoImage = async ( readAt: ReadAt, size: number, ): Promise => { try { const head = await readAt(0, Math.min(size, 8)); if (head.byteLength < 8) // A recognized magic with no room for a header is a truncated image. return hasMachoMagic(head) ? { status: "malformed", reason: "Mach-O header is truncated" } : { status: "not-mach-o" }; const view = viewOf(head); const magic = view.getUint32(0, false); let header: ReturnType; if (magic === FAT_MAGIC && size <= MAX_LOAD_COMMAND_BYTES) { const complete = await readAt(0, size); header = complete.byteLength === size ? readFatContainerHeader(complete, size) : readFatContainerHeader(head, size); } else header = readFatContainerHeader(head, size); if (header.state !== "not-fat") { if (header.state === "needs-data") { header = readFatContainerHeader( await readAt(0, header.requiredBytes), size, ); } if (header.state === "not-fat") return { status: "not-mach-o" }; if (header.state === "needs-data") throw new MachoFormatIssue("malformed", "FAT table is truncated"); if (header.state === "malformed") throw new MachoFormatIssue("malformed", header.reason); let identity = classifyFatContainerIdentity(header, () => undefined); if (identity.state === "needs-slice-header") { const sliceHeaders = new Map(); for (const offset of identity.offsets) { const bytes = await readAt(offset, 4); sliceHeaders.set( offset, bytes.byteLength < 4 ? null : viewOf(bytes).getUint32(0, false), ); } identity = classifyFatContainerIdentity(header, (offset) => sliceHeaders.get(offset), ); } if (identity.state === "not-mach-o") return { status: "not-mach-o" }; if (identity.state === "malformed") { // A large CAFEBABE input whose bounded prefix resembles an invalid // known-CPU FAT table remains unknown until its JVM identity can be // checked; do not turn the resource limit into a malformed claim. if (magic === FAT_MAGIC && size > MAX_LOAD_COMMAND_BYTES) throw new MachoFormatIssue( "unsupported", "FAT/JVM identity is unresolved because the class identity scan exceeds the 16 MiB limit", ); throw new MachoFormatIssue(identity.category, identity.reason); } if (identity.state === "needs-slice-header") return { status: "not-mach-o" }; return { status: "parsed", slices: await readFatSlices(readAt, header.slices), }; } if (magic === MH_CIGAM || magic === MH_CIGAM_64) return { status: "parsed", slices: [await readSlice(readAt, 0, size)] }; if (magic === MH_MAGIC || magic === MH_MAGIC_64) throw new MachoFormatIssue( "unsupported", "big-endian Mach-O images are not supported", ); return { status: "not-mach-o" }; } catch (cause: unknown) { if (cause instanceof MachoFormatIssue) return { status: cause.status, reason: cause.message }; throw cause; } }; const readFatSlices = async ( readAt: ReadAt, declarations: readonly FatSliceDeclaration[], ): Promise => { const slices: MachoSlice[] = []; // Keep validating after an unsupported slice so a later malformed slice is // reported as malformed whatever the slice order. let unsupported: MachoFormatIssue | undefined; for (const declaration of declarations) { const { offset, size: length, cpuType, cpuSubtype } = declaration; let slice: MachoSlice; try { slice = await readSlice(readAt, offset, length, { fatCpuType: cpuType, fatCpuSubtype: cpuSubtype, alignmentExponent: declaration.alignmentExponent, }); } catch (cause: unknown) { if ( !(cause instanceof MachoFormatIssue) || cause.status !== "unsupported" ) throw cause; unsupported ??= cause; continue; } const identityIssue = validateFatSliceIdentity(declaration, { cpuType: slice.cpu_type, cpuSubtype: slice.cpu_subtype, }); if (identityIssue !== null) throw new MachoFormatIssue("malformed", identityIssue); slices.push(slice); } if (unsupported !== undefined) throw unsupported; return slices; }; const readSlice = async ( readAt: ReadAt, base: number, length: number, fat: { readonly fatCpuType: number; readonly fatCpuSubtype: number; readonly alignmentExponent: number; } | null = null, ): Promise => { const header = viewOf(await readExact(readAt, base, 28, "Mach-O header")); const headerBytes = new Uint8Array( header.buffer, header.byteOffset, header.byteLength, ); const identity = readMachoHeaderPrefixFacts(headerBytes).identity; const magic = header.getUint32(0, true); if (magic === MH_MAGIC_64 || magic === MH_MAGIC) { if (identity === null) throw new MachoFormatIssue( "malformed", `slice at offset ${base} has no Mach-O header`, ); const wide = magic === MH_MAGIC_64; const headerSize = wide ? 32 : 28; const commandCount = header.getUint32(16, true); const commandBytes = header.getUint32(20, true); if (commandBytes > MAX_LOAD_COMMAND_BYTES) throw new MachoFormatIssue( "unsupported", `load commands occupy ${commandBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`, ); if (headerSize + commandBytes > length) throw new MachoFormatIssue( "malformed", "load commands extend beyond the Mach-O slice", ); const commands = await readExact( readAt, base + headerSize, commandBytes, "load commands", ); const slice = decodeCommands(commands, { wide, cpuType: identity.cpuType, cpuSubtype: identity.cpuSubtype, fileType: header.getUint32(12, true), commandCount, sliceOffset: base, sliceSize: length, fatCpuType: fat?.fatCpuType ?? null, fatCpuSubtype: fat?.fatCpuSubtype ?? null, fatAlignmentExponent: fat?.alignmentExponent ?? null, }); const distinctPlatforms = [...new Set(slice.platforms)]; slice.platform = distinctPlatforms.length === 1 ? (distinctPlatforms[0] ?? null) : null; return slice; } throw new MachoFormatIssue( magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64) ? "unsupported" : "malformed", magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64) ? "big-endian Mach-O slices are not supported" : `slice at offset ${base} has no Mach-O header`, ); }; const decodeCommands = ( bytes: Uint8Array, header: { readonly wide: boolean; readonly cpuType: number; readonly cpuSubtype: number; readonly fileType: number; readonly commandCount: number; readonly sliceOffset: number; readonly sliceSize: number; readonly fatCpuType: number | null; readonly fatCpuSubtype: number | null; readonly fatAlignmentExponent: number | null; }, ): MachoSlice => { const view = viewOf(bytes); const slice: MachoSlice = { architecture: architectureName(header.cpuType, header.cpuSubtype), slice_offset: header.sliceOffset, slice_size: header.sliceSize, cpu_type: header.cpuType, cpu_subtype: header.cpuSubtype, fat_cpu_type: header.fatCpuType, fat_cpu_subtype: header.fatCpuSubtype, fat_alignment_exponent: header.fatAlignmentExponent, file_type: fileTypeName(header.fileType), file_type_code: header.fileType, platform: null, platforms: [], install_name: null, dependencies: [], rpaths: [], dyld_environment: [], code_signature_present: false, }; 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 body = bytes.subarray(offset, offset + size); decodeCommand(command, body, index, slice); offset += size; } if (offset !== bytes.byteLength) throw new MachoFormatIssue( "malformed", `load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`, ); return slice; }; const decodeCommand = ( command: number, body: Uint8Array, index: number, slice: MachoSlice, ): void => { if (command === LC_ID_DYLIB) { slice.install_name = commandString(body, 24, index); return; } if (command === LC_RPATH) { slice.rpaths.push(commandString(body, 12, index)); return; } if (command === LC_DYLD_ENVIRONMENT) { slice.dyld_environment.push(commandString(body, 12, index)); return; } if (command === LC_CODE_SIGNATURE) { // linkedit_data_command: cmd, cmdsize, dataoff, datasize. if (body.byteLength < 16) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for LC_CODE_SIGNATURE`, ); slice.code_signature_present = true; return; } if (command === LC_BUILD_VERSION) { if (body.byteLength < 24) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for LC_BUILD_VERSION`, ); const view = viewOf(body); const toolCount = view.getUint32(20, true); if (toolCount > Math.floor((body.byteLength - 24) / 8)) throw new MachoFormatIssue( "malformed", `load command ${index} LC_BUILD_VERSION tool records exceed cmdsize`, ); const platform = view.getUint32(8, true); slice.platforms.push(platform); return; } const legacyPlatform = legacyVersionMinPlatform(command, slice.cpu_type); if (legacyPlatform !== undefined) { if (body.byteLength < 16) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for a version-min command`, ); slice.platforms.push(legacyPlatform); return; } const name = DEPENDENCY_COMMANDS.get(command); if (name !== undefined) slice.dependencies.push(decodeDependency(name, body, index)); }; const decodeDependency = ( command: MachoDependency["command"], body: Uint8Array, index: number, ): MachoDependency => { if (body.byteLength < 24) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for a dylib command`, ); const view = viewOf(body); const nameOffset = view.getUint32(8, true); const usesFlags = nameOffset === 28 && body.byteLength >= 28 && view.getUint32(12, true) === DYLIB_USE_MARKER; const flags = usesFlags ? view.getUint32(24, true) : 0; return { command, encoding: usesFlags ? "dylib_use_command" : "dylib_command", install_name: commandString(body, usesFlags ? 28 : 24, index), weak: command === "LC_LOAD_WEAK_DYLIB" || (flags & DYLIB_USE_WEAK_LINK) !== 0, upward: command === "LC_LOAD_UPWARD_DYLIB" || (flags & DYLIB_USE_UPWARD) !== 0, reexport: command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0, delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0, current_version: version(view.getUint32(16, true)), compatibility_version: version(view.getUint32(20, true)), }; }; /** NUL-terminated UTF-8 string at a `lc_str` offset inside one command. */ const commandString = ( body: Uint8Array, minimumOffset: number, index: number, ): string => { if (body.byteLength < 12) throw new MachoFormatIssue( "malformed", `load command ${index} is too short to hold a string offset`, ); const offset = viewOf(body).getUint32(8, true); if (offset < minimumOffset || offset >= body.byteLength) throw new MachoFormatIssue( "malformed", `load command ${index} has string offset ${offset} outside the command`, ); const end = body.indexOf(0, offset); if (end < 0) throw new MachoFormatIssue( "malformed", `load command ${index} has an unterminated string`, ); try { // lc_str contains name/path data, so a leading U+FEFF is part of its identity. return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( body.subarray(offset, end), ); } catch { throw new MachoFormatIssue( "malformed", `load command ${index} has a string that is not UTF-8`, ); } }; const readExact = async ( readAt: ReadAt, offset: number, length: number, label: string, ): Promise => { const bytes = await readAt(offset, length); if (bytes.byteLength < length) throw new MachoFormatIssue("malformed", `${label} is truncated`); return bytes; }; const CPU_ARCH_ABI64 = 0x01000000; const CPU_ARCH_ABI64_32 = 0x02000000; const CPU_TYPE_X86 = 7; const CPU_TYPE_ARM = 12; const CPU_SUBTYPE_ARM64E = 2; const CPU_SUBTYPE_ARM64E_X1 = 12; const CPU_SUBTYPE_X86_64_H = 8; const architectureName = (cpuType: number, cpuSubtype: number): string => { const subtype = machoCpuSubtypeBase(cpuSubtype); if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64)) // Both pointer-authentication subtypes belong to the arm64e family. return subtype === CPU_SUBTYPE_ARM64E || subtype === CPU_SUBTYPE_ARM64E_X1 ? "arm64e" : "arm64"; if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64_32)) return "arm64_32"; if (cpuType === (CPU_TYPE_X86 | CPU_ARCH_ABI64)) return subtype === CPU_SUBTYPE_X86_64_H ? "x86_64h" : "x86_64"; if (cpuType === CPU_TYPE_X86) return "i386"; if (cpuType === CPU_TYPE_ARM) return "arm"; return `cpu-${cpuType.toString(16)}`; }; const fileTypeName = (fileType: number): MachoSlice["file_type"] => fileType === 2 ? "execute" : fileType === 6 ? "dylib" : fileType === 8 ? "bundle" : "other"; const version = (value: number): string => `${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`; const swap = (value: number): number => (((value & 0xff) << 24) | ((value & 0xff00) << 8) | ((value >>> 8) & 0xff00) | (value >>> 24)) >>> 0; const viewOf = (bytes: Uint8Array): DataView => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);