574 lines
19 KiB
TypeScript
574 lines
19 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { open, type FileHandle } from "node:fs/promises";
|
|
|
|
import { applePlatform } from "../../domain/apple/applePlatforms.js";
|
|
import type { MachoImageFacts } from "../../domain/apple/dylibResolution.js";
|
|
import type {
|
|
DyldCacheMapping,
|
|
DyldCacheSubcache,
|
|
DyldSharedCacheHeader,
|
|
} from "../../domain/apple/dyldSharedCache.js";
|
|
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
|
import { readMachoImage } from "./MachoLoadCommandReader.js";
|
|
|
|
const MAGIC_PREFIX = "dyld_v1";
|
|
const DEVELOPMENT_EXTENSION = ".development";
|
|
/** Header fields are valid only below `mappingOffset`; real headers are under 1 KiB. */
|
|
const MAX_HEADER_BYTES = 64 * 1024;
|
|
const MAX_IMAGES = 200_000;
|
|
const MAX_MAPPINGS = 64;
|
|
const MAX_SUBCACHES = 256;
|
|
const MAX_PATH_BYTES = 4096;
|
|
const BLOCK_BYTES = 64 * 1024;
|
|
const HASH_CHUNK_BYTES = 1024 * 1024;
|
|
|
|
/** Byte offsets of `dyld_cache_header` fields in Apple's open-source dyld. */
|
|
const FIELD = {
|
|
mappingOffset: 0x10,
|
|
mappingCount: 0x14,
|
|
imagesOffsetOld: 0x18,
|
|
imagesCountOld: 0x1c,
|
|
uuid: 0x58,
|
|
cacheType: 0x68,
|
|
platform: 0xd8,
|
|
formatBits: 0xdc,
|
|
sharedRegionStart: 0xe0,
|
|
sharedRegionSize: 0xe8,
|
|
maxSlide: 0xf0,
|
|
osVersion: 0x16c,
|
|
altPlatform: 0x170,
|
|
altOsVersion: 0x174,
|
|
subCacheArrayOffset: 0x188,
|
|
subCacheArrayCount: 0x18c,
|
|
symbolFileUuid: 0x190,
|
|
imagesOffset: 0x1c0,
|
|
imagesCount: 0x1c4,
|
|
cacheSubType: 0x1c8,
|
|
} as const;
|
|
|
|
const hex = (value: bigint | number): string => `0x${value.toString(16)}`;
|
|
|
|
const uuidText = (bytes: Buffer): string => {
|
|
const text = bytes.toString("hex").toUpperCase();
|
|
return `${text.slice(0, 8)}-${text.slice(8, 12)}-${text.slice(12, 16)}-${text.slice(16, 20)}-${text.slice(20)}`;
|
|
};
|
|
|
|
const safeNumber = (value: bigint, label: string): number => {
|
|
if (value > BigInt(Number.MAX_SAFE_INTEGER))
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`dyld cache ${label} exceeds the safe range`,
|
|
);
|
|
return Number(value);
|
|
};
|
|
|
|
/** Block-cached positional reads over one cache file. */
|
|
class CacheFile {
|
|
readonly #blocks = new Map<number, Buffer>();
|
|
|
|
private constructor(
|
|
readonly suffix: string,
|
|
readonly handle: FileHandle,
|
|
readonly size: number,
|
|
) {}
|
|
|
|
static async open(path: string, suffix: string): Promise<CacheFile> {
|
|
const handle = await open(path, "r");
|
|
try {
|
|
return new CacheFile(suffix, handle, (await handle.stat()).size);
|
|
} catch (cause: unknown) {
|
|
await handle.close();
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
async read(offset: number, length: number): Promise<Buffer> {
|
|
if (offset < 0 || offset + length > this.size)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`dyld cache read at ${hex(offset)} extends beyond ${this.suffix === "" ? "the main file" : `subcache ${this.suffix}`}`,
|
|
);
|
|
if (length > BLOCK_BYTES) {
|
|
const bytes = Buffer.alloc(length);
|
|
await this.readInto(bytes, 0, length, offset);
|
|
return bytes;
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
for (let position = offset; position < offset + length;) {
|
|
const index = Math.floor(position / BLOCK_BYTES);
|
|
let block = this.#blocks.get(index);
|
|
if (block === undefined) {
|
|
const start = index * BLOCK_BYTES;
|
|
block = Buffer.alloc(Math.min(BLOCK_BYTES, this.size - start));
|
|
await this.readInto(block, 0, block.length, start);
|
|
this.#blocks.set(index, block);
|
|
}
|
|
const from = position - index * BLOCK_BYTES;
|
|
const take = Math.min(block.length - from, offset + length - position);
|
|
chunks.push(block.subarray(from, from + take));
|
|
position += take;
|
|
}
|
|
return chunks.length === 1
|
|
? (chunks[0] ?? Buffer.alloc(0))
|
|
: Buffer.concat(chunks);
|
|
}
|
|
|
|
/** Positional read that retries short reads; slow filesystems may return partial data. */
|
|
private async readInto(
|
|
target: Buffer,
|
|
targetOffset: number,
|
|
length: number,
|
|
position: number,
|
|
): Promise<void> {
|
|
let done = 0;
|
|
while (done < length) {
|
|
const { bytesRead } = await this.handle.read(
|
|
target,
|
|
targetOffset + done,
|
|
length - done,
|
|
position + done,
|
|
);
|
|
if (bytesRead === 0)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`dyld cache read at ${hex(position)} is truncated`,
|
|
);
|
|
done += bytesRead;
|
|
}
|
|
}
|
|
|
|
async cString(offset: number): Promise<string> {
|
|
const bytes = await this.read(
|
|
offset,
|
|
Math.min(MAX_PATH_BYTES, this.size - offset),
|
|
);
|
|
const end = bytes.indexOf(0);
|
|
if (end < 0)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`dyld cache string at ${hex(offset)} is unterminated`,
|
|
);
|
|
return bytes.toString("utf8", 0, end);
|
|
}
|
|
}
|
|
|
|
interface ParsedHeader {
|
|
readonly magic: string;
|
|
readonly uuid: string;
|
|
readonly mappings: readonly {
|
|
readonly address: bigint;
|
|
readonly size: bigint;
|
|
readonly fileOffset: bigint;
|
|
readonly maxProt: number;
|
|
readonly initProt: number;
|
|
}[];
|
|
readonly header: Buffer;
|
|
readonly length: number;
|
|
}
|
|
|
|
const has = (parsed: ParsedHeader, offset: number, width: number): boolean =>
|
|
parsed.length >= offset + width;
|
|
|
|
const readHeader = async (file: CacheFile): Promise<ParsedHeader> => {
|
|
if (file.size < 0x20)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"File is too short to be a dyld shared cache",
|
|
);
|
|
const prefix = await file.read(0, 0x20);
|
|
const magic = prefix.toString("latin1", 0, 16).replace(/\0+$/u, "");
|
|
if (!magic.startsWith(MAGIC_PREFIX))
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"File is not a dyld shared cache",
|
|
);
|
|
const mappingOffset = prefix.readUInt32LE(FIELD.mappingOffset);
|
|
const mappingCount = prefix.readUInt32LE(FIELD.mappingCount);
|
|
if (
|
|
mappingOffset < 0x20 ||
|
|
mappingOffset > MAX_HEADER_BYTES ||
|
|
mappingCount === 0 ||
|
|
mappingCount > MAX_MAPPINGS
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"dyld cache header has an invalid mapping table",
|
|
);
|
|
const header = await file.read(0, mappingOffset);
|
|
const table = await file.read(mappingOffset, mappingCount * 32);
|
|
const mappings = Array.from({ length: mappingCount }, (_, index) => ({
|
|
address: table.readBigUInt64LE(index * 32),
|
|
size: table.readBigUInt64LE(index * 32 + 8),
|
|
fileOffset: table.readBigUInt64LE(index * 32 + 16),
|
|
maxProt: table.readUInt32LE(index * 32 + 24),
|
|
initProt: table.readUInt32LE(index * 32 + 28),
|
|
}));
|
|
const parsed = { magic, uuid: "", mappings, header, length: mappingOffset };
|
|
return {
|
|
...parsed,
|
|
uuid: has(parsed, FIELD.uuid, 16)
|
|
? uuidText(header.subarray(FIELD.uuid, FIELD.uuid + 16))
|
|
: "",
|
|
};
|
|
};
|
|
|
|
const version = (value: number): string | null =>
|
|
value === 0
|
|
? null
|
|
: `${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`;
|
|
|
|
/**
|
|
* Decode `cacheType` according to header generation. Modern headers
|
|
* (those carrying `cacheSubType`) use `0 = development`, `1 = production`;
|
|
* older single-file headers use the historical `1 = development`,
|
|
* `0 = optimized` polarity.
|
|
*/
|
|
const decodeCacheType = (
|
|
cacheType: number | null,
|
|
modern: boolean,
|
|
): DyldSharedCacheHeader["cache_type"] => {
|
|
if (cacheType === null) return null;
|
|
if (modern)
|
|
return cacheType === 0
|
|
? "development"
|
|
: cacheType === 1
|
|
? "production"
|
|
: cacheType === 2
|
|
? "multi-cache"
|
|
: null;
|
|
return cacheType === 1
|
|
? "development"
|
|
: cacheType === 0
|
|
? "production"
|
|
: null;
|
|
};
|
|
|
|
/** A main dyld shared cache file and the subcaches it names. */
|
|
export class DyldSharedCache {
|
|
readonly #byPath: ReadonlyMap<
|
|
string,
|
|
{ readonly path: string; readonly address: number }
|
|
>;
|
|
|
|
private constructor(
|
|
readonly header: DyldSharedCacheHeader,
|
|
readonly images: readonly {
|
|
readonly path: string;
|
|
readonly address: number;
|
|
}[],
|
|
private readonly files: readonly CacheFile[],
|
|
private readonly regions: readonly {
|
|
readonly file: CacheFile;
|
|
readonly address: number;
|
|
readonly size: number;
|
|
readonly fileOffset: number;
|
|
}[],
|
|
) {
|
|
this.#byPath = new Map(images.map((image) => [image.path, image]));
|
|
}
|
|
|
|
/** Open the main cache file and every subcache beside it. */
|
|
static async open(
|
|
path: string,
|
|
signal?: AbortSignal,
|
|
): Promise<DyldSharedCache> {
|
|
const main = await CacheFile.open(path, "");
|
|
const files = [main];
|
|
try {
|
|
const parsed = await readHeader(main);
|
|
const { named, entries: subcaches } =
|
|
await DyldSharedCache.#subcacheEntries(main, parsed);
|
|
// With named suffixes, dyld strips a development main file's extension
|
|
// first: dyld_shared_cache_arm64e.development -> dyld_shared_cache_arm64e.01.development.
|
|
const base =
|
|
named && path.endsWith(DEVELOPMENT_EXTENSION)
|
|
? path.slice(0, -DEVELOPMENT_EXTENSION.length)
|
|
: path;
|
|
const statuses: DyldCacheSubcache[] = [];
|
|
const parsedFiles = [{ file: main, parsed }];
|
|
for (const entry of subcaches) {
|
|
signal?.throwIfAborted();
|
|
let file: CacheFile;
|
|
try {
|
|
file = await CacheFile.open(`${base}${entry.suffix}`, entry.suffix);
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
cause.code === "ENOENT"
|
|
) {
|
|
statuses.push({ ...entry, status: "missing", observed_uuid: null });
|
|
continue;
|
|
}
|
|
throw cause;
|
|
}
|
|
files.push(file);
|
|
const subParsed = await readHeader(file);
|
|
const matches = subParsed.uuid === entry.uuid;
|
|
statuses.push({
|
|
...entry,
|
|
status: matches ? "present" : "uuid-mismatch",
|
|
observed_uuid: subParsed.uuid,
|
|
});
|
|
if (matches) parsedFiles.push({ file, parsed: subParsed });
|
|
}
|
|
const header = DyldSharedCache.#summary(parsed, parsedFiles, statuses);
|
|
const images = await DyldSharedCache.#images(main, parsed, signal);
|
|
const regions = parsedFiles.flatMap(({ file, parsed: item }) =>
|
|
item.mappings.map((mapping) => {
|
|
const fileOffset = safeNumber(
|
|
mapping.fileOffset,
|
|
"mapping file offset",
|
|
);
|
|
const size = safeNumber(mapping.size, "mapping size");
|
|
// A truncated file that declares mappings beyond its bytes cannot
|
|
// map those addresses; drop the unverified extent.
|
|
if (fileOffset < 0 || size < 0 || fileOffset + size > file.size)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`dyld cache mapping in ${file.suffix === "" ? "the main file" : `subcache ${file.suffix}`} extends beyond its file`,
|
|
);
|
|
return {
|
|
file,
|
|
address: safeNumber(mapping.address, "mapping address"),
|
|
size,
|
|
fileOffset,
|
|
};
|
|
}),
|
|
);
|
|
return new DyldSharedCache(header, images, files, regions);
|
|
} catch (cause: unknown) {
|
|
await Promise.allSettled(files.map(({ handle }) => handle.close()));
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
static async #subcacheEntries(
|
|
main: CacheFile,
|
|
parsed: ParsedHeader,
|
|
): Promise<{
|
|
/** Entries carry explicit file suffixes (dyld_subcache_entry v2). */
|
|
readonly named: boolean;
|
|
readonly entries: {
|
|
readonly suffix: string;
|
|
readonly uuid: string;
|
|
readonly vm_offset: string;
|
|
}[];
|
|
}> {
|
|
if (!has(parsed, FIELD.subCacheArrayCount, 4))
|
|
return { named: false, entries: [] };
|
|
const offset = parsed.header.readUInt32LE(FIELD.subCacheArrayOffset);
|
|
const count = parsed.header.readUInt32LE(FIELD.subCacheArrayCount);
|
|
// Headers that include cacheSubType use entries with an explicit file suffix.
|
|
const named = has(parsed, FIELD.cacheSubType, 4);
|
|
if (count === 0) return { named, entries: [] };
|
|
if (count > MAX_SUBCACHES)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"dyld cache lists too many subcaches",
|
|
);
|
|
const size = named ? 56 : 24;
|
|
const table = await main.read(offset, count * size);
|
|
const entries = Array.from({ length: count }, (_, index) => {
|
|
const base = index * size;
|
|
const suffix = named
|
|
? table.toString("latin1", base + 24, base + 56).replace(/\0.*$/su, "")
|
|
: `.${index + 1}`;
|
|
if (!/^\.[A-Za-z0-9.]+$/u.test(suffix))
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"dyld subcache has an unsafe file suffix",
|
|
);
|
|
return {
|
|
suffix,
|
|
uuid: uuidText(table.subarray(base, base + 16)),
|
|
vm_offset: hex(table.readBigUInt64LE(base + 16)),
|
|
};
|
|
});
|
|
return { named, entries };
|
|
}
|
|
|
|
static #summary(
|
|
parsed: ParsedHeader,
|
|
files: readonly {
|
|
readonly file: CacheFile;
|
|
readonly parsed: ParsedHeader;
|
|
}[],
|
|
subcaches: readonly DyldCacheSubcache[],
|
|
): DyldSharedCacheHeader {
|
|
const header = parsed.header;
|
|
const u32 = (offset: number): number | null =>
|
|
has(parsed, offset, 4) ? header.readUInt32LE(offset) : null;
|
|
const u64 = (offset: number): string | null =>
|
|
has(parsed, offset, 8) ? hex(header.readBigUInt64LE(offset)) : null;
|
|
const platform = u32(FIELD.platform);
|
|
const altPlatform = u32(FIELD.altPlatform);
|
|
const cacheType = has(parsed, FIELD.cacheType, 8)
|
|
? Number(header.readBigUInt64LE(FIELD.cacheType))
|
|
: null;
|
|
const symbols = has(parsed, FIELD.symbolFileUuid, 16)
|
|
? header.subarray(FIELD.symbolFileUuid, FIELD.symbolFileUuid + 16)
|
|
: undefined;
|
|
const mappings: DyldCacheMapping[] = files.flatMap(
|
|
({ file, parsed: item }) =>
|
|
item.mappings.map((mapping) => ({
|
|
file: file.suffix,
|
|
address: hex(mapping.address),
|
|
size: hex(mapping.size),
|
|
file_offset: hex(mapping.fileOffset),
|
|
max_protection: mapping.maxProt,
|
|
initial_protection: mapping.initProt,
|
|
})),
|
|
);
|
|
return {
|
|
magic: parsed.magic,
|
|
architecture: parsed.magic.slice(MAGIC_PREFIX.length).trim(),
|
|
uuid: parsed.uuid,
|
|
platform: platform === null ? null : applePlatform(platform),
|
|
os_version: version(u32(FIELD.osVersion) ?? 0),
|
|
alt_platform:
|
|
altPlatform === null || altPlatform === 0
|
|
? null
|
|
: applePlatform(altPlatform),
|
|
alt_os_version: version(u32(FIELD.altOsVersion) ?? 0),
|
|
cache_type: decodeCacheType(
|
|
cacheType,
|
|
has(parsed, FIELD.cacheSubType, 4),
|
|
),
|
|
shared_region:
|
|
u64(FIELD.sharedRegionStart) === null
|
|
? null
|
|
: {
|
|
start: u64(FIELD.sharedRegionStart) ?? "0x0",
|
|
size: u64(FIELD.sharedRegionSize) ?? "0x0",
|
|
},
|
|
max_slide: u64(FIELD.maxSlide),
|
|
mappings,
|
|
subcaches: [...subcaches],
|
|
symbols_file_uuid:
|
|
symbols === undefined || symbols.every((byte) => byte === 0)
|
|
? null
|
|
: uuidText(symbols),
|
|
};
|
|
}
|
|
|
|
static async #images(
|
|
main: CacheFile,
|
|
parsed: ParsedHeader,
|
|
signal?: AbortSignal,
|
|
): Promise<{ readonly path: string; readonly address: number }[]> {
|
|
const modern = has(parsed, FIELD.imagesCount, 4);
|
|
const offset = parsed.header.readUInt32LE(
|
|
modern ? FIELD.imagesOffset : FIELD.imagesOffsetOld,
|
|
);
|
|
const count = parsed.header.readUInt32LE(
|
|
modern ? FIELD.imagesCount : FIELD.imagesCountOld,
|
|
);
|
|
if (count > MAX_IMAGES)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
`dyld cache lists more than ${MAX_IMAGES} images`,
|
|
);
|
|
const table = await main.read(offset, count * 32);
|
|
const images: { path: string; address: number }[] = [];
|
|
for (let index = 0; index < count; index++) {
|
|
if (index % 512 === 0) signal?.throwIfAborted();
|
|
images.push({
|
|
address: safeNumber(table.readBigUInt64LE(index * 32), "image address"),
|
|
path: await main.cString(table.readUInt32LE(index * 32 + 24)),
|
|
});
|
|
}
|
|
return images;
|
|
}
|
|
|
|
/** Locate an image by its exact install path. */
|
|
find(
|
|
path: string,
|
|
): { readonly path: string; readonly address: number } | undefined {
|
|
return this.#byPath.get(path);
|
|
}
|
|
|
|
/**
|
|
* Whether an install path is listed and its bytes lie in a mapping of the
|
|
* main file or a subcache whose UUID matched.
|
|
*/
|
|
locate(path: string): "mapped" | "unverified" | "absent" {
|
|
const image = this.find(path);
|
|
if (image === undefined) return "absent";
|
|
return this.#region(image.address) === undefined ? "unverified" : "mapped";
|
|
}
|
|
|
|
#region(address: number) {
|
|
return this.regions.find(
|
|
(region) =>
|
|
address >= region.address && address < region.address + region.size,
|
|
);
|
|
}
|
|
|
|
/** Parse one cached image's load commands through the cache's VM mappings. */
|
|
async imageFacts(
|
|
path: string,
|
|
): Promise<
|
|
{ readonly file: string; readonly facts: MachoImageFacts } | undefined
|
|
> {
|
|
const image = this.find(path);
|
|
if (image === undefined) return undefined;
|
|
const region = this.#region(image.address);
|
|
if (region === undefined) return undefined;
|
|
const base = region.fileOffset + (image.address - region.address);
|
|
const available = region.size - (image.address - region.address);
|
|
const facts = await readMachoImage(
|
|
async (offset, length) =>
|
|
region.file.read(
|
|
base + offset,
|
|
Math.max(0, Math.min(length, available - offset)),
|
|
),
|
|
available,
|
|
);
|
|
return { file: region.file.suffix, facts };
|
|
}
|
|
|
|
/** Digest of the main file through the handle its header was parsed from. */
|
|
async mainSha256(signal?: AbortSignal): Promise<string> {
|
|
const main = this.files[0];
|
|
if (main === undefined)
|
|
throw new ArtifactReaderFailure("format", "dyld cache has no main file");
|
|
return this.hashFile(main, signal);
|
|
}
|
|
|
|
/** SHA-256 of every readable subcache, keyed by suffix, through their open handles. */
|
|
async subcacheSha256(signal?: AbortSignal): Promise<Record<string, string>> {
|
|
const out: Record<string, string> = {};
|
|
for (const file of this.files.slice(1)) {
|
|
signal?.throwIfAborted();
|
|
out[file.suffix] = await this.hashFile(file, signal);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private async hashFile(
|
|
file: { readonly handle: FileHandle; readonly size: number },
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
const hash = createHash("sha256");
|
|
const buffer = Buffer.alloc(HASH_CHUNK_BYTES);
|
|
for (let position = 0; position < file.size;) {
|
|
signal?.throwIfAborted();
|
|
const { bytesRead } = await file.handle.read(
|
|
buffer,
|
|
0,
|
|
Math.min(buffer.length, file.size - position),
|
|
position,
|
|
);
|
|
if (bytesRead === 0) break;
|
|
hash.update(buffer.subarray(0, bytesRead));
|
|
position += bytesRead;
|
|
}
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
await Promise.allSettled(this.files.map(({ handle }) => handle.close()));
|
|
}
|
|
}
|