From 4e98543e904edb739f86cc08b2e1beda5b8d213c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:51:06 +0800 Subject: [PATCH] fix(apple): admit only compatible dyld subcache mappings (#1090) UUID, architecture, and cache VM offset are one admission decision, so a matching UUID cannot publish contradictory mappings. A shared-cache trace hit also requires the mapped bytes to parse as a Mach-O the process can load. Co-authored-by: Cursor Agent --- src/artifacts/apple/DyldSharedCacheReader.ts | 62 +++++++++++++++-- src/artifacts/apple/DylibResolutionReader.ts | 1 + src/domain/apple/dylibResolution.test.ts | 33 +++++++++ src/domain/apple/dylibResolution.ts | 26 ++++--- src/domain/apple/dylibResolutionSchema.ts | 12 +++- .../filesystem/dyldSharedCache.test.ts | 68 +++++++++++++++++++ 6 files changed, 186 insertions(+), 16 deletions(-) diff --git a/src/artifacts/apple/DyldSharedCacheReader.ts b/src/artifacts/apple/DyldSharedCacheReader.ts index 5529475b8..e3bb2259e 100644 --- a/src/artifacts/apple/DyldSharedCacheReader.ts +++ b/src/artifacts/apple/DyldSharedCacheReader.ts @@ -279,6 +279,20 @@ const readHeader = async (file: CacheFile): Promise => { }; }; +const cacheArchitecture = (magic: string): string => + magic.slice(MAGIC_PREFIX.length).trim(); + +/** Lowest mapping address: the subcache base `cacheVMOffset` is measured from. */ +const mappingBase = (parsed: ParsedHeader): bigint => { + const first = parsed.mappings[0]; + if (first === undefined) + throw new ArtifactReaderFailure("format", "dyld cache has no mappings"); + return parsed.mappings.reduce( + (lowest, mapping) => (mapping.address < lowest ? mapping.address : lowest), + first.address, + ); +}; + const version = (value: number): string | null => value === 0 ? null @@ -385,13 +399,16 @@ export class DyldSharedCache { } files.push(file); const subParsed = await readHeader(file); - const matches = subParsed.uuid === entry.uuid; + // UUID, architecture, and VM offset are one admission decision. + // A later overlap or image-address check cannot repair a companion + // that already contradicts the main header. + const status = DyldSharedCache.#admitSubcache(parsed, entry, subParsed); statuses.push({ ...entry, - status: matches ? "present" : "uuid-mismatch", + status, observed_uuid: subParsed.uuid, }); - if (matches) parsedFiles.push({ file, parsed: subParsed }); + if (status === "present") parsedFiles.push({ file, parsed: subParsed }); } const header = DyldSharedCache.#summary(parsed, parsedFiles, statuses); const images = await DyldSharedCache.#images(main, parsed, signal); @@ -501,6 +518,38 @@ export class DyldSharedCache { return { named, entries }; } + /** + * Whether a companion file is the subcache the main header named. + * UUID, magic, and `cacheVMOffset` are checked together; failure to match + * the UUID leaves the cache partial, while a matching UUID that contradicts + * the main header is a format error and contributes no mappings. + */ + static #admitSubcache( + main: ParsedHeader, + entry: { + readonly suffix: string; + readonly uuid: string; + readonly vm_offset: string; + }, + sub: ParsedHeader, + ): "present" | "uuid-mismatch" { + if (sub.uuid !== entry.uuid) return "uuid-mismatch"; + if (sub.magic !== main.magic) + throw new ArtifactReaderFailure( + "format", + `dyld subcache ${entry.suffix} architecture ${cacheArchitecture(sub.magic)} does not match the main cache architecture ${cacheArchitecture(main.magic)}`, + ); + const mainBase = mappingBase(main); + const actual = mappingBase(sub); + const expected = mainBase + BigInt(entry.vm_offset); + if (actual !== expected) + throw new ArtifactReaderFailure( + "format", + `dyld subcache ${entry.suffix} mapping base ${hex(actual)} disagrees with cache VM offset ${entry.vm_offset} from main base ${hex(mainBase)}`, + ); + return "present"; + } + static #summary( parsed: ParsedHeader, files: readonly { @@ -538,7 +587,7 @@ export class DyldSharedCache { ); return { magic: parsed.magic, - architecture: parsed.magic.slice(MAGIC_PREFIX.length).trim(), + architecture: cacheArchitecture(parsed.magic), uuid: parsed.uuid, platform: cachePlatform(platform, simulator), header_platform: platform === null ? null : applePlatform(platform), @@ -611,8 +660,9 @@ export class DyldSharedCache { } /** - * Whether an install path is listed and its bytes lie in a mapping of the - * main file or a subcache whose UUID matched. + * Whether an install path is listed and its address lies in a mapping of an + * admitted file. This is address coverage: `imageFacts` has to parse a + * compatible Mach-O before the path is a shared-cache load. */ locate(path: string): "mapped" | "unverified" | "absent" { const image = this.find(path); diff --git a/src/artifacts/apple/DylibResolutionReader.ts b/src/artifacts/apple/DylibResolutionReader.ts index ca0c9a744..f2d736e52 100644 --- a/src/artifacts/apple/DylibResolutionReader.ts +++ b/src/artifacts/apple/DylibResolutionReader.ts @@ -225,6 +225,7 @@ const sharedCacheView = (cache: DyldSharedCache): DylibSharedCacheView => ({ .filter(({ status }) => status !== "present") .map(({ suffix, status }) => `${suffix} (${status})`), lookup: (path) => cache.locate(path), + image: async (path) => (await cache.imageFacts(path))?.facts, }); /** diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index e69073bac..d4272c76e 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -540,6 +540,7 @@ const cache = ( ].includes(path) ? "mapped" : "absent", + image: () => Promise.resolve(parsed(slice())), ...overrides, }); @@ -597,6 +598,38 @@ describe("shared cache resolution", () => { ); }); + it("does not treat mapped bytes as a load until they parse as a compatible Mach-O", async () => { + const root = memoryView({ + [MAIN]: executable({ + dependencies: [dependency("/usr/lib/libSystem.B.dylib")], + }), + }); + const notMachO = await traceDylibLoading(root, { + roots: [MAIN], + sharedCache: cache({ + image: () => Promise.resolve({ status: "not-mach-o" }), + }), + }); + expect(notMachO.edges[0]?.resolution).toEqual({ + status: "undetermined", + image: null, + }); + expect(notMachO.coverage).toMatchObject({ + status: "partial", + unverified_shared_cache_images: ["/usr/lib/libSystem.B.dylib"], + }); + expect(notMachO.images.map(({ path }) => path)).toEqual([MAIN]); + const wrongArchitecture = await traceDylibLoading(root, { + roots: [MAIN], + sharedCache: cache({ + image: () => Promise.resolve(parsed(slice({ architecture: "x86_64" }))), + }), + }); + expect(wrongArchitecture.edges[0]?.candidates[0]?.outcome).toBe( + "undetermined", + ); + }); + it("keeps a cache hit conditional after an earlier undeterminable candidate", async () => { const trace = await traceDylibLoading( memoryView({ diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 78f7872e2..f675e6f39 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -127,14 +127,24 @@ const evaluateCandidate = async ( rpath_owner: template.rpathOwner, }; if (template.expansion.scope === "outside") { - const cached = - context.sharedCache?.lookup(template.expansion.path) ?? "absent"; - if (cached === "mapped") - return { - ...base, - outcome: "shared-cache", - resolved_path: template.expansion.path, - }; + const sharedCache = context.sharedCache; + const cached = sharedCache?.lookup(template.expansion.path) ?? "absent"; + if (cached === "mapped" && sharedCache !== undefined) { + // Address coverage is not a load. Confirm a compatible Mach-O without + // retaining its commands, so cache images stay leaves. + const facts = await sharedCache.image(template.expansion.path); + if ( + facts?.status === "parsed" && + compatibleSlice(facts.slices, context.architecture) !== undefined + ) + return { + ...base, + outcome: "shared-cache", + resolved_path: template.expansion.path, + }; + context.unverifiedCacheImages.add(template.expansion.path); + return { ...base, outcome: "undetermined", resolved_path: null }; + } // Listed in the cache, but its subcache is unavailable: dyld might load it. if (cached === "unverified") { context.unverifiedCacheImages.add(template.expansion.path); diff --git a/src/domain/apple/dylibResolutionSchema.ts b/src/domain/apple/dylibResolutionSchema.ts index a6c618236..28ad8c066 100644 --- a/src/domain/apple/dylibResolutionSchema.ts +++ b/src/domain/apple/dylibResolutionSchema.ts @@ -80,10 +80,18 @@ export interface DylibSharedCacheView { /** Subcaches whose bytes are missing or do not match, such as `.03 (missing)`. */ readonly unavailableSubcaches: readonly string[]; /** - * `mapped`: listed and inside a verified mapping; `unverified`: listed, but - * the subcache holding it is unavailable; `absent`: not listed. + * `mapped`: listed and inside an admitted mapping. That is address coverage, + * not a load: a shared-cache hit also requires `image` to parse a slice this + * process can load. `unverified`: listed, but the subcache holding it is + * unavailable. `absent`: not listed. */ lookup(path: string): "mapped" | "unverified" | "absent"; + /** + * Load commands at a mapped install path. Undefined when `lookup` is not + * `mapped`. Tracing uses this only to confirm the hit; cached load commands + * are not traversed. + */ + image(path: string): Promise; } const candidateSchema = z.strictObject({ diff --git a/tests/boundary/filesystem/dyldSharedCache.test.ts b/tests/boundary/filesystem/dyldSharedCache.test.ts index 1d8672d92..f487f3a6c 100644 --- a/tests/boundary/filesystem/dyldSharedCache.test.ts +++ b/tests/boundary/filesystem/dyldSharedCache.test.ts @@ -37,8 +37,11 @@ it("rejects a directory and overlapping combined VM mappings with specific reaso const mainTable = main.readUInt32LE(0x10); const subTable = subcache.readUInt32LE(0x10); subcache.writeBigUInt64LE(main.readBigUInt64LE(mainTable), subTable); + // Keep the declared VM offset consistent so the failure is the overlap. + main.writeBigUInt64LE(0n, main.readUInt32LE(0x188) + 16); const path = await writeCache({ ...fixture, + main, subcaches: [{ ...original, bytes: subcache }], }); const result = await inspectDyldSharedCache({ cache_path: path }); @@ -54,6 +57,71 @@ const dylib = (installName: string, dependencies: readonly Uint8Array[] = []) => commands: [dylibCommand(LC.ID_DYLIB, installName), ...dependencies], }); +it("rejects a same-UUID subcache whose architecture or VM offset disagrees", async () => { + const fixture = dyldCacheFixture(IMAGES); + const original = fixture.subcaches[0]; + if (original === undefined) throw new Error("missing subcache fixture"); + const wrongArchitecture = Buffer.from(original.bytes); + wrongArchitecture.write("dyld_v1 x86_64".padEnd(15, " "), 0, "latin1"); + const architecturePath = await writeCache({ + ...fixture, + subcaches: [{ ...original, bytes: wrongArchitecture }], + }); + expect( + await inspectDyldSharedCache({ cache_path: architecturePath }), + ).toMatchObject({ + ok: false, + error: { + reason: "format", + detail: expect.stringContaining("does not match"), + }, + }); + const main = Buffer.from(fixture.main); + main.writeBigUInt64LE(0n, main.readUInt32LE(0x188) + 16); + const offsetPath = await writeCache({ ...fixture, main }); + expect( + await inspectDyldSharedCache({ cache_path: offsetPath }), + ).toMatchObject({ + ok: false, + error: { reason: "format", detail: expect.stringContaining("disagrees") }, + }); +}); + +it("does not resolve a cached path whose bytes are not a Mach-O image", async () => { + const path = await writeCache( + dyldCacheFixture([ + { path: "/usr/lib/libSystem.B.dylib", bytes: Buffer.alloc(64, 0) }, + ]), + ); + const targetPath = join(dirname(path), "tool"); + const bytes = machoImage({ + fileType: FILE_TYPE.execute, + commands: [ + buildVersionCommand(1), + dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"), + ], + }); + await writeFile(targetPath, bytes); + const trace = await traceDylibResolution({ + rootPath: dirname(path), + targetPath, + targetSha256: createHash("sha256").update(bytes).digest("hex"), + enumerateRoots: false, + parameters: { shared_cache: path }, + }); + expect(trace.edges[0]?.candidates[0]?.outcome).toBe("undetermined"); + expect(trace.edges[0]?.resolution).toEqual({ + status: "undetermined", + image: null, + }); + expect(trace.coverage.unverified_shared_cache_images).toEqual([ + "/usr/lib/libSystem.B.dylib", + ]); + expect(trace.images.map(({ path: image }) => image)).not.toContain( + "/usr/lib/libSystem.B.dylib", + ); +}); + it("rejects unmapped images when no unavailable subcache can explain the address", async () => { const fixture = dyldCacheFixture(IMAGES); const main = Buffer.from(fixture.main);