mirror of
https://github.com/morluto/rea.git
synced 2026-11-04 22:06:10 +00:00
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
import type { FixupCommands } from "./AppleMachoFixups.js";
|
|
import {
|
|
MACHO_ARCHITECTURE_POLICIES,
|
|
machoSubtypeRank,
|
|
} from "./MachoArchitectureSelection.js";
|
|
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
|
|
import {
|
|
readFatSliceDeclarations,
|
|
validateFatSlice,
|
|
type FatSliceDeclaration,
|
|
} from "../domain/apple/machoContainer.js";
|
|
|
|
/** One mapped Mach-O segment with file-backed VA range. */
|
|
export interface Segment {
|
|
address: bigint;
|
|
size: bigint;
|
|
offset: number;
|
|
executable: boolean;
|
|
}
|
|
|
|
/** Named Mach-O section within a loaded segment. */
|
|
export interface Section {
|
|
name: string;
|
|
address: bigint;
|
|
size: number;
|
|
}
|
|
|
|
/** Selected thin slice bounds inside a Mach-O or FAT container. */
|
|
export interface MachoSlice {
|
|
slice: number;
|
|
sliceEnd: number;
|
|
/** FAT table identity, absent for a thin image. */
|
|
fatDeclaration?: {
|
|
offset: number;
|
|
size: number;
|
|
cpuType: number;
|
|
cpuSubtype: number;
|
|
alignmentExponent: number;
|
|
index: number;
|
|
tableEnd: number;
|
|
};
|
|
}
|
|
|
|
/** Parsed little-endian 64-bit Mach-O layout for the selected architecture slice. */
|
|
export interface MachoLayout {
|
|
segments: Segment[];
|
|
sections: Section[];
|
|
/** Fixup load commands and dependent dylibs of the selected slice. */
|
|
fixups: FixupCommands;
|
|
/** VM address of the segment that maps file offset 0 (the preferred load address). */
|
|
baseAddress: bigint;
|
|
/** Map a virtual address range onto a file offset within the selected slice. */
|
|
offset(address: bigint, size?: number): number;
|
|
}
|
|
|
|
const hex = (value: bigint) => `0x${value.toString(16)}`;
|
|
|
|
/** Classify a FAT magic word; thin Mach-O returns null. */
|
|
export const fatHeaderFormat = (
|
|
magic: number,
|
|
): { readonly littleEndian: boolean; readonly fat64: boolean } | null => {
|
|
switch (magic) {
|
|
case 0xcafebabe:
|
|
return { littleEndian: false, fat64: false };
|
|
case 0xcafebabf:
|
|
return { littleEndian: false, fat64: true };
|
|
case 0xbebafeca:
|
|
return { littleEndian: true, fat64: false };
|
|
case 0xbfbafeca:
|
|
return { littleEndian: true, fat64: true };
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/** Select the thin Mach-O slice for `architecture` inside a FAT or thin container. */
|
|
export const selectMachoSlice = (
|
|
bytes: Buffer,
|
|
architecture: string,
|
|
): MachoSlice => {
|
|
const magic = bytes.readUInt32BE(0);
|
|
const header = fatHeaderFormat(magic);
|
|
if (header === null) return { slice: 0, sliceEnd: bytes.length };
|
|
const { littleEndian, fat64 } = header;
|
|
const stride = fat64 ? 32 : 20;
|
|
const readUInt32 = littleEndian
|
|
? (offset: number) => bytes.readUInt32LE(offset)
|
|
: (offset: number) => bytes.readUInt32BE(offset);
|
|
const byteOrder = littleEndian ? "little-endian" : "big-endian";
|
|
const malformed = (reason: string) =>
|
|
new RangeError(`${reason} (FAT header byte order: ${byteOrder})`);
|
|
const invalid = (reason: string) =>
|
|
new TypeError(`${reason} (FAT header byte order: ${byteOrder})`);
|
|
if (bytes.length < 8) throw malformed("Malformed FAT architecture table");
|
|
const count = readUInt32(4);
|
|
const headerEnd = 8 + count * stride;
|
|
if (count > 128 || headerEnd > bytes.length)
|
|
throw malformed("Malformed FAT architecture table");
|
|
const policy = MACHO_ARCHITECTURE_POLICIES[architecture];
|
|
if (policy === undefined)
|
|
throw invalid("Requested FAT architecture is absent");
|
|
const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), {
|
|
count,
|
|
wide: fat64,
|
|
littleEndian,
|
|
});
|
|
if (declarations.status !== "parsed") throw malformed(declarations.reason);
|
|
let best: FatSliceDeclaration | undefined;
|
|
let bestRank = Number.POSITIVE_INFINITY;
|
|
let ambiguous = false;
|
|
for (const declaration of declarations.slices) {
|
|
const issue = validateFatSlice(declaration, bytes.length, headerEnd);
|
|
if (issue !== null) throw malformed(issue);
|
|
if (declaration.cpuType !== policy.cpuType) continue;
|
|
const rank = machoSubtypeRank(policy, declaration.cpuSubtype);
|
|
if (best !== undefined && rank >= bestRank) {
|
|
// Equal ranks keep FAT table order unless the slices share a subtype.
|
|
if (
|
|
rank === bestRank &&
|
|
machoCpuSubtypeBase(declaration.cpuSubtype) ===
|
|
machoCpuSubtypeBase(best.cpuSubtype)
|
|
)
|
|
ambiguous = true;
|
|
continue;
|
|
}
|
|
best = declaration;
|
|
bestRank = rank;
|
|
ambiguous = false;
|
|
}
|
|
if (best === undefined) throw invalid("Requested FAT architecture is absent");
|
|
if (ambiguous) throw invalid("Ambiguous FAT architecture slice");
|
|
const selected: MachoSlice = {
|
|
slice: best.offset,
|
|
sliceEnd: best.offset + best.size,
|
|
fatDeclaration: { ...best, tableEnd: headerEnd },
|
|
};
|
|
if (selected.slice + 4 > bytes.length)
|
|
throw malformed("Truncated Mach-O header");
|
|
const sliceMagic = bytes.readUInt32LE(selected.slice);
|
|
if (sliceMagic === MH_MAGIC || sliceMagic === MH_CIGAM)
|
|
throw invalid(
|
|
`Unsupported 32-bit Mach-O metadata for requested CPU type ${architecture}`,
|
|
);
|
|
return selected;
|
|
};
|
|
|
|
const MH_MAGIC = 0xfeedface;
|
|
const MH_CIGAM = 0xcefaedfe;
|
|
|
|
/** Parse segments/sections for a little-endian 64-bit Mach-O architecture slice. */
|
|
export const parseMachoLayout = (
|
|
bytes: Buffer,
|
|
architecture: string,
|
|
): MachoLayout => {
|
|
if (bytes.length < 4) throw new RangeError("Truncated Mach-O header");
|
|
const selection = selectMachoSlice(bytes, architecture);
|
|
const { slice, sliceEnd } = selection;
|
|
if (slice === 0 && sliceEnd === bytes.length && bytes.length < 32)
|
|
throw new RangeError("Truncated Mach-O header");
|
|
if (slice + 32 > sliceEnd || bytes.readUInt32LE(slice) !== 0xfeedfacf)
|
|
throw new TypeError(
|
|
"Only little-endian 64-bit Mach-O metadata is supported",
|
|
);
|
|
const fat = selection.fatDeclaration;
|
|
if (fat !== undefined) {
|
|
const issue = validateFatSlice(fat, bytes.length, fat.tableEnd, {
|
|
cpuType: bytes.readUInt32LE(slice + 4),
|
|
cpuSubtype: bytes.readUInt32LE(slice + 8),
|
|
});
|
|
if (issue !== null) throw new TypeError(issue);
|
|
}
|
|
const commands = bytes.readUInt32LE(slice + 16);
|
|
const commandEnd = slice + 32 + bytes.readUInt32LE(slice + 20);
|
|
if (commands > 4096 || commandEnd > sliceEnd)
|
|
throw new RangeError("Malformed Mach-O command bounds");
|
|
const state: LayoutState = {
|
|
slice,
|
|
sliceEnd,
|
|
segments: [],
|
|
sections: [],
|
|
dylibs: [],
|
|
binds: [],
|
|
rebases: [],
|
|
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 command = {
|
|
cursor,
|
|
kind: bytes.readUInt32LE(cursor),
|
|
size: bytes.readUInt32LE(cursor + 4),
|
|
};
|
|
if (command.size < 8 || 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 { segments, sections, chained, binds, rebases, dylibs } = state;
|
|
const offset = (address: bigint, size = 1): number => {
|
|
const matches = segments.filter(
|
|
(segment) =>
|
|
address >= segment.address &&
|
|
address + BigInt(size) <= segment.address + segment.size,
|
|
);
|
|
if (matches.length !== 1)
|
|
throw new RangeError(
|
|
`Unmapped or ambiguous metadata pointer ${hex(address)}`,
|
|
);
|
|
const segment = matches[0];
|
|
if (segment === undefined) throw new RangeError("Missing metadata segment");
|
|
return segment.offset + Number(address - segment.address);
|
|
};
|
|
return {
|
|
segments,
|
|
sections,
|
|
offset,
|
|
fixups: { chained, binds, rebases, dylibs },
|
|
baseAddress: state.baseAddress ?? 0n,
|
|
};
|
|
};
|
|
|
|
/** Load commands and fixup ranges collected while walking one slice. */
|
|
interface LayoutState {
|
|
readonly slice: number;
|
|
readonly sliceEnd: number;
|
|
readonly segments: Segment[];
|
|
readonly sections: Section[];
|
|
readonly dylibs: string[];
|
|
readonly binds: FixupCommands["binds"][number][];
|
|
readonly rebases: FixupCommands["rebases"][number][];
|
|
chained: FixupCommands["chained"];
|
|
baseAddress: bigint | undefined;
|
|
}
|
|
|
|
/** One load command header inside the selected slice. */
|
|
interface LoadCommand {
|
|
readonly cursor: number;
|
|
readonly kind: number;
|
|
readonly size: number;
|
|
}
|
|
|
|
/** Record an `LC_SEGMENT_64` and its section table. */
|
|
const collectSegment = (
|
|
bytes: Buffer,
|
|
{ cursor, size }: LoadCommand,
|
|
state: LayoutState,
|
|
): void => {
|
|
if (size < 72) throw new RangeError("Truncated segment command");
|
|
const address = bytes.readBigUInt64LE(cursor + 24),
|
|
fileSize = bytes.readBigUInt64LE(cursor + 48),
|
|
fileOffset = bytes.readBigUInt64LE(cursor + 40);
|
|
if (fileOffset + fileSize > BigInt(state.sliceEnd - state.slice))
|
|
throw new RangeError("Segment file range exceeds target bytes");
|
|
state.segments.push({
|
|
address,
|
|
size: fileSize,
|
|
offset: state.slice + Number(fileOffset),
|
|
executable: (bytes.readUInt32LE(cursor + 60) & 4) !== 0,
|
|
});
|
|
if (fileOffset === 0n && fileSize > 0n) state.baseAddress ??= address;
|
|
const count = bytes.readUInt32LE(cursor + 64);
|
|
if (72 + count * 80 > size) throw new RangeError("Truncated section table");
|
|
for (let section = 0; section < count; section++) {
|
|
const position = cursor + 72 + section * 80;
|
|
const sectionSize = bytes.readBigUInt64LE(position + 40);
|
|
if (sectionSize > BigInt(Number.MAX_SAFE_INTEGER))
|
|
throw new RangeError("Section size exceeds exact numeric range");
|
|
state.sections.push({
|
|
name: bytes
|
|
.subarray(position, position + 16)
|
|
// ASCII decoding masks high bits and can fabricate known metadata names.
|
|
.toString("latin1")
|
|
.replace(/\0.*$/u, ""),
|
|
address: bytes.readBigUInt64LE(position + 32),
|
|
size: Number(sectionSize),
|
|
});
|
|
}
|
|
};
|
|
|
|
const LC_DYLD_INFO = 0x22;
|
|
const LC_DYLD_INFO_ONLY = 0x80000022;
|
|
const LC_DYLD_CHAINED_FIXUPS = 0x80000034;
|
|
const DYLIB_COMMANDS: readonly number[] = [
|
|
0xc, 0x80000018, 0x8000001f, 0x20, 0x80000023,
|
|
];
|
|
|
|
/** Record fixup data ranges and dylib install names from one load command. */
|
|
const collectFixupCommand = (
|
|
bytes: Buffer,
|
|
{ cursor, kind, size }: LoadCommand,
|
|
state: LayoutState,
|
|
): void => {
|
|
const range = (at: number): { offset: number; size: number } => {
|
|
const offset = state.slice + bytes.readUInt32LE(at);
|
|
const length = bytes.readUInt32LE(at + 4);
|
|
if (offset + length > state.sliceEnd)
|
|
throw new RangeError("Fixup data exceeds the Mach-O slice");
|
|
return { offset, size: length };
|
|
};
|
|
if (kind === LC_DYLD_CHAINED_FIXUPS) {
|
|
if (size < 16) throw new RangeError("Truncated chained-fixups command");
|
|
state.chained = range(cursor + 8);
|
|
} else if (kind === LC_DYLD_INFO || kind === LC_DYLD_INFO_ONLY) {
|
|
if (size < 48) throw new RangeError("Truncated dyld-info command");
|
|
const rebase = range(cursor + 8);
|
|
if (rebase.size > 0) state.rebases.push(rebase);
|
|
for (const [at, stream] of [
|
|
[cursor + 16, "bind"],
|
|
[cursor + 24, "weak"],
|
|
[cursor + 32, "lazy"],
|
|
] as const) {
|
|
const bound = range(at);
|
|
if (bound.size > 0) state.binds.push({ ...bound, stream });
|
|
}
|
|
} else if (DYLIB_COMMANDS.includes(kind)) {
|
|
if (size < 24) throw new RangeError("Truncated dylib command");
|
|
const nameOffset = bytes.readUInt32LE(cursor + 8);
|
|
if (nameOffset < 24 || nameOffset >= size)
|
|
throw new RangeError("Dylib install name is outside its load command");
|
|
const end = bytes.indexOf(0, cursor + nameOffset);
|
|
if (end < 0 || end >= cursor + size)
|
|
throw new RangeError("Unterminated dylib install name");
|
|
state.dylibs.push(bytes.toString("utf8", cursor + nameOffset, end));
|
|
}
|
|
};
|