Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 1ede7a0864 fix(apple): trace LC_LAZY_LOAD_DYLIB_INFO lazy dylib dependencies
Apple's linker encodes -lazy_library as LC_LAZY_LOAD_DYLIB_INFO (0x3a), a
linkedit_data_command whose __LINKEDIT payload is dyld's
LazyLoadDylibLinkEdit record. The Mach-O load-command reader ignored the
command, so trace-dylib-resolution dropped the dependency while reporting
complete coverage.

Decode the record at the owning parser, validating it as dyld's
LazyLoadDylib::valid() does for the fields that name the dylib, and
normalize lazy semantics once as a dependency 'lazy' flag that the trace and
findings consume instead of matching command names. Lazy-info records carry
no dylib versions, so those fields are null for them.

Fixes #5551

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:11:50 +00:00
8 changed files with 364 additions and 21 deletions

No files matched your search

+6 -3
View File
@@ -180,8 +180,10 @@ rea trace-dylib-resolution MyApp.app --root Contents/MacOS/MyApp --architecture
Load commands are parsed in TypeScript from Mach-O and FAT headers, including
the `dylib_use_command` encoding newer linkers emit for flags such as delayed
initialization. Nothing is executed and no Apple tool is required, so the tool
also works off macOS.
initialization, and the `LC_LAZY_LOAD_DYLIB_INFO` records they emit for
`-lazy_library`. Lazy-info records carry no dylib versions, so their
`current_version` and `compatibility_version` are null. Nothing is executed and
no Apple tool is required, so the tool also works off macOS.
Resolution follows dyld:
@@ -222,7 +224,8 @@ cache rather than on disk, so REA does not check them against the host.
`findings` are derived, not observed:
- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`.
`LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
Lazy dependencies (`lazy: true`, from `LC_LAZY_LOAD_DYLIB` or
`LC_LAZY_LOAD_DYLIB_INFO`) are resolved but not traversed, because dyld
loads them on first use; an unresolved one is not a launch failure.
- `earlier-rpath-candidate-absent`: a compatible Mach-O placed at an earlier
modeled search path could take precedence, subject to unmodeled search inputs
+67 -2
View File
@@ -7,6 +7,7 @@ export const LC = {
REEXPORT_DYLIB: 0x8000001f,
LAZY_LOAD_DYLIB: 0x20,
LOAD_UPWARD_DYLIB: 0x80000023,
LAZY_LOAD_DYLIB_INFO: 0x3a,
RPATH: 0x8000001c,
DYLD_ENVIRONMENT: 0x27,
CODE_SIGNATURE: 0x1d,
@@ -102,18 +103,71 @@ export const versionMinCommand = (command: number): Uint8Array => {
return bytes;
};
/**
* dyld's `LazyLoadDylibLinkEdit` payload as Apple's linker lays it out: the
* 24-byte header, the symbol offset array, the path and the symbol names.
*/
export const lazyLoadDylibInfo = (
path: string,
options: {
readonly flags?: number;
readonly symbols?: readonly string[];
} = {},
): Uint8Array => {
const symbols = (options.symbols ?? ["_lf"]).map((name) =>
encoder.encode(name),
);
const name = encoder.encode(path);
const pathOffset = 24 + symbols.length * 4;
let end = pathOffset + name.length + 1;
const symbolOffsets = symbols.map((symbol) => {
const offset = end;
end += symbol.length + 1;
return offset;
});
const bytes = new Uint8Array(Math.ceil(end / 8) * 8);
const view = new DataView(bytes.buffer);
view.setUint32(0, pathOffset, true);
view.setUint32(4, 0x8008, true);
view.setUint16(8, options.flags ?? 0, true);
view.setUint16(10, 6, true);
view.setUint32(12, 0x8000, true);
view.setUint32(16, symbols.length, true);
view.setUint32(20, 24, true);
symbolOffsets.forEach((offset, index) => {
view.setUint32(24 + index * 4, offset, true);
bytes.set(symbols[index] ?? new Uint8Array(), offset);
});
bytes.set(name, pathOffset);
return bytes;
};
/** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */
export const machoImage = (options: {
readonly cpu?: { readonly type: number; readonly subtype: number };
readonly fileType?: number;
readonly commands?: readonly Uint8Array[];
/** `linkedit_data_command`s appended to `commands`, with data placed after them. */
readonly linkedit?: readonly {
readonly command: number;
readonly data: Uint8Array;
}[];
readonly wide?: boolean;
}): Uint8Array => {
const wide = options.wide ?? true;
const commands = options.commands ?? [];
const linkedit = options.linkedit ?? [];
const commands = [
...(options.commands ?? []),
...linkedit.map(() => new Uint8Array(16)),
];
const headerSize = wide ? 32 : 28;
const commandBytes = commands.reduce((total, item) => total + item.length, 0);
const bytes = new Uint8Array(headerSize + commandBytes + 16);
const dataStart = headerSize + commandBytes + 16;
const dataBytes = linkedit.reduce(
(total, item) => total + item.data.length,
0,
);
const bytes = new Uint8Array(dataStart + dataBytes);
const view = new DataView(bytes.buffer);
const cpu = options.cpu ?? CPU.arm64;
view.setUint32(0, wide ? 0xfeedfacf : 0xfeedface, true);
@@ -127,6 +181,17 @@ export const machoImage = (options: {
bytes.set(command, offset);
offset += command.length;
}
let dataOffset = dataStart;
let commandOffset = headerSize + commandBytes - linkedit.length * 16;
for (const { command, data } of linkedit) {
view.setUint32(commandOffset, command, true);
view.setUint32(commandOffset + 4, 16, true);
view.setUint32(commandOffset + 8, dataOffset, true);
view.setUint32(commandOffset + 12, data.length, true);
bytes.set(data, dataOffset);
commandOffset += 16;
dataOffset += data.length;
}
return bytes;
};
@@ -14,6 +14,7 @@ import {
dylibCommand,
dylibUseCommand,
fatImage,
lazyLoadDylibInfo,
machoImage,
readerOf,
rpathCommand,
@@ -353,6 +354,98 @@ describe("Mach-O load command reader: decodes thin-image load commands", () => {
});
});
describe("Mach-O load command reader: decodes LC_LAZY_LOAD_DYLIB_INFO", () => {
it("decodes LC_LAZY_LOAD_DYLIB_INFO records in load-command order", async () => {
const facts = await read(
machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib")],
linkedit: [
{
command: LC.LAZY_LOAD_DYLIB_INFO,
data: lazyLoadDylibInfo("@rpath/libl.dylib"),
},
{
command: LC.LAZY_LOAD_DYLIB_INFO,
data: lazyLoadDylibInfo("@rpath/libmaybe.dylib", {
flags: 0x1,
symbols: ["_a", "_b"],
}),
},
],
}),
);
if (facts.status !== "parsed") throw new Error(facts.status);
expect(facts.slices[0]?.dependencies.slice(1)).toEqual([
{
command: "LC_LAZY_LOAD_DYLIB_INFO",
encoding: "lazy_load_dylib_info",
install_name: "@rpath/libl.dylib",
weak: false,
upward: false,
reexport: false,
delayed_init: false,
lazy: true,
current_version: null,
compatibility_version: null,
},
expect.objectContaining({
install_name: "@rpath/libmaybe.dylib",
weak: true,
lazy: true,
}),
]);
});
it("rejects LC_LAZY_LOAD_DYLIB_INFO data that dyld would reject", async () => {
const withData = (data: Uint8Array) =>
read(
machoImage({
linkedit: [{ command: LC.LAZY_LOAD_DYLIB_INFO, data }],
}),
);
const valid = lazyLoadDylibInfo("@rpath/libl.dylib");
const patched = (offset: number, value: number) => {
const bytes = valid.slice();
new DataView(bytes.buffer).setUint32(offset, value, true);
return bytes;
};
await expect(withData(valid.subarray(0, 20))).resolves.toEqual({
status: "malformed",
reason:
"load command 0 LC_LAZY_LOAD_DYLIB_INFO data is too short for its header",
});
await expect(withData(patched(0, valid.length))).resolves.toMatchObject({
status: "malformed",
reason: expect.stringContaining("path offset"),
});
await expect(withData(patched(16, 1000))).resolves.toMatchObject({
status: "malformed",
reason: expect.stringContaining("symbol table exceeds its data"),
});
await expect(
withData(patched(24, valid.length + 1)),
).resolves.toMatchObject({
status: "malformed",
reason: expect.stringContaining("symbol 0 is outside its data"),
});
const unterminated = valid.slice();
unterminated.fill(0x41, 28);
await expect(withData(unterminated)).resolves.toMatchObject({
status: "malformed",
reason: "load command 0 has an unterminated string",
});
const beyond = machoImage({
linkedit: [{ command: LC.LAZY_LOAD_DYLIB_INFO, data: valid }],
});
await expect(
readMachoImage(readerOf(beyond), beyond.length - 1),
).resolves.toMatchObject({
status: "malformed",
reason: expect.stringContaining("data extends beyond the Mach-O slice"),
});
});
});
describe("Mach-O load command reader: reads and validates universal binary slices", () => {
it("reads every slice of FAT and FAT_64 containers in either byte order", async () => {
for (const [wide, littleEndian] of [
+109 -6
View File
@@ -33,6 +33,7 @@ const LC_LOAD_WEAK_DYLIB = 0x80000018;
const LC_REEXPORT_DYLIB = 0x8000001f;
const LC_LAZY_LOAD_DYLIB = 0x20;
const LC_LOAD_UPWARD_DYLIB = 0x80000023;
const LC_LAZY_LOAD_DYLIB_INFO = 0x3a;
const LC_RPATH = 0x8000001c;
const LC_DYLD_ENVIRONMENT = 0x27;
const LC_CODE_SIGNATURE = 0x1d;
@@ -90,6 +91,16 @@ const DYLIB_USE_REEXPORT = 0x2;
const DYLIB_USE_UPWARD = 0x4;
const DYLIB_USE_DELAYED_INIT = 0x8;
/**
* dyld's `LazyLoadDylibLinkEdit` (dyld `mach_o/LazyLoadDylib.h`), the
* __LINKEDIT payload of `LC_LAZY_LOAD_DYLIB_INFO`: loadPathOffset,
* flagImageOffset, flags, pointerFormat, chainStartImageOffset, symbolsCount
* and symbolStringArrayOffset, followed by the path and symbol strings.
*/
const LAZY_LOAD_DYLIB_INFO_HEADER_BYTES = 24;
/** `LazyLoadDylib::dylibMayBeMissing()`: the lazy dylib is weak linked. */
const LAZY_LOAD_DYLIB_WEAK_LINK = 0x1;
const DEPENDENCY_COMMANDS: ReadonlyMap<number, MachoDependency["command"]> =
new Map([
[LC_LOAD_DYLIB, "LC_LOAD_DYLIB"],
@@ -293,7 +304,7 @@ const readSlice = async (
commandBytes,
"load commands",
);
const slice = decodeCommands(commands, {
const slice = await decodeCommands(readAt, commands, {
wide,
cpuType: identity.cpuType,
cpuSubtype: identity.cpuSubtype,
@@ -320,7 +331,8 @@ const readSlice = async (
);
};
const decodeCommands = (
const decodeCommands = async (
readAt: ReadAt,
bytes: Uint8Array,
header: {
readonly wide: boolean;
@@ -334,7 +346,7 @@ const decodeCommands = (
readonly fatCpuSubtype: number | null;
readonly fatAlignmentExponent: number | null;
},
): MachoSlice => {
): Promise<MachoSlice> => {
const view = viewOf(bytes);
const slice: MachoSlice = {
architecture: architectureName(header.cpuType, header.cpuSubtype),
@@ -374,7 +386,11 @@ const decodeCommands = (
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
);
const body = bytes.subarray(offset, offset + size);
decodeCommand(command, body, index, slice);
if (command === LC_LAZY_LOAD_DYLIB_INFO)
slice.dependencies.push(
await readLazyLoadDylibInfo(readAt, body, index, header),
);
else decodeCommand(command, body, index, slice);
offset += size;
}
if (offset !== bytes.byteLength)
@@ -473,11 +489,89 @@ const decodeDependency = (
reexport:
command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0,
delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0,
lazy: command === "LC_LAZY_LOAD_DYLIB",
current_version: version(view.getUint32(16, true)),
compatibility_version: version(view.getUint32(20, true)),
};
};
/**
* Decode one `LC_LAZY_LOAD_DYLIB_INFO` record from its __LINKEDIT payload.
* Validation follows dyld's `LazyLoadDylib::valid()` except for the flag and
* chain-start image offsets, which need the VM layout and do not affect
* which dylib is named.
*/
const readLazyLoadDylibInfo = async (
readAt: ReadAt,
body: Uint8Array,
index: number,
slice: { readonly sliceOffset: number; readonly sliceSize: number },
): Promise<MachoDependency> => {
// linkedit_data_command: cmd, cmdsize, dataoff, datasize.
if (body.byteLength < 16)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for LC_LAZY_LOAD_DYLIB_INFO`,
);
const command = viewOf(body);
const dataOffset = command.getUint32(8, true);
const dataSize = command.getUint32(12, true);
if (dataSize > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data occupies ${dataSize} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
);
if (dataOffset + dataSize > slice.sliceSize)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data extends beyond the Mach-O slice`,
);
if (dataSize < LAZY_LOAD_DYLIB_INFO_HEADER_BYTES)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data is too short for its header`,
);
const blob = await readExact(
readAt,
slice.sliceOffset + dataOffset,
dataSize,
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data`,
);
const view = viewOf(blob);
const symbolCount = view.getUint32(16, true);
const symbolArrayOffset = view.getUint32(20, true);
if (symbolArrayOffset + symbolCount * 4 > dataSize)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol table exceeds its data`,
);
for (let symbol = 0; symbol < symbolCount; symbol++)
if (view.getUint32(symbolArrayOffset + symbol * 4, true) > dataSize)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol ${symbol} is outside its data`,
);
const pathOffset = view.getUint32(0, true);
if (pathOffset >= dataSize)
throw new MachoFormatIssue(
"malformed",
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO path offset ${pathOffset} is outside its data`,
);
return {
command: "LC_LAZY_LOAD_DYLIB_INFO",
encoding: "lazy_load_dylib_info",
install_name: terminatedString(blob, pathOffset, index),
weak: (view.getUint16(8, true) & LAZY_LOAD_DYLIB_WEAK_LINK) !== 0,
upward: false,
reexport: false,
delayed_init: false,
lazy: true,
// The record carries no dylib versions.
current_version: null,
compatibility_version: null,
};
};
/** NUL-terminated UTF-8 string at a `lc_str` offset inside one command. */
const commandString = (
body: Uint8Array,
@@ -495,7 +589,16 @@ const commandString = (
"malformed",
`load command ${index} has string offset ${offset} outside the command`,
);
const end = body.indexOf(0, offset);
return terminatedString(body, offset, index);
};
/** NUL-terminated UTF-8 string starting at `offset` inside one record. */
const terminatedString = (
bytes: Uint8Array,
offset: number,
index: number,
): string => {
const end = bytes.indexOf(0, offset);
if (end < 0)
throw new MachoFormatIssue(
"malformed",
@@ -504,7 +607,7 @@ const commandString = (
try {
// lc_str contains name/path data, so a leading U+FEFF is part of its identity.
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
body.subarray(offset, end),
bytes.subarray(offset, end),
);
} catch {
throw new MachoFormatIssue(
+1
View File
@@ -47,6 +47,7 @@ const dependency = (
upward: false,
reexport: false,
delayed_init: false,
lazy: overrides.command === "LC_LAZY_LOAD_DYLIB",
current_version: "1.0.0",
compatibility_version: "1.0.0",
...overrides,
+13 -5
View File
@@ -47,18 +47,26 @@ const dependencyCommandSchema = z.enum([
"LC_REEXPORT_DYLIB",
"LC_LAZY_LOAD_DYLIB",
"LC_LOAD_UPWARD_DYLIB",
"LC_LAZY_LOAD_DYLIB_INFO",
]);
const dependencySchema = z.strictObject({
command: dependencyCommandSchema,
encoding: z.enum(["dylib_command", "dylib_use_command"]),
encoding: z.enum([
"dylib_command",
"dylib_use_command",
"lazy_load_dylib_info",
]),
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(),
/** dyld loads the dylib on first use of one of its symbols, not at launch. */
lazy: z.boolean(),
/** Null when the encoding records no dylib versions. */
current_version: z.string().nullable(),
compatibility_version: z.string().nullable(),
});
/** Observed dylib-loading facts of one Mach-O slice. */
@@ -528,8 +536,8 @@ const traceProcess = async (
for (const dependency of current.slice.dependencies) {
const edge = await resolveDependency(dependency, loader, context);
edges.push(edge);
// dyld loads LC_LAZY_LOAD_DYLIB images on first use, not at launch.
if (dependency.command === "LC_LAZY_LOAD_DYLIB") continue;
// dyld loads lazy dylibs on first use, not at launch.
if (dependency.lazy) continue;
const image = edge.resolution.image;
if (image === null || context.loaded.has(image)) continue;
const facts = context.images.get(image);
+6 -4
View File
@@ -62,16 +62,18 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
image: edge.loader,
basis: "derived",
} as const;
const missing = `No candidate for ${edge.command === "LC_LAZY_LOAD_DYLIB" ? "lazily loaded " : edge.weak ? "weak dependency " : ""}${edge.install_name} exists in the analyzed root`;
const missing = `No candidate for ${edge.lazy ? `lazily loaded ${edge.weak ? "weak " : ""}` : edge.weak ? "weak dependency " : ""}${edge.install_name} exists in the analyzed root`;
// Below a conditional fallback, every consequence applies only if it loads.
const when = edge.loader_conditional
? `. ${edge.loader} loads only conditionally; if it loads, `
: "; ";
if (edge.command === "LC_LAZY_LOAD_DYLIB")
if (edge.lazy)
return {
...base,
kind: "lazy-load-unresolved",
explanation: `${missing}${when}dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
explanation: edge.weak
? `${missing}${when}dyld tries to load it on first use and continues without it.`
: `${missing}${when}dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
};
return edge.weak
? {
@@ -87,7 +89,7 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
};
export const DYLIB_RESOLUTION_LIMITATIONS = [
"LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.",
"Lazy dependencies (LC_LAZY_LOAD_DYLIB and LC_LAZY_LOAD_DYLIB_INFO) are resolved but not traversed, because dyld loads them only on first use.",
"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.",
+69 -1
View File
@@ -1,4 +1,4 @@
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
import { chmod, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect } from "vitest";
@@ -11,6 +11,7 @@ import {
dylibCommand,
dyldEnvironmentCommand,
fatImage,
lazyLoadDylibInfo,
machoImage,
rpathCommand,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
@@ -157,6 +158,73 @@ cliTest(
},
);
cliTest(
"resolves LC_LAZY_LOAD_DYLIB_INFO dependencies as lazy loads",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-dylib-lazy-");
const executable = join(directory, "lazy");
// Apple's linker encodes `-lazy_library` as LC_LAZY_LOAD_DYLIB_INFO.
await writeFiles(directory, {
lazy: machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
rpathCommand("@executable_path/lib"),
],
linkedit: [
{
command: LC.LAZY_LOAD_DYLIB_INFO,
data: lazyLoadDylibInfo("@rpath/libl.dylib"),
},
],
}),
"lib/libl.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
dylibCommand(LC.ID_DYLIB, "@rpath/libl.dylib"),
],
}),
});
const trace = async () => {
const output = await cli.run({
arguments: ["trace-dylib-resolution", executable, "--json"],
environment: ENVIRONMENT,
});
expect(output.exitCode, output.stderr).toBe(0);
return dylibResolutionResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
};
const lazyEdge = (result: Awaited<ReturnType<typeof trace>>) =>
result.edges.find(
({ install_name }) => install_name === "@rpath/libl.dylib",
);
const present = await trace();
expect(lazyEdge(present)).toMatchObject({
command: "LC_LAZY_LOAD_DYLIB_INFO",
encoding: "lazy_load_dylib_info",
lazy: true,
weak: false,
current_version: null,
resolution: { status: "resolved", image: "lib/libl.dylib" },
});
expect(present.findings).toEqual([]);
expect(present.coverage.status).toBe("complete");
await rm(join(directory, "lib/libl.dylib"));
const absent = await trace();
expect(lazyEdge(absent)?.resolution).toEqual({
status: "unresolved",
image: null,
});
// A missing lazy dylib fails on first use, not at launch.
expect(absent.findings.map(({ kind }) => kind)).toEqual([
"lazy-load-unresolved",
]);
expect(absent.coverage.status).toBe("complete");
},
);
describe("trace-dylib-resolution CLI", () => {
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
const app = await fixtureApp();