Files
reverse-engineering-anything/src/domain/dylibResolution.ts
T
Kaoru0822andClaude Opus 5.5 a0ec3e2016 fix(artifacts): follow dyld slice grading and report unparsed images
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>
2026-10-07 11:07:31 +00:00

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;