fix(apple): harden dyld cache reads, mappings, and resolution findings

This commit is contained in:
morluto 2026-10-08 15:36:31 +08:00
parent cd622e0e95
commit d081ffbdce
8 changed files with 138 additions and 20 deletions

View File

@ -108,12 +108,17 @@ export const inspectDyldSharedCache = async (
const unreadable = cache.header.subcaches
.filter(({ status }) => status !== "present")
.map(({ suffix }) => suffix);
// Hash through the open handles the header was parsed from, so a path
// replacement mid-inspection cannot attribute new bytes to old facts.
const mainSha = await cache.mainSha256(signal);
const subcacheSha = await cache.subcacheSha256(signal);
return ok(
dyldSharedCacheResultSchema.parse({
cache_path: cachePath,
// The header and image list were read through this same handle.
main_file_sha256: await cache.mainSha256(signal),
main_file_sha256: mainSha,
...cache.header,
subcache_sha256: subcacheSha,
images_total: cache.images.length,
images: cache.images.map(({ path, address }) => ({
path,

View File

@ -90,7 +90,7 @@ class CacheFile {
);
if (length > BLOCK_BYTES) {
const bytes = Buffer.alloc(length);
await this.handle.read(bytes, 0, length, offset);
await this.readInto(bytes, 0, length, offset);
return bytes;
}
const chunks: Buffer[] = [];
@ -100,7 +100,7 @@ class CacheFile {
if (block === undefined) {
const start = index * BLOCK_BYTES;
block = Buffer.alloc(Math.min(BLOCK_BYTES, this.size - start));
await this.handle.read(block, 0, block.length, start);
await this.readInto(block, 0, block.length, start);
this.#blocks.set(index, block);
}
const from = position - index * BLOCK_BYTES;
@ -113,6 +113,30 @@ class CacheFile {
: 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,
@ -264,12 +288,26 @@ export class DyldSharedCache {
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) => ({
file,
address: safeNumber(mapping.address, "mapping address"),
size: safeNumber(mapping.size, "mapping size"),
fileOffset: safeNumber(mapping.fileOffset, "mapping file offset"),
})),
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) {
@ -472,14 +510,31 @@ export class DyldSharedCache {
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 < main.size;) {
for (let position = 0; position < file.size;) {
signal?.throwIfAborted();
const { bytesRead } = await main.handle.read(
const { bytesRead } = await file.handle.read(
buffer,
0,
Math.min(buffer.length, main.size - position),
Math.min(buffer.length, file.size - position),
position,
);
if (bytesRead === 0) break;

View File

@ -87,11 +87,17 @@ export const traceDylibResolution = async (options: {
readonly signal?: AbortSignal;
}): Promise<DylibResolutionResult> => {
const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
if (!parsed.success)
if (!parsed.success) {
const issue = parsed.error.issues[0];
const at =
issue !== undefined && issue.path.length > 0
? ` (${issue.path.join(".")} ${issue.message})`
: "";
throw new ArtifactReaderFailure(
"path",
"roots must be normalized paths relative to the analyzed root",
`dylib resolution parameters are invalid${at}`,
);
}
let cache: DyldSharedCache | undefined;
// Canonicalization is inside the translated region: a revoked permission
// keeps its reason and path.

View File

@ -271,6 +271,13 @@ const decodeCommands = (
decodeCommand(command, body, index, slice);
offset += size;
}
// A header that declares fewer commands than its sizeofcmds could hide
// dependency loads in the unconsumed bytes.
if (offset !== bytes.byteLength)
throw new MachoFormatIssue(
"malformed",
`load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`,
);
return slice;
};

View File

@ -37,7 +37,13 @@ export const resolveTreePath = async (
if (entry.kind === "symlink") {
hops += 1;
if (hops > MAX_SYMLINK_HOPS) return { kind: "absent" };
if (entry.target.startsWith("/")) return { kind: "escapes" };
if (
entry.target.startsWith("/") ||
/^[a-zA-Z]:[\\/]/u.test(entry.target) ||
entry.target.startsWith("\\\\") ||
entry.target.startsWith("//")
)
return { kind: "escapes" };
pending.unshift(...segmentsOf(entry.target));
continue;
}

View File

@ -65,6 +65,8 @@ export const dyldSharedCacheResultSchema = z.object({
images_total: z.number().int().nonnegative(),
images: z.array(z.object({ path: z.string(), address: hexSchema })),
inspected_images: z.array(inspectedImageSchema),
/** SHA-256 of every readable subcache backing inspected images, keyed by suffix. */
subcache_sha256: z.record(z.string(), digestSchema).default({}),
coverage: z.object({
status: z.enum(["complete", "partial"]),
unreadable_subcaches: z.array(z.string()),
@ -82,4 +84,5 @@ export type InspectedCacheImage = z.infer<typeof inspectedImageSchema>;
export const DYLD_SHARED_CACHE_LIMITATIONS = [
"The cache describes the operating system it was built for; it is evidence about this cache file set, not about another host.",
"Image headers are read through the cache's VM mappings; slide info, local symbols, and code signatures are not parsed, and images are not extracted.",
"Install paths are matched exactly against the cache image table; dyld alias canonicalization is not modeled, so an alias may report absent or undetermined though dyld would load it from the cache.",
];

View File

@ -378,6 +378,16 @@ export const traceDylibLoading = async (
const unparsed = reported
.filter(([, facts]) => facts.status !== "parsed")
.map(([path]) => path);
// LC_DYLD_ENVIRONMENT can prepend search paths dyld honors at launch; its
// settings are reported but not modeled, so resolutions stay conditional.
const environmentRoots = roots.filter(({ image, architecture }) => {
const facts = images.get(image);
return (
facts?.status === "parsed" &&
(facts.slices.find((s) => s.architecture === architecture)
?.dyld_environment.length ?? 0) > 0
);
});
return {
roots,
images: reported.map(([path, facts]) => ({
@ -397,7 +407,8 @@ export const traceDylibLoading = async (
status:
unparsed.length === 0 &&
withoutArchitecture.length === 0 &&
unverifiedCacheImages.size === 0
unverifiedCacheImages.size === 0 &&
environmentRoots.length === 0
? "complete"
: "partial",
unparsed_images: unparsed,
@ -407,6 +418,11 @@ export const traceDylibLoading = async (
limitations: [
...DYLIB_RESOLUTION_LIMITATIONS,
...sharedCacheLimitations(request.sharedCache, unserved),
...(environmentRoots.length === 0
? []
: [
`Roots ${environmentRoots.map(({ image }) => image).join(", ")} set dyld environment variables through LC_DYLD_ENVIRONMENT; their resolutions are conditional because those search paths are not modeled.`,
]),
],
};
};

View File

@ -18,22 +18,42 @@ export const deriveFindings = (
edges.forEach((edge, index) => {
if (edge.resolution.status === "unresolved")
findings.push(unresolvedFinding(edge, index));
if (edge.resolution.status === "unresolved" && edge.loader_conditional) {
// loader_conditional already qualifies the unresolved explanation.
}
const resolvedAt = edge.candidates.findIndex(
({ outcome }) => outcome === "resolved" || outcome === "shared-cache",
);
const earlier = edge.candidates
const earlierAbsent = edge.candidates
.slice(0, Math.max(resolvedAt, 0))
.filter(
({ source, outcome }) => source === "rpath" && outcome === "absent",
)
.map(({ path }) => path);
if (resolvedAt > 0 && earlier.length > 0)
const earlierUnknown = edge.candidates
.slice(0, Math.max(resolvedAt, 0))
.some(({ outcome }) =>
[
"outside-target",
"escapes-target",
"unsupported",
"undetermined",
].includes(outcome),
);
const loaderQualifies =
edge.loader_conditional === true
? " This loader itself loads only conditionally; if it never loads, this search never occurs."
: "";
const unknownQualifies = earlierUnknown
? " An earlier candidate outside the analyzed root may still win at runtime, so this fallback loads only conditionally."
: "";
if (resolvedAt > 0 && earlierAbsent.length > 0)
findings.push({
kind: "earlier-rpath-candidate-absent",
edge_index: index,
image: edge.loader,
basis: "derived",
explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
explanation: `dyld searches ${earlierAbsent.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).${loaderQualifies}${unknownQualifies}`,
});
});
for (const { image, architecture } of roots) {
@ -134,7 +154,7 @@ export const sharedCacheLimitations = (
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides. Pass shared_cache to look them up in a cache.",
];
return [
"Absolute install names and rpaths were looked up only in the supplied dyld shared cache; paths it does not list stay outside-target because the host filesystem outside the analyzed root is not read. On another OS build the cache contents, and therefore these outcomes, can differ.",
"Absolute install names and rpaths were looked up only in the supplied dyld shared cache; paths it does not list stay outside-target because the host filesystem outside the analyzed root is not read. Shared-cache hits assume no on-disk override (root or unzippered twin) though dyld checks the filesystem first; treat them as conditional when the external disk state is unknown. On another OS build the cache contents, and therefore these outcomes, can differ.",
...unserved.map(
({ image, architecture, reason }) =>
`The supplied shared cache was not used for ${image} (${architecture}): ${reason}. Its absolute paths stay outside-target.`,