From 1502cfbc8ca0d1d1d86eebf334018dfb418bbac0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:37:15 +0800 Subject: [PATCH] fix(apple): resolve absolute dylib paths inside the selected root Preserve load-command paths as evidence while passing a separate root-relative lookup path to the bounded tree resolver. Apply the same containment rule to absolute install names and rpath bases, including caller-selected root symlink spellings. Validation: 140 source tests, full check:ci, docs and runtime build passed. Compiler-built literal, rpath and relative fixtures passed CLI/stdio MCP parity, preserved both dependency edges and left all bytes unchanged. External system paths stayed outside-target; relative control was unchanged. Fixtures were not executed, and actual dyld execution was not qualified. Closes #5637 --- docs/apple-application-analysis.md | 10 +- src/artifacts/apple/DylibResolutionReader.ts | 24 +- .../apple/MachoLoadCommandReader.test.ts | 237 +++++++++++++++++- src/domain/apple/dyldPaths.ts | 16 +- src/domain/apple/dylibResolution.test.ts | 1 + src/domain/apple/dylibResolution.ts | 43 +++- 6 files changed, 312 insertions(+), 19 deletions(-) diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index 8425877e62..926aae0d41 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -228,9 +228,13 @@ root supplies. Every edge below it has `loader_conditional: true`, and a request that reuses it by install name is `conditional` too. Findings on those edges say that they apply only if the conditional image loads. -Absolute install names such as `/usr/lib/libSystem.B.dylib` are -`outside-target`. On macOS 11 and later most of them live in the dyld shared -cache rather than on disk, so REA does not check them against the host. +Absolute install names that name a path beneath the analyzed root are checked +within that root, with symlinks and `..` resolved by the same bounded tree +resolver. Absolute paths outside the root, such as +`/usr/lib/libSystem.B.dylib`, are `outside-target`; on macOS 11 and later most +system libraries live in the dyld shared cache rather than on disk, so REA does +not check them against the host. Mach-O path strings are not converted from +`file:` URLs or looked up through unrelated host paths. `findings` are derived, not observed: diff --git a/src/artifacts/apple/DylibResolutionReader.ts b/src/artifacts/apple/DylibResolutionReader.ts index 42219ddb20..30e1205275 100644 --- a/src/artifacts/apple/DylibResolutionReader.ts +++ b/src/artifacts/apple/DylibResolutionReader.ts @@ -1,6 +1,6 @@ import type { BigIntStats } from "node:fs"; import { lstat, readlink, realpath } from "node:fs/promises"; -import { join, relative, sep } from "node:path"; +import { isAbsolute, join, relative, sep } from "node:path"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { resolveTreePath } from "../../domain/apple/dyldPaths.js"; @@ -36,10 +36,25 @@ class FilesystemTreeView implements DylibTreeView { constructor( private readonly root: string, + private readonly sourceRoot: string, private readonly files: DylibFileAccess, private readonly signal?: AbortSignal, ) {} + rootRelativePath(absolutePath: string): string | undefined { + const roots = isAbsolute(this.sourceRoot) + ? [this.root, this.sourceRoot] + : [this.root]; + for (let root of roots) { + while (root.length > 1 && root.endsWith(sep)) root = root.slice(0, -1); + if (absolutePath === root) return ""; + const prefix = root === sep ? sep : `${root}${sep}`; + if (absolutePath.startsWith(prefix)) + return absolutePath.slice(prefix.length).split(sep).join("/"); + } + return undefined; + } + entry(path: string): Promise { const cached = this.#entries.get(path); if (cached !== undefined) return cached; @@ -113,7 +128,12 @@ export const traceDylibResolution = async (options: { .split(sep) .join("/"); assertArtifactReadActive(options.signal); - const view = new FilesystemTreeView(root, files, options.signal); + const view = new FilesystemTreeView( + root, + options.rootPath, + files, + options.signal, + ); const { roots: requested, unclassified } = parsed.data.roots !== undefined ? { roots: parsed.data.roots, unclassified: [] } diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index fdd9a79815..94dd4891c1 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, rename, writeFile } from "node:fs/promises"; +import { mkdir, realpath, rename, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -142,6 +142,241 @@ it("uses legacy simulator and device platform facts when resolving dylibs", asyn ); }); +describe("absolute dyld candidate paths at the filesystem reader boundary", () => { + it.each(["install-name", "rpath"] as const)( + "resolves an absolute in-root %s while preserving its source path", + async (kind) => { + const root = await createTestTempDirectory("rea-dylib-absolute-in-root-"); + const canonicalRoot = await realpath(root); + const bridgePath = "Frameworks/libBridge.dylib"; + const leafPath = "Frameworks/libLeaf.dylib"; + const bridgeAbsolute = `${canonicalRoot}/${bridgePath}`; + const leafName = "@loader_path/libLeaf.dylib"; + const executable = machoImage({ + commands: [ + buildVersionCommand(1), + ...(kind === "rpath" + ? [rpathCommand(`${canonicalRoot}/Frameworks`)] + : []), + dylibCommand( + LC.LOAD_DYLIB, + kind === "rpath" ? "@rpath/libBridge.dylib" : bridgeAbsolute, + ), + ], + }); + const bridge = machoImage({ + fileType: FILE_TYPE.dylib, + commands: [ + buildVersionCommand(1), + dylibCommand(LC.LOAD_DYLIB, leafName), + ], + }); + const leaf = machoImage({ + fileType: FILE_TYPE.dylib, + commands: [buildVersionCommand(1)], + }); + await mkdir(join(root, "Frameworks"), { recursive: true }); + await writeFile(join(root, "App"), executable); + await writeFile(bridgeAbsolute, bridge); + await writeFile(join(root, leafPath), leaf); + + const trace = await traceDylibResolution({ + rootPath: root, + targetPath: join(root, "App"), + targetSha256: createHash("sha256").update(executable).digest("hex"), + enumerateRoots: false, + parameters: { roots: ["App"] }, + }); + + expect(trace.edges[0]).toMatchObject({ + install_name: + kind === "rpath" ? "@rpath/libBridge.dylib" : bridgeAbsolute, + candidates: [ + { + path: bridgeAbsolute, + ...(kind === "rpath" + ? { rpath: `${canonicalRoot}/Frameworks` } + : {}), + outcome: "resolved", + resolved_path: bridgePath, + }, + ], + resolution: { status: "resolved", image: bridgePath }, + }); + expect(trace.edges).toContainEqual( + expect.objectContaining({ + loader: bridgePath, + install_name: leafName, + resolution: { status: "resolved", image: leafPath }, + }), + ); + expect(trace.images.map(({ path }) => path)).toEqual([ + "App", + bridgePath, + leafPath, + ]); + }, + ); + + it("keeps a genuinely external absolute candidate outside the analyzed root", async () => { + const root = await createTestTempDirectory("rea-dylib-absolute-outside-"); + const canonicalRoot = await realpath(root); + const executable = machoImage({ + commands: [ + buildVersionCommand(1), + dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"), + dylibCommand(LC.LOAD_DYLIB, `${canonicalRoot}/../outside.dylib`), + dylibCommand( + LC.LOAD_DYLIB, + `file://${canonicalRoot}/Frameworks/libBridge.dylib`, + ), + ], + }); + await writeFile(join(root, "App"), executable); + const trace = await traceDylibResolution({ + rootPath: root, + targetPath: join(root, "App"), + targetSha256: createHash("sha256").update(executable).digest("hex"), + enumerateRoots: false, + parameters: { roots: ["App"] }, + }); + + expect(trace.edges[0]).toMatchObject({ + candidates: [ + { + path: "/usr/lib/libSystem.B.dylib", + outcome: "outside-target", + resolved_path: null, + }, + ], + resolution: { status: "undetermined", image: null }, + }); + expect(trace.edges[1]).toMatchObject({ + candidates: [{ outcome: "escapes-target", resolved_path: null }], + resolution: { status: "undetermined", image: null }, + }); + expect(trace.edges[2]).toMatchObject({ + install_name: `file://${canonicalRoot}/Frameworks/libBridge.dylib`, + candidates: [ + { + path: `file://${canonicalRoot}/Frameworks/libBridge.dylib`, + outcome: "undetermined", + resolved_path: null, + }, + ], + resolution: { status: "undetermined", image: null }, + }); + expect(trace.images.map(({ path }) => path)).toEqual(["App"]); + }); +}); + +describe("absolute dyld candidate traversal", () => { + it("maps an absolute candidate spelled through the selected root symlink", async () => { + const root = await createTestTempDirectory( + "rea-dylib-absolute-root-alias-", + ); + const aliasRoot = join(root, "bundle-alias"); + const bridgePath = "Frameworks/libBridge.dylib"; + const bridgeAbsolute = `${aliasRoot}/${bridgePath}`; + const executable = machoImage({ + commands: [ + buildVersionCommand(1), + dylibCommand(LC.LOAD_DYLIB, bridgeAbsolute), + ], + }); + const bridge = machoImage({ + fileType: FILE_TYPE.dylib, + commands: [buildVersionCommand(1)], + }); + await symlink(".", aliasRoot); + await mkdir(join(root, "Frameworks"), { recursive: true }); + await writeFile(join(root, "App"), executable); + await writeFile(join(root, bridgePath), bridge); + + const trace = await traceDylibResolution({ + rootPath: aliasRoot, + targetPath: `${aliasRoot}/App`, + targetSha256: createHash("sha256").update(executable).digest("hex"), + enumerateRoots: false, + parameters: { roots: ["App"] }, + }); + + expect(trace.edges[0]).toMatchObject({ + install_name: bridgeAbsolute, + candidates: [ + { + path: bridgeAbsolute, + outcome: "resolved", + resolved_path: bridgePath, + }, + ], + resolution: { status: "resolved", image: bridgePath }, + }); + }); + + it.skipIf(process.platform === "win32")( + "preserves symlink-then-parent traversal for an absolute in-root path", + async () => { + const root = await createTestTempDirectory("rea-dylib-absolute-symlink-"); + const canonicalRoot = await realpath(root); + const bridgePath = "Frameworks/Versions/libBridge.dylib"; + const decoyPath = "Frameworks/libBridge.dylib"; + const sourcePath = `${canonicalRoot}/Frameworks/Current/../libBridge.dylib`; + const executable = machoImage({ + commands: [ + buildVersionCommand(1), + dylibCommand(LC.LOAD_DYLIB, sourcePath), + ], + }); + const bridge = machoImage({ + fileType: FILE_TYPE.dylib, + commands: [ + buildVersionCommand(1), + dylibCommand(LC.LOAD_DYLIB, "@loader_path/libLeaf.dylib"), + ], + }); + const leaf = machoImage({ + fileType: FILE_TYPE.dylib, + commands: [buildVersionCommand(1)], + }); + await mkdir(join(root, "Frameworks/Versions/A"), { recursive: true }); + await symlink("Versions/A", join(root, "Frameworks/Current")); + await writeFile(join(root, "App"), executable); + await writeFile(join(root, bridgePath), bridge); + await writeFile(join(root, decoyPath), new Uint8Array([0, 1, 2])); + await writeFile(join(root, "Frameworks/Versions/libLeaf.dylib"), leaf); + + const trace = await traceDylibResolution({ + rootPath: root, + targetPath: join(root, "App"), + targetSha256: createHash("sha256").update(executable).digest("hex"), + enumerateRoots: false, + parameters: { roots: ["App"] }, + }); + + expect(trace.edges[0]).toMatchObject({ + candidates: [ + { + path: sourcePath, + outcome: "resolved", + resolved_path: bridgePath, + }, + ], + resolution: { status: "resolved", image: bridgePath }, + }); + expect(trace.edges).toContainEqual( + expect.objectContaining({ + loader: bridgePath, + resolution: { + status: "resolved", + image: "Frameworks/Versions/libLeaf.dylib", + }, + }), + ); + }, + ); +}); + it("selects matching arm64e subtype slices when tracing a mixed FAT image", async () => { const root = await createTestTempDirectory("rea-macho-arm64e-subtypes-"); const executablePath = "Contents/MacOS/App"; diff --git a/src/domain/apple/dyldPaths.ts b/src/domain/apple/dyldPaths.ts index af2764d291..7feb8da936 100644 --- a/src/domain/apple/dyldPaths.ts +++ b/src/domain/apple/dyldPaths.ts @@ -68,7 +68,13 @@ export const joinPath = (directory: string, rest: string): string => directory === "" ? rest : rest === "" ? directory : `${directory}/${rest}`; export type Expansion = - | { readonly scope: "target"; readonly path: string } + | { + readonly scope: "target"; + /** Source-facing expansion retained in candidate Evidence. */ + readonly path: string; + /** Root-relative lookup identity when `path` is an absolute source path. */ + readonly lookupPath?: string; + } | { readonly scope: "outside"; readonly path: string } | { readonly scope: "undetermined"; readonly path: string }; @@ -78,6 +84,7 @@ export const expandPrefix = ( context: { readonly ownerDirectory: string; readonly executable: string | null; + readonly rootRelativePath: (absolutePath: string) => string | undefined; }, ): Expansion => { const variable = /^@(executable_path|loader_path)(?:\/(.*))?$/u.exec(raw); @@ -92,7 +99,12 @@ export const expandPrefix = ( path: joinPath(directoryOf(context.executable), rest), }; } - if (raw.startsWith("/")) return { scope: "outside", path: raw }; + if (raw.startsWith("/")) { + const lookupPath = context.rootRelativePath(raw); + return lookupPath === undefined + ? { scope: "outside", path: raw } + : { scope: "target", path: raw, lookupPath }; + } return { scope: "undetermined", path: raw }; }; diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index f1c44572b9..dd4c18df28 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -80,6 +80,7 @@ const memoryView = ( entries.set(path, { kind: "symlink", target }); } return { + rootRelativePath: () => undefined, entry: (path) => Promise.resolve(entries.get(path)), image: (path) => { const facts = files[path]; diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 7e46976390..1adfdc79fc 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -114,6 +114,8 @@ export type DylibTreeEntry = /** Read-only, lazily probed view of the analyzed root. Paths are root-relative. */ export interface DylibTreeView { + /** Map an absolute source path only when it is spelled beneath this root. */ + rootRelativePath(absolutePath: string): string | undefined; entry(path: string): Promise; image(path: string): Promise; } @@ -252,6 +254,24 @@ interface CandidateTemplate { readonly rpathOwner: string | null; } +const expandCandidatePath = ( + raw: string, + ownerDirectory: string, + context: ProcessContext, +): Expansion => + expandPrefix(raw, { + ownerDirectory, + executable: context.executable, + rootRelativePath: (absolutePath) => + context.view.rootRelativePath(absolutePath), + }); + +const appendRpath = (base: string, rest: string): string => { + if (base === "") return rest; + if (base === "/") return `/${rest}`; + return `${base.replace(/\/+$/u, "")}/${rest}`; +}; + const candidateTemplates = ( dependency: MachoDependency, loader: string, @@ -266,10 +286,7 @@ const candidateTemplates = ( : "literal"; return [ { - expansion: expandPrefix(name, { - ownerDirectory: directoryOf(loader), - executable: context.executable, - }), + expansion: expandCandidatePath(name, directoryOf(loader), context), source, rpath: null, rpathOwner: null, @@ -280,16 +297,17 @@ const candidateTemplates = ( const owners = [...(context.loaded.get(loader)?.chain ?? [loader])].reverse(); return owners.flatMap((owner) => (context.loaded.get(owner)?.slice.rpaths ?? []).map((rpath) => { - const base = expandPrefix(rpath, { - ownerDirectory: directoryOf(owner), - executable: context.executable, - }); + const base = expandCandidatePath(rpath, directoryOf(owner), context); + const lookupPath = base.scope === "target" ? base.lookupPath : undefined; const expansion: Expansion = { scope: base.scope, path: - base.scope === "outside" - ? `${base.path.replace(/\/+$/u, "")}/${rest}` + base.scope === "outside" || lookupPath !== undefined + ? appendRpath(base.path, rest) : `${base.path}${base.path === "" ? "" : "/"}${rest}`, + ...(lookupPath === undefined + ? {} + : { lookupPath: appendRpath(lookupPath, rest) }), }; return { expansion, source: "rpath" as const, rpath, rpathOwner: owner }; }), @@ -310,7 +328,10 @@ const evaluateCandidate = async ( return { ...base, outcome: "outside-target", resolved_path: null }; if (template.expansion.scope === "undetermined") return { ...base, outcome: "undetermined", resolved_path: null }; - const lookup = await resolveTreePath(context.view, template.expansion.path); + const lookup = await resolveTreePath( + context.view, + template.expansion.lookupPath ?? template.expansion.path, + ); if (lookup.kind === "escapes") return { ...base, outcome: "escapes-target", resolved_path: null }; if (lookup.kind !== "file")