Files
reverse-engineering-anything/src/artifacts/apple/MachoLoadCommandReader.ts
T

1033 lines
33 KiB
TypeScript

import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js";
import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js";
import {
MAX_JVM_CLASS_IDENTITY_BYTES,
JVM_CLASS_IDENTITY_LIMIT_REASON,
} from "../../domain/apple/jvmClassFile.js";
import type {
MachoDependency,
MachoImageFacts,
MachoSlice,
} from "../../domain/apple/dylibResolution.js";
import {
machoArchitectureName,
machoCpuSubtypeBase,
} from "../../domain/apple/machoCpuIdentity.js";
import {
legacyMachoPlatform,
type MachoVersionMinPlatform,
} from "../../domain/apple/machoPlatform.js";
import {
classifyFatContainerIdentity,
readFatContainerHeader,
readFatSliceDeclarations,
readMachoHeaderPrefixFacts,
validateFatSlice,
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<Uint8Array>;
/** Load commands larger than this are refused instead of buffered. */
export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024;
/**
* Identical `LC_LAZY_LOAD_DYLIB_INFO` commands are 16 bytes, so the load-command
* byte cap alone would retain about a million dependency results. Stop before
* those results are kept or traced.
*/
export const MAX_LAZY_INFO_DEPENDENCIES = 1024;
/**
* Symbol names are validated and then discarded. Distinct one-byte strings
* stay under the 16 MiB byte budgets while retaining one cache entry per
* offset, so stop before that map and those decodes grow.
*/
export const MAX_LAZY_INFO_SYMBOL_REFERENCES = 64 * 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_SEGMENT = 0x1;
const LC_SEGMENT_64 = 0x19;
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_LAZY_LOAD_DYLIB_INFO = 0x3a;
const LC_LOAD_UPWARD_DYLIB = 0x80000023;
const LC_RPATH = 0x8000001c;
const LC_DYLD_ENVIRONMENT = 0x27;
const LC_CODE_SIGNATURE = 0x1d;
const LC_BUILD_VERSION = 0x32;
const CPU_TYPE_ARM64 = 0x0100000c;
/** loader.h command codes and their device platform identities. */
const VERSION_MIN_PLATFORMS: ReadonlyMap<number, MachoVersionMinPlatform> =
new Map([
[0x24, 1], // LC_VERSION_MIN_MACOSX
[0x25, 2], // LC_VERSION_MIN_IPHONEOS
[0x2f, 3], // LC_VERSION_MIN_TVOS
[0x30, 4], // LC_VERSION_MIN_WATCHOS
]);
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
const DYLIB_USE_MARKER = 0x1a741800;
const LAZY_INFO_HEADER_BYTES = 24;
/** ld64 may-be-missing bit: the lazy image is allowed to be absent. */
const LAZY_INFO_MAY_BE_MISSING = 0x1;
/**
* dyld's linkedit image-offset checks start at one page, then grow to the
* runtime end of every segment except `__LINKEDIT`.
*/
const LAZY_INFO_MIN_VM_OFFSET = 0x4000n;
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<number, MachoDependency["command"]> =
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<MachoImageFacts> => {
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<typeof readFatContainerHeader>;
if (magic === FAT_MAGIC && size <= MAX_JVM_CLASS_IDENTITY_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<number, number | null>();
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_JVM_CLASS_IDENTITY_BYTES)
throw new MachoFormatIssue(
"unsupported",
JVM_CLASS_IDENTITY_LIMIT_REASON,
);
throw new MachoFormatIssue(identity.category, identity.reason);
}
if (identity.state === "needs-slice-header")
return { status: "not-mach-o" };
const hiddenSlice = await readAppleHiddenArm64Slice(readAt, size, header);
return {
status: "parsed",
slices: await readFatSlices(readAt, header.slices),
...(hiddenSlice === null ? {} : { hiddenSlices: [hiddenSlice] }),
};
}
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;
}
};
/**
* Apple lipo stores one hidden ARM64 row after the declared 32-bit FAT table.
* It remains separate from the raw nfat_arch declarations and is only admitted
* from the single table-to-first-slice slot cctools recognizes.
*/
const readAppleHiddenArm64Slice = async (
readAt: ReadAt,
fileSize: number,
header: Extract<
ReturnType<typeof readFatContainerHeader>,
{ state: "parsed" }
>,
): Promise<MachoSlice | null> => {
if (header.wide) return null;
const firstOffset = Math.min(...header.slices.map(({ offset }) => offset));
const end = header.tableEnd + 20;
if (end > fileSize || end > firstOffset) return null;
const bytes = await readAt(header.tableEnd, 20);
if (bytes.byteLength < 20)
throw new MachoFormatIssue(
"malformed",
"Hidden ARM64 FAT row is truncated",
);
const row = readFatSliceDeclarations(bytes, {
count: 1,
wide: false,
littleEndian: header.littleEndian,
});
if (row.status !== "parsed")
throw new MachoFormatIssue("malformed", row.reason);
const declaration = { ...row.slices[0]!, index: header.count };
if (declaration.cpuType !== CPU_TYPE_ARM64) return null;
const rangeIssue = validateFatSlice(declaration, fileSize, end);
if (rangeIssue !== null) throw new MachoFormatIssue("malformed", rangeIssue);
const slice = await readSlice(readAt, declaration.offset, declaration.size, {
fatCpuType: declaration.cpuType,
fatCpuSubtype: declaration.cpuSubtype,
alignmentExponent: declaration.alignmentExponent,
});
const identityIssue = validateFatSliceIdentity(declaration, {
cpuType: slice.cpu_type,
cpuSubtype: slice.cpu_subtype,
});
if (identityIssue !== null)
throw new MachoFormatIssue("malformed", identityIssue);
return { ...slice, hidden: true };
};
const readFatSlices = async (
readAt: ReadAt,
declarations: readonly FatSliceDeclaration[],
): Promise<MachoSlice[]> => {
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<MachoSlice> => {
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 decoded = 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 slice = decoded.slice;
await appendDependencies(
slice,
decoded.dependencies,
readAt,
base,
length,
decoded.maxVmOffset,
);
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;
},
): {
readonly slice: MachoSlice;
readonly dependencies: readonly (
| { readonly kind: "parsed"; readonly value: MachoDependency }
| LazyLoadDylibInfoCommand
)[];
readonly maxVmOffset: bigint;
} => {
const slice: MachoSlice = {
architecture: machoArchitectureName(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,
};
const dependencies: Array<
| { readonly kind: "parsed"; readonly value: MachoDependency }
| LazyLoadDylibInfoCommand
> = [];
const segments: SegmentSpan[] = [];
let unreadableSegment: number | undefined;
let lazyInfo = false;
let lazyInfoCount = 0;
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);
if (span === undefined) unreadableSegment ??= index;
else segments.push(span);
}
if (command === LC_LAZY_LOAD_DYLIB_INFO) {
lazyInfoCount += 1;
if (lazyInfoCount > MAX_LAZY_INFO_DEPENDENCIES)
throw new MachoFormatIssue(
"unsupported",
`LC_LAZY_LOAD_DYLIB_INFO commands exceed the ${MAX_LAZY_INFO_DEPENDENCIES}-dependency limit`,
);
lazyInfo = true;
}
const decoded = decodeCommand(command, body, index, slice);
if (decoded !== undefined) dependencies.push(decoded);
}
const complete = commands.finish();
if (!complete.ok) throw new MachoFormatIssue("malformed", complete.error);
if (lazyInfo && unreadableSegment !== undefined)
throw new MachoFormatIssue(
"malformed",
`load command ${unreadableSegment} is too short to establish the slice VM range for LC_LAZY_LOAD_DYLIB_INFO`,
);
return { slice, dependencies, maxVmOffset: maximumVmOffset(segments) };
};
interface LazyLoadDylibInfoCommand {
readonly kind: "lazy-info";
readonly index: number;
readonly dataOffset: number;
readonly dataSize: number;
}
interface SegmentSpan {
readonly name: string;
readonly vmaddr: bigint;
readonly vmsize: bigint;
}
/** Runtime end of the mapped image, excluding `__LINKEDIT`, floored at one page. */
const maximumVmOffset = (segments: readonly SegmentSpan[]): bigint => {
let textVmAddr = 0n;
for (const segment of segments) {
if (segment.name === "__TEXT") {
textVmAddr = segment.vmaddr;
break;
}
}
let max = LAZY_INFO_MIN_VM_OFFSET;
for (const segment of segments) {
if (segment.name === "__LINKEDIT") continue;
const end = segment.vmaddr - textVmAddr + segment.vmsize;
if (end > max) max = end;
}
return max;
};
const readSegmentSpan = (
body: Uint8Array,
wide: boolean,
): SegmentSpan | undefined => {
const parsed = readMachoSegmentHeader(body, wide, true);
if (!parsed.ok) return undefined;
const { nameBytes, address: vmaddr, memorySize: vmsize } = parsed.value;
const end = nameBytes.indexOf(0);
return {
name: new TextDecoder("utf-8", { fatal: false }).decode(
end < 0 ? nameBytes : nameBytes.subarray(0, end),
),
vmaddr,
vmsize,
};
};
interface CachedLazyString {
readonly text: string;
/** Absolute file offset of the terminating NUL. */
readonly end: number;
}
interface LazyPayloadWork {
readonly dependencies: Map<string, MachoDependency>;
readonly reads: { fileOffset: number; bytes: Uint8Array }[];
readonly strings: Map<number, CachedLazyString>;
payloadBytes: number;
/** Scanned string bytes. Repeats and overlaps each count in full. */
stringBytes: number;
/** Symbol-string references. Repeats each count. */
symbolReferences: number;
rangeChecks: number;
}
const containedPayload = (
reads: readonly { fileOffset: number; bytes: Uint8Array }[],
fileOffset: number,
size: number,
work: LazyPayloadWork,
): Uint8Array | undefined => {
for (const read of reads) {
work.rangeChecks += 1;
if (work.rangeChecks > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
`LC_LAZY_LOAD_DYLIB_INFO payload range lookup exceeds the ${MAX_LOAD_COMMAND_BYTES}-check limit`,
);
const start = fileOffset - read.fileOffset;
if (
start >= 0 &&
Number.isSafeInteger(start) &&
start + size <= read.bytes.byteLength
)
return read.bytes.subarray(start, start + size);
}
return undefined;
};
const appendDependencies = async (
slice: MachoSlice,
dependencies: readonly (
| { readonly kind: "parsed"; readonly value: MachoDependency }
| LazyLoadDylibInfoCommand
)[],
readAt: ReadAt,
base: number,
length: number,
maxVmOffset: bigint,
): Promise<void> => {
const work: LazyPayloadWork = {
dependencies: new Map(),
reads: [],
strings: new Map(),
payloadBytes: 0,
stringBytes: 0,
symbolReferences: 0,
rangeChecks: 0,
};
for (const dependency of dependencies) {
if (dependency.kind === "parsed") {
slice.dependencies.push(dependency.value);
continue;
}
const key = `${dependency.dataOffset}:${dependency.dataSize}`;
const cached = work.dependencies.get(key);
if (cached !== undefined) {
slice.dependencies.push(cached);
continue;
}
const value = await readLazyLoadDylibInfo(
readAt,
base,
length,
dependency,
maxVmOffset,
work,
);
work.dependencies.set(key, value);
slice.dependencies.push(value);
}
};
const decodeCommand = (
command: number,
body: Uint8Array,
index: number,
slice: MachoSlice,
):
| { readonly kind: "parsed"; readonly value: MachoDependency }
| LazyLoadDylibInfoCommand
| undefined => {
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 = VERSION_MIN_PLATFORMS.get(command);
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(legacyMachoPlatform(legacyPlatform, slice.cpu_type));
return;
}
if (command === LC_LAZY_LOAD_DYLIB_INFO)
return decodeLazyLoadDylibInfoCommand(body, index);
const name = DEPENDENCY_COMMANDS.get(command);
if (name !== undefined)
return { kind: "parsed", value: decodeDependency(name, body, index) };
return undefined;
};
/**
* `LC_LAZY_LOAD_DYLIB_INFO` is a linkedit-data command. The install name is
* not in the command; it is a NUL-terminated string in the payload.
*/
const decodeLazyLoadDylibInfoCommand = (
body: Uint8Array,
index: number,
): LazyLoadDylibInfoCommand => {
if (body.byteLength < 16)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for LC_LAZY_LOAD_DYLIB_INFO`,
);
const view = viewOf(body);
const dataOffset = view.getUint32(8, true);
const dataSize = view.getUint32(12, true);
if (dataSize < LAZY_INFO_HEADER_BYTES)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO payload is shorter than its 24-byte header`,
);
if (dataSize > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
`LC_LAZY_LOAD_DYLIB_INFO payload occupies ${dataSize} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
);
return { kind: "lazy-info", index, dataOffset, dataSize };
};
const readLazyPayloadBytes = async (
readAt: ReadAt,
fileOffset: number,
size: number,
index: number,
work: LazyPayloadWork,
): Promise<Uint8Array> => {
const covered = containedPayload(work.reads, fileOffset, size, work);
if (covered !== undefined) return covered;
work.payloadBytes += size;
if (work.payloadBytes > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
`LC_LAZY_LOAD_DYLIB_INFO payloads occupy ${work.payloadBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte aggregate limit`,
);
const payload = await readExact(
readAt,
fileOffset,
size,
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO payload`,
);
work.reads.push({ fileOffset, bytes: payload });
return payload;
};
const readLazyLoadDylibInfo = async (
readAt: ReadAt,
base: number,
length: number,
command: LazyLoadDylibInfoCommand,
maxVmOffset: bigint,
work: LazyPayloadWork,
): Promise<MachoDependency> => {
if (
command.dataOffset > length ||
command.dataSize > length - command.dataOffset
)
throw new MachoFormatIssue(
"malformed",
`load command ${command.index} LC_LAZY_LOAD_DYLIB_INFO payload extends beyond the Mach-O slice`,
);
const fileOffset = base + command.dataOffset;
if (!Number.isSafeInteger(fileOffset))
throw new MachoFormatIssue(
"malformed",
`load command ${command.index} LC_LAZY_LOAD_DYLIB_INFO payload offset is not a safe integer`,
);
const payload = await readLazyPayloadBytes(
readAt,
fileOffset,
command.dataSize,
command.index,
work,
);
return decodeLazyLoadDylibInfoPayload(
payload,
command.index,
fileOffset,
maxVmOffset,
work,
);
};
const decodeLazyLoadDylibInfoPayload = (
payload: Uint8Array,
index: number,
fileOffset: number,
maxVmOffset: bigint,
work: LazyPayloadWork,
): MachoDependency => {
const view = viewOf(payload);
const pathOffset = view.getUint32(0, true);
const flagImageOffset = view.getUint32(4, true);
const flags = view.getUint16(8, true);
const chainStartImageOffset = view.getUint32(12, true);
const symbolCount = view.getUint32(16, true);
const symbolArrayOffset = view.getUint32(20, true);
if (BigInt(flagImageOffset) > maxVmOffset)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO flag image offset 0x${flagImageOffset.toString(16)} is beyond the slice VM range 0x${maxVmOffset.toString(16)}`,
);
if (BigInt(chainStartImageOffset) > maxVmOffset)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO chain start image offset 0x${chainStartImageOffset.toString(16)} is beyond the slice VM range 0x${maxVmOffset.toString(16)}`,
);
if ((flags & ~LAZY_INFO_MAY_BE_MISSING) !== 0)
throw new MachoFormatIssue(
"unsupported",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO flags 0x${flags.toString(16)} include unrecognized bits`,
);
if (
symbolArrayOffset > payload.byteLength ||
symbolCount > (payload.byteLength - symbolArrayOffset) / 4
)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol table extends beyond the payload`,
);
const installName = payloadString(
payload,
pathOffset,
fileOffset,
index,
"load path",
work,
true,
);
const symbolReferences = work.symbolReferences + symbolCount;
if (symbolReferences > MAX_LAZY_INFO_SYMBOL_REFERENCES)
throw new MachoFormatIssue(
"unsupported",
`LC_LAZY_LOAD_DYLIB_INFO symbol validation exceeds the ${MAX_LAZY_INFO_SYMBOL_REFERENCES}-reference limit`,
);
work.symbolReferences = symbolReferences;
for (let symbol = 0; symbol < symbolCount; symbol += 1) {
const offset = view.getUint32(symbolArrayOffset + symbol * 4, true);
payloadString(
payload,
offset,
fileOffset,
index,
`symbol ${symbol}`,
work,
false,
);
}
return {
command: "LC_LAZY_LOAD_DYLIB_INFO",
encoding: "lazy_load_dylib_info",
install_name: installName,
weak: (flags & LAZY_INFO_MAY_BE_MISSING) !== 0,
upward: false,
reexport: false,
delayed_init: false,
current_version: null,
compatibility_version: null,
};
};
const chargeLazyString = (work: LazyPayloadWork, examined: number): void => {
const occupied = work.stringBytes + examined;
if (occupied > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
`LC_LAZY_LOAD_DYLIB_INFO string validation occupies ${occupied} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
);
work.stringBytes = occupied;
};
/**
* NUL-terminated UTF-8 string after the fixed payload header.
* dyld's load-path scan stops before the payload's final byte, so a NUL
* in that byte does not terminate the path.
*/
const payloadString = (
payload: Uint8Array,
offset: number,
fileOffset: number,
index: number,
label: string,
work: LazyPayloadWork,
excludeFinalByte: boolean,
): string => {
if (offset < LAZY_INFO_HEADER_BYTES)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} offset ${offset} is inside the payload header`,
);
if (offset >= payload.byteLength)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} offset ${offset} is outside the payload`,
);
const absolute = Number.isSafeInteger(fileOffset + offset)
? fileOffset + offset
: undefined;
const cached =
absolute === undefined ? undefined : work.strings.get(absolute);
const terminatorLimit = excludeFinalByte
? payload.byteLength - 1
: payload.byteLength;
if (cached !== undefined && absolute !== undefined) {
const end = cached.end - fileOffset;
if (end < offset || end >= terminatorLimit)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`,
);
chargeLazyString(work, cached.end - absolute + 1);
return cached.text;
}
if (offset >= terminatorLimit)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`,
);
const remaining = MAX_LOAD_COMMAND_BYTES - work.stringBytes;
const windowEnd = Math.min(terminatorLimit, offset + remaining);
const end = payload.subarray(0, windowEnd).indexOf(0, offset);
if (end < 0) {
if (windowEnd < terminatorLimit)
throw new MachoFormatIssue(
"unsupported",
`LC_LAZY_LOAD_DYLIB_INFO string validation occupies ${work.stringBytes + (windowEnd - offset) + 1} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
);
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`,
);
}
chargeLazyString(work, end - offset + 1);
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
payload.subarray(offset, end),
);
} catch {
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is not UTF-8`,
);
}
if (absolute !== undefined && Number.isSafeInteger(fileOffset + end))
work.strings.set(absolute, { text, end: fileOffset + end });
return text;
};
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<Uint8Array> => {
const bytes = await readAt(offset, length);
if (bytes.byteLength < length)
throw new MachoFormatIssue("malformed", `${label} is truncated`);
return bytes;
};
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);