mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 03:15:54 +00:00
Address review findings in trace_dylib_resolution: - Choose child slices by dyld's graded architectures, so an x86_64h process loads a generic x86_64 slice instead of reporting architecture-missing. - Read FAT containers with the byte-swapped FAT_CIGAM and FAT_CIGAM_64 magics, which target opening already accepts. - Report string-bearing load commands too short to hold their string offset as malformed instead of failing with a RangeError. - Keep recognized but unsupported images as an `unsupported` candidate outcome. It is undeterminable like an outside-root path, not malformed. - List Mach-O files that do not parse during root enumeration as unparsed images, so coverage is partial rather than silently complete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
544 lines
18 KiB
TypeScript
544 lines
18 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import {
|
|
compatibleSlice,
|
|
directoryOf,
|
|
expandPrefix,
|
|
joinPath,
|
|
resolveTreePath,
|
|
type Expansion,
|
|
} from "./dyldPaths.js";
|
|
import { digestSchema } from "./digests.js";
|
|
|
|
/** Normalized path below the analyzed root: no `.`/`..` or empty segments. */
|
|
const ROOT_RELATIVE_PATH =
|
|
/^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))(?!.*\/\/)(?!.*\/$)[^\\\0]+$/u;
|
|
const rootRelativePathSchema = z.string().min(1).regex(ROOT_RELATIVE_PATH);
|
|
|
|
/** Caller selection of process roots and one CPU slice. */
|
|
export const dylibResolutionInputSchema = z.strictObject({
|
|
roots: z.array(rootRelativePathSchema).min(1).optional(),
|
|
architecture: z.enum(["arm64", "arm64e", "x86_64"]).optional(),
|
|
});
|
|
|
|
const dependencyCommandSchema = z.enum([
|
|
"LC_LOAD_DYLIB",
|
|
"LC_LOAD_WEAK_DYLIB",
|
|
"LC_REEXPORT_DYLIB",
|
|
"LC_LAZY_LOAD_DYLIB",
|
|
"LC_LOAD_UPWARD_DYLIB",
|
|
]);
|
|
|
|
const dependencySchema = z.strictObject({
|
|
command: dependencyCommandSchema,
|
|
encoding: z.enum(["dylib_command", "dylib_use_command"]),
|
|
install_name: z.string(),
|
|
weak: z.boolean(),
|
|
upward: z.boolean(),
|
|
reexport: z.boolean(),
|
|
delayed_init: z.boolean(),
|
|
current_version: z.string(),
|
|
compatibility_version: z.string(),
|
|
});
|
|
|
|
/** Observed dylib-loading facts of one Mach-O slice. */
|
|
export const machoSliceSchema = z.strictObject({
|
|
architecture: z.string().min(1),
|
|
file_type: z.enum(["execute", "dylib", "bundle", "other"]),
|
|
install_name: z.string().nullable(),
|
|
dependencies: z.array(dependencySchema),
|
|
rpaths: z.array(z.string()),
|
|
dyld_environment: z.array(z.string()),
|
|
code_signature_present: z.boolean(),
|
|
});
|
|
|
|
export type MachoDependency = z.infer<typeof dependencySchema>;
|
|
export type MachoSlice = z.infer<typeof machoSliceSchema>;
|
|
|
|
/** Header parse of one file; malformed and unsupported images keep their reason. */
|
|
export type MachoImageFacts =
|
|
| { readonly status: "parsed"; readonly slices: readonly MachoSlice[] }
|
|
| {
|
|
readonly status: "malformed" | "unsupported";
|
|
readonly reason: string;
|
|
}
|
|
| { readonly status: "not-mach-o" };
|
|
|
|
/** One observed path below the analyzed root. Symlink targets are not followed by the view. */
|
|
export type DylibTreeEntry =
|
|
| { readonly kind: "file" | "directory" }
|
|
| { readonly kind: "symlink"; readonly target: string };
|
|
|
|
/** Read-only, lazily probed view of the analyzed root. Paths are root-relative. */
|
|
export interface DylibTreeView {
|
|
entry(path: string): Promise<DylibTreeEntry | undefined>;
|
|
image(path: string): Promise<MachoImageFacts>;
|
|
}
|
|
|
|
const candidateSchema = z.strictObject({
|
|
path: z.string(),
|
|
source: z.enum([
|
|
"literal",
|
|
"executable_path",
|
|
"loader_path",
|
|
"rpath",
|
|
"already-loaded",
|
|
]),
|
|
rpath: z.string().nullable(),
|
|
rpath_owner: z.string().nullable(),
|
|
outcome: z.enum([
|
|
"resolved",
|
|
"absent",
|
|
"not-mach-o",
|
|
"malformed",
|
|
"unsupported",
|
|
"architecture-missing",
|
|
"outside-target",
|
|
"escapes-target",
|
|
"undetermined",
|
|
]),
|
|
resolved_path: z.string().nullable(),
|
|
});
|
|
|
|
const edgeSchema = z.strictObject({
|
|
root: z.string(),
|
|
architecture: z.string(),
|
|
loader: z.string(),
|
|
via: z.array(z.string()),
|
|
...dependencySchema.shape,
|
|
candidates: z.array(candidateSchema),
|
|
resolution: z.strictObject({
|
|
status: z.enum(["resolved", "conditional", "unresolved", "undetermined"]),
|
|
image: z.string().nullable(),
|
|
}),
|
|
install_name_matches: z.boolean().nullable(),
|
|
});
|
|
|
|
const findingSchema = z.strictObject({
|
|
kind: z.enum([
|
|
"required-load-unresolved",
|
|
"weak-load-unresolved",
|
|
"earlier-rpath-candidate-absent",
|
|
"dyld-environment-present",
|
|
]),
|
|
edge_index: z.number().int().nonnegative().nullable(),
|
|
image: z.string().nullable(),
|
|
basis: z.literal("derived"),
|
|
explanation: z.string().min(1),
|
|
});
|
|
|
|
const imageSchema = z.strictObject({
|
|
path: z.string(),
|
|
sha256: digestSchema,
|
|
parse_status: z.enum(["parsed", "malformed", "unsupported"]),
|
|
reason: z.string().nullable(),
|
|
slices: z.array(machoSliceSchema.omit({ dependencies: true })),
|
|
});
|
|
|
|
/** Static dyld load-path resolution for every selected process root. */
|
|
export const dylibResolutionResultSchema = z.strictObject({
|
|
root_path: z.string(),
|
|
target_sha256: digestSchema,
|
|
roots: z.array(
|
|
z.strictObject({ image: z.string(), architecture: z.string() }),
|
|
),
|
|
images: z.array(imageSchema),
|
|
edges: z.array(edgeSchema),
|
|
findings: z.array(findingSchema),
|
|
coverage: z.strictObject({
|
|
status: z.enum(["complete", "partial"]),
|
|
unparsed_images: z.array(z.string()),
|
|
roots_without_architecture: z.array(z.string()),
|
|
}),
|
|
limitations: z.array(z.string().min(1)),
|
|
});
|
|
|
|
export type DylibResolutionResult = z.infer<typeof dylibResolutionResultSchema>;
|
|
type Candidate = z.infer<typeof candidateSchema>;
|
|
type Edge = z.infer<typeof edgeSchema>;
|
|
type Finding = z.infer<typeof findingSchema>;
|
|
|
|
/** Result before the adapter adds file digests and the analyzed root. */
|
|
export type DylibTrace = Omit<
|
|
DylibResolutionResult,
|
|
"root_path" | "target_sha256" | "images"
|
|
> & {
|
|
readonly images: readonly (Omit<
|
|
DylibResolutionResult["images"][number],
|
|
"sha256"
|
|
> & { readonly sha256?: never })[];
|
|
};
|
|
|
|
interface LoadedImage {
|
|
readonly slice: MachoSlice;
|
|
/** Images from the process root to this image; the rpath stack, outermost first. */
|
|
readonly chain: readonly string[];
|
|
}
|
|
|
|
interface ProcessContext {
|
|
readonly view: DylibTreeView;
|
|
readonly root: string;
|
|
readonly architecture: string;
|
|
readonly executable: string | null;
|
|
readonly loaded: Map<string, LoadedImage>;
|
|
readonly byInstallName: Map<string, string>;
|
|
readonly images: Map<string, MachoImageFacts>;
|
|
}
|
|
|
|
interface CandidateTemplate {
|
|
readonly expansion: Expansion;
|
|
readonly source: Candidate["source"];
|
|
readonly rpath: string | null;
|
|
readonly rpathOwner: string | null;
|
|
}
|
|
|
|
const candidateTemplates = (
|
|
dependency: MachoDependency,
|
|
loader: string,
|
|
context: ProcessContext,
|
|
): CandidateTemplate[] => {
|
|
const name = dependency.install_name;
|
|
if (!name.startsWith("@rpath/")) {
|
|
const source = name.startsWith("@executable_path")
|
|
? "executable_path"
|
|
: name.startsWith("@loader_path")
|
|
? "loader_path"
|
|
: "literal";
|
|
return [
|
|
{
|
|
expansion: expandPrefix(name, {
|
|
ownerDirectory: directoryOf(loader),
|
|
executable: context.executable,
|
|
}),
|
|
source,
|
|
rpath: null,
|
|
rpathOwner: null,
|
|
},
|
|
];
|
|
}
|
|
const rest = name.slice("@rpath/".length);
|
|
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 expansion: Expansion = {
|
|
scope: base.scope,
|
|
path:
|
|
base.scope === "outside"
|
|
? `${base.path.replace(/\/+$/u, "")}/${rest}`
|
|
: joinPath(base.path, rest),
|
|
};
|
|
return { expansion, source: "rpath" as const, rpath, rpathOwner: owner };
|
|
}),
|
|
);
|
|
};
|
|
|
|
const evaluateCandidate = async (
|
|
template: CandidateTemplate,
|
|
context: ProcessContext,
|
|
): Promise<Candidate> => {
|
|
const base = {
|
|
path: template.expansion.path,
|
|
source: template.source,
|
|
rpath: template.rpath,
|
|
rpath_owner: template.rpathOwner,
|
|
};
|
|
if (template.expansion.scope === "outside")
|
|
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);
|
|
if (lookup.kind === "escapes")
|
|
return { ...base, outcome: "escapes-target", resolved_path: null };
|
|
if (lookup.kind !== "file")
|
|
return { ...base, outcome: "absent", resolved_path: null };
|
|
const facts = await imageFacts(context, lookup.path);
|
|
const outcome: Candidate["outcome"] =
|
|
facts.status !== "parsed"
|
|
? facts.status
|
|
: compatibleSlice(facts.slices, context.architecture) === undefined
|
|
? "architecture-missing"
|
|
: "resolved";
|
|
return { ...base, outcome, resolved_path: lookup.path };
|
|
};
|
|
|
|
const imageFacts = async (
|
|
context: ProcessContext,
|
|
path: string,
|
|
): Promise<MachoImageFacts> => {
|
|
const known = context.images.get(path);
|
|
if (known !== undefined) return known;
|
|
const facts = await context.view.image(path);
|
|
context.images.set(path, facts);
|
|
return facts;
|
|
};
|
|
|
|
const resolution = (candidates: readonly Candidate[]): Edge["resolution"] => {
|
|
let external = false;
|
|
for (const candidate of candidates) {
|
|
if (candidate.outcome === "resolved")
|
|
return {
|
|
status: external ? "conditional" : "resolved",
|
|
image: candidate.resolved_path,
|
|
};
|
|
// dyld might load these; REA cannot decide them from the analyzed root.
|
|
if (
|
|
candidate.outcome === "outside-target" ||
|
|
candidate.outcome === "escapes-target" ||
|
|
candidate.outcome === "unsupported" ||
|
|
candidate.outcome === "undetermined"
|
|
)
|
|
external = true;
|
|
}
|
|
return { status: external ? "undetermined" : "unresolved", image: null };
|
|
};
|
|
|
|
const resolveDependency = async (
|
|
dependency: MachoDependency,
|
|
loader: string,
|
|
context: ProcessContext,
|
|
): Promise<Edge> => {
|
|
const chain = context.loaded.get(loader)?.chain ?? [loader];
|
|
const loaded = context.byInstallName.get(dependency.install_name);
|
|
const candidates: Candidate[] = [];
|
|
if (loaded !== undefined)
|
|
candidates.push({
|
|
path: dependency.install_name,
|
|
source: "already-loaded",
|
|
rpath: null,
|
|
rpath_owner: null,
|
|
outcome: "resolved",
|
|
resolved_path: loaded,
|
|
});
|
|
else
|
|
for (const template of candidateTemplates(dependency, loader, context)) {
|
|
const candidate = await evaluateCandidate(template, context);
|
|
candidates.push(candidate);
|
|
// dyld stops at the first loadable candidate.
|
|
if (candidate.outcome === "resolved") break;
|
|
}
|
|
const resolved = resolution(candidates);
|
|
const image =
|
|
resolved.image === null ? undefined : context.images.get(resolved.image);
|
|
const slice =
|
|
image?.status === "parsed"
|
|
? compatibleSlice(image.slices, context.architecture)
|
|
: undefined;
|
|
return {
|
|
root: context.root,
|
|
architecture: context.architecture,
|
|
loader,
|
|
via: [...chain],
|
|
...dependency,
|
|
candidates,
|
|
resolution: resolved,
|
|
install_name_matches:
|
|
slice === undefined || slice.install_name === null
|
|
? null
|
|
: slice.install_name === dependency.install_name,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Follow dyld4's dependents-first order: resolve every dependency of one
|
|
* image, then recurse into the images it newly loaded, in load-command order.
|
|
*/
|
|
const traceProcess = async (
|
|
context: ProcessContext,
|
|
rootSlice: MachoSlice,
|
|
signal?: AbortSignal,
|
|
): Promise<Edge[]> => {
|
|
const edges: Edge[] = [];
|
|
context.loaded.set(context.root, { slice: rootSlice, chain: [context.root] });
|
|
if (rootSlice.install_name !== null)
|
|
context.byInstallName.set(rootSlice.install_name, context.root);
|
|
const pending = [context.root];
|
|
while (pending.length > 0) {
|
|
signal?.throwIfAborted();
|
|
const loader = pending.pop() ?? "";
|
|
const current = context.loaded.get(loader);
|
|
if (current === undefined) continue;
|
|
const created: string[] = [];
|
|
for (const dependency of current.slice.dependencies) {
|
|
const edge = await resolveDependency(dependency, loader, context);
|
|
edges.push(edge);
|
|
const image = edge.resolution.image;
|
|
if (image === null || context.loaded.has(image)) continue;
|
|
const facts = context.images.get(image);
|
|
const slice =
|
|
facts?.status === "parsed"
|
|
? compatibleSlice(facts.slices, context.architecture)
|
|
: undefined;
|
|
if (slice === undefined) continue;
|
|
context.loaded.set(image, { slice, chain: [...current.chain, image] });
|
|
if (
|
|
slice.install_name !== null &&
|
|
!context.byInstallName.has(slice.install_name)
|
|
)
|
|
context.byInstallName.set(slice.install_name, image);
|
|
created.push(image);
|
|
}
|
|
pending.push(...created.reverse());
|
|
}
|
|
return edges;
|
|
};
|
|
|
|
/** Trace dyld load paths from each process root through the analyzed root. */
|
|
export const traceDylibLoading = async (
|
|
view: DylibTreeView,
|
|
request: {
|
|
readonly roots: readonly string[];
|
|
/** Mach-O files whose root role could not be classified because they did not parse. */
|
|
readonly unclassified?: readonly string[];
|
|
readonly architecture?: string;
|
|
readonly signal?: AbortSignal;
|
|
},
|
|
): Promise<DylibTrace> => {
|
|
const images = new Map<string, MachoImageFacts>();
|
|
for (const path of request.unclassified ?? [])
|
|
images.set(path, await view.image(path));
|
|
const roots: DylibTrace["roots"][number][] = [];
|
|
const edges: Edge[] = [];
|
|
const withoutArchitecture: string[] = [];
|
|
for (const root of request.roots) {
|
|
const facts = await view.image(root);
|
|
images.set(root, facts);
|
|
if (facts.status !== "parsed") continue;
|
|
const slices = facts.slices.filter(
|
|
({ architecture }) =>
|
|
request.architecture === undefined ||
|
|
architecture === request.architecture,
|
|
);
|
|
if (slices.length === 0) withoutArchitecture.push(root);
|
|
for (const slice of slices) {
|
|
roots.push({ image: root, architecture: slice.architecture });
|
|
edges.push(
|
|
...(await traceProcess(
|
|
{
|
|
view,
|
|
root,
|
|
architecture: slice.architecture,
|
|
executable: slice.file_type === "execute" ? root : null,
|
|
loaded: new Map(),
|
|
byInstallName: new Map(),
|
|
images,
|
|
},
|
|
slice,
|
|
request.signal,
|
|
)),
|
|
);
|
|
}
|
|
}
|
|
const reported = [...images.entries()]
|
|
.filter(([, facts]) => facts.status !== "not-mach-o")
|
|
.sort(([left], [right]) => compare(left, right));
|
|
const unparsed = reported
|
|
.filter(([, facts]) => facts.status !== "parsed")
|
|
.map(([path]) => path);
|
|
return {
|
|
roots,
|
|
images: reported.map(([path, facts]) => ({
|
|
path,
|
|
parse_status:
|
|
facts.status === "not-mach-o" ? "unsupported" : facts.status,
|
|
reason:
|
|
facts.status === "parsed" || facts.status === "not-mach-o"
|
|
? null
|
|
: facts.reason,
|
|
slices:
|
|
facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [],
|
|
})),
|
|
edges,
|
|
findings: deriveFindings(edges, roots, images),
|
|
coverage: {
|
|
status:
|
|
unparsed.length === 0 && withoutArchitecture.length === 0
|
|
? "complete"
|
|
: "partial",
|
|
unparsed_images: unparsed,
|
|
roots_without_architecture: withoutArchitecture,
|
|
},
|
|
limitations: DYLIB_RESOLUTION_LIMITATIONS,
|
|
};
|
|
};
|
|
|
|
const withoutDependencies = (
|
|
slice: MachoSlice,
|
|
): Omit<MachoSlice, "dependencies"> => ({
|
|
architecture: slice.architecture,
|
|
file_type: slice.file_type,
|
|
install_name: slice.install_name,
|
|
rpaths: slice.rpaths,
|
|
dyld_environment: slice.dyld_environment,
|
|
code_signature_present: slice.code_signature_present,
|
|
});
|
|
|
|
const deriveFindings = (
|
|
edges: readonly Edge[],
|
|
roots: DylibTrace["roots"],
|
|
images: ReadonlyMap<string, MachoImageFacts>,
|
|
): Finding[] => {
|
|
const findings: Finding[] = [];
|
|
edges.forEach((edge, index) => {
|
|
if (edge.resolution.status === "unresolved")
|
|
findings.push({
|
|
kind: edge.weak ? "weak-load-unresolved" : "required-load-unresolved",
|
|
edge_index: index,
|
|
image: edge.loader,
|
|
basis: "derived",
|
|
explanation: edge.weak
|
|
? `No candidate for weak dependency ${edge.install_name} exists in the analyzed root; dyld continues without it.`
|
|
: `No candidate for ${edge.install_name} exists in the analyzed root; dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
|
|
});
|
|
const resolvedAt = edge.candidates.findIndex(
|
|
({ outcome }) => outcome === "resolved",
|
|
);
|
|
const earlier = edge.candidates
|
|
.slice(0, Math.max(resolvedAt, 0))
|
|
.filter(
|
|
({ source, outcome }) => source === "rpath" && outcome === "absent",
|
|
)
|
|
.map(({ path }) => path);
|
|
if (resolvedAt > 0 && earlier.length > 0)
|
|
findings.push({
|
|
kind: "earlier-rpath-candidate-absent",
|
|
edge_index: index,
|
|
image: edge.loader,
|
|
basis: "derived",
|
|
explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
|
|
});
|
|
});
|
|
for (const { image, architecture } of roots) {
|
|
const facts = images.get(image);
|
|
const environment =
|
|
facts?.status === "parsed"
|
|
? (facts.slices.find((slice) => slice.architecture === architecture)
|
|
?.dyld_environment ?? [])
|
|
: [];
|
|
if (environment.length > 0)
|
|
findings.push({
|
|
kind: "dyld-environment-present",
|
|
edge_index: null,
|
|
image,
|
|
basis: "derived",
|
|
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`,
|
|
});
|
|
}
|
|
return findings;
|
|
};
|
|
|
|
const DYLIB_RESOLUTION_LIMITATIONS = [
|
|
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
|
|
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
|
|
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
|
|
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack of the first chain; a later request whose install name matches an already loaded image reuses it.",
|
|
"@loader_path uses each image's symlink-resolved path within the analyzed root.",
|
|
"Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.",
|
|
];
|
|
|
|
const compare = (left: string, right: string): number =>
|
|
left < right ? -1 : left > right ? 1 : 0;
|