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 <cursoragent@cursor.com>
This commit is contained in:
morluto 2026-10-08 22:51:06 +08:00 committed by GitHub
parent 03bbecd941
commit 4e98543e90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 186 additions and 16 deletions

View File

@ -279,6 +279,20 @@ const readHeader = async (file: CacheFile): Promise<ParsedHeader> => {
};
};
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);

View File

@ -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,
});
/**

View File

@ -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({

View File

@ -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);

View File

@ -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<MachoImageFacts | undefined>;
}
const candidateSchema = z.strictObject({

View File

@ -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);