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
This commit is contained in:
morluto committed 2026-10-21 07:37:15 +08:00
1 parent 84c89d1107
commit 1502cfbc8c
6 files changed
+312 -19

No files matched your search

+7 -3
View File
@@ -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:
+22 -2
View File
@@ -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<DylibTreeEntry | undefined> {
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: [] }
@@ -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";
+14 -2
View File
@@ -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 };
};
+1
View File
@@ -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];
+32 -11
View File
@@ -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<DylibTreeEntry | undefined>;
image(path: string): Promise<MachoImageFacts>;
}
@@ -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")