fix(apple): harden dyld cache reads, mappings, and resolution findings
This commit is contained in:
parent
cd622e0e95
commit
d081ffbdce
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue