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:
parent
03bbecd941
commit
4e98543e90
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue