diff --git a/src/application/apple/DyldSharedCacheService.ts b/src/application/apple/DyldSharedCacheService.ts index 1216d0190..e1064291f 100644 --- a/src/application/apple/DyldSharedCacheService.ts +++ b/src/application/apple/DyldSharedCacheService.ts @@ -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, diff --git a/src/artifacts/apple/DyldSharedCacheReader.ts b/src/artifacts/apple/DyldSharedCacheReader.ts index 93cb7215f..3dd0f9806 100644 --- a/src/artifacts/apple/DyldSharedCacheReader.ts +++ b/src/artifacts/apple/DyldSharedCacheReader.ts @@ -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 { + 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 { 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> { + const out: Record = {}; + 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 { 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; diff --git a/src/artifacts/apple/DylibResolutionReader.ts b/src/artifacts/apple/DylibResolutionReader.ts index 006a2f61c..fb071c2c4 100644 --- a/src/artifacts/apple/DylibResolutionReader.ts +++ b/src/artifacts/apple/DylibResolutionReader.ts @@ -87,11 +87,17 @@ export const traceDylibResolution = async (options: { readonly signal?: AbortSignal; }): Promise => { 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. diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index aea687948..b1e3bafad 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -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; }; diff --git a/src/domain/apple/dyldPaths.ts b/src/domain/apple/dyldPaths.ts index 8528e2887..ce0c69785 100644 --- a/src/domain/apple/dyldPaths.ts +++ b/src/domain/apple/dyldPaths.ts @@ -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; } diff --git a/src/domain/apple/dyldSharedCache.ts b/src/domain/apple/dyldSharedCache.ts index 7bd555504..13cfb70fd 100644 --- a/src/domain/apple/dyldSharedCache.ts +++ b/src/domain/apple/dyldSharedCache.ts @@ -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; 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.", ]; diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 31e9bcfcd..dc47979fd 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -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.`, + ]), ], }; }; diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts index d144a454c..bbe9bd030 100644 --- a/src/domain/apple/dylibResolutionFindings.ts +++ b/src/domain/apple/dylibResolutionFindings.ts @@ -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.`,